Files
EasyTier/easytier-web/src/client_manager/session/runtime_revision.rs
T

1428 lines
47 KiB
Rust

use std::collections::{HashMap, HashSet};
use easytier::proto::{
api::manage::{
DeleteNetworkInstanceRequest, DeleteNetworkInstanceResponse,
ListNetworkInstanceMetaRequest, ListNetworkInstanceRequest, NetworkConfig, NetworkMeta,
RunNetworkInstanceRequest,
},
rpc_types::controller::BaseController,
web::HeartbeatRequest,
};
use easytier_core::management::remote_client::{ListNetworkProps, Storage as _};
use tokio::sync::{RwLock, broadcast};
use super::{
ManagedConfigRevisionDelta, SessionConfigClient, SessionData, SessionRpcClient,
SessionRpcService,
};
use crate::client_manager::{
managed_config::{self, PersistedConfigSource},
runtime_reconcile,
storage::{StorageInner, WeakRefStorage},
};
async fn recv_latest_heartbeat(
heartbeat_waiter: &mut broadcast::Receiver<HeartbeatRequest>,
) -> Option<HeartbeatRequest> {
let mut req = loop {
match heartbeat_waiter.recv().await {
Ok(req) => break req,
Err(broadcast::error::RecvError::Lagged(skipped)) => {
tracing::warn!(
skipped,
"heartbeat reconcile worker lagged, waiting for latest request"
);
}
Err(broadcast::error::RecvError::Closed) => {
tracing::error!("Failed to receive heartbeat request: channel closed");
return None;
}
}
};
// Drop any heartbeat backlog accumulated while the previous reconcile
// round was doing DB/RPC IO. The newest heartbeat has the freshest
// runtime instance list, which is all this task needs.
loop {
match heartbeat_waiter.try_recv() {
Ok(next_req) => req = next_req,
Err(broadcast::error::TryRecvError::Empty) => break,
Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
Err(broadcast::error::TryRecvError::Closed) => return None,
}
}
Some(req)
}
pub(super) async fn reconcile_network_configs_on_heartbeat(
session_data: std::sync::Weak<RwLock<SessionData>>,
mut heartbeat_waiter: broadcast::Receiver<HeartbeatRequest>,
storage: WeakRefStorage,
mut rpc_client: SessionRpcClient,
mut config_client: SessionConfigClient,
) {
let mut cache = ReconcileCache::default();
loop {
let Some(req) = recv_latest_heartbeat(&mut heartbeat_waiter).await else {
return;
};
let Some(storage) = storage.upgrade() else {
tracing::error!("Failed to get storage");
return;
};
let mut round =
match prepare_reconcile_round(&session_data, &storage, &mut rpc_client, req).await {
RoundStatus::Ready(round) => round,
RoundStatus::Skip => continue,
RoundStatus::Stop => return,
};
if cache.runtime_config_epoch != round.runtime_config_epoch {
cache = ReconcileCache {
runtime_config_epoch: round.runtime_config_epoch,
..Default::default()
};
}
let running_metas =
match sync_running_sources_for_round(&mut rpc_client, &storage, &mut round).await {
RoundStatus::Ready(running_metas) => running_metas,
RoundStatus::Skip => continue,
RoundStatus::Stop => return,
};
let mut mutation_fence = RuntimeMutationFence::default();
let context = ReconcileRoundContext {
session_data: &session_data,
round: &round,
};
let mut outcome = match &round.scope {
ReconcileScope::Full => {
let desired_web_inst_ids =
managed_config::desired_web_source_instance_ids(&round.local_configs);
cache.runtime_configs.retain_desired(&desired_web_inst_ids);
match cleanup_stale_web_source_instances(
&context,
&storage,
&mut rpc_client,
running_metas.as_deref(),
&desired_web_inst_ids,
&mut cache,
&mut mutation_fence,
)
.await
{
RoundStatus::Ready(outcome) => outcome,
RoundStatus::Skip => continue,
RoundStatus::Stop => return,
}
}
ReconcileScope::Patch {
delete_instance_ids,
..
} => {
match cleanup_patch_deleted_instances(
&session_data,
&mut rpc_client,
&round,
running_metas.as_deref(),
delete_instance_ids,
&mut cache,
&mut mutation_fence,
)
.await
{
RoundStatus::Ready(outcome) => outcome,
RoundStatus::Skip => continue,
RoundStatus::Stop => return,
}
}
};
outcome.merge(
reconcile_desired_runtime_configs(
&context,
&mut rpc_client,
&mut config_client,
&mut cache,
&mut mutation_fence,
)
.await,
);
if !outcome.has_failed {
match &round.scope {
ReconcileScope::Full => {
cache.last_desired_web_inst_ids = Some(
managed_config::desired_web_source_instance_ids(&round.local_configs),
);
}
ReconcileScope::Patch {
upsert_instance_ids,
delete_instance_ids,
} => {
if let Some(last) = &mut cache.last_desired_web_inst_ids {
last.retain(|id| !delete_instance_ids.contains(id));
last.extend(upsert_instance_ids.iter().cloned());
}
}
}
}
match mark_config_revision_applied_if_current(
&session_data,
&storage,
&round,
&outcome,
&mutation_fence,
)
.await
{
RoundStatus::Ready(()) | RoundStatus::Skip => {}
RoundStatus::Stop => return,
}
}
}
enum RoundStatus<T> {
Ready(T),
Skip,
Stop,
}
enum ConfigActionResult {
Success,
Failed,
StopRound,
}
#[derive(Default)]
struct ReconcileCache {
runtime_config_epoch: u64,
cleaned_web_source_instances: bool,
last_desired_web_inst_ids: Option<HashSet<String>>,
runtime_configs: SessionRuntimeConfigCache,
}
#[derive(Default)]
struct SessionRuntimeConfigCache {
entries: HashMap<String, NetworkConfig>,
}
impl SessionRuntimeConfigCache {
fn plan(
&self,
inst_id: &str,
desired_config: NetworkConfig,
) -> anyhow::Result<Option<runtime_reconcile::RuntimeReconcileAction>> {
let Some(observed_config) = self.entries.get(inst_id) else {
return Ok(None);
};
runtime_reconcile::prepare_web_source_runtime_reconcile_from_current(
observed_config,
desired_config,
)
.map(Some)
}
fn remember(&mut self, inst_id: &str, observed_config: NetworkConfig) {
self.entries.insert(inst_id.to_string(), observed_config);
}
fn forget(&mut self, inst_id: &str) {
self.entries.remove(inst_id);
}
fn forget_many<'a>(&mut self, inst_ids: impl IntoIterator<Item = &'a String>) {
for inst_id in inst_ids {
self.entries.remove(inst_id);
}
}
fn retain_desired(&mut self, desired_web_inst_ids: &HashSet<String>) {
self.entries
.retain(|inst_id, _| desired_web_inst_ids.contains(inst_id));
}
}
#[derive(Default)]
struct ReconcileOutcome {
has_failed: bool,
managed_revision_failed: bool,
}
#[derive(Default)]
struct RuntimeMutationFence {
started: bool,
}
impl ReconcileOutcome {
fn record_failure(&mut self, managed_revision_failed: bool) {
self.has_failed = true;
self.managed_revision_failed |= managed_revision_failed;
}
fn merge(&mut self, other: Self) {
self.has_failed |= other.has_failed;
self.managed_revision_failed |= other.managed_revision_failed;
}
}
struct ReconcileRound {
req: HeartbeatRequest,
machine_id: uuid::Uuid,
user_id: i32,
running_inst_ids: HashSet<String>,
local_configs: Vec<crate::db::entity::user_running_network_configs::Model>,
target_config_revision: Option<String>,
should_apply_runtime_revision: bool,
scope: ReconcileScope,
runtime_config_epoch: u64,
}
struct ReconcileRoundContext<'a> {
session_data: &'a std::sync::Weak<RwLock<SessionData>>,
round: &'a ReconcileRound,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ReconcileScope {
Full,
Patch {
upsert_instance_ids: HashSet<String>,
delete_instance_ids: HashSet<String>,
},
}
fn select_reconcile_scope(
applied_revision: Option<&str>,
target_revision: Option<&str>,
pending_delta: Option<&ManagedConfigRevisionDelta>,
) -> ReconcileScope {
match pending_delta {
Some(delta)
if applied_revision == Some(delta.expected_revision.as_str())
&& target_revision == Some(delta.target_revision.as_str()) =>
{
ReconcileScope::Patch {
upsert_instance_ids: delta.upsert_instance_ids.clone(),
delete_instance_ids: delta.delete_instance_ids.clone(),
}
}
_ => ReconcileScope::Full,
}
}
async fn prepare_reconcile_round(
session_data: &std::sync::Weak<RwLock<SessionData>>,
storage: &StorageInner,
rpc_client: &mut SessionRpcClient,
req: HeartbeatRequest,
) -> RoundStatus<ReconcileRound> {
let Some(machine_id) = req.machine_id.map(uuid::Uuid::from) else {
tracing::warn!(?req, "Machine id is not set, ignore");
return RoundStatus::Skip;
};
if !SessionRpcService::runtime_heartbeat_is_current(session_data, &req).await {
tracing::debug!(?machine_id, "skip stale heartbeat reconcile request");
return RoundStatus::Skip;
}
let user_id = match storage
.db
.get_user_id_by_token(req.user_token.clone())
.await
{
Ok(Some(user_id)) => user_id,
Ok(None) => {
tracing::info!("User not found by token: {:?}", req.user_token);
return RoundStatus::Stop;
}
Err(e) => {
tracing::error!("Failed to get user id by token, error: {:?}", e);
return RoundStatus::Stop;
}
};
let (applied_config_revision, pending_delta, runtime_config_epoch) = {
let Some(data) = session_data.upgrade() else {
return RoundStatus::Stop;
};
let data = data.read().await;
(
data.applied_config_revision.clone(),
data.pending_managed_config_delta.clone(),
data.runtime_config_epoch,
)
};
let target_config_revision = match storage
.db
.get_managed_config_revision((user_id, machine_id))
.await
{
Ok(revision) => revision,
Err(e) => {
tracing::error!("Failed to read managed config revision, error: {:?}", e);
return RoundStatus::Stop;
}
};
let should_apply_runtime_revision =
target_config_revision.is_some() && target_config_revision != applied_config_revision;
let mut scope = if should_apply_runtime_revision {
select_reconcile_scope(
applied_config_revision.as_deref(),
target_config_revision.as_deref(),
pending_delta.as_ref(),
)
} else {
ReconcileScope::Full
};
let running_inst_ids = match running_instance_ids_for_round(
rpc_client,
&req,
user_id,
machine_id,
should_apply_runtime_revision,
)
.await
{
RoundStatus::Ready(ids) => ids,
RoundStatus::Skip => return RoundStatus::Skip,
RoundStatus::Stop => return RoundStatus::Stop,
};
let local_configs = match load_round_configs(storage, user_id, machine_id, &scope).await {
Ok(Some(configs)) => configs,
Ok(None) => {
tracing::warn!(
?user_id,
?machine_id,
"Managed config Patch no longer matches persisted rows; using Full reconcile"
);
scope = ReconcileScope::Full;
match storage
.db
.list_network_configs((user_id, machine_id), ListNetworkProps::EnabledOnly)
.await
{
Ok(configs) => configs,
Err(e) => {
tracing::error!("Failed to list network configs, error: {:?}", e);
return RoundStatus::Stop;
}
}
}
Err(e) => {
tracing::error!("Failed to load managed config Patch rows, error: {:?}", e);
return RoundStatus::Stop;
}
};
RoundStatus::Ready(ReconcileRound {
req,
machine_id,
user_id,
running_inst_ids,
local_configs,
target_config_revision,
should_apply_runtime_revision,
scope,
runtime_config_epoch,
})
}
async fn load_round_configs(
storage: &StorageInner,
user_id: i32,
machine_id: uuid::Uuid,
scope: &ReconcileScope,
) -> Result<Option<Vec<crate::db::entity::user_running_network_configs::Model>>, sea_orm::DbErr> {
let ReconcileScope::Patch {
upsert_instance_ids,
..
} = scope
else {
return storage
.db
.list_network_configs((user_id, machine_id), ListNetworkProps::EnabledOnly)
.await
.map(Some);
};
let mut instance_ids = upsert_instance_ids.iter().collect::<Vec<_>>();
instance_ids.sort_unstable();
let mut configs = Vec::with_capacity(instance_ids.len());
for instance_id in instance_ids {
let Some(config) = storage
.db
.get_network_config((user_id, machine_id), instance_id)
.await?
else {
return Ok(None);
};
if config.disabled
|| PersistedConfigSource::from_db(&config.source) != PersistedConfigSource::Web
{
return Ok(None);
}
configs.push(config);
}
Ok(Some(configs))
}
async fn running_instance_ids_for_round(
rpc_client: &mut SessionRpcClient,
req: &HeartbeatRequest,
user_id: i32,
machine_id: uuid::Uuid,
should_apply_runtime_revision: bool,
) -> RoundStatus<HashSet<String>> {
if !should_apply_runtime_revision {
return RoundStatus::Ready(
req.running_network_instances
.iter()
.map(|x| x.to_string())
.collect(),
);
}
match rpc_client
.list_network_instance(BaseController::default(), ListNetworkInstanceRequest {})
.await
{
Ok(resp) => RoundStatus::Ready(resp.inst_ids.iter().map(|x| x.to_string()).collect()),
Err(error) => {
tracing::warn!(
?user_id,
?machine_id,
?error,
"Failed to refresh running instances for managed config revision"
);
RoundStatus::Skip
}
}
}
async fn sync_running_sources_for_round(
rpc_client: &mut SessionRpcClient,
storage: &StorageInner,
round: &mut ReconcileRound,
) -> RoundStatus<Option<Vec<NetworkMeta>>> {
if !round.req.support_config_source {
return RoundStatus::Ready(None);
}
let ret = if round.running_inst_ids.is_empty() {
Ok(Vec::new())
} else {
rpc_client
.list_network_instance_meta(
BaseController::default(),
ListNetworkInstanceMetaRequest {
inst_ids: managed_config::parse_instance_ids(
round.running_inst_ids.iter().cloned(),
),
},
)
.await
.map(|resp| resp.metas)
};
match ret {
Ok(metas) => {
if let Err(e) = managed_config::sync_running_config_sources(
&storage.db,
round.user_id,
round.machine_id,
&round.local_configs,
&metas,
)
.await
{
tracing::warn!(
user_id = ?round.user_id,
machine_id = ?round.machine_id,
%e,
"Failed to sync running network config sources"
);
} else if !metas.is_empty() && matches!(round.scope, ReconcileScope::Full) {
round.local_configs = match storage
.db
.list_network_configs(
(round.user_id, round.machine_id),
ListNetworkProps::EnabledOnly,
)
.await
{
Ok(configs) => configs,
Err(e) => {
tracing::error!(
"Failed to reload network configs after source sync, error: {:?}",
e
);
return RoundStatus::Stop;
}
};
}
RoundStatus::Ready(Some(metas))
}
Err(e) => {
tracing::warn!(
user_id = ?round.user_id,
%e,
"Failed to list running network instance metadata"
);
RoundStatus::Ready(None)
}
}
}
async fn cleanup_stale_web_source_instances(
context: &ReconcileRoundContext<'_>,
storage: &StorageInner,
rpc_client: &mut SessionRpcClient,
running_metas: Option<&[NetworkMeta]>,
desired_web_inst_ids: &HashSet<String>,
cache: &mut ReconcileCache,
mutation_fence: &mut RuntimeMutationFence,
) -> RoundStatus<ReconcileOutcome> {
let session_data = context.session_data;
let round = context.round;
let desired_changed = cache
.last_desired_web_inst_ids
.as_ref()
.is_none_or(|last| last != desired_web_inst_ids);
if cache.cleaned_web_source_instances && !desired_changed {
return RoundStatus::Ready(ReconcileOutcome::default());
}
let db_web_inst_ids = match storage
.db
.list_network_configs((round.user_id, round.machine_id), ListNetworkProps::All)
.await
{
Ok(configs) => managed_config::desired_web_source_instance_ids(&configs),
Err(e) => {
tracing::error!("Failed to list all network configs, error: {:?}", e);
return RoundStatus::Stop;
}
};
let running_web_inst_ids = managed_config::running_web_source_instance_ids(
&round.running_inst_ids,
&db_web_inst_ids,
running_metas,
);
let should_delete_inst_ids = running_web_inst_ids
.difference(desired_web_inst_ids)
.cloned()
.collect::<HashSet<_>>();
let should_delete_ids =
managed_config::parse_instance_ids(should_delete_inst_ids.iter().cloned());
let mut outcome = ReconcileOutcome::default();
if !should_delete_ids.is_empty() {
if !begin_managed_runtime_mutation(session_data, round, mutation_fence).await {
tracing::debug!(
machine_id = ?round.machine_id,
"skip stale cleanup because the managed runtime fence is no longer current"
);
return RoundStatus::Skip;
}
let ret = rpc_client
.delete_network_instance(
BaseController::default(),
DeleteNetworkInstanceRequest {
inst_ids: should_delete_ids,
},
)
.await;
tracing::info!(
user_id = ?round.user_id,
"Clean stale web-source network instances on heartbeat: {:?}, user_token: {:?}",
ret,
round.req.user_token
);
match ret {
Err(_) => outcome.record_failure(true),
Ok(response) => {
let undeleted_instance_ids =
retained_requested_instance_ids(response, &should_delete_inst_ids);
if undeleted_instance_ids.is_empty() {
cache.runtime_configs.forget_many(&should_delete_inst_ids);
} else {
tracing::warn!(
user_id = ?round.user_id,
machine_id = ?round.machine_id,
instance_ids = ?undeleted_instance_ids,
"Stale managed instances were retained by the runtime"
);
outcome.record_failure(true);
}
}
}
}
if !outcome.has_failed {
cache.cleaned_web_source_instances = true;
cache.last_desired_web_inst_ids = Some(desired_web_inst_ids.clone());
}
RoundStatus::Ready(outcome)
}
async fn cleanup_patch_deleted_instances(
session_data: &std::sync::Weak<RwLock<SessionData>>,
rpc_client: &mut SessionRpcClient,
round: &ReconcileRound,
running_metas: Option<&[NetworkMeta]>,
delete_instance_ids: &HashSet<String>,
cache: &mut ReconcileCache,
mutation_fence: &mut RuntimeMutationFence,
) -> RoundStatus<ReconcileOutcome> {
let running_web_instance_ids: HashSet<String> = match running_metas {
Some(metas) => managed_config::running_web_source_instance_ids(
&round.running_inst_ids,
delete_instance_ids,
Some(metas),
)
.intersection(delete_instance_ids)
.cloned()
.collect(),
None => round
.running_inst_ids
.intersection(delete_instance_ids)
.cloned()
.collect(),
};
if running_web_instance_ids.is_empty() {
cache
.runtime_configs
.forget_many(delete_instance_ids.iter());
return RoundStatus::Ready(ReconcileOutcome::default());
}
if !begin_managed_runtime_mutation(session_data, round, mutation_fence).await {
tracing::debug!(
machine_id = ?round.machine_id,
"skip managed config Patch cleanup because the runtime fence is no longer current"
);
return RoundStatus::Skip;
}
let ret = rpc_client
.delete_network_instance(
BaseController::default(),
DeleteNetworkInstanceRequest {
inst_ids: managed_config::parse_instance_ids(
running_web_instance_ids.iter().cloned(),
),
},
)
.await;
tracing::info!(
user_id = ?round.user_id,
deleted_instance_ids = ?running_web_instance_ids,
"Apply managed config Patch deletions at runtime: {:?}",
ret
);
let mut outcome = ReconcileOutcome::default();
match ret {
Err(_) => outcome.record_failure(true),
Ok(response) => {
let undeleted_instance_ids =
retained_requested_instance_ids(response, &running_web_instance_ids);
if undeleted_instance_ids.is_empty() {
cache
.runtime_configs
.forget_many(delete_instance_ids.iter());
} else {
tracing::warn!(
user_id = ?round.user_id,
machine_id = ?round.machine_id,
instance_ids = ?undeleted_instance_ids,
"Managed config Patch deletion was retained by the runtime"
);
outcome.record_failure(true);
}
}
}
RoundStatus::Ready(outcome)
}
async fn begin_managed_runtime_mutation(
session_data: &std::sync::Weak<RwLock<SessionData>>,
round: &ReconcileRound,
mutation_fence: &mut RuntimeMutationFence,
) -> bool {
let Some(data) = session_data.upgrade() else {
return false;
};
let mut data = data.write().await;
if !SessionRpcService::runtime_heartbeat_is_current_locked(&data, &round.req)
|| data.runtime_config_epoch != round.runtime_config_epoch
{
return false;
}
if !mutation_fence.started {
data.applied_config_revision = None;
data.pending_managed_config_delta = None;
mutation_fence.started = true;
}
true
}
fn retained_requested_instance_ids(
response: DeleteNetworkInstanceResponse,
requested_instance_ids: &HashSet<String>,
) -> HashSet<String> {
response
.remain_inst_ids
.into_iter()
.map(|instance_id| uuid::Uuid::from(instance_id).to_string())
.filter(|instance_id| requested_instance_ids.contains(instance_id))
.collect()
}
async fn reconcile_desired_runtime_configs(
context: &ReconcileRoundContext<'_>,
rpc_client: &mut SessionRpcClient,
config_client: &mut SessionConfigClient,
cache: &mut ReconcileCache,
mutation_fence: &mut RuntimeMutationFence,
) -> ReconcileOutcome {
let session_data = context.session_data;
let round = context.round;
let mut outcome = ReconcileOutcome::default();
// After stale web-owned instances are removed, start every enabled
// config that the latest heartbeat did not report as running. When
// a managed config revision is pending, also reconcile running
// web-owned configs before reporting that revision as applied.
for config in &round.local_configs {
let source = PersistedConfigSource::from_db(&config.source);
let is_running = round.running_inst_ids.contains(&config.network_instance_id);
let should_reconcile_running_web_config = is_running
&& round.should_apply_runtime_revision
&& source == PersistedConfigSource::Web;
if is_running && !should_reconcile_running_web_config {
continue;
}
let desired_config = match serde_json::from_str::<NetworkConfig>(&config.network_config) {
Ok(cfg) => cfg,
Err(e) => {
tracing::error!(
user_id = ?round.user_id,
machine_id = ?round.machine_id,
instance_id = %config.network_instance_id,
"Failed to deserialize network config, skipping: {:?}",
e
);
if source == PersistedConfigSource::Web {
cache.runtime_configs.forget(&config.network_instance_id);
}
outcome.record_failure(source == PersistedConfigSource::Web);
continue;
}
};
let action_result = if should_reconcile_running_web_config {
reconcile_running_web_config(
context,
rpc_client,
config_client,
config,
desired_config,
&mut cache.runtime_configs,
mutation_fence,
)
.await
} else {
if source == PersistedConfigSource::Web {
cache.runtime_configs.forget(&config.network_instance_id);
}
let action_result = run_missing_network_config(
session_data,
rpc_client,
round,
config,
desired_config.clone(),
mutation_fence,
)
.await;
if matches!(action_result, ConfigActionResult::Success)
&& source == PersistedConfigSource::Web
{
if let Err(e) = remember_web_runtime_config_after_run(
rpc_client,
&config.network_instance_id,
&desired_config,
&mut cache.runtime_configs,
)
.await
{
tracing::error!(
user_id = ?round.user_id,
machine_id = ?round.machine_id,
instance_id = %config.network_instance_id,
"Failed to cache runtime config after run: {:?}",
e
);
ConfigActionResult::Failed
} else {
action_result
}
} else {
action_result
}
};
match action_result {
ConfigActionResult::Success => {}
ConfigActionResult::Failed => {
if source == PersistedConfigSource::Web {
cache.runtime_configs.forget(&config.network_instance_id);
}
outcome.record_failure(source == PersistedConfigSource::Web)
}
ConfigActionResult::StopRound => {
if source == PersistedConfigSource::Web {
cache.runtime_configs.forget(&config.network_instance_id);
}
outcome.record_failure(source == PersistedConfigSource::Web);
break;
}
}
}
outcome
}
async fn reconcile_running_web_config(
context: &ReconcileRoundContext<'_>,
rpc_client: &mut SessionRpcClient,
config_client: &mut SessionConfigClient,
config: &crate::db::entity::user_running_network_configs::Model,
desired_config: NetworkConfig,
runtime_config_cache: &mut SessionRuntimeConfigCache,
mutation_fence: &mut RuntimeMutationFence,
) -> ConfigActionResult {
let session_data = context.session_data;
let round = context.round;
if !SessionRpcService::runtime_heartbeat_is_current(session_data, &round.req).await {
tracing::debug!(
machine_id = ?round.machine_id,
instance_id = %config.network_instance_id,
"skip runtime reconcile because webhook session is no longer current"
);
return ConfigActionResult::StopRound;
}
let ret = async {
let action =
match runtime_config_cache.plan(&config.network_instance_id, desired_config.clone())? {
Some(action) => action,
None => {
runtime_reconcile::prepare_web_source_runtime_reconcile(
&mut *rpc_client,
&config.network_instance_id,
desired_config.clone(),
true,
)
.await?
}
};
if !SessionRpcService::runtime_heartbeat_is_current(session_data, &round.req).await {
anyhow::bail!("webhook session is no longer current before runtime reconcile apply");
}
if !matches!(action, runtime_reconcile::RuntimeReconcileAction::None)
&& !begin_managed_runtime_mutation(session_data, round, mutation_fence).await
{
anyhow::bail!("managed runtime mutation fence is no longer current");
}
let observed_config = runtime_reconcile::apply_web_source_runtime_reconcile(
&mut *rpc_client,
&mut *config_client,
&config.network_instance_id,
desired_config.clone(),
action,
)
.await?;
runtime_config_cache.remember(&config.network_instance_id, observed_config);
Ok::<(), anyhow::Error>(())
}
.await;
tracing::info!(
user_id = ?round.user_id,
instance_id = %config.network_instance_id,
"Reconcile running web-source network instance: {:?}, user_token: {:?}",
ret,
round.req.user_token
);
if ret.is_ok() {
ConfigActionResult::Success
} else {
runtime_config_cache.forget(&config.network_instance_id);
ConfigActionResult::Failed
}
}
async fn run_missing_network_config(
session_data: &std::sync::Weak<RwLock<SessionData>>,
rpc_client: &mut SessionRpcClient,
round: &ReconcileRound,
config: &crate::db::entity::user_running_network_configs::Model,
desired_config: NetworkConfig,
mutation_fence: &mut RuntimeMutationFence,
) -> ConfigActionResult {
if !SessionRpcService::runtime_heartbeat_is_current(session_data, &round.req).await {
tracing::debug!(
machine_id = ?round.machine_id,
instance_id = %config.network_instance_id,
"skip run network instance because webhook session is no longer current"
);
return ConfigActionResult::StopRound;
}
let source = PersistedConfigSource::from_db(&config.source);
if source == PersistedConfigSource::Web
&& !begin_managed_runtime_mutation(session_data, round, mutation_fence).await
{
tracing::debug!(
machine_id = ?round.machine_id,
instance_id = %config.network_instance_id,
"skip run network instance because the managed runtime fence is no longer current"
);
return ConfigActionResult::StopRound;
}
let ret = rpc_client
.run_network_instance(
BaseController::default(),
RunNetworkInstanceRequest {
inst_id: Some(config.network_instance_id.clone().into()),
config: Some(desired_config),
overwrite: false,
source: source.auto_run_rpc_source() as i32,
},
)
.await;
tracing::info!(
user_id = ?round.user_id,
"Run network instance: {:?}, user_token: {:?}",
ret,
round.req.user_token
);
if ret.is_ok() {
ConfigActionResult::Success
} else {
ConfigActionResult::Failed
}
}
async fn remember_web_runtime_config_after_run(
rpc_client: &mut SessionRpcClient,
inst_id: &str,
desired_config: &NetworkConfig,
runtime_config_cache: &mut SessionRuntimeConfigCache,
) -> anyhow::Result<()> {
let observed_config = runtime_reconcile::get_runtime_config(rpc_client, inst_id).await?;
remember_if_runtime_matches_desired(
inst_id,
desired_config,
observed_config,
runtime_config_cache,
)
}
fn remember_if_runtime_matches_desired(
inst_id: &str,
desired_config: &NetworkConfig,
observed_config: NetworkConfig,
runtime_config_cache: &mut SessionRuntimeConfigCache,
) -> anyhow::Result<()> {
let action = runtime_reconcile::prepare_web_source_runtime_reconcile_from_current(
&observed_config,
desired_config.clone(),
)?;
if !matches!(action, runtime_reconcile::RuntimeReconcileAction::None) {
anyhow::bail!("runtime config still differs after managed run");
}
runtime_config_cache.remember(inst_id, observed_config);
Ok(())
}
async fn mark_config_revision_applied_if_current(
session_data: &std::sync::Weak<RwLock<SessionData>>,
storage: &StorageInner,
round: &ReconcileRound,
outcome: &ReconcileOutcome,
mutation_fence: &RuntimeMutationFence,
) -> RoundStatus<()> {
if outcome.managed_revision_failed
|| (!round.should_apply_runtime_revision && !mutation_fence.started)
{
return RoundStatus::Ready(());
}
let current_target_config_revision = match storage
.db
.get_managed_config_revision((round.user_id, round.machine_id))
.await
{
Ok(revision) => revision,
Err(e) => {
tracing::error!("Failed to verify managed config revision, error: {:?}", e);
return RoundStatus::Stop;
}
};
if current_target_config_revision != round.target_config_revision {
return RoundStatus::Ready(());
}
let Some(data) = session_data.upgrade() else {
return RoundStatus::Stop;
};
let notify = {
let mut data = data.write().await;
if !SessionRpcService::runtime_heartbeat_is_current_locked(&data, &round.req) {
return RoundStatus::Ready(());
}
if data.runtime_config_epoch != round.runtime_config_epoch {
return RoundStatus::Ready(());
}
record_applied_config_revision(&mut data, round.target_config_revision.clone())
};
if let Some(notify) = notify {
notify.notify_one();
}
RoundStatus::Ready(())
}
fn record_applied_config_revision(
data: &mut SessionData,
revision: Option<String>,
) -> Option<std::sync::Arc<tokio::sync::Notify>> {
let changed = data.applied_config_revision != revision;
data.applied_config_revision = revision;
data.pending_managed_config_delta = None;
changed.then(|| SessionRpcService::mark_webhook_validation_dirty_locked(data))
}
#[cfg(test)]
mod tests {
use easytier::proto::api::manage::{NetworkingMethod, PortForwardConfig};
use super::*;
fn config_with_port_forwards(port_forwards: Vec<PortForwardConfig>) -> NetworkConfig {
NetworkConfig {
instance_id: Some("11111111-1111-1111-1111-111111111111".to_string()),
dhcp: Some(true),
network_name: Some("managed".to_string()),
network_secret: Some("secret".to_string()),
networking_method: Some(NetworkingMethod::Manual as i32),
port_forwards,
..Default::default()
}
}
fn port_forward(bind_port: u32, dst_port: u32) -> PortForwardConfig {
PortForwardConfig {
bind_ip: "127.0.0.1".to_string(),
bind_port,
dst_ip: "10.144.0.1".to_string(),
dst_port,
proto: "tcp".to_string(),
}
}
#[tokio::test]
async fn newly_applied_revision_wakes_webhook_validation() {
let storage =
crate::client_manager::storage::Storage::new(crate::db::Db::memory_db().await);
let mut data = SessionData::new(
storage.weak_ref(),
url::Url::parse("http://127.0.0.1").unwrap(),
None,
std::sync::Arc::new(crate::FeatureFlags::default()),
std::sync::Arc::new(crate::webhook::WebhookConfig::new(
None, None, None, None, None,
)),
);
let notify = record_applied_config_revision(&mut data, Some("rev-applied".to_string()))
.expect("new applied revision should wake validation");
assert_eq!(data.applied_config_revision.as_deref(), Some("rev-applied"));
assert!(data.webhook_validation_dirty);
notify.notify_one();
tokio::time::timeout(std::time::Duration::from_millis(100), notify.notified())
.await
.expect("validation worker was not notified");
}
#[tokio::test]
async fn unchanged_applied_revision_does_not_add_validation_work() {
let storage =
crate::client_manager::storage::Storage::new(crate::db::Db::memory_db().await);
let mut data = SessionData::new(
storage.weak_ref(),
url::Url::parse("http://127.0.0.1").unwrap(),
None,
std::sync::Arc::new(crate::FeatureFlags::default()),
std::sync::Arc::new(crate::webhook::WebhookConfig::new(
None, None, None, None, None,
)),
);
data.applied_config_revision = Some("rev-applied".to_string());
assert!(
record_applied_config_revision(&mut data, Some("rev-applied".to_string())).is_none()
);
assert!(!data.webhook_validation_dirty);
}
#[test]
fn patch_delete_requires_runtime_to_remove_every_requested_instance() {
let deleted_id = uuid::Uuid::new_v4();
let requested = HashSet::from([deleted_id.to_string()]);
assert_eq!(
retained_requested_instance_ids(
DeleteNetworkInstanceResponse {
remain_inst_ids: vec![deleted_id.into()],
},
&requested,
),
requested
);
assert!(
retained_requested_instance_ids(
DeleteNetworkInstanceResponse {
remain_inst_ids: Vec::new(),
},
&requested,
)
.is_empty()
);
}
#[tokio::test]
async fn managed_runtime_mutation_clears_old_applied_revision_before_side_effects() {
let machine_id = uuid::Uuid::new_v4();
let req = HeartbeatRequest {
user_token: "token".to_string(),
machine_id: Some(machine_id.into()),
..Default::default()
};
let storage =
crate::client_manager::storage::Storage::new(crate::db::Db::memory_db().await);
let client_url = url::Url::parse("http://127.0.0.1").unwrap();
let mut data = SessionData::new(
storage.weak_ref(),
client_url.clone(),
None,
std::sync::Arc::new(crate::FeatureFlags::default()),
std::sync::Arc::new(crate::webhook::WebhookConfig::new(
None, None, None, None, None,
)),
);
data.storage_token = Some(crate::client_manager::storage::StorageToken {
token: req.user_token.clone(),
client_url,
machine_id,
user_id: 7,
});
data.req = Some(req.clone());
data.auth_state = super::super::SessionAuthState::Authorized;
data.applied_config_revision = Some("rev-a".to_string());
data.pending_managed_config_delta = Some(revision_delta("rev-a", "rev-b"));
data.runtime_config_epoch = 11;
let session_data = std::sync::Arc::new(RwLock::new(data));
let round = ReconcileRound {
req,
machine_id,
user_id: 7,
running_inst_ids: HashSet::new(),
local_configs: Vec::new(),
target_config_revision: Some("rev-b".to_string()),
should_apply_runtime_revision: true,
scope: ReconcileScope::Full,
runtime_config_epoch: 11,
};
let mut mutation_fence = RuntimeMutationFence::default();
assert!(
begin_managed_runtime_mutation(
&std::sync::Arc::downgrade(&session_data),
&round,
&mut mutation_fence,
)
.await
);
let data = session_data.read().await;
assert!(mutation_fence.started);
assert_eq!(data.applied_config_revision, None);
assert_eq!(data.pending_managed_config_delta, None);
assert_eq!(data.runtime_config_epoch, 11);
}
fn revision_delta(base: &str, target: &str) -> ManagedConfigRevisionDelta {
ManagedConfigRevisionDelta {
expected_revision: base.to_string(),
target_revision: target.to_string(),
upsert_instance_ids: HashSet::from(["upsert".to_string()]),
delete_instance_ids: HashSet::from(["delete".to_string()]),
}
}
#[test]
fn exact_revision_delta_selects_targeted_reconcile() {
let delta = revision_delta("rev-1", "rev-2");
assert_eq!(
select_reconcile_scope(Some("rev-1"), Some("rev-2"), Some(&delta)),
ReconcileScope::Patch {
upsert_instance_ids: HashSet::from(["upsert".to_string()]),
delete_instance_ids: HashSet::from(["delete".to_string()]),
}
);
}
#[test]
fn revision_gap_uses_full_reconcile() {
let delta = revision_delta("rev-1", "rev-2");
assert_eq!(
select_reconcile_scope(Some("older"), Some("rev-2"), Some(&delta)),
ReconcileScope::Full
);
assert_eq!(
select_reconcile_scope(Some("rev-1"), Some("newer"), Some(&delta)),
ReconcileScope::Full
);
assert_eq!(
select_reconcile_scope(Some("rev-1"), Some("rev-2"), None),
ReconcileScope::Full
);
}
#[test]
fn session_runtime_config_cache_misses_unknown_instance() {
let cache = SessionRuntimeConfigCache::default();
let action = cache
.plan("missing", config_with_port_forwards(Vec::new()))
.expect("prepare action");
assert!(action.is_none());
}
#[test]
fn session_runtime_config_cache_skips_matching_observed_config() {
let mut cache = SessionRuntimeConfigCache::default();
let config = config_with_port_forwards(vec![port_forward(23000, 5174)]);
cache.remember("managed", config.clone());
let action = cache
.plan("managed", config)
.expect("prepare action")
.expect("cached action");
assert!(matches!(
action,
runtime_reconcile::RuntimeReconcileAction::None
));
}
#[test]
fn session_runtime_config_cache_plans_patch_from_observed_config() {
let mut cache = SessionRuntimeConfigCache::default();
let current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
cache.remember("managed", current);
let action = cache
.plan("managed", desired)
.expect("prepare action")
.expect("cached action");
let runtime_reconcile::RuntimeReconcileAction::Patch(patch) = action else {
panic!("expected cached runtime config to produce hot patch");
};
assert_eq!(patch.port_forwards.len(), 1);
}
#[test]
fn session_runtime_config_cache_retain_desired_removes_stale_entries() {
let mut cache = SessionRuntimeConfigCache::default();
let config = config_with_port_forwards(Vec::new());
cache.remember("keep", config.clone());
cache.remember("drop", config);
cache.retain_desired(&HashSet::from(["keep".to_string()]));
assert!(cache.entries.contains_key("keep"));
assert!(!cache.entries.contains_key("drop"));
}
#[test]
fn session_runtime_config_cache_forget_removes_observed_config() {
let mut cache = SessionRuntimeConfigCache::default();
let config = config_with_port_forwards(Vec::new());
cache.remember("managed", config.clone());
cache.forget("managed");
let action = cache
.plan("managed", config)
.expect("prepare action after remove");
assert!(action.is_none());
}
#[test]
fn missing_run_remembers_observed_config_when_it_matches_desired() {
let mut cache = SessionRuntimeConfigCache::default();
let config = config_with_port_forwards(vec![port_forward(23000, 5174)]);
remember_if_runtime_matches_desired("managed", &config, config.clone(), &mut cache)
.expect("remember observed config after run");
let action = cache
.plan("managed", config)
.expect("prepare action after run")
.expect("cached action");
assert!(matches!(
action,
runtime_reconcile::RuntimeReconcileAction::None
));
}
#[test]
fn missing_run_does_not_remember_observed_config_that_still_differs() {
let mut cache = SessionRuntimeConfigCache::default();
let current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
let err = remember_if_runtime_matches_desired("managed", &desired, current, &mut cache)
.expect_err("expected stale run result not to be cached");
assert!(
err.to_string()
.contains("runtime config still differs after managed run")
);
let action = cache
.plan("managed", desired)
.expect("prepare action after stale run result");
assert!(action.is_none());
}
}