fix(config): preserve and patch empty manual route mode (#2709)

* fix(config): preserve empty manual route mode in API conversion

Treat present TOML routes as manual mode even when the list is empty.
Both Core readback and Web normalization must distinguish routes=[]
from automatic discovery; otherwise Web can skip a required overwrite
and Core can report a different mode from its running configuration.

Cover API/TOML round trips and receiver reconcile mode transitions.
Verify default-feature tests, clippy, formatting and real system routes
in three isolated Linux network namespaces.

* fix(config): patch manual route mode and verify legacy readback

Add an explicit manual-route mode to route patches and advertise typed
readback support through GetConfig. Apply the mode and route edits in the
existing route transaction without restarting the instance; keep released
callers that omit the new field on their existing route semantics.

Web gates route patches on the observed capability. For old clients with
ambiguous empty routes, recover the actual mode from GetConfig TOML or
instance-scoped ShowNodeInfo before acknowledging application. Supporting
clients use typed readback directly. Keep capability evidence in the
existing session cache and preserve its invalidation rules.

Cover mode transitions, persistence failure, stable peer identity, legacy
readback, compact clients, failed observations and cache capability gates.
Document the runtime contract and compatibility boundary.

* refactor(web): advertise route patch support in heartbeats

Declare manual-route patch and typed readback support on the WebClient
heartbeat alongside existing client capabilities. Select route updates
and legacy readback from the current authenticated Session heartbeat,
including when reconciliation reuses an observed configuration.

Remove the unreleased GetConfig capability field and per-instance
capability wrapper. Keep only observed configuration in the existing
cache, and retain strict application confirmation after full Run.

Cover the client declaration, legacy default and capability changes
against the same cached configuration. Update the runtime contract.

---------

Co-authored-by: Codex <codex@users.noreply.github.com>
This commit is contained in:
KKRainbowandCodex authored and GitHub committed 2026-10-16 00:47:43 +08:00
1 parent 7588062f7e
commit 99db92a3ae
14 files changed
+702 -132

No files matched your search

Generated
+1
View File
@@ -2942,6 +2942,7 @@ dependencies = [
"thunk-rs",
"tokio",
"tokio-util",
"toml 1.1.6+spec-1.1.0",
"tower",
"tower-http 0.7.1",
"tower-sessions",
+22
View File
@@ -502,6 +502,28 @@ behavior:
- gateway and connectivity settings;
- runtime configuration patches.
Manual routing retains three distinct states: absent routes select automatic
routing, a present empty list installs no remote subnet routes, and a present
nonempty list selects explicit routes. `NetworkConfig.enable_manual_routes`
preserves this distinction on readback. `InstanceConfigPatch` applies its
optional `enable_manual_routes` together with route edits in one route
sub-patch: true retains an empty manual list and false restores automatic
routing. Omitting the mode preserves released RoutePatch behavior, including
returning to automatic routing when the edited list becomes empty. This does
not change the ordered partial-commit contract of a multi-field patch.
`HeartbeatRequest.support_manual_routes_patch` advertises this complete route
contract, including typed empty-manual readback, on the WebClient Session.
The Web controller uses the current authenticated heartbeat to choose route
patches; observed instance configuration caches contain no capability state.
Released clients omit the flag and still receive
full configuration replacements for route changes. For their ambiguous empty
readback, Web reads the existing `GetConfig.toml_config`, or, when absent, the
instance-scoped `ShowNodeInfo` TOML used by 2.6.4. It reads only the instance ID
and route field, requires a matching instance, and confirms the actual mode
before recording application. Supporting clients use typed readback directly;
compact clients with raw GetConfig need no Peer management RPC.
Host persistence is an effect following a successful core transaction. A Host
Adapter must not call back into an instance to obtain a hidden configuration
snapshot while core is applying an operation.
+1 -3
View File
@@ -107,9 +107,7 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
});
}
if let Some(routes) = config.get_routes()
&& !routes.is_empty()
{
if let Some(routes) = config.get_routes() {
result.enable_manual_routes = Some(true);
result.routes = routes.iter().map(ToString::to_string).collect();
}
+40 -3
View File
@@ -653,9 +653,7 @@ impl NetworkConfigExt for NetworkConfig {
});
}
if let Some(routes) = config.get_routes()
&& !routes.is_empty()
{
if let Some(routes) = config.get_routes() {
result.enable_manual_routes = Some(true);
result.routes = routes.iter().map(|r| r.to_string()).collect();
}
@@ -718,6 +716,45 @@ mod tests {
}
}
#[test]
fn manual_routes_preserve_empty_and_automatic_modes() {
for routes in [Vec::new(), vec!["10.20.0.0/24".to_owned()]] {
for enabled in [None, Some(false), Some(true)] {
let mut input = standalone_config();
input.enable_manual_routes = enabled;
input.routes = routes.clone();
let config = input.gen_config().unwrap();
let expected = if enabled == Some(true) {
Some(routes.iter().map(|route| route.parse().unwrap()).collect())
} else {
None
};
assert_eq!(config.get_routes(), expected);
let readback = NetworkConfig::new_from_config(config.clone()).unwrap();
assert_eq!(
readback.enable_manual_routes,
enabled.filter(|value| *value)
);
assert_eq!(readback.gen_config().unwrap().get_routes(), expected);
assert_eq!(
TomlConfigLoader::new_from_str(&config.dump())
.unwrap()
.get_routes(),
expected
);
#[cfg(feature = "web-client")]
{
let projected = super::super::api::network_config_from_toml(&config);
assert_eq!(
projected.enable_manual_routes,
readback.enable_manual_routes
);
assert_eq!(projected.routes, readback.routes);
}
}
}
}
#[test]
fn dhcp_cidr_survives_api_and_toml_round_trips() {
let mut input = standalone_config();
+82
View File
@@ -884,6 +884,88 @@ network_secret = "network-secret"
assert!(error.to_string().contains("instance is not ready"));
}
#[cfg(feature = "management")]
#[tokio::test]
async fn manual_route_patch_preserves_empty_mode_without_restart() {
use easytier_proto::api::config::{ConfigPatchAction, InstanceConfigPatch, RoutePatch};
let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16);
let config = TomlConfig::new_from_str(
"[network_identity]\nnetwork_name = 'manual-routes'\nnetwork_secret = 'secret'",
)
.unwrap();
let instance =
CoreInstance::from_toml(config, adapters(None, Arc::new(packet_sink))).unwrap();
instance.start().await.unwrap();
let peer_id = instance.peer_id();
let persistence = RecordingConfigPatchPersistence {
writes: std::sync::Mutex::new(Vec::new()),
fail: AtomicBool::new(true),
};
let route: cidr::Ipv4Cidr = "10.20.0.0/24".parse().unwrap();
let add = RoutePatch {
action: ConfigPatchAction::Add as i32,
cidr: Some("10.20.0.0/24".parse::<cidr::Ipv4Inet>().unwrap().into()),
};
let clear = RoutePatch {
action: ConfigPatchAction::Clear as i32,
..Default::default()
};
let failed = crate::management::apply_config_patch(
&instance,
InstanceConfigPatch {
enable_manual_routes: Some(true),
routes: vec![add],
..Default::default()
},
Some(&persistence),
)
.await;
assert!(failed.is_err());
assert_eq!(instance.toml_config().unwrap().get_routes(), None);
assert_eq!(
instance.runtime_config.snapshot().services.manual_routes,
None
);
assert!(persistence.writes.lock().unwrap().is_empty());
persistence.fail.store(false, Ordering::Relaxed);
for (mode, patches, expected) in [
(Some(true), Vec::new(), Some(Vec::new())),
(Some(true), vec![add], Some(vec![route])),
(Some(true), vec![clear], Some(Vec::new())),
(Some(false), Vec::new(), None),
// Released callers that omit the new mode keep their old behavior.
(None, vec![add], Some(vec![route])),
(None, vec![clear], None),
] {
crate::management::apply_config_patch(
&instance,
InstanceConfigPatch {
enable_manual_routes: mode,
routes: patches,
..Default::default()
},
Some(&persistence),
)
.await
.unwrap();
assert_eq!(instance.toml_config().unwrap().get_routes(), expected);
assert_eq!(
instance.runtime_config.snapshot().services.manual_routes,
expected
.as_ref()
.map(|routes| routes.iter().copied().collect())
);
let writes = persistence.writes.lock().unwrap();
let persisted = TomlConfig::new_from_str(writes.last().unwrap()).unwrap();
assert_eq!(persisted.get_routes(), expected);
assert_eq!(instance.peer_id(), peer_id);
assert_eq!(instance.state(), CoreInstanceState::Running);
}
instance.stop().await;
}
#[cfg(feature = "management")]
#[tokio::test]
async fn managed_credential_patch_is_durable_atomic_and_does_not_restart() {
@@ -63,7 +63,7 @@ where
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
result?;
let result = patch_routes(&candidate, patch.routes);
let result = patch_routes(&candidate, patch.routes, patch.enable_manual_routes);
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
result?;
@@ -455,15 +455,25 @@ fn patch_proxy_networks(
Ok(())
}
fn patch_routes(config: &TomlConfig, patches: Vec<RoutePatch>) -> anyhow::Result<()> {
if patches.is_empty() {
fn patch_routes(
config: &TomlConfig,
patches: Vec<RoutePatch>,
enable_manual_routes: Option<bool>,
) -> anyhow::Result<()> {
if patches.is_empty() && enable_manual_routes.is_none() {
return Ok(());
}
let mut current = config.get_routes().unwrap_or_default();
let patches = patches.into_iter().map(Into::into).collect::<Vec<_>>();
trace_patchables(&patches);
config::patch_vec(&mut current, patches);
config.set_routes((!current.is_empty()).then_some(current));
// Mode and list form one configuration update. An explicit mode supports
// empty manual routes; callers omitting it retain the released semantics.
config.set_routes(
enable_manual_routes
.unwrap_or(!current.is_empty())
.then_some(current),
);
Ok(())
}
@@ -435,6 +435,7 @@ fn build_heartbeat_request(
.map(Into::into)
.collect(),
support_heartbeat_policy: true,
support_manual_routes_patch: true,
}
}
@@ -748,6 +749,8 @@ mod tests {
vec![failed]
);
assert!(request.support_heartbeat_policy);
assert!(request.support_manual_routes_patch);
assert!(!HeartbeatRequest::default().support_manual_routes_patch);
}
#[test]
+3
View File
@@ -31,6 +31,9 @@ message InstanceConfigPatch {
repeated VpnPortalClientPatch vpn_portal_clients = 15;
api.manage.ManagedCredentialSet managed_credentials = 16;
optional bool prefer_peer_relay = 17;
// Applies with routes: true preserves an empty manual list; false restores
// automatic routes. Omission preserves legacy RoutePatch behavior.
optional bool enable_manual_routes = 18;
}
message VpnPortalClientPatch {
+3
View File
@@ -24,6 +24,9 @@ message HeartbeatRequest {
bool support_config_source = 9;
repeated common.UUID failed_network_instances = 10;
bool support_heartbeat_policy = 11;
// Supports explicit manual mode patches and typed empty-manual readback.
// Older peers silently ignore unknown patch fields; missing means false.
bool support_manual_routes_patch = 12;
}
message HeartbeatResponse {
+1
View File
@@ -67,6 +67,7 @@ clap = { workspace = true, features = [
] }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
toml.workspace = true
uuid = { workspace = true, features = [
"v4",
"fast-rng",
@@ -10,14 +10,14 @@ use easytier::{
acl::Acl,
api::{
config::{
AclPatch, ConfigPatchAction, InstanceConfigPatch, PatchConfigRequest,
PortForwardPatch, ProxyNetworkPatch, VpnPortalClientPatch,
AclPatch, ConfigPatchAction, GetConfigRequest, InstanceConfigPatch,
PatchConfigRequest, PortForwardPatch, ProxyNetworkPatch, RoutePatch,
VpnPortalClientPatch,
},
instance::{InstanceIdentifier, instance_identifier},
instance::{InstanceIdentifier, ShowNodeInfoRequest, instance_identifier},
manage::{
ConfigSource as RpcConfigSource, GetNetworkInstanceConfigRequest,
ManagedCredentialConfig, ManagedCredentialSet, NetworkConfig,
RunNetworkInstanceRequest,
ConfigSource as RpcConfigSource, ManagedCredentialConfig, ManagedCredentialSet,
NetworkConfig, RunNetworkInstanceRequest,
},
},
common::{CompressionAlgoPb, FlagsPatch, Ipv4Inet as RpcIpv4Inet},
@@ -26,7 +26,7 @@ use easytier::{
};
use optionize::Retain;
use super::session::{SessionConfigClient, SessionRpcClient};
use super::session::{SessionConfigClient, SessionPeerClient, SessionRpcClient};
pub(super) enum RuntimeReconcileAction {
Unchanged(Box<NetworkConfig>),
@@ -284,6 +284,7 @@ fn is_automatic_windows_dev_name(dev_name: &str) -> bool {
fn web_source_runtime_patch(
current: &NetworkConfig,
desired: &NetworkConfig,
support_manual_routes_patch: bool,
) -> anyhow::Result<Option<InstanceConfigPatch>> {
let mut current_base = hot_patch_base(current)?;
let mut desired_base = hot_patch_base(desired)?;
@@ -299,11 +300,30 @@ fn web_source_runtime_patch(
}
let current_hostname = current_base.hostname.take().unwrap_or_default();
let desired_hostname = desired_base.hostname.take().unwrap_or_default();
let mut patch = InstanceConfigPatch::default();
if support_manual_routes_patch {
let current_mode = current_base.enable_manual_routes.take().unwrap_or_default();
let desired_mode = desired_base.enable_manual_routes.take().unwrap_or_default();
let current_routes = std::mem::take(&mut current_base.routes);
let desired_routes = std::mem::take(&mut desired_base.routes);
if current_mode != desired_mode || current_routes != desired_routes {
patch.enable_manual_routes = Some(desired_mode);
patch.routes.push(RoutePatch {
action: ConfigPatchAction::Clear as i32,
..Default::default()
});
for route in desired_routes {
patch.routes.push(RoutePatch {
action: ConfigPatchAction::Add as i32,
cidr: Some(parse_rpc_ipv4_inet(&route)?),
});
}
}
}
if current_base != desired_base {
return Ok(None);
}
let mut patch = InstanceConfigPatch::default();
if desired.hostname.is_some() && current_hostname != desired_hostname {
patch.hostname = Some(desired_hostname);
}
@@ -397,7 +417,9 @@ pub(super) fn ensure_runtime_config_converged(
desired: &NetworkConfig,
hostname_applied: bool,
) -> anyhow::Result<()> {
let patch = web_source_runtime_patch(current, desired)?;
// Capability changes how a difference is applied, never whether the
// observed configuration matches the desired configuration.
let patch = web_source_runtime_patch(current, desired, true)?;
match patch {
Some(mut patch) => {
// Release 2.6.4 omits a configured hostname when it equals
@@ -438,26 +460,83 @@ async fn run_web_source_instance(
}
pub(super) async fn get_runtime_config(
rpc_client: &mut SessionRpcClient,
config_client: &mut SessionConfigClient,
peer_client: &mut SessionPeerClient,
inst_id: &str,
support_manual_routes_patch: bool,
) -> anyhow::Result<NetworkConfig> {
rpc_client
.get_network_instance_config(
let response = config_client
.get_config(
BaseController::default(),
GetNetworkInstanceConfigRequest {
inst_id: Some(inst_id.to_string().into()),
GetConfigRequest {
instance: Some(instance_identifier(inst_id)?),
},
)
.await?
.await?;
let mut config = response
.config
.ok_or_else(|| anyhow::anyhow!("runtime returned empty config for {inst_id}"))
.ok_or_else(|| anyhow::anyhow!("runtime returned empty config for {inst_id}"))?;
if !support_manual_routes_patch && config.routes.is_empty() {
// Released 2.6.4 omits the manual-mode bit for routes=[]. Its
// instance-scoped ShowNodeInfo retains the original TOML. Clients
// with GetConfig.toml_config need no Peer RPC; supporting clients
// above already preserve the mode in the typed configuration.
let raw = if response.toml_config.is_empty() {
peer_client
.show_node_info(
BaseController::default(),
ShowNodeInfoRequest {
instance: Some(instance_identifier(inst_id)?),
},
)
.await?
.node_info
.ok_or_else(|| anyhow::anyhow!("runtime returned no node info for {inst_id}"))?
.config
} else {
response.toml_config
};
restore_manual_routes_from_toml(&mut config, inst_id, &raw)?;
}
Ok(config)
}
fn restore_manual_routes_from_toml(
config: &mut NetworkConfig,
inst_id: &str,
raw: &str,
) -> anyhow::Result<()> {
// Read only this compatibility field. Parsing a released client's entire
// TOML with the current schema would reject unrelated historical fields.
#[derive(serde::Deserialize)]
struct Routes {
instance_id: uuid::Uuid,
routes: Option<Vec<cidr::Ipv4Cidr>>,
}
// Parser diagnostics can include credential-bearing TOML source lines.
let observed: Routes = toml::from_str(raw)
.map_err(|_| anyhow::anyhow!("runtime returned invalid route TOML for {inst_id}"))?;
anyhow::ensure!(
observed.instance_id == inst_id.parse::<uuid::Uuid>()?,
"runtime returned route TOML for a different instance"
);
config.enable_manual_routes = observed.routes.as_ref().map(|_| true);
config.routes = observed
.routes
.unwrap_or_default()
.into_iter()
.map(|route| route.to_string())
.collect();
Ok(())
}
pub(super) async fn prepare_web_source_runtime_reconcile(
rpc_client: &mut SessionRpcClient,
config_client: &mut SessionConfigClient,
peer_client: &mut SessionPeerClient,
inst_id: &str,
desired_config: NetworkConfig,
is_running: bool,
support_manual_routes_patch: bool,
) -> anyhow::Result<RuntimeReconcileAction> {
if !is_running {
return Ok(RuntimeReconcileAction::Run {
@@ -466,16 +545,29 @@ pub(super) async fn prepare_web_source_runtime_reconcile(
});
}
let current_config = get_runtime_config(rpc_client, inst_id).await?;
let current_config = get_runtime_config(
config_client,
peer_client,
inst_id,
support_manual_routes_patch,
)
.await?;
prepare_web_source_runtime_reconcile_from_current(&current_config, desired_config)
prepare_web_source_runtime_reconcile_from_current(
&current_config,
desired_config,
support_manual_routes_patch,
)
}
pub(super) fn prepare_web_source_runtime_reconcile_from_current(
current_config: &NetworkConfig,
desired_config: NetworkConfig,
support_manual_routes_patch: bool,
) -> anyhow::Result<RuntimeReconcileAction> {
let Some(patch) = web_source_runtime_patch(current_config, &desired_config)? else {
let Some(patch) =
web_source_runtime_patch(current_config, &desired_config, support_manual_routes_patch)?
else {
let mut run_config = desired_config;
if run_config.hostname.is_none() {
run_config.hostname = current_config.hostname.clone();
@@ -497,16 +589,24 @@ pub(super) fn prepare_web_source_runtime_reconcile_from_current(
pub(super) async fn apply_web_source_runtime_reconcile(
rpc_client: &mut SessionRpcClient,
config_client: &mut SessionConfigClient,
peer_client: &mut SessionPeerClient,
inst_id: &str,
desired_config: NetworkConfig,
action: RuntimeReconcileAction,
support_manual_routes_patch: bool,
) -> anyhow::Result<NetworkConfig> {
match action {
RuntimeReconcileAction::Unchanged(current_config) => Ok(*current_config),
RuntimeReconcileAction::Run { config, overwrite } => {
let hostname_applied = config.hostname.is_some();
run_web_source_instance(rpc_client, inst_id, *config, overwrite).await?;
let mut current_config = get_runtime_config(rpc_client, inst_id).await?;
let mut current_config = get_runtime_config(
config_client,
peer_client,
inst_id,
support_manual_routes_patch,
)
.await?;
ensure_runtime_config_converged(&current_config, &desired_config, hostname_applied)?;
restore_omitted_hostname(&mut current_config, &desired_config, hostname_applied);
Ok(current_config)
@@ -522,7 +622,13 @@ pub(super) async fn apply_web_source_runtime_reconcile(
},
)
.await?;
let mut current_config = get_runtime_config(rpc_client, inst_id).await?;
let mut current_config = get_runtime_config(
config_client,
peer_client,
inst_id,
support_manual_routes_patch,
)
.await?;
ensure_runtime_config_converged(&current_config, &desired_config, hostname_applied)?;
restore_omitted_hostname(&mut current_config, &desired_config, hostname_applied);
Ok(current_config)
@@ -641,7 +747,7 @@ mod tests {
"0.0.0.0:22121",
);
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.unwrap()
.expect("client-only changes must be hot-patchable");
@@ -676,7 +782,7 @@ mod tests {
"0.0.0.0:22121",
);
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.unwrap()
.unwrap();
assert!(patch.vpn_portal_clients.is_empty());
@@ -693,7 +799,7 @@ mod tests {
"0.0.0.0:22122",
);
assert!(
web_source_runtime_patch(&current, &desired)
web_source_runtime_patch(&current, &desired, false)
.unwrap()
.is_none()
);
@@ -710,7 +816,7 @@ mod tests {
.unwrap()
.wireguard_private_key = Some("bm90LXRoZS1zYW1lLWtleQ==".to_owned());
assert!(
web_source_runtime_patch(&current, &different_key)
web_source_runtime_patch(&current, &different_key, false)
.unwrap()
.is_none()
);
@@ -725,12 +831,12 @@ mod tests {
);
assert!(
web_source_runtime_patch(&without_portal, &with_portal)
web_source_runtime_patch(&without_portal, &with_portal, false)
.unwrap()
.is_none()
);
assert!(
web_source_runtime_patch(&with_portal, &without_portal)
web_source_runtime_patch(&with_portal, &without_portal, false)
.unwrap()
.is_none()
);
@@ -738,23 +844,23 @@ mod tests {
let mut disabled = with_portal.clone();
disabled.vpn_portal_config.as_mut().unwrap().enabled = Some(false);
assert!(
web_source_runtime_patch(&with_portal, &disabled)
web_source_runtime_patch(&with_portal, &disabled, false)
.unwrap()
.is_none()
);
assert!(
web_source_runtime_patch(&disabled, &with_portal)
web_source_runtime_patch(&disabled, &with_portal, false)
.unwrap()
.is_none()
);
assert_eq!(
web_source_runtime_patch(&disabled, &disabled).unwrap(),
web_source_runtime_patch(&disabled, &disabled, false).unwrap(),
Some(InstanceConfigPatch::default())
);
let mut edited = disabled.clone();
edited.vpn_portal_config.as_mut().unwrap().clients.clear();
assert!(
web_source_runtime_patch(&disabled, &edited)
web_source_runtime_patch(&disabled, &edited, false)
.unwrap()
.is_none()
);
@@ -774,7 +880,7 @@ mod tests {
let desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.expect("build patch")
.expect("hot patch");
@@ -796,7 +902,7 @@ mod tests {
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
let desired = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.expect("build patch")
.expect("hot patch");
@@ -818,7 +924,7 @@ mod tests {
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23000, 5174)]);
let desired = config_with_port_forwards(vec![port_forward(23000, 5174)]);
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.expect("build patch")
.expect("hot patch");
@@ -849,7 +955,7 @@ mod tests {
current.dev_name = Some("et_3_abcd".to_string());
let desired = config_with_port_forwards(Vec::new());
let action = prepare_web_source_runtime_reconcile_from_current(&current, desired)
let action = prepare_web_source_runtime_reconcile_from_current(&current, desired, false)
.expect("prepare reconcile");
assert!(matches!(action, RuntimeReconcileAction::Unchanged(_)));
@@ -862,7 +968,7 @@ mod tests {
let mut desired = config_with_port_forwards(Vec::new());
desired.dev_name = Some(String::new());
let action = prepare_web_source_runtime_reconcile_from_current(&current, desired)
let action = prepare_web_source_runtime_reconcile_from_current(&current, desired, false)
.expect("prepare reconcile");
assert!(matches!(action, RuntimeReconcileAction::Unchanged(_)));
@@ -875,7 +981,7 @@ mod tests {
let mut desired = config_with_port_forwards(Vec::new());
desired.dev_name = Some(String::new());
let action = prepare_web_source_runtime_reconcile_from_current(&current, desired)
let action = prepare_web_source_runtime_reconcile_from_current(&current, desired, false)
.expect("prepare reconcile");
let RuntimeReconcileAction::Run { overwrite, .. } = action else {
panic!("clearing an explicit device name should require a full overwrite");
@@ -891,7 +997,7 @@ mod tests {
let mut desired = config_with_port_forwards(Vec::new());
desired.dev_name = Some("managed-device".to_string());
let action = prepare_web_source_runtime_reconcile_from_current(&current, desired)
let action = prepare_web_source_runtime_reconcile_from_current(&current, desired, false)
.expect("prepare reconcile");
let RuntimeReconcileAction::Run { overwrite, .. } = action else {
panic!("explicit device name should require a full overwrite");
@@ -927,7 +1033,7 @@ mod tests {
desired_port_forward.proto = "TCP".to_string();
let desired = config_with_port_forwards(vec![desired_port_forward]);
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.expect("build patch")
.expect("hot patch");
@@ -942,7 +1048,7 @@ mod tests {
desired.network_secret = Some("new-secret".to_string());
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
let patch = web_source_runtime_patch(&current, &desired, false).expect("build patch");
assert!(patch.is_none());
}
@@ -955,9 +1061,12 @@ mod tests {
unmanaged_desired.hostname = None;
unmanaged_desired.network_secret = Some("new-secret".to_string());
let action =
prepare_web_source_runtime_reconcile_from_current(&current, unmanaged_desired.clone())
.expect("prepare full overwrite");
let action = prepare_web_source_runtime_reconcile_from_current(
&current,
unmanaged_desired.clone(),
false,
)
.expect("prepare full overwrite");
let RuntimeReconcileAction::Run { config, overwrite } = action else {
panic!("non-hot change should require a full overwrite");
};
@@ -967,9 +1076,12 @@ mod tests {
let observed_after_run = *config;
let mut explicit_clear = unmanaged_desired;
explicit_clear.hostname = Some(String::new());
let action =
prepare_web_source_runtime_reconcile_from_current(&observed_after_run, explicit_clear)
.expect("prepare explicit clear");
let action = prepare_web_source_runtime_reconcile_from_current(
&observed_after_run,
explicit_clear,
false,
)
.expect("prepare explicit clear");
let RuntimeReconcileAction::Patch(patch) = action else {
panic!("explicit clear should patch the preserved runtime hostname");
};
@@ -988,7 +1100,7 @@ mod tests {
..Default::default()
}];
let action = prepare_web_source_runtime_reconcile_from_current(&current, desired)
let action = prepare_web_source_runtime_reconcile_from_current(&current, desired, false)
.expect("prepare reconcile");
let RuntimeReconcileAction::Patch(patch) = action else {
panic!("managed credential change must use a hot patch");
@@ -998,6 +1110,248 @@ mod tests {
assert_eq!(managed.entries[0].credential_id, "managed");
}
#[test]
fn empty_manual_routes_require_overwrite_when_switching_modes() {
let automatic = config_with_port_forwards(Vec::new());
let mut empty_manual = automatic.clone();
empty_manual.enable_manual_routes = Some(true);
assert!(empty_manual.routes.is_empty());
for (current, desired) in [(&automatic, &empty_manual), (&empty_manual, &automatic)] {
let action =
prepare_web_source_runtime_reconcile_from_current(current, desired.clone(), false)
.expect("prepare mode change");
assert!(matches!(
action,
RuntimeReconcileAction::Run {
overwrite: true,
..
}
));
}
let action = prepare_web_source_runtime_reconcile_from_current(
&empty_manual,
empty_manual.clone(),
false,
)
.expect("prepare unchanged empty manual routes");
assert!(matches!(action, RuntimeReconcileAction::Unchanged(_)));
}
#[test]
fn manual_route_modes_and_lists_patch_when_supported() {
let automatic = config_with_port_forwards(Vec::new());
let mut empty = automatic.clone();
empty.enable_manual_routes = Some(true);
let mut nonempty = empty.clone();
nonempty.routes = vec!["10.20.0.0/24".to_owned()];
for current in [&automatic, &empty, &nonempty] {
for desired in [&automatic, &empty, &nonempty] {
let action = prepare_web_source_runtime_reconcile_from_current(
current,
desired.clone(),
true,
)
.unwrap();
if current == desired {
assert!(matches!(action, RuntimeReconcileAction::Unchanged(_)));
continue;
}
let RuntimeReconcileAction::Patch(patch) = action else {
panic!("route-only changes must not restart a supporting Core");
};
assert_eq!(
patch.enable_manual_routes,
Some(desired.enable_manual_routes.unwrap_or_default())
);
assert_eq!(patch.routes[0].action, ConfigPatchAction::Clear as i32);
assert_eq!(patch.routes.len(), desired.routes.len() + 1);
for (entry, cidr) in patch.routes.iter().skip(1).zip(&desired.routes) {
assert_eq!(entry.action, ConfigPatchAction::Add as i32);
assert_eq!(
entry.cidr.as_ref(),
Some(&parse_rpc_ipv4_inet(cidr).unwrap())
);
}
assert!(ensure_runtime_config_converged(current, desired, false).is_err());
}
}
}
#[test]
fn raw_route_readback_distinguishes_empty_and_automatic_without_guessing() {
let id = "11111111-1111-1111-1111-111111111111";
let mut config = config_with_port_forwards(Vec::new());
for (routes, expected) in [("routes=[]\n", Some(true)), ("", None)] {
let raw = format!("instance_id='{id}'\n{routes}[historical_field]\nunknown=true");
restore_manual_routes_from_toml(&mut config, id, &raw).unwrap();
assert_eq!(config.enable_manual_routes, expected);
assert!(config.routes.is_empty());
}
for raw in [
String::new(),
format!("instance_id='{id}'\nroutes='credential-secret-do-not-log'"),
"instance_id='22222222-2222-2222-2222-222222222222'\nroutes=[]".to_owned(),
] {
let error = restore_manual_routes_from_toml(&mut config, id, &raw).unwrap_err();
assert!(!error.to_string().contains("credential-secret"));
}
}
#[tokio::test]
async fn runtime_route_readback_uses_current_rpc_or_released_node_info() {
use easytier::proto::api::{
config::{ConfigRpc, GetConfigResponse, PatchConfigResponse},
instance::*,
};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
struct ConfigReader(GetConfigResponse);
#[async_trait::async_trait]
impl ConfigRpc for ConfigReader {
type Controller = BaseController;
async fn get_config(
&self,
_: BaseController,
_: GetConfigRequest,
) -> easytier::proto::rpc_types::error::Result<GetConfigResponse> {
Ok(self.0.clone())
}
async fn patch_config(
&self,
_: BaseController,
_: PatchConfigRequest,
) -> easytier::proto::rpc_types::error::Result<PatchConfigResponse> {
unreachable!("readback must not patch configuration")
}
}
struct PeerReader {
raw: Option<String>,
calls: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl PeerManageRpc for PeerReader {
type Controller = BaseController;
async fn list_peer(
&self,
_: BaseController,
_: ListPeerRequest,
) -> easytier::proto::rpc_types::error::Result<ListPeerResponse> {
unreachable!("route configuration readback only uses ShowNodeInfo")
}
async fn list_public_ipv6_info(
&self,
_: BaseController,
_: ListPublicIpv6InfoRequest,
) -> easytier::proto::rpc_types::error::Result<ListPublicIpv6InfoResponse> {
unreachable!("route configuration readback only uses ShowNodeInfo")
}
async fn list_route(
&self,
_: BaseController,
_: ListRouteRequest,
) -> easytier::proto::rpc_types::error::Result<ListRouteResponse> {
unreachable!("route configuration readback only uses ShowNodeInfo")
}
async fn dump_route(
&self,
_: BaseController,
_: DumpRouteRequest,
) -> easytier::proto::rpc_types::error::Result<DumpRouteResponse> {
unreachable!("route configuration readback only uses ShowNodeInfo")
}
async fn list_foreign_network(
&self,
_: BaseController,
_: ListForeignNetworkRequest,
) -> easytier::proto::rpc_types::error::Result<ListForeignNetworkResponse> {
unreachable!("route configuration readback only uses ShowNodeInfo")
}
async fn list_global_foreign_network(
&self,
_: BaseController,
_: ListGlobalForeignNetworkRequest,
) -> easytier::proto::rpc_types::error::Result<ListGlobalForeignNetworkResponse>
{
unreachable!("route configuration readback only uses ShowNodeInfo")
}
async fn get_foreign_network_summary(
&self,
_: BaseController,
_: GetForeignNetworkSummaryRequest,
) -> easytier::proto::rpc_types::error::Result<GetForeignNetworkSummaryResponse>
{
unreachable!("route configuration readback only uses ShowNodeInfo")
}
async fn show_node_info(
&self,
_: BaseController,
_: ShowNodeInfoRequest,
) -> easytier::proto::rpc_types::error::Result<ShowNodeInfoResponse> {
self.calls.fetch_add(1, Ordering::Relaxed);
let raw = self
.raw
.clone()
.ok_or_else(|| anyhow::anyhow!("Peer RPC unavailable"))?;
Ok(ShowNodeInfoResponse {
node_info: Some(NodeInfo {
config: raw,
..Default::default()
}),
})
}
}
let id = "11111111-1111-1111-1111-111111111111";
for (has_raw_config, supports_patch) in [(false, false), (true, false), (false, true)] {
for manual in [false, true] {
let raw = format!(
"instance_id='{id}'\n{}",
if manual { "routes=[]" } else { "" }
);
let mut config: SessionConfigClient = Box::new(ConfigReader(GetConfigResponse {
config: Some(NetworkConfig {
enable_manual_routes: (supports_patch && manual).then_some(true),
..config_with_port_forwards(Vec::new())
}),
toml_config: if has_raw_config {
raw.clone()
} else {
String::new()
},
}));
let calls = Arc::new(AtomicUsize::new(0));
let mut peer: SessionPeerClient = Box::new(PeerReader {
raw: (!has_raw_config && !supports_patch).then_some(raw),
calls: calls.clone(),
});
let observed = get_runtime_config(&mut config, &mut peer, id, supports_patch)
.await
.unwrap();
assert_eq!(observed.enable_manual_routes, manual.then_some(true));
assert_eq!(
calls.load(Ordering::Relaxed),
usize::from(!has_raw_config && !supports_patch)
);
}
}
let mut config: SessionConfigClient = Box::new(ConfigReader(GetConfigResponse {
config: Some(config_with_port_forwards(Vec::new())),
..Default::default()
}));
let mut peer: SessionPeerClient = Box::new(PeerReader {
raw: None,
calls: Arc::new(AtomicUsize::new(0)),
});
assert!(
get_runtime_config(&mut config, &mut peer, id, false)
.await
.is_err()
);
}
#[test]
fn runtime_patch_rejects_routes_change() {
let mut current = config_with_port_forwards(Vec::new());
@@ -1007,7 +1361,7 @@ mod tests {
desired.enable_manual_routes = Some(true);
desired.routes = vec!["10.2.0.0/16".to_string(), "10.3.0.0/16".to_string()];
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
let patch = web_source_runtime_patch(&current, &desired, false).expect("build patch");
assert!(patch.is_none());
}
@@ -1025,7 +1379,7 @@ mod tests {
"10.3.0.0/16".to_string(),
];
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.expect("build patch")
.expect("hot patch");
@@ -1066,7 +1420,7 @@ mod tests {
let mut desired = config_with_port_forwards(Vec::new());
desired.proxy_cidrs = vec!["10.1.2.0/24->10.1.3.0/24".to_string()];
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.expect("build patch")
.expect("hot patch");
@@ -1095,7 +1449,7 @@ mod tests {
let mut desired = config_with_port_forwards(Vec::new());
desired.proxy_cidrs = vec!["10.1.2.0/24".to_string()];
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
let patch = web_source_runtime_patch(&current, &desired, false).expect("build patch");
assert!(patch.is_none());
}
@@ -1106,7 +1460,7 @@ mod tests {
current.proxy_cidrs = vec!["10.1.2.0/24".to_string()];
let desired = config_with_port_forwards(Vec::new());
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.expect("build patch")
.expect("hot patch");
@@ -1127,7 +1481,7 @@ mod tests {
let mut desired = current.clone();
desired.disable_relay_data = Some(true);
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.expect("build patch")
.expect("hot patch");
@@ -1140,7 +1494,7 @@ mod tests {
let mut desired = current.clone();
desired.prefer_peer_relay = Some(true);
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.expect("build patch")
.expect("hot patch");
@@ -1154,7 +1508,7 @@ mod tests {
let mut desired = current.clone();
desired.no_tun = Some(true);
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
let patch = web_source_runtime_patch(&current, &desired, false).expect("build patch");
assert!(patch.is_none());
}
@@ -1165,7 +1519,7 @@ mod tests {
let mut desired = current.clone();
desired.encryption_algorithm = Some("managed-test-algo".to_string());
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
let patch = web_source_runtime_patch(&current, &desired, false).expect("build patch");
assert!(patch.is_none());
}
@@ -1176,7 +1530,7 @@ mod tests {
let mut desired = current.clone();
desired.data_compress_algo = Some(CompressionAlgoPb::Zstd as i32);
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
let patch = web_source_runtime_patch(&current, &desired, false).expect("build patch");
assert!(patch.is_none());
}
@@ -1197,7 +1551,7 @@ mod tests {
local_public_key: None,
});
let patch = web_source_runtime_patch(&current, &desired).expect("build patch");
let patch = web_source_runtime_patch(&current, &desired, false).expect("build patch");
assert!(patch.is_none());
}
@@ -1218,7 +1572,7 @@ mod tests {
local_public_key: None,
});
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.expect("build patch")
.expect("hot patch");
@@ -1241,7 +1595,7 @@ mod tests {
let desired =
config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]);
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.expect("build patch")
.expect("hot patch");
@@ -1263,7 +1617,7 @@ mod tests {
let mut desired = config_with_port_forwards(Vec::new());
desired.hostname = Some("desired-host".to_string());
let action = prepare_web_source_runtime_reconcile_from_current(&current, desired)
let action = prepare_web_source_runtime_reconcile_from_current(&current, desired, false)
.expect("prepare reconcile");
let RuntimeReconcileAction::Patch(patch) = action else {
panic!("hostname-only change should use a hot patch");
@@ -1320,7 +1674,7 @@ mod tests {
let mut desired = config_with_port_forwards(Vec::new());
desired.hostname = Some(String::new());
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.expect("build patch")
.expect("hot patch");
@@ -1333,7 +1687,7 @@ mod tests {
let mut desired = config_with_port_forwards(Vec::new());
desired.hostname = Some(String::new());
let action = prepare_web_source_runtime_reconcile_from_current(&current, desired)
let action = prepare_web_source_runtime_reconcile_from_current(&current, desired, false)
.expect("prepare reconcile");
assert!(matches!(action, RuntimeReconcileAction::Unchanged(_)));
@@ -1345,7 +1699,7 @@ mod tests {
current.hostname = Some("desired-host".to_string());
let desired = current.clone();
let action = prepare_web_source_runtime_reconcile_from_current(&current, desired)
let action = prepare_web_source_runtime_reconcile_from_current(&current, desired, false)
.expect("prepare reconcile");
assert!(matches!(action, RuntimeReconcileAction::Unchanged(_)));
@@ -1357,7 +1711,7 @@ mod tests {
let mut desired = config_with_port_forwards(Vec::new());
desired.hostname = Some("a".repeat(33));
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.expect("build patch")
.expect("hot patch");
@@ -1370,7 +1724,7 @@ mod tests {
let mut desired = config_with_port_forwards(Vec::new());
desired.hostname = Some("node\u{7}-name".to_string());
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.expect("build patch")
.expect("hot patch");
@@ -1384,7 +1738,7 @@ mod tests {
let mut desired = config_with_port_forwards(Vec::new());
desired.hostname = Some("\u{7}\n".to_string());
let patch = web_source_runtime_patch(&current, &desired)
let patch = web_source_runtime_patch(&current, &desired, false)
.expect("build patch")
.expect("hot patch");
@@ -10,6 +10,7 @@ use anyhow::Context;
use easytier::proto::{
api::{
config::{ConfigRpc, ConfigRpcClientFactory},
instance::{PeerManageRpc, PeerManageRpcClientFactory},
manage::{WebClientService, WebClientServiceClientFactory},
},
rpc::bidirect::BidirectRpcManager,
@@ -926,6 +927,7 @@ impl Debug for Session {
pub(super) type SessionRpcClient = Box<dyn WebClientService<Controller = BaseController> + Send>;
pub(super) type SessionConfigClient = Box<dyn ConfigRpc<Controller = BaseController> + Send>;
pub(super) type SessionPeerClient = Box<dyn PeerManageRpc<Controller = BaseController> + Send>;
impl Session {
pub fn new(
@@ -983,6 +985,7 @@ impl Session {
data.storage.clone(),
self.scoped_rpc_client(),
self.scoped_config_client(),
self.scoped_client::<PeerManageRpcClientFactory<BaseController>>(),
),
)));
}
@@ -1,5 +1,6 @@
use std::collections::{HashMap, HashSet};
use anyhow::Context as _;
use easytier::proto::{
api::manage::{
DeleteNetworkInstanceRequest, DeleteNetworkInstanceResponse,
@@ -13,8 +14,8 @@ use easytier_core::management::remote_client::{ListNetworkProps, Storage as _};
use tokio::sync::{RwLock, broadcast};
use super::{
ManagedConfigReconcileHint, SessionConfigClient, SessionData, SessionRpcClient,
SessionRpcService,
ManagedConfigReconcileHint, SessionConfigClient, SessionData, SessionPeerClient,
SessionRpcClient, SessionRpcService,
};
use crate::client_manager::{
managed_config::{self, PersistedConfigSource},
@@ -56,13 +57,25 @@ async fn recv_latest_heartbeat(
Some(req)
}
struct SessionClients {
rpc: SessionRpcClient,
config: SessionConfigClient,
peer: SessionPeerClient,
}
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,
rpc_client: SessionRpcClient,
config_client: SessionConfigClient,
peer_client: SessionPeerClient,
) {
let mut clients = SessionClients {
rpc: rpc_client,
config: config_client,
peer: peer_client,
};
let mut cache = ReconcileCache::default();
loop {
let Some(req) = recv_latest_heartbeat(&mut heartbeat_waiter).await else {
@@ -74,14 +87,14 @@ pub(super) async fn reconcile_network_configs_on_heartbeat(
};
let mut round =
match prepare_reconcile_round(&session_data, &storage, &mut rpc_client, req).await {
match prepare_reconcile_round(&session_data, &storage, &mut clients.rpc, req).await {
RoundStatus::Ready(round) => round,
RoundStatus::Skip => continue,
RoundStatus::Stop => return,
};
cache.reset_if_runtime_config_cache_epoch_changed(round.runtime_config_cache_epoch);
let running_metas =
match sync_running_sources_for_round(&mut rpc_client, &storage, &mut round).await {
match sync_running_sources_for_round(&mut clients.rpc, &storage, &mut round).await {
RoundStatus::Ready(running_metas) => running_metas,
RoundStatus::Skip => continue,
RoundStatus::Stop => return,
@@ -105,7 +118,7 @@ pub(super) async fn reconcile_network_configs_on_heartbeat(
match cleanup_stale_web_source_instances(
&context,
&storage,
&mut rpc_client,
&mut clients.rpc,
running_metas.as_deref(),
&desired_web_inst_ids,
&mut cache,
@@ -121,7 +134,7 @@ pub(super) async fn reconcile_network_configs_on_heartbeat(
ReconcileScope::Patch { .. } => {
match cleanup_patch_deleted_instances(
&session_data,
&mut rpc_client,
&mut clients.rpc,
&round,
running_metas.as_deref(),
&round.delete_instance_ids,
@@ -140,8 +153,7 @@ pub(super) async fn reconcile_network_configs_on_heartbeat(
outcome.merge(
reconcile_desired_runtime_configs(
&context,
&mut rpc_client,
&mut config_client,
&mut clients,
&mut cache,
&mut mutation_fence,
)
@@ -227,6 +239,7 @@ impl SessionRuntimeConfigCache {
&self,
inst_id: &str,
desired_config: NetworkConfig,
support_manual_routes_patch: bool,
) -> anyhow::Result<Option<runtime_reconcile::RuntimeReconcileAction>> {
let Some(observed_config) = self.entries.get(inst_id) else {
return Ok(None);
@@ -235,6 +248,7 @@ impl SessionRuntimeConfigCache {
runtime_reconcile::prepare_web_source_runtime_reconcile_from_current(
observed_config,
desired_config,
support_manual_routes_patch,
)
.map(Some)
}
@@ -920,8 +934,7 @@ fn should_reconcile_running_web_config(
async fn reconcile_desired_runtime_configs(
context: &ReconcileRoundContext<'_>,
rpc_client: &mut SessionRpcClient,
config_client: &mut SessionConfigClient,
clients: &mut SessionClients,
cache: &mut ReconcileCache,
mutation_fence: &mut RuntimeMutationFence,
) -> ReconcileOutcome {
@@ -963,8 +976,7 @@ async fn reconcile_desired_runtime_configs(
let action_result = if should_reconcile_running_web_config {
reconcile_running_web_config(
context,
rpc_client,
config_client,
clients,
config,
desired_config,
&mut cache.runtime_configs,
@@ -977,7 +989,7 @@ async fn reconcile_desired_runtime_configs(
}
let action_result = run_missing_network_config(
session_data,
rpc_client,
&mut clients.rpc,
round,
config,
desired_config.clone(),
@@ -988,10 +1000,11 @@ async fn reconcile_desired_runtime_configs(
&& source == PersistedConfigSource::Web
{
if let Err(e) = remember_web_runtime_config_after_run(
rpc_client,
clients,
&config.network_instance_id,
&desired_config,
&mut cache.runtime_configs,
round.req.support_manual_routes_patch,
)
.await
{
@@ -1034,8 +1047,7 @@ async fn reconcile_desired_runtime_configs(
async fn reconcile_running_web_config(
context: &ReconcileRoundContext<'_>,
rpc_client: &mut SessionRpcClient,
config_client: &mut SessionConfigClient,
clients: &mut SessionClients,
config: &crate::db::entity::user_running_network_configs::Model,
desired_config: NetworkConfig,
runtime_config_cache: &mut SessionRuntimeConfigCache,
@@ -1054,19 +1066,24 @@ async fn reconcile_running_web_config(
let operation_started_at = std::time::Instant::now();
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?
}
};
let action = match runtime_config_cache.plan(
&config.network_instance_id,
desired_config.clone(),
round.req.support_manual_routes_patch,
)? {
Some(action) => action,
None => {
runtime_reconcile::prepare_web_source_runtime_reconcile(
&mut clients.config,
&mut clients.peer,
&config.network_instance_id,
desired_config.clone(),
true,
round.req.support_manual_routes_patch,
)
.await?
}
};
if !SessionRpcService::runtime_heartbeat_is_current(session_data, &round.req).await {
anyhow::bail!("webhook session is no longer current before runtime reconcile apply");
}
@@ -1078,11 +1095,13 @@ async fn reconcile_running_web_config(
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,
&mut clients.rpc,
&mut clients.config,
&mut clients.peer,
&config.network_instance_id,
desired_config.clone(),
action,
round.req.support_manual_routes_patch,
)
.await?;
runtime_config_cache.remember(&config.network_instance_id, observed_config);
@@ -1186,12 +1205,19 @@ async fn record_direct_run_result(
}
async fn remember_web_runtime_config_after_run(
rpc_client: &mut SessionRpcClient,
clients: &mut SessionClients,
inst_id: &str,
desired_config: &NetworkConfig,
runtime_config_cache: &mut SessionRuntimeConfigCache,
support_manual_routes_patch: bool,
) -> anyhow::Result<()> {
let observed_config = runtime_reconcile::get_runtime_config(rpc_client, inst_id).await?;
let observed_config = runtime_reconcile::get_runtime_config(
&mut clients.config,
&mut clients.peer,
inst_id,
support_manual_routes_patch,
)
.await?;
remember_if_runtime_matches_desired(
inst_id,
desired_config,
@@ -1206,16 +1232,8 @@ fn remember_if_runtime_matches_desired(
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::Unchanged(_)
) {
anyhow::bail!("runtime config still differs after managed run");
}
runtime_reconcile::ensure_runtime_config_converged(&observed_config, desired_config, false)
.context("runtime config still differs after managed run")?;
runtime_config_cache.remember(inst_id, observed_config);
Ok(())
}
@@ -1839,12 +1857,46 @@ mod tests {
fn session_runtime_config_cache_misses_unknown_instance() {
let cache = SessionRuntimeConfigCache::default();
let action = cache
.plan("missing", config_with_port_forwards(Vec::new()))
.plan("missing", config_with_port_forwards(Vec::new()), false)
.expect("prepare action");
assert!(action.is_none());
}
#[test]
fn session_runtime_config_cache_uses_current_heartbeat_route_support() {
let automatic = config_with_port_forwards(Vec::new());
let mut manual = automatic.clone();
manual.enable_manual_routes = Some(true);
let mut cache = SessionRuntimeConfigCache::default();
cache.remember("managed", automatic);
for supported in [false, true, false] {
let heartbeat = HeartbeatRequest {
support_manual_routes_patch: supported,
..Default::default()
};
let action = cache
.plan(
"managed",
manual.clone(),
heartbeat.support_manual_routes_patch,
)
.unwrap()
.unwrap();
assert_eq!(
matches!(action, runtime_reconcile::RuntimeReconcileAction::Patch(_)),
supported
);
assert_eq!(
matches!(
action,
runtime_reconcile::RuntimeReconcileAction::Run { .. }
),
!supported
);
}
}
#[test]
fn session_runtime_config_cache_skips_matching_observed_config() {
let mut cache = SessionRuntimeConfigCache::default();
@@ -1852,7 +1904,7 @@ mod tests {
cache.remember("managed", config.clone());
let action = cache
.plan("managed", config)
.plan("managed", config, false)
.expect("prepare action")
.expect("cached action");
@@ -1871,7 +1923,7 @@ mod tests {
let unmanaged_desired = config_with_port_forwards(Vec::new());
let action = cache
.plan("managed", unmanaged_desired)
.plan("managed", unmanaged_desired, false)
.expect("prepare unmanaged hostname action")
.expect("cached action");
let runtime_reconcile::RuntimeReconcileAction::Unchanged(observed) = action else {
@@ -1883,7 +1935,7 @@ mod tests {
let mut explicit_clear = config_with_port_forwards(Vec::new());
explicit_clear.hostname = Some(String::new());
let action = cache
.plan("managed", explicit_clear)
.plan("managed", explicit_clear, false)
.expect("prepare explicit clear action")
.expect("cached action");
let runtime_reconcile::RuntimeReconcileAction::Patch(patch) = action else {
@@ -1902,7 +1954,7 @@ mod tests {
cache.remember("managed", current);
let action = cache
.plan("managed", desired)
.plan("managed", desired, false)
.expect("prepare action")
.expect("cached action");
@@ -1934,7 +1986,7 @@ mod tests {
cache.forget("managed");
let action = cache
.plan("managed", config)
.plan("managed", config, false)
.expect("prepare action after remove");
assert!(action.is_none());
}
@@ -1947,7 +1999,7 @@ mod tests {
remember_if_runtime_matches_desired("managed", &config, config.clone(), &mut cache)
.expect("remember observed config after run");
let action = cache
.plan("managed", config)
.plan("managed", config, false)
.expect("prepare action after run")
.expect("cached action");
@@ -1972,7 +2024,7 @@ mod tests {
.contains("runtime config still differs after managed run")
);
let action = cache
.plan("managed", desired)
.plan("managed", desired, false)
.expect("prepare action after stale run result");
assert!(action.is_none());
}
@@ -2007,7 +2059,7 @@ mod tests {
let mut cache = SessionRuntimeConfigCache::default();
cache.remember("managed", observed);
let action = cache
.plan("managed", desired)
.plan("managed", desired, false)
.expect("prepare action after restore")
.expect("cached action");
+1
View File
@@ -531,6 +531,7 @@ impl NetworkApi {
support_config_source: false,
failed_network_instances: Vec::new(),
support_heartbeat_policy: false,
support_manual_routes_patch: false,
}),
};
machines.push(ListMachineItem {