diff --git a/Cargo.lock b/Cargo.lock index 7cd845a2..b7186129 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,6 +86,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -657,6 +707,46 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.58" @@ -705,6 +795,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -2820,6 +2916,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -3829,6 +3931,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssh" version = "0.11.6" @@ -6520,6 +6628,34 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synapse" +version = "0.7.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "clap", + "dirs", + "rmcp", + "schemars 1.2.1", + "serde", + "serde_json", + "soma-fleet", + "soma-infra", + "soma-ops", + "synapse-application", + "tempfile", + "thiserror 2.0.19", + "tokio", + "tokio-util", + "toml 1.1.4+spec-1.1.0", + "tower", + "tower-http 0.7.0", + "tracing", + "tracing-subscriber", +] + [[package]] name = "synapse-application" version = "0.6.2" @@ -6871,7 +7007,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.19", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "url", "urlpattern", "uuid", @@ -6896,7 +7032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -7272,11 +7408,13 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" dependencies = [ + "base64 0.22.1", "bitflags 2.13.1", "bytes", "http", "http-body", "http-body-util", + "mime", "percent-encoding", "pin-project-lite", "tokio", @@ -7627,6 +7765,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.24.0" diff --git a/Cargo.toml b/Cargo.toml index 7e9c6811..6daf4c08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ [workspace] members = [ "apps/soma", + "apps/synapse", "crates/integrations/gotify", "crates/integrations/unifi", "crates/shared/auth", diff --git a/Justfile b/Justfile index 293e2fd4..de604a4b 100644 --- a/Justfile +++ b/Justfile @@ -283,11 +283,18 @@ synapse-canonical-read-check: cargo test -p soma-infra --all-features cargo test -p synapse-application -# Validate canonical reads plus lifecycle, artifact, build, and replacement mutations +# Validate all 35 canonical reads and all 21 canonical mutations synapse-mutation-check: cargo test -p soma-infra --all-features cargo test -p synapse-application +# Validate the standalone Synapse CLI, REST, HTTP MCP, and stdio MCP product +synapse-standalone-check: + cargo test -p synapse --all-targets + cargo clippy -p synapse --all-targets -- -D warnings + RUSTDOCFLAGS="-D warnings" cargo doc -p synapse --no-deps + cargo run -p synapse -- --compact operations > /dev/null + # Validate neutral fleet contracts and all optional drivers fleet-check: cargo test -p soma-fleet --all-features diff --git a/apps/synapse/Cargo.toml b/apps/synapse/Cargo.toml new file mode 100644 index 00000000..d276df77 --- /dev/null +++ b/apps/synapse/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "synapse" +version = "0.7.0" +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +description = "Standalone canonical infrastructure operations runtime" +homepage.workspace = true +license.workspace = true +repository.workspace = true +publish = false +autobins = false + +[package.metadata.soma-architecture] +layer = "app" + +[lib] +name = "synapse" + +[[bin]] +name = "synapse" +path = "src/bin/synapse.rs" + +[dependencies] +anyhow = "1" +async-trait = "0.1" +axum = "0.8" +clap = { version = "4", features = ["derive", "env"] } +dirs = "6" +rmcp = { workspace = true, features = ["server", "transport-io", "transport-streamable-http-server", "elicitation"] } +schemars = "1.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +synapse-application = { workspace = true } +soma-fleet = { workspace = true, features = ["openssh-driver", "process-driver"] } +soma-infra = { workspace = true, features = ["bollard-driver", "linux-filesystem", "process-driver", "remote-bollard"] } +soma-ops = { workspace = true } +thiserror = "2" +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", default-features = false } +toml = "1" +tower = { version = "0.5", features = ["util"] } +tower-http = { version = "0.7", features = ["auth", "cors", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +[dev-dependencies] +rmcp = { workspace = true, features = ["client", "transport-child-process"] } +tempfile = "3" + +[lints] +workspace = true diff --git a/apps/synapse/README.md b/apps/synapse/README.md new file mode 100644 index 00000000..a63ca632 --- /dev/null +++ b/apps/synapse/README.md @@ -0,0 +1,66 @@ +# Synapse + +Standalone Synapse is the native product adapter over Soma's canonical operations engine. It links `synapse-application`, `soma-ops`, `soma-fleet`, and `soma-infra` directly and has no dependency on `crates/synapse/import`. + +## Coverage + +- 35 read operations +- 21 mutation operations +- 59 total canonical operations +- CLI, REST, HTTP MCP, and stdio MCP +- optional historical `flux` and `scout` request aliases +- canonical JSON results only + +## Run + +```bash +cargo run -p synapse -- operations +cargo run -p synapse -- run product.help --params '{}' +cargo run -p synapse -- plan container.restart \ + --params '{"host":"local","container_id":"api"}' +cargo run -p synapse -- run container.restart --yes \ + --params '{"host":"local","container_id":"api"}' +cargo run -p synapse -- mcp +cargo run -p synapse -- serve +``` + +Configuration is loaded from `--config`, `SYNAPSE_CONFIG`, or the platform config directory at `synapse/config.toml`. If no config exists, Synapse starts with one local host confined to the current working directory. + +Start from [`config.example.toml`](config.example.toml). Every filesystem, build, execution, and transfer path requires an explicit absolute root for the target host. + +## HTTP + +Public routes: `GET /health`, `GET /ready`, and `GET /status`. + +Protected routes when `server.api_token` is set: + +- `GET /operations` +- `GET /activity` +- `GET /openapi.json` +- `POST /v1/operations//plan` +- `POST /v1/operations//execute` +- `/mcp` + +```bash +curl -sS -H 'Authorization: Bearer replace-with-a-long-random-token' \ + -H 'Content-Type: application/json' \ + http://127.0.0.1:40070/v1/operations/product.help/execute \ + -d '{"parameters":{}}' +``` + +## Mutation authorization + +Mutations always build an exact target- and topology-bound plan before authorization. + +- CLI requires `--yes`. +- REST requires `confirmed: true`. +- MCP asks the client to affirm both `confirm` and `understood` through elicitation. +- `server.allow_mutations = true` enables product-level automatic confirmation and should be used only on a deliberately trusted deployment. + +Authorization evidence is bound to the exact plan fingerprint and expires after `authorization_ttl_secs`. Send-state uncertainty and independent postcondition verification remain part of the canonical result. + +## Verification + +```bash +just synapse-standalone-check +``` diff --git a/apps/synapse/config.example.toml b/apps/synapse/config.example.toml new file mode 100644 index 00000000..8a72de1c --- /dev/null +++ b/apps/synapse/config.example.toml @@ -0,0 +1,40 @@ +[server] +bind = "127.0.0.1:40070" +# Protect /operations, /activity, /openapi.json, /v1/*, and /mcp. +# Health, readiness, and status remain unauthenticated. +api_token = "replace-with-a-long-random-token" +# False requires CLI --yes, REST confirmed=true, or successful MCP elicitation. +allow_mutations = false +request_timeout_secs = 30 +authorization_ttl_secs = 60 +default_host = "local" +max_fanout_concurrency = 8 + +[[hosts]] +id = "local" +read_roots = ["/srv", "/var/log"] +build_roots = ["/srv/build"] +transfer_source_roots = ["/srv/export"] +transfer_destination_roots = ["/srv/import"] +labels = ["local"] + +[hosts.endpoint] +kind = "local" + +[[hosts]] +id = "nashost" +read_roots = ["/srv/data", "/srv/appdata", "/var/log"] +build_roots = ["/srv/appdata/compose"] +transfer_source_roots = ["/srv/data"] +transfer_destination_roots = ["/srv/data"] +labels = ["nas", "docker"] +docker_socket = "/var/run/docker.sock" + +[hosts.endpoint] +kind = "ssh" +host = "nashost" +port = 22 +user = "synapse" +identity_file = "/home/synapse/.ssh/id_ed25519" +config_file = "/home/synapse/.ssh/config" +known_hosts_file = "/home/synapse/.ssh/known_hosts" diff --git a/apps/synapse/src/activity.rs b/apps/synapse/src/activity.rs new file mode 100644 index 00000000..4f3b9603 --- /dev/null +++ b/apps/synapse/src/activity.rs @@ -0,0 +1,96 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use soma_ops::Timestamp; + +const DEFAULT_ACTIVITY_CAPACITY: usize = 512; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActivityEvent { + pub timestamp: Timestamp, + pub surface: String, + pub operation: String, + pub success: bool, + pub elapsed_ms: u64, + pub message: Option, +} + +#[derive(Clone)] +pub struct ActivityLog { + events: Arc>>, + capacity: usize, +} + +impl Default for ActivityLog { + fn default() -> Self { + Self::new(DEFAULT_ACTIVITY_CAPACITY) + } +} + +impl ActivityLog { + pub fn new(capacity: usize) -> Self { + Self { + events: Arc::new(Mutex::new(VecDeque::new())), + capacity: capacity.clamp(1, 10_000), + } + } + + pub fn record( + &self, + surface: impl Into, + operation: impl Into, + success: bool, + elapsed: Duration, + message: Option, + ) { + let mut events = self + .events + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + while events.len() >= self.capacity { + events.pop_front(); + } + events.push_back(ActivityEvent { + timestamp: Timestamp::now(), + surface: bounded(surface.into(), 64), + operation: bounded(operation.into(), 256), + success, + elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX), + message: message.map(|message| bounded(message, 512)), + }); + } + + pub fn snapshot(&self) -> Vec { + self.events + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .cloned() + .collect() + } + + pub fn len(&self) -> usize { + self.events + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +fn bounded(mut value: String, max: usize) -> String { + value.retain(|character| !character.is_control()); + if value.chars().count() > max { + value = value.chars().take(max).collect(); + } + value +} + +#[cfg(test)] +#[path = "activity_tests.rs"] +mod tests; diff --git a/apps/synapse/src/activity_tests.rs b/apps/synapse/src/activity_tests.rs new file mode 100644 index 00000000..7fcef738 --- /dev/null +++ b/apps/synapse/src/activity_tests.rs @@ -0,0 +1,24 @@ +use super::*; + +#[test] +fn activity_is_bounded_ordered_and_sanitized() { + let log = ActivityLog::new(2); + for index in 0..3 { + log.record( + "test", + format!("op-{index}"), + index == 2, + Duration::ZERO, + Some( + "x +" + .into(), + ), + ); + } + let events = log.snapshot(); + assert_eq!(events.len(), 2); + assert_eq!(events[0].operation, "op-1"); + assert_eq!(events[1].operation, "op-2"); + assert_eq!(events[1].message.as_deref(), Some("x")); +} diff --git a/apps/synapse/src/bin/synapse.rs b/apps/synapse/src/bin/synapse.rs new file mode 100644 index 00000000..9e28eb29 --- /dev/null +++ b/apps/synapse/src/bin/synapse.rs @@ -0,0 +1,8 @@ +#[tokio::main] +async fn main() -> anyhow::Result<()> { + synapse::run(std::env::args_os()).await +} + +#[cfg(test)] +#[path = "synapse_tests.rs"] +mod tests; diff --git a/apps/synapse/src/bin/synapse_tests.rs b/apps/synapse/src/bin/synapse_tests.rs new file mode 100644 index 00000000..c6516bb7 --- /dev/null +++ b/apps/synapse/src/bin/synapse_tests.rs @@ -0,0 +1,4 @@ +#[test] +fn binary_name_is_stable() { + assert_eq!(env!("CARGO_PKG_NAME"), "synapse"); +} diff --git a/apps/synapse/src/cli.rs b/apps/synapse/src/cli.rs new file mode 100644 index 00000000..fdfefc4c --- /dev/null +++ b/apps/synapse/src/cli.rs @@ -0,0 +1,219 @@ +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use clap::{Args, Parser, Subcommand, ValueEnum}; +use serde_json::Value; +use synapse_application::LegacyTool; +use tokio_util::sync::CancellationToken; +use tracing_subscriber::EnvFilter; + +use crate::{ExecuteOptions, StandaloneError, StandaloneRuntime, SynapseConfig}; + +#[derive(Debug, Parser)] +#[command( + name = "synapse", + version, + about = "Canonical infrastructure operations runtime" +)] +struct Cli { + #[arg(long, env = "SYNAPSE_CONFIG", global = true)] + config: Option, + #[arg(long, global = true)] + compact: bool, + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + Serve { + #[arg(long)] + bind: Option, + }, + Mcp, + Operations, + Plan(OperationArgs), + Run(OperationArgs), + Legacy(LegacyArgs), +} + +#[derive(Debug, Args)] +struct OperationArgs { + operation: String, + #[arg(long, default_value = "{}", conflicts_with = "params_file")] + params: String, + #[arg(long)] + params_file: Option, + #[arg(long)] + yes: bool, + #[arg(long)] + idempotency_key: Option, + #[arg(long)] + actor: Option, +} + +#[derive(Debug, Args)] +struct LegacyArgs { + #[arg(value_enum)] + tool: LegacyToolArg, + #[arg(long, default_value = "{}", conflicts_with = "input_file")] + input: String, + #[arg(long)] + input_file: Option, + #[arg(long)] + yes: bool, + #[arg(long)] + idempotency_key: Option, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum LegacyToolArg { + Flux, + Scout, +} + +impl From for LegacyTool { + fn from(value: LegacyToolArg) -> Self { + match value { + LegacyToolArg::Flux => Self::Flux, + LegacyToolArg::Scout => Self::Scout, + } + } +} + +pub async fn run(args: I) -> anyhow::Result<()> +where + I: IntoIterator, + T: Into + Clone, +{ + init_tracing(); + let cli = Cli::try_parse_from(args)?; + let mut config = SynapseConfig::load(cli.config.as_deref())?; + if let Command::Serve { bind: Some(bind) } = &cli.command { + config.server.bind = bind.clone(); + } + let runtime = Arc::new(StandaloneRuntime::from_config(config)?); + let outcome = match cli.command { + Command::Serve { .. } => crate::http::serve(Arc::clone(&runtime)).await, + Command::Mcp => crate::mcp::serve_stdio(Arc::clone(&runtime)).await, + Command::Operations => { + print_value(&runtime.operation_catalog_json(), cli.compact)?; + Ok(()) + } + Command::Plan(args) => run_plan(&runtime, args, cli.compact).await, + Command::Run(args) => run_operation(&runtime, args, cli.compact).await, + Command::Legacy(args) => run_legacy(&runtime, args, cli.compact).await, + }; + runtime.shutdown().await; + outcome +} + +async fn run_plan( + runtime: &StandaloneRuntime, + args: OperationArgs, + compact: bool, +) -> anyhow::Result<()> { + let parameters = input_value(&args.params, args.params_file.as_deref())?; + let options = options(&args); + let plan = runtime.plan(&args.operation, ¶meters, &options).await?; + print_value(&serde_json::to_value(plan)?, compact) +} + +async fn run_operation( + runtime: &StandaloneRuntime, + args: OperationArgs, + compact: bool, +) -> anyhow::Result<()> { + let parameters = input_value(&args.params, args.params_file.as_deref())?; + let options = options(&args); + match runtime + .execute( + &args.operation, + ¶meters, + &options, + &CancellationToken::new(), + ) + .await + { + Ok(value) => print_value(&value, compact), + Err(error @ StandaloneError::ConfirmationRequired(_)) => { + let plan = error.plan().expect("confirmation error carries plan"); + print_value( + &serde_json::json!({ + "error": "confirmation_required", + "message": "review the plan and rerun with --yes", + "plan": plan, + }), + compact, + )?; + anyhow::bail!("mutation confirmation required") + } + Err(error) => Err(error.into()), + } +} + +async fn run_legacy( + runtime: &StandaloneRuntime, + args: LegacyArgs, + compact: bool, +) -> anyhow::Result<()> { + let input = input_value(&args.input, args.input_file.as_deref())?; + let options = ExecuteOptions { + confirmed: args.yes, + idempotency_key: args.idempotency_key, + actor: Some("legacy-cli".into()), + }; + let value = runtime + .execute_legacy( + args.tool.into(), + &input, + &options, + &CancellationToken::new(), + ) + .await?; + print_value(&value, compact) +} + +fn options(args: &OperationArgs) -> ExecuteOptions { + ExecuteOptions { + confirmed: args.yes, + idempotency_key: args.idempotency_key.clone(), + actor: args.actor.clone().or_else(|| Some("cli".into())), + } +} + +fn input_value(inline: &str, file: Option<&Path>) -> anyhow::Result { + let text = if let Some(path) = file { + std::fs::read_to_string(path) + .map_err(|error| anyhow::anyhow!("cannot read {}: {error}", path.display()))? + } else { + inline.to_owned() + }; + let value: Value = serde_json::from_str(&text)?; + if !value.is_object() { + anyhow::bail!("operation input must be a JSON object"); + } + Ok(value) +} + +fn print_value(value: &Value, compact: bool) -> anyhow::Result<()> { + if compact { + println!("{}", serde_json::to_string(value)?); + } else { + println!("{}", serde_json::to_string_pretty(value)?); + } + Ok(()) +} + +fn init_tracing() { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .try_init(); +} + +#[cfg(test)] +#[path = "cli_tests.rs"] +mod tests; diff --git a/apps/synapse/src/cli_tests.rs b/apps/synapse/src/cli_tests.rs new file mode 100644 index 00000000..966a0ef0 --- /dev/null +++ b/apps/synapse/src/cli_tests.rs @@ -0,0 +1,14 @@ +use super::*; + +#[test] +fn canonical_run_requires_an_operation_and_json_object() { + let cli = Cli::try_parse_from(["synapse", "run", "product.help"]).unwrap(); + assert!(matches!(cli.command, Command::Run(_))); + assert!(input_value("[]", None).is_err()); +} + +#[test] +fn legacy_tool_names_are_closed() { + assert!(Cli::try_parse_from(["synapse", "legacy", "flux"]).is_ok()); + assert!(Cli::try_parse_from(["synapse", "legacy", "other"]).is_err()); +} diff --git a/apps/synapse/src/composition.rs b/apps/synapse/src/composition.rs new file mode 100644 index 00000000..12c8c309 --- /dev/null +++ b/apps/synapse/src/composition.rs @@ -0,0 +1,141 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use soma_fleet::{ConnectionPool, HostId, OpenSshConnector, OpenSshDriver}; +use soma_infra::{ + BollardClientProvider, BuildContextInspector, BuildContextPolicy, CommandBuildContextInspector, + CommandComposeBuildMutator, CommandComposeInspector, CommandFileTransfer, + CommandFilesystemQueryInspector, CommandHostExec, CommandHostSystemInspector, + CommandImageBuildMutator, CommandLogReader, CommandProcessInspector, CommandZfsInspector, + FileReadPolicy, FileTransferPolicy, FilesystemQueryInspector, HostExecPolicy, + LinuxCommandHostInspector, +}; +use synapse_application::{ + SynapseBuildPorts, SynapseCatalog, SynapseExecPorts, SynapseFinalPorts, SynapseMutationPorts, + SynapseMutationRuntime, SynapseReadPorts, SynapseReadRuntime, SynapseRecreatePorts, +}; + +use crate::activity::ActivityLog; +use crate::config::SynapseConfig; +use crate::fleet::{ + PerHostBuildContext, PerHostFilesystem, RoutedCommandExecutor, StaticHostRepository, +}; +use crate::{StandaloneError, StandaloneRuntime}; + +impl StandaloneRuntime { + pub fn from_config(config: SynapseConfig) -> Result { + config.validate()?; + let snapshot = crate::fleet::topology(&config)?; + let repository = Arc::new(StaticHostRepository::new(snapshot)); + + let connector = OpenSshConnector::default(); + let ssh = Arc::new(OpenSshDriver::new(connector.clone())); + let executor = Arc::new(RoutedCommandExecutor::new(Arc::clone(&ssh))); + let docker_pool = Arc::new(ConnectionPool::new(Arc::new(connector))); + let mut docker = BollardClientProvider::new(docker_pool); + for host in &config.hosts { + if let Some(socket) = &host.docker_socket { + docker = docker.with_remote_socket(HostId::new(&host.id)?, socket)?; + } + } + let docker = Arc::new(docker); + let compose = Arc::new(CommandComposeInspector::new(Arc::clone(&executor))); + + let mut filesystem_drivers = BTreeMap::>::new(); + let mut build_drivers = BTreeMap::>::new(); + let mut host_exec = CommandHostExec::new(executor.clone()); + let mut transfer = CommandFileTransfer::new(executor.clone()); + for host in &config.hosts { + let id = HostId::new(&host.id)?; + let file_policy = FileReadPolicy::new(host.read_roots.clone())?; + filesystem_drivers.insert( + id.clone(), + Arc::new(CommandFilesystemQueryInspector::new( + Arc::clone(&executor), + file_policy, + )), + ); + if !host.build_roots.is_empty() { + build_drivers.insert( + id.clone(), + Arc::new(CommandBuildContextInspector::new( + Arc::clone(&executor), + BuildContextPolicy::new(host.build_roots.clone())?, + )), + ); + } + host_exec = + host_exec.with_policy(id.clone(), HostExecPolicy::new(host.read_roots.clone())?); + if !host.transfer_source_roots.is_empty() && !host.transfer_destination_roots.is_empty() + { + transfer = transfer.with_policy( + id, + FileTransferPolicy::new( + host.transfer_source_roots.clone(), + host.transfer_destination_roots.clone(), + )?, + ); + } + } + let filesystem = Arc::new(PerHostFilesystem::new(filesystem_drivers)); + let contexts = Arc::new(PerHostBuildContext::new(build_drivers)); + let host_exec = Arc::new(host_exec); + let transfer = Arc::new(transfer); + + let mut read = SynapseReadRuntime::new(SynapseReadPorts { + hosts: repository.clone(), + host: Arc::new(LinuxCommandHostInspector::new(Arc::clone(&executor))), + host_system: Arc::new(CommandHostSystemInspector::new(Arc::clone(&executor))), + docker: docker.clone(), + compose: compose.clone(), + filesystem: filesystem.clone(), + processes: Arc::new(CommandProcessInspector::new(Arc::clone(&executor))), + logs: Arc::new(CommandLogReader::new(Arc::clone(&executor))), + zfs: Arc::new(CommandZfsInspector::new(Arc::clone(&executor))), + }) + .with_timeout(config.server.request_timeout()); + if let Some(default) = &config.server.default_host { + read = read.with_default_host(HostId::new(default)?); + } + + let mutation = SynapseMutationRuntime::new(SynapseMutationPorts { + hosts: repository, + docker: docker.clone(), + compose: Some(compose.clone()), + artifacts: Some(docker.clone()), + compose_pull: Some(compose.clone()), + builds: Some(SynapseBuildPorts { + contexts, + image: Arc::new(CommandImageBuildMutator::new(Arc::clone(&executor))), + compose: Arc::new(CommandComposeBuildMutator::new(Arc::clone(&executor))), + }), + recreate: Some(SynapseRecreatePorts { + containers: docker.clone(), + compose: compose.clone(), + }), + exec: Some(SynapseExecPorts { + containers: docker.clone(), + hosts: host_exec, + max_fanout_concurrency: config.server.max_fanout_concurrency.clamp(1, 256), + }), + final_mutations: Some(SynapseFinalPorts { + cleanup: docker, + compose_down: compose, + transfer, + }), + }); + + Ok(Self { + config, + catalog: SynapseCatalog::embedded(), + read, + mutation, + ssh, + activity: ActivityLog::default(), + }) + } +} + +#[cfg(test)] +#[path = "composition_tests.rs"] +mod tests; diff --git a/apps/synapse/src/composition_tests.rs b/apps/synapse/src/composition_tests.rs new file mode 100644 index 00000000..98983297 --- /dev/null +++ b/apps/synapse/src/composition_tests.rs @@ -0,0 +1,7 @@ +use super::*; + +#[test] +fn default_product_composes_every_runtime_port() { + let runtime = StandaloneRuntime::from_config(SynapseConfig::default()).unwrap(); + assert_eq!(runtime.catalog().operation_count(), 59); +} diff --git a/apps/synapse/src/config.rs b/apps/synapse/src/config.rs new file mode 100644 index 00000000..9289e0a2 --- /dev/null +++ b/apps/synapse/src/config.rs @@ -0,0 +1,200 @@ +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +const DEFAULT_PORT: u16 = 40070; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SynapseConfig { + pub server: ServerConfig, + pub hosts: Vec, +} + +impl Default for SynapseConfig { + fn default() -> Self { + let root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); + Self { + server: ServerConfig::default(), + hosts: vec![HostConfig::local("local", root)], + } + } +} + +impl SynapseConfig { + pub fn load(path: Option<&Path>) -> anyhow::Result { + let explicit = path.is_some(); + let path = path.map(Path::to_path_buf).or_else(default_config_path); + let Some(path) = path else { + return Ok(Self::default()); + }; + match std::fs::read_to_string(&path) { + Ok(text) => toml::from_str(&text).map_err(|error| { + anyhow::anyhow!("invalid Synapse config {}: {error}", path.display()) + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound && !explicit => { + Ok(Self::default()) + } + Err(error) => Err(anyhow::anyhow!( + "cannot read Synapse config {}: {error}", + path.display() + )), + } + } + + pub fn validate(&self) -> anyhow::Result<()> { + if self.hosts.is_empty() { + anyhow::bail!("at least one host must be configured"); + } + let mut ids = std::collections::BTreeSet::new(); + for host in &self.hosts { + if !ids.insert(host.id.as_str()) { + anyhow::bail!("duplicate host id: {}", host.id); + } + host.validate()?; + } + if let Some(default) = self.server.default_host.as_deref() + && !ids.contains(default) + { + anyhow::bail!("default host is not configured: {default}"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ServerConfig { + pub bind: String, + pub api_token: Option, + pub allow_mutations: bool, + pub request_timeout_secs: u64, + pub authorization_ttl_secs: u64, + pub default_host: Option, + pub max_fanout_concurrency: usize, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + bind: format!("127.0.0.1:{DEFAULT_PORT}"), + api_token: None, + allow_mutations: false, + request_timeout_secs: 30, + authorization_ttl_secs: 60, + default_host: Some("local".into()), + max_fanout_concurrency: 8, + } + } +} + +impl ServerConfig { + pub fn bind_addr(&self) -> anyhow::Result { + self.bind + .parse() + .map_err(|error| anyhow::anyhow!("invalid server.bind {}: {error}", self.bind)) + } + + pub fn request_timeout(&self) -> Duration { + Duration::from_secs(self.request_timeout_secs.clamp(1, 3_600)) + } + + pub fn authorization_ttl(&self) -> Duration { + Duration::from_secs(self.authorization_ttl_secs.clamp(1, 3_600)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostConfig { + pub id: String, + pub endpoint: EndpointConfig, + #[serde(default)] + pub labels: Vec, + #[serde(default)] + pub read_roots: Vec, + #[serde(default)] + pub build_roots: Vec, + #[serde(default)] + pub transfer_source_roots: Vec, + #[serde(default)] + pub transfer_destination_roots: Vec, + #[serde(default)] + pub docker_socket: Option, +} + +impl HostConfig { + fn local(id: &str, root: PathBuf) -> Self { + Self { + id: id.into(), + endpoint: EndpointConfig::Local, + labels: Vec::new(), + read_roots: vec![root.clone()], + build_roots: vec![root.clone()], + transfer_source_roots: vec![root.clone()], + transfer_destination_roots: vec![root], + docker_socket: None, + } + } + + fn validate(&self) -> anyhow::Result<()> { + if self.read_roots.is_empty() { + anyhow::bail!("host {} requires at least one read root", self.id); + } + for (name, roots) in [ + ("read_roots", &self.read_roots), + ("build_roots", &self.build_roots), + ("transfer_source_roots", &self.transfer_source_roots), + ( + "transfer_destination_roots", + &self.transfer_destination_roots, + ), + ] { + for root in roots { + if !root.is_absolute() { + anyhow::bail!( + "host {} {name} must be absolute: {}", + self.id, + root.display() + ); + } + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum EndpointConfig { + Local, + Ssh { + host: String, + #[serde(default = "default_ssh_port")] + port: u16, + #[serde(default)] + user: Option, + #[serde(default)] + identity_file: Option, + #[serde(default)] + config_file: Option, + #[serde(default)] + known_hosts_file: Option, + }, +} + +const fn default_ssh_port() -> u16 { + 22 +} + +fn default_config_path() -> Option { + std::env::var_os("SYNAPSE_CONFIG") + .map(PathBuf::from) + .or_else(|| dirs::config_dir().map(|dir| dir.join("synapse/config.toml"))) +} + +#[cfg(test)] +#[path = "config_tests.rs"] +mod tests; diff --git a/apps/synapse/src/config_tests.rs b/apps/synapse/src/config_tests.rs new file mode 100644 index 00000000..6071e77e --- /dev/null +++ b/apps/synapse/src/config_tests.rs @@ -0,0 +1,21 @@ +use super::*; + +#[test] +fn default_config_is_local_and_confined_to_current_directory() { + let config = SynapseConfig::default(); + config.validate().unwrap(); + assert_eq!(config.hosts.len(), 1); + assert!(matches!(config.hosts[0].endpoint, EndpointConfig::Local)); + assert!(config.hosts[0].read_roots[0].is_absolute()); +} + +#[test] +fn config_rejects_duplicate_hosts_and_relative_roots() { + let mut config = SynapseConfig::default(); + config.hosts.push(config.hosts[0].clone()); + assert!(config.validate().is_err()); + + let mut config = SynapseConfig::default(); + config.hosts[0].read_roots = vec![PathBuf::from("relative")]; + assert!(config.validate().is_err()); +} diff --git a/apps/synapse/src/fleet.rs b/apps/synapse/src/fleet.rs new file mode 100644 index 00000000..ff0c9395 --- /dev/null +++ b/apps/synapse/src/fleet.rs @@ -0,0 +1,206 @@ +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; +use soma_fleet::{ + CommandExecutor, CommandOutput, CommandRequest, FleetError, FleetResult, HostEndpoint, HostId, + HostRecord, HostRepository, LocalProcessDriver, OpenSshDriver, SshEndpoint, TopologySnapshot, +}; +use soma_infra::{ + BuildContextFingerprint, BuildContextInspector, FileFindRequest, FileSearch, FileTail, + FileTailRequest, FilesystemQueryInspector, InfraError, InfraResult, PathRead, PathReadRequest, +}; +use soma_ops::Timestamp; +use tokio_util::sync::CancellationToken; + +use crate::config::{EndpointConfig, SynapseConfig}; + +#[derive(Clone)] +pub struct StaticHostRepository { + snapshot: TopologySnapshot, +} + +impl StaticHostRepository { + pub fn new(snapshot: TopologySnapshot) -> Self { + Self { snapshot } + } +} + +#[async_trait] +impl HostRepository for StaticHostRepository { + async fn snapshot(&self) -> FleetResult { + Ok(self.snapshot.clone()) + } +} + +pub struct RoutedCommandExecutor { + local: LocalProcessDriver, + ssh: Arc, +} + +impl RoutedCommandExecutor { + pub fn new(ssh: Arc) -> Self { + Self { + local: LocalProcessDriver, + ssh, + } + } +} + +#[async_trait] +impl CommandExecutor for RoutedCommandExecutor { + async fn execute( + &self, + host: &HostRecord, + request: &CommandRequest, + cancellation: &CancellationToken, + ) -> FleetResult { + match host.endpoint() { + HostEndpoint::Local => self.local.execute(host, request, cancellation).await, + HostEndpoint::Ssh(_) => self.ssh.execute(host, request, cancellation).await, + HostEndpoint::Http(_) => Err(FleetError::Command { + host: host.id().clone(), + message: "HTTP fleet endpoints cannot execute process commands".into(), + }), + } + } +} + +pub struct PerHostFilesystem { + drivers: BTreeMap>, +} + +impl PerHostFilesystem { + pub fn new(drivers: BTreeMap>) -> Self { + Self { drivers } + } + + fn driver(&self, host: &HostRecord) -> InfraResult<&dyn FilesystemQueryInspector> { + self.drivers + .get(host.id()) + .map(Arc::as_ref) + .ok_or_else(|| policy_error("filesystem", host)) + } +} + +#[async_trait] +impl FilesystemQueryInspector for PerHostFilesystem { + async fn read_path( + &self, + host: &HostRecord, + path: &Path, + request: &PathReadRequest, + cancellation: &CancellationToken, + ) -> InfraResult { + self.driver(host)? + .read_path(host, path, request, cancellation) + .await + } + + async fn find( + &self, + host: &HostRecord, + path: &Path, + request: &FileFindRequest, + cancellation: &CancellationToken, + ) -> InfraResult { + self.driver(host)? + .find(host, path, request, cancellation) + .await + } + + async fn tail( + &self, + host: &HostRecord, + path: &Path, + request: &FileTailRequest, + cancellation: &CancellationToken, + ) -> InfraResult { + self.driver(host)? + .tail(host, path, request, cancellation) + .await + } +} + +pub struct PerHostBuildContext { + drivers: BTreeMap>, +} + +impl PerHostBuildContext { + pub fn new(drivers: BTreeMap>) -> Self { + Self { drivers } + } +} + +#[async_trait] +impl BuildContextInspector for PerHostBuildContext { + async fn fingerprint( + &self, + host: &HostRecord, + path: &Path, + deadline: Timestamp, + cancellation: &CancellationToken, + ) -> InfraResult { + self.drivers + .get(host.id()) + .ok_or_else(|| policy_error("build-context", host))? + .fingerprint(host, path, deadline, cancellation) + .await + } +} + +pub fn topology(config: &SynapseConfig) -> anyhow::Result { + let hosts = config + .hosts + .iter() + .map(host_record) + .collect::>>()?; + TopologySnapshot::new(hosts).map_err(Into::into) +} + +fn host_record(config: &crate::config::HostConfig) -> anyhow::Result { + let id = HostId::new(&config.id)?; + let endpoint = match &config.endpoint { + EndpointConfig::Local => HostEndpoint::Local, + EndpointConfig::Ssh { + host, + port, + user, + identity_file, + config_file, + known_hosts_file, + } => { + let mut endpoint = SshEndpoint::new(host)?.with_port(*port)?; + if let Some(user) = user { + endpoint = endpoint.with_user(user)?; + } + if let Some(path) = identity_file { + endpoint = endpoint.with_identity_file(path)?; + } + if let Some(path) = config_file { + endpoint = endpoint.with_config_file(path)?; + } + if let Some(path) = known_hosts_file { + endpoint = endpoint.with_known_hosts_file(path)?; + } + HostEndpoint::Ssh(endpoint) + } + }; + let mut record = HostRecord::new(id, endpoint); + for label in &config.labels { + record = record.with_label(label)?; + } + Ok(record) +} + +fn policy_error(domain: &'static str, host: &HostRecord) -> InfraError { + InfraError::InvalidRequest { + domain, + message: format!("{domain} policy is not configured for {}", host.id()), + } +} + +#[cfg(test)] +#[path = "fleet_tests.rs"] +mod tests; diff --git a/apps/synapse/src/fleet_tests.rs b/apps/synapse/src/fleet_tests.rs new file mode 100644 index 00000000..af38dfa7 --- /dev/null +++ b/apps/synapse/src/fleet_tests.rs @@ -0,0 +1,29 @@ +use super::*; + +#[tokio::test] +async fn default_topology_and_repository_are_stable() { + let snapshot = topology(&SynapseConfig::default()).unwrap(); + assert_eq!(snapshot.len(), 1); + let repository = StaticHostRepository::new(snapshot.clone()); + assert_eq!(repository.snapshot().await.unwrap(), snapshot); +} + +#[tokio::test] +async fn routed_executor_keeps_http_endpoints_fail_closed() { + let executor = RoutedCommandExecutor::new(Arc::new(OpenSshDriver::default())); + let host = HostRecord::new( + HostId::new("remote-http").unwrap(), + HostEndpoint::Http(soma_fleet::HttpEndpoint::new("https://example.com").unwrap()), + ); + let request = CommandRequest::new( + "true", + Vec::::new(), + Timestamp::from_unix_millis(Timestamp::now().unix_millis() + 1_000), + ) + .unwrap(); + let error = executor + .execute(&host, &request, &CancellationToken::new()) + .await + .unwrap_err(); + assert!(error.to_string().contains("HTTP fleet endpoints")); +} diff --git a/apps/synapse/src/http.rs b/apps/synapse/src/http.rs new file mode 100644 index 00000000..716172cb --- /dev/null +++ b/apps/synapse/src/http.rs @@ -0,0 +1,238 @@ +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::http::{Request, StatusCode, header::AUTHORIZATION}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio_util::sync::CancellationToken; +use tower_http::trace::TraceLayer; + +use crate::{ExecuteOptions, StandaloneError, StandaloneRuntime}; + +#[derive(Clone)] +struct HttpState { + runtime: Arc, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct OperationBody { + parameters: Value, + confirmed: bool, + idempotency_key: Option, + actor: Option, +} + +impl Default for OperationBody { + fn default() -> Self { + Self { + parameters: json!({}), + confirmed: false, + idempotency_key: None, + actor: None, + } + } +} + +pub async fn serve(runtime: Arc) -> anyhow::Result<()> { + let bind = runtime.config().server.bind_addr()?; + let router = router(runtime); + let listener = tokio::net::TcpListener::bind(bind).await?; + tracing::info!(%bind, "standalone Synapse listening"); + axum::serve(listener, router) + .with_graceful_shutdown(shutdown_signal()) + .await?; + Ok(()) +} + +pub fn router(runtime: Arc) -> Router { + let state = HttpState { + runtime: Arc::clone(&runtime), + }; + let api = Router::new() + .route("/operations", get(operations)) + .route("/activity", get(activity)) + .route("/openapi.json", get(openapi)) + .route("/v1/operations/{operation}/plan", post(plan)) + .route("/v1/operations/{operation}/execute", post(execute)) + .with_state(state.clone()) + .nest_service("/mcp", crate::mcp::http_service(Arc::clone(&runtime))); + let protected = if let Some(token) = runtime.config().server.api_token.as_deref() { + let expected = Arc::<[u8]>::from(format!("Bearer {token}").into_bytes()); + api.layer(middleware::from_fn_with_state(expected, bearer_auth)) + } else { + api + }; + Router::new() + .route("/health", get(health)) + .route("/ready", get(ready)) + .route("/status", get(status)) + .with_state(state) + .merge(protected) + .layer(TraceLayer::new_for_http()) +} + +async fn health() -> Json { + Json(json!({"status":"ok"})) +} + +async fn ready(State(state): State) -> Json { + Json(json!({ + "status":"ready", + "operations":state.runtime.catalog().operation_count(), + "hosts":state.runtime.config().hosts.len() + })) +} + +async fn status(State(state): State) -> Json { + Json(json!({ + "name":"synapse", + "version":env!("CARGO_PKG_VERSION"), + "canonical_operations":state.runtime.catalog().operation_count(), + "reads":35, + "mutations":21, + "hosts":state.runtime.config().hosts.len(), + "activity_events":state.runtime.activity().len(), + "mutation_policy": if state.runtime.config().server.allow_mutations { + "configured_auto_confirmation" + } else { + "explicit_confirmation_required" + }, + "authentication": if state.runtime.config().server.api_token.is_some() { + "bearer" + } else { + "none" + } + })) +} + +async fn operations(State(state): State) -> Json { + Json(state.runtime.operation_catalog_json()) +} + +async fn activity(State(state): State) -> Json { + Json(serde_json::to_value(state.runtime.activity().snapshot()).expect("activity serializes")) +} + +async fn openapi(State(state): State) -> Json { + Json(crate::openapi::document(state.runtime.as_ref())) +} + +async fn plan( + State(state): State, + Path(operation): Path, + Json(body): Json, +) -> Result, ApiError> { + let options = body.options("http-plan"); + let plan = state + .runtime + .plan(&operation, &body.parameters, &options) + .await?; + Ok(Json( + serde_json::to_value(plan).map_err(anyhow::Error::from)?, + )) +} + +async fn execute( + State(state): State, + Path(operation): Path, + Json(body): Json, +) -> Result, ApiError> { + let options = body.options("http"); + let result = state + .runtime + .execute( + &operation, + &body.parameters, + &options, + &CancellationToken::new(), + ) + .await?; + Ok(Json(result)) +} + +impl OperationBody { + fn options(&self, default_actor: &str) -> ExecuteOptions { + ExecuteOptions { + confirmed: self.confirmed, + idempotency_key: self.idempotency_key.clone(), + actor: self.actor.clone().or_else(|| Some(default_actor.into())), + } + } +} + +struct ApiError(StandaloneError); + +impl From for ApiError +where + E: Into, +{ + fn from(error: E) -> Self { + Self(error.into()) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + if let Some(plan) = self.0.plan() { + return ( + StatusCode::PRECONDITION_REQUIRED, + Json(json!({ + "error":"confirmation_required", + "message":self.0.to_string(), + "plan":plan + })), + ) + .into_response(); + } + ( + StatusCode::BAD_REQUEST, + Json(json!({"error":"request_failed","message":self.0.to_string()})), + ) + .into_response() + } +} + +async fn bearer_auth( + State(expected): State>, + request: Request, + next: Next, +) -> Response { + let actual = request + .headers() + .get(AUTHORIZATION) + .map(|value| value.as_bytes()) + .unwrap_or_default(); + if constant_time_eq(actual, expected.as_ref()) { + next.run(request).await + } else { + ( + StatusCode::UNAUTHORIZED, + Json(json!({"error":"unauthorized"})), + ) + .into_response() + } +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + let mut difference = left.len() ^ right.len(); + for index in 0..left.len().max(right.len()) { + let left = left.get(index).copied().unwrap_or_default(); + let right = right.get(index).copied().unwrap_or_default(); + difference |= usize::from(left ^ right); + } + difference == 0 +} + +async fn shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; +} + +#[cfg(test)] +#[path = "http_tests.rs"] +mod tests; diff --git a/apps/synapse/src/http_tests.rs b/apps/synapse/src/http_tests.rs new file mode 100644 index 00000000..52896ba5 --- /dev/null +++ b/apps/synapse/src/http_tests.rs @@ -0,0 +1,94 @@ +use axum::body::Body; +use axum::http::Request; +use tower::ServiceExt; + +use super::*; +use crate::SynapseConfig; + +#[tokio::test] +async fn public_health_and_canonical_read_routes_work() { + let runtime = Arc::new(StandaloneRuntime::from_config(SynapseConfig::default()).unwrap()); + let app = router(runtime); + let health = app + .clone() + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(health.status(), StatusCode::OK); + + let request = Request::builder() + .method("POST") + .uri("/v1/operations/product.help/execute") + .header("content-type", "application/json") + .body(Body::from(r#"{"parameters":{}}"#)) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn mutation_without_confirmation_returns_the_plan() { + let runtime = Arc::new(StandaloneRuntime::from_config(SynapseConfig::default()).unwrap()); + let request = Request::builder() + .method("POST") + .uri("/v1/operations/container.start/execute") + .header("content-type", "application/json") + .body(Body::from( + r#"{"parameters":{"host":"local","container_id":"missing"}}"#, + )) + .unwrap(); + let response = router(runtime).oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED); +} + +#[tokio::test] +async fn activity_openapi_and_bearer_policy_are_product_owned() { + let mut config = SynapseConfig::default(); + config.server.api_token = Some("secret-token".into()); + let runtime = Arc::new(StandaloneRuntime::from_config(config).unwrap()); + let app = router(Arc::clone(&runtime)); + + let denied = app + .clone() + .oneshot( + Request::builder() + .uri("/operations") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(denied.status(), StatusCode::UNAUTHORIZED); + + let allowed = app + .clone() + .oneshot( + Request::builder() + .uri("/openapi.json") + .header("authorization", "Bearer secret-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(allowed.status(), StatusCode::OK); + + runtime + .execute( + "product.help", + &serde_json::json!({}), + &ExecuteOptions { + actor: Some("http-test".into()), + ..Default::default() + }, + &CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(runtime.activity().len(), 1); +} diff --git a/apps/synapse/src/lib.rs b/apps/synapse/src/lib.rs new file mode 100644 index 00000000..3f3fe91c --- /dev/null +++ b/apps/synapse/src/lib.rs @@ -0,0 +1,31 @@ +#![forbid(unsafe_code)] + +//! Standalone Synapse product runtime over Soma's canonical operation catalog, fleet, and infrastructure engines. +//! +//! CLI, REST, HTTP MCP, and stdio MCP adapters all delegate to the same 59-operation runtime. + +mod activity; +mod cli; +mod composition; +mod config; +mod fleet; +mod http; +mod mcp; +mod openapi; +mod runtime; + +pub use activity::{ActivityEvent, ActivityLog}; +pub use config::{EndpointConfig, HostConfig, ServerConfig, SynapseConfig}; +pub use runtime::{ExecuteOptions, StandaloneError, StandaloneRuntime}; + +pub async fn run(args: I) -> anyhow::Result<()> +where + I: IntoIterator, + T: Into + Clone, +{ + cli::run(args).await +} + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; diff --git a/apps/synapse/src/lib_tests.rs b/apps/synapse/src/lib_tests.rs new file mode 100644 index 00000000..676067ae --- /dev/null +++ b/apps/synapse/src/lib_tests.rs @@ -0,0 +1,7 @@ +#[test] +fn package_exposes_canonical_runtime() { + assert_eq!( + synapse_application::SynapseCatalog::embedded().operation_count(), + 59 + ); +} diff --git a/apps/synapse/src/mcp.rs b/apps/synapse/src/mcp.rs new file mode 100644 index 00000000..d432c27f --- /dev/null +++ b/apps/synapse/src/mcp.rs @@ -0,0 +1,339 @@ +use std::borrow::Cow; +use std::sync::Arc; +use std::time::Duration; + +use rmcp::transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, +}; +use rmcp::{ + ErrorData, RoleServer, ServerHandler, ServiceExt, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, Implementation, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, ToolAnnotations, + }, + service::{ElicitationError, Peer, RequestContext}, + transport::stdio, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use soma_ops::OperationPlan; +use synapse_application::LegacyTool; +use tokio_util::sync::CancellationToken; + +use crate::{ExecuteOptions, StandaloneError, StandaloneRuntime}; + +#[derive(Clone)] +pub struct SynapseMcpServer { + runtime: Arc, +} + +const ELICIT_TIMEOUT: Duration = Duration::from_secs(15); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +struct ConfirmMutation { + confirm: bool, + understood: bool, +} + +rmcp::elicit_safe!(ConfirmMutation); + +impl SynapseMcpServer { + pub fn new(runtime: Arc) -> Self { + Self { runtime } + } +} + +impl ServerHandler for SynapseMcpServer { + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListToolsResult { + tools: tool_definitions(self.runtime.as_ref()), + ..Default::default() + }) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let arguments = Value::Object(request.arguments.unwrap_or_default()); + let result = match request.name.as_ref() { + "synapse" => execute_canonical(self.runtime.as_ref(), arguments, &context.peer).await, + "flux" => { + execute_legacy( + self.runtime.as_ref(), + LegacyTool::Flux, + arguments, + &context.peer, + ) + .await + } + "scout" => { + execute_legacy( + self.runtime.as_ref(), + LegacyTool::Scout, + arguments, + &context.peer, + ) + .await + } + name => Err(StandaloneError::UnknownOperation(format!( + "unknown MCP tool: {name}" + ))), + }; + Ok(match result { + Ok(value) => CallToolResult::structured(value), + Err(error) => error_result(error), + } + .into()) + } + + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("synapse", env!("CARGO_PKG_VERSION"))) + } +} + +pub async fn serve_stdio(runtime: Arc) -> anyhow::Result<()> { + let service = SynapseMcpServer::new(runtime).serve(stdio()).await?; + service.waiting().await?; + Ok(()) +} + +pub fn http_service( + runtime: Arc, +) -> StreamableHttpService { + let config = StreamableHttpServerConfig::default() + .with_legacy_session_mode(false) + .with_json_response(true); + StreamableHttpService::new( + move || Ok(SynapseMcpServer::new(Arc::clone(&runtime))), + Default::default(), + config, + ) +} + +fn tool_definitions(runtime: &StandaloneRuntime) -> Vec { + vec![ + Tool::new_with_raw( + Cow::Borrowed("synapse"), + Some(Cow::Borrowed( + "Execute one canonical Synapse infrastructure operation", + )), + Arc::new(canonical_schema(runtime)), + ) + .with_annotations(ToolAnnotations::new().open_world(true)), + legacy_tool(runtime, LegacyTool::Flux), + legacy_tool(runtime, LegacyTool::Scout), + ] +} + +fn legacy_tool(runtime: &StandaloneRuntime, tool: LegacyTool) -> Tool { + let schema = add_execution_fields(runtime.catalog().legacy_tool_schema(tool)); + let map = schema + .as_object() + .cloned() + .expect("legacy tool schema is an object"); + Tool::new_with_raw( + Cow::Borrowed(tool.as_str()), + Some(Cow::Owned(format!( + "Optional historical {} request alias returning canonical JSON", + tool.as_str() + ))), + Arc::new(map), + ) + .with_annotations(ToolAnnotations::new().open_world(true)) +} + +fn canonical_schema(runtime: &StandaloneRuntime) -> Map { + let operations = runtime + .catalog() + .operations() + .map(|operation| Value::String(operation.name().to_string())) + .collect::>(); + Map::from_iter([ + ( + "$schema".into(), + Value::String("https://json-schema.org/draft/2020-12/schema".into()), + ), + ("type".into(), Value::String("object".into())), + ( + "properties".into(), + json!({ + "operation": {"type":"string", "enum": operations}, + "parameters": {"type":"object"}, + "confirmed": {"type":"boolean", "default":false}, + "idempotency_key": {"type":"string", "minLength":1, "maxLength":256}, + "actor": {"type":"string", "minLength":1, "maxLength":256} + }), + ), + ("required".into(), json!(["operation", "parameters"])), + ("additionalProperties".into(), Value::Bool(false)), + ]) +} + +fn add_execution_fields(mut schema: Value) -> Value { + let Some(branches) = schema.get_mut("oneOf").and_then(Value::as_array_mut) else { + return schema; + }; + for branch in branches { + let Some(properties) = branch.get_mut("properties").and_then(Value::as_object_mut) else { + continue; + }; + properties.insert( + "confirmed".into(), + json!({"type":"boolean","default":false}), + ); + properties.insert( + "idempotency_key".into(), + json!({"type":"string","minLength":1,"maxLength":256}), + ); + properties.insert( + "actor".into(), + json!({"type":"string","minLength":1,"maxLength":256}), + ); + } + schema +} + +async fn execute_canonical( + runtime: &StandaloneRuntime, + arguments: Value, + peer: &Peer, +) -> Result { + let object = arguments.as_object().ok_or_else(|| { + StandaloneError::UnknownOperation("MCP arguments must be an object".into()) + })?; + let operation = object + .get("operation") + .and_then(Value::as_str) + .ok_or_else(|| StandaloneError::UnknownOperation("operation is required".into()))?; + let parameters = object + .get("parameters") + .cloned() + .unwrap_or_else(|| json!({})); + let mut options = surface_options(object); + let cancellation = CancellationToken::new(); + match runtime + .execute(operation, ¶meters, &options, &cancellation) + .await + { + Err(error @ StandaloneError::ConfirmationRequired(_)) if !options.confirmed => { + elicit_confirmation(peer, error.plan().expect("confirmation carries plan")).await?; + options.confirmed = true; + runtime + .execute(operation, ¶meters, &options, &cancellation) + .await + } + result => result, + } +} + +async fn execute_legacy( + runtime: &StandaloneRuntime, + tool: LegacyTool, + arguments: Value, + peer: &Peer, +) -> Result { + let mut object = arguments.as_object().cloned().ok_or_else(|| { + StandaloneError::UnknownOperation("MCP arguments must be an object".into()) + })?; + let mut options = surface_options(&object); + object.remove("confirmed"); + object.remove("idempotency_key"); + object.remove("actor"); + let input = Value::Object(object); + let cancellation = CancellationToken::new(); + match runtime + .execute_legacy(tool, &input, &options, &cancellation) + .await + { + Err(error @ StandaloneError::ConfirmationRequired(_)) if !options.confirmed => { + elicit_confirmation(peer, error.plan().expect("confirmation carries plan")).await?; + options.confirmed = true; + runtime + .execute_legacy(tool, &input, &options, &cancellation) + .await + } + result => result, + } +} + +async fn elicit_confirmation( + peer: &Peer, + plan: &OperationPlan, +) -> Result<(), StandaloneError> { + let target = + serde_json::to_string(plan.target()).unwrap_or_else(|_| "".into()); + let message = format!( + "Confirm {} for target {} using plan {}. This operation may change infrastructure state.", + plan.operation(), + target, + plan.fingerprint().as_str() + ); + let outcome = + tokio::time::timeout(ELICIT_TIMEOUT, peer.elicit::(message)).await; + match outcome { + Err(_) => Err(confirmation_error("MCP mutation confirmation timed out")), + Ok(Err(ElicitationError::UserDeclined)) => { + Err(confirmation_error("MCP mutation confirmation was declined")) + } + Ok(Err(ElicitationError::UserCancelled)) => Err(confirmation_error( + "MCP mutation confirmation was cancelled", + )), + Ok(Err(ElicitationError::CapabilityNotSupported)) => Err(confirmation_error( + "MCP client does not support elicitation; provide confirmed=true only from a trusted caller", + )), + Ok(Err(error)) => Err(confirmation_error(&format!( + "MCP mutation confirmation failed: {error}" + ))), + Ok(Ok(Some(answer))) if answer.confirm && answer.understood => Ok(()), + Ok(Ok(_)) => Err(confirmation_error( + "MCP mutation confirmation requires both confirm and understood", + )), + } +} + +fn confirmation_error(message: &str) -> StandaloneError { + StandaloneError::Other(anyhow::anyhow!(message.to_owned())) +} + +fn surface_options(object: &Map) -> ExecuteOptions { + ExecuteOptions { + confirmed: object + .get("confirmed") + .and_then(Value::as_bool) + .unwrap_or(false), + idempotency_key: object + .get("idempotency_key") + .and_then(Value::as_str) + .map(str::to_owned), + actor: object + .get("actor") + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| Some("mcp".into())), + } +} + +fn error_result(error: StandaloneError) -> CallToolResult { + let payload = if let Some(plan) = error.plan() { + json!({ + "error": "confirmation_required", + "message": error.to_string(), + "plan": plan, + }) + } else { + json!({"error":"execution_failed", "message":error.to_string()}) + }; + CallToolResult::structured_error(payload) +} + +#[cfg(test)] +#[path = "mcp_tests.rs"] +mod tests; diff --git a/apps/synapse/src/mcp_tests.rs b/apps/synapse/src/mcp_tests.rs new file mode 100644 index 00000000..a21f055f --- /dev/null +++ b/apps/synapse/src/mcp_tests.rs @@ -0,0 +1,31 @@ +use super::*; +use crate::SynapseConfig; + +#[test] +fn mcp_exposes_canonical_and_optional_legacy_tools() { + let runtime = StandaloneRuntime::from_config(SynapseConfig::default()).unwrap(); + let tools = tool_definitions(&runtime); + assert_eq!(tools.len(), 3); + assert_eq!(tools[0].name.as_ref(), "synapse"); + assert_eq!(tools[1].name.as_ref(), "flux"); + assert_eq!(tools[2].name.as_ref(), "scout"); +} + +#[test] +fn canonical_schema_covers_every_operation() { + let runtime = StandaloneRuntime::from_config(SynapseConfig::default()).unwrap(); + let schema = canonical_schema(&runtime); + let operations = schema["properties"]["operation"]["enum"] + .as_array() + .unwrap(); + assert_eq!(operations.len(), 59); +} + +#[test] +fn mutation_elicitation_requires_two_explicit_affirmations() { + let schema = schemars::schema_for!(ConfirmMutation); + let value = serde_json::to_value(schema).unwrap(); + let required = value["required"].as_array().unwrap(); + assert!(required.contains(&serde_json::json!("confirm"))); + assert!(required.contains(&serde_json::json!("understood"))); +} diff --git a/apps/synapse/src/openapi.rs b/apps/synapse/src/openapi.rs new file mode 100644 index 00000000..56d0dbd9 --- /dev/null +++ b/apps/synapse/src/openapi.rs @@ -0,0 +1,93 @@ +use serde_json::{Map, Value, json}; +use soma_ops::{AccessClass, OperationName}; + +use crate::StandaloneRuntime; + +pub fn document(runtime: &StandaloneRuntime) -> Value { + let mut paths = Map::from_iter([ + ("/health".into(), simple_get("Health")), + ("/ready".into(), simple_get("Readiness")), + ("/status".into(), simple_get("Status")), + ("/activity".into(), simple_get("Recent activity")), + ( + "/operations".into(), + simple_get("Canonical operation catalog"), + ), + ]); + for operation in runtime.catalog().operations() { + let name = operation.name(); + let parameter_schema = runtime + .catalog() + .parameter_schema(name) + .expect("every operation has a parameter schema") + .schema() + .clone(); + let result_schema = runtime + .catalog() + .result_schema(name) + .expect("every operation has a result schema") + .schema() + .clone(); + let request_schema = json!({ + "type":"object", + "properties":{ + "parameters":parameter_schema, + "confirmed":{"type":"boolean","default":false}, + "idempotency_key":{"type":"string","minLength":1,"maxLength":256}, + "actor":{"type":"string","minLength":1,"maxLength":256} + }, + "required":["parameters"], + "additionalProperties":false + }); + paths.insert( + format!("/v1/operations/{}/execute", name.as_str()), + post_operation(name, request_schema.clone(), result_schema), + ); + if operation.access() == AccessClass::Mutation { + paths.insert( + format!("/v1/operations/{}/plan", name.as_str()), + post_operation(name, request_schema, json!({"type":"object"})), + ); + } + } + json!({ + "openapi":"3.1.0", + "info":{ + "title":"Synapse Canonical Operations API", + "version":env!("CARGO_PKG_VERSION"), + "description":"Standalone CLI, REST, and MCP product over the 59-operation canonical engine" + }, + "paths":paths, + "components":{ + "securitySchemes":{ + "bearerAuth":{"type":"http","scheme":"bearer"} + } + } + }) +} + +fn simple_get(summary: &str) -> Value { + json!({"get":{"summary":summary,"responses":{"200":{"description":"Success"}}}}) +} + +fn post_operation(name: &OperationName, request: Value, response: Value) -> Value { + json!({ + "post":{ + "operationId":name.as_str().replace('.', "_"), + "summary":name.as_str(), + "requestBody":{ + "required":true, + "content":{"application/json":{"schema":request}} + }, + "responses":{ + "200":{"description":"Canonical result","content":{"application/json":{"schema":response}}}, + "400":{"description":"Invalid request"}, + "428":{"description":"Mutation confirmation required"} + } + } + }) +} + +#[cfg(test)] +#[path = "openapi_tests.rs"] +mod tests; diff --git a/apps/synapse/src/openapi_tests.rs b/apps/synapse/src/openapi_tests.rs new file mode 100644 index 00000000..f5c9e7e7 --- /dev/null +++ b/apps/synapse/src/openapi_tests.rs @@ -0,0 +1,16 @@ +use super::*; +use crate::SynapseConfig; + +#[test] +fn openapi_contains_every_execute_and_mutation_plan_path() { + let runtime = StandaloneRuntime::from_config(SynapseConfig::default()).unwrap(); + let document = document(&runtime); + let paths = document["paths"].as_object().unwrap(); + let execute = paths + .keys() + .filter(|path| path.ends_with("/execute")) + .count(); + let plans = paths.keys().filter(|path| path.ends_with("/plan")).count(); + assert_eq!(execute, 59); + assert_eq!(plans, 21); +} diff --git a/apps/synapse/src/runtime.rs b/apps/synapse/src/runtime.rs new file mode 100644 index 00000000..7f255a0f --- /dev/null +++ b/apps/synapse/src/runtime.rs @@ -0,0 +1,240 @@ +use std::sync::Arc; +use std::time::Instant; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use soma_fleet::OpenSshDriver; +use soma_ops::{ + AccessClass, ActorRef, AuthorizationEvidence, AuthorizationScope, IdempotencyKey, + OperationContext, OperationName, OperationPlan, ProducerRef, Timestamp, +}; +use synapse_application::{ + ExecutionError, LegacyTool, SynapseCatalog, SynapseMutationRuntime, SynapseReadRuntime, +}; +use tokio_util::sync::CancellationToken; + +use crate::activity::ActivityLog; +use crate::config::SynapseConfig; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ExecuteOptions { + pub confirmed: bool, + pub idempotency_key: Option, + pub actor: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum StandaloneError { + #[error("unknown canonical operation: {0}")] + UnknownOperation(String), + #[error("mutation confirmation is required")] + ConfirmationRequired(Box), + #[error(transparent)] + Execution(#[from] ExecutionError), + #[error(transparent)] + Compatibility(#[from] synapse_application::CompatibilityError), + #[error(transparent)] + FleetIdentity(#[from] soma_fleet::IdentityError), + #[error(transparent)] + Infra(#[from] soma_infra::InfraError), + #[error(transparent)] + OperationIdentity(#[from] soma_ops::IdentityError), + #[error(transparent)] + Idempotency(#[from] soma_ops::IdempotencyKeyError), + #[error(transparent)] + Authorization(#[from] soma_ops::AuthorizationError), + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl StandaloneError { + pub fn plan(&self) -> Option<&OperationPlan> { + match self { + Self::ConfirmationRequired(plan) => Some(plan), + _ => None, + } + } +} + +pub struct StandaloneRuntime { + pub(crate) config: SynapseConfig, + pub(crate) catalog: &'static SynapseCatalog, + pub(crate) read: SynapseReadRuntime, + pub(crate) mutation: SynapseMutationRuntime, + pub(crate) ssh: Arc, + pub(crate) activity: ActivityLog, +} + +impl StandaloneRuntime { + pub fn config(&self) -> &SynapseConfig { + &self.config + } + + pub fn catalog(&self) -> &'static SynapseCatalog { + self.catalog + } + + pub fn activity(&self) -> &ActivityLog { + &self.activity + } + + pub async fn plan( + &self, + operation: &str, + parameters: &Value, + options: &ExecuteOptions, + ) -> Result { + let operation = OperationName::new(operation) + .map_err(|_| StandaloneError::UnknownOperation(operation.to_owned()))?; + let spec = self + .catalog + .operation(&operation) + .ok_or_else(|| StandaloneError::UnknownOperation(operation.to_string()))?; + if spec.access() != AccessClass::Mutation { + return Err(StandaloneError::UnknownOperation(format!( + "{} is not a mutation", + operation + ))); + } + let context = self.context(spec.idempotent(), options)?; + self.mutation + .plan(&operation, parameters, &context) + .await + .map_err(Into::into) + } + + pub async fn execute( + &self, + operation: &str, + parameters: &Value, + options: &ExecuteOptions, + cancellation: &CancellationToken, + ) -> Result { + let started = Instant::now(); + let result = self + .execute_inner(operation, parameters, options, cancellation) + .await; + self.activity.record( + options.actor.as_deref().unwrap_or("standalone"), + operation, + result.is_ok(), + started.elapsed(), + result.as_ref().err().map(ToString::to_string), + ); + result + } + + async fn execute_inner( + &self, + operation: &str, + parameters: &Value, + options: &ExecuteOptions, + cancellation: &CancellationToken, + ) -> Result { + let operation = OperationName::new(operation) + .map_err(|_| StandaloneError::UnknownOperation(operation.to_owned()))?; + let spec = self + .catalog + .operation(&operation) + .ok_or_else(|| StandaloneError::UnknownOperation(operation.to_string()))?; + if spec.access() == AccessClass::Read { + return self + .read + .execute(&operation, parameters, cancellation) + .await + .map_err(Into::into); + } + + let context = self.context(spec.idempotent(), options)?; + let plan = self.mutation.plan(&operation, parameters, &context).await?; + if !options.confirmed && !self.config.server.allow_mutations { + return Err(StandaloneError::ConfirmationRequired(Box::new(plan))); + } + let now = Timestamp::now(); + let ttl = + i64::try_from(self.config.server.authorization_ttl().as_millis()).unwrap_or(i64::MAX); + let authorization = AuthorizationEvidence::new( + ProducerRef::new("synapse-standalone", env!("CARGO_PKG_VERSION"))?, + AuthorizationScope::new(operation.clone(), plan.target().clone()), + now, + Timestamp::from_unix_millis(now.unix_millis().saturating_add(ttl)), + )? + .with_plan_fingerprint(plan.fingerprint().clone()) + .with_confirmation_ref(if options.confirmed { + "standalone:explicit-confirmation" + } else { + "standalone:configured-auto-confirmation" + })?; + let result = self + .mutation + .execute( + &operation, + parameters, + &context, + &plan, + &authorization, + cancellation, + ) + .await?; + serde_json::to_value(result).map_err(|error| anyhow::anyhow!(error).into()) + } + + pub async fn execute_legacy( + &self, + tool: LegacyTool, + input: &Value, + options: &ExecuteOptions, + cancellation: &CancellationToken, + ) -> Result { + let normalized = self.catalog.normalize_legacy_request(tool, input)?; + self.execute( + normalized.operation().as_str(), + normalized.parameters(), + options, + cancellation, + ) + .await + } + + pub fn operation_catalog_json(&self) -> Value { + serde_json::to_value(self.catalog.operations().collect::>()) + .expect("checked-in operations serialize") + } + + pub async fn shutdown(&self) { + let _ = self.ssh.shutdown().await; + } + + fn context( + &self, + idempotent: bool, + options: &ExecuteOptions, + ) -> Result { + let now = Timestamp::now(); + let timeout = + i64::try_from(self.config.server.request_timeout().as_millis()).unwrap_or(i64::MAX); + let actor = ActorRef::new( + "synapse", + options.actor.as_deref().unwrap_or("standalone-client"), + )?; + let mut context = + OperationContext::new() + .with_actor(actor) + .with_deadline(Timestamp::from_unix_millis( + now.unix_millis().saturating_add(timeout), + )); + if idempotent { + let key = options + .idempotency_key + .clone() + .unwrap_or_else(|| format!("synapse-{}", context.operation_id())); + context = context.with_idempotency_key(IdempotencyKey::new(key)?); + } + Ok(context) + } +} + +#[cfg(test)] +#[path = "runtime_tests.rs"] +mod tests; diff --git a/apps/synapse/src/runtime_tests.rs b/apps/synapse/src/runtime_tests.rs new file mode 100644 index 00000000..3b53f411 --- /dev/null +++ b/apps/synapse/src/runtime_tests.rs @@ -0,0 +1,35 @@ +use super::*; + +#[tokio::test] +async fn default_runtime_executes_product_help_through_canonical_read_engine() { + let runtime = StandaloneRuntime::from_config(SynapseConfig::default()).unwrap(); + let value = runtime + .execute( + "product.help", + &serde_json::json!({}), + &ExecuteOptions::default(), + &CancellationToken::new(), + ) + .await + .unwrap(); + assert!( + value["operations"] + .as_array() + .is_some_and(|items| !items.is_empty()) + ); +} + +#[tokio::test] +async fn mutations_return_the_exact_plan_until_confirmed() { + let runtime = StandaloneRuntime::from_config(SynapseConfig::default()).unwrap(); + let error = runtime + .execute( + "container.start", + &serde_json::json!({"host":"local","container_id":"missing"}), + &ExecuteOptions::default(), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + assert!(error.plan().is_some()); +} diff --git a/apps/synapse/tests/stdio_mcp.rs b/apps/synapse/tests/stdio_mcp.rs new file mode 100644 index 00000000..4e27c36d --- /dev/null +++ b/apps/synapse/tests/stdio_mcp.rs @@ -0,0 +1,74 @@ +use std::process::Stdio; +use std::time::Duration; + +use rmcp::{ + model::CallToolRequestParams, + service::ServiceExt, + transport::{ConfigureCommandExt, TokioChildProcess}, +}; +use serde_json::json; +use tokio::process::Command; + +const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(30); +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(15); + +#[tokio::test] +async fn stdio_binary_discovers_and_calls_canonical_runtime() -> anyhow::Result<()> { + let temp = tempfile::tempdir()?; + let work = temp.path().join("work"); + std::fs::create_dir(&work)?; + let binary = env!("CARGO_BIN_EXE_synapse"); + let (transport, _stderr) = + TokioChildProcess::builder(Command::new(binary).configure(|command| { + command + .arg("mcp") + .current_dir(&work) + .env("HOME", temp.path()) + .env_remove("SYNAPSE_CONFIG") + .env("RUST_LOG", "warn"); + })) + .stderr(Stdio::piped()) + .spawn()?; + let service = tokio::time::timeout(INITIALIZE_TIMEOUT, ().serve(transport)) + .await + .map_err(|_| anyhow::anyhow!("stdio MCP initialization timed out"))??; + + let tools = tokio::time::timeout(RESPONSE_TIMEOUT, service.list_tools(Default::default())) + .await + .map_err(|_| anyhow::anyhow!("stdio MCP tools/list timed out"))??; + let names = tools + .tools + .iter() + .map(|tool| tool.name.as_ref()) + .collect::>(); + assert_eq!(names, vec!["synapse", "flux", "scout"]); + + let result = tokio::time::timeout( + RESPONSE_TIMEOUT, + service.call_tool( + CallToolRequestParams::new("synapse").with_arguments( + json!({"operation":"product.help","parameters":{}}) + .as_object() + .unwrap() + .clone(), + ), + ), + ) + .await + .map_err(|_| anyhow::anyhow!("stdio MCP tool call timed out"))??; + assert_eq!(result.is_error, Some(false)); + let output = result + .structured_content + .expect("canonical tool result should be structured JSON"); + assert!( + output["operations"] + .as_array() + .is_some_and(|operations| operations.len() == 59), + "unexpected product.help output: {output}" + ); + + tokio::time::timeout(RESPONSE_TIMEOUT, service.cancel()) + .await + .map_err(|_| anyhow::anyhow!("stdio MCP shutdown timed out"))??; + Ok(()) +} diff --git a/config/Dockerfile b/config/Dockerfile index ad007c67..79f5ee40 100644 --- a/config/Dockerfile +++ b/config/Dockerfile @@ -52,6 +52,7 @@ RUN apt-get update && apt-get install -y pkg-config libssl-dev libseccomp-dev && # builds that only change app code skip this expensive step entirely. COPY Cargo.toml Cargo.lock ./ COPY apps/soma/ apps/soma/ +COPY apps/synapse/ apps/synapse/ COPY crates/ crates/ # Workspace member: cargo refuses to load the workspace without it, even # though the soma binary does not depend on the PyO3 extension. diff --git a/crates/shared/operations/fleet/src/command.rs b/crates/shared/operations/fleet/src/command.rs index 7672e33e..fc65993c 100644 --- a/crates/shared/operations/fleet/src/command.rs +++ b/crates/shared/operations/fleet/src/command.rs @@ -7,8 +7,10 @@ use crate::{RequestError, request::validate_absolute_path}; const MAX_PROGRAM_CHARS: usize = 4096; const MAX_ARGUMENT_CHARS: usize = 4096; -const MAX_ARGUMENTS: usize = 256; +// Allows the 256 canonical command arguments plus a bounded typed-launcher prelude. +const MAX_ARGUMENTS: usize = 320; const MAX_OUTPUT_BYTES: usize = 16 * 1024 * 1024; +const MAX_STDIN_BYTES: usize = 64 * 1024 * 1024; /// Bounded exec-style command request with no shell interpretation. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -16,6 +18,7 @@ pub struct CommandRequest { program: String, args: Vec, working_dir: Option, + stdin: Option>, deadline: Timestamp, max_stdout_bytes: usize, max_stderr_bytes: usize, @@ -50,6 +53,7 @@ impl CommandRequest { program, args, working_dir: None, + stdin: None, deadline, max_stdout_bytes: 256 * 1024, max_stderr_bytes: 256 * 1024, @@ -65,6 +69,18 @@ impl CommandRequest { Ok(self) } + /// Sets bounded stdin bytes delivered without shell interpretation. + pub fn with_stdin(mut self, stdin: Vec) -> Result { + if stdin.len() > MAX_STDIN_BYTES { + return Err(RequestError::InvalidStdinLimit { + bytes: stdin.len(), + max: MAX_STDIN_BYTES, + }); + } + self.stdin = Some(stdin); + Ok(self) + } + /// Sets bounded stdout and stderr budgets. pub fn with_output_limits( mut self, @@ -105,6 +121,12 @@ impl CommandRequest { self.working_dir.as_deref() } + /// Returns optional bounded stdin bytes. + #[must_use] + pub fn stdin(&self) -> Option<&[u8]> { + self.stdin.as_deref() + } + /// Returns the request deadline. #[must_use] pub const fn deadline(&self) -> Timestamp { diff --git a/crates/shared/operations/fleet/src/command_tests.rs b/crates/shared/operations/fleet/src/command_tests.rs index 2b2d7952..2cfe9818 100644 --- a/crates/shared/operations/fleet/src/command_tests.rs +++ b/crates/shared/operations/fleet/src/command_tests.rs @@ -14,3 +14,40 @@ fn command_contract_defaults_are_bounded() { assert_eq!(request.max_stderr_bytes(), 256 * 1024); assert!(request.working_dir().is_none()); } + +#[test] +fn command_transport_allows_bounded_typed_launcher_overhead() { + let accepted = vec!["x"; 320]; + assert!(CommandRequest::new("python3", accepted, Timestamp::from_unix_millis(100),).is_ok()); + let rejected = vec!["x"; 321]; + assert!(matches!( + CommandRequest::new("python3", rejected, Timestamp::from_unix_millis(100)), + Err(RequestError::TooManyArguments { + count: 321, + max: 320 + }) + )); +} + +#[test] +fn command_stdin_is_optional_and_bounded() { + let request = CommandRequest::new( + "cat", + Vec::::new(), + Timestamp::from_unix_millis(100), + ) + .unwrap() + .with_stdin(b"hello".to_vec()) + .unwrap(); + assert_eq!(request.stdin(), Some(b"hello".as_slice())); + assert!( + CommandRequest::new( + "cat", + Vec::::new(), + Timestamp::from_unix_millis(100), + ) + .unwrap() + .with_stdin(vec![0; 64 * 1024 * 1024 + 1]) + .is_err() + ); +} diff --git a/crates/shared/operations/fleet/src/fanout.rs b/crates/shared/operations/fleet/src/fanout.rs index 00a057e5..975f512d 100644 --- a/crates/shared/operations/fleet/src/fanout.rs +++ b/crates/shared/operations/fleet/src/fanout.rs @@ -95,6 +95,12 @@ impl TargetOutcome { pub fn kind(&self) -> &TargetOutcomeKind { &self.kind } + + /// Consumes the outcome into its stable index, host, and terminal kind. + #[must_use] + pub fn into_parts(self) -> (usize, HostId, TargetOutcomeKind) { + (self.index, self.host, self.kind) + } } /// Complete stable-order fanout report. @@ -110,6 +116,12 @@ impl FanoutReport { &self.outcomes } + /// Consumes the report and returns outcomes in original target order. + #[must_use] + pub fn into_outcomes(self) -> Vec> { + self.outcomes + } + /// Returns successful target count. #[must_use] pub fn success_count(&self) -> usize { @@ -182,16 +194,41 @@ impl FanoutScheduler { E: Send, F: Fn(HostRecord, CancellationToken) -> Fut + Send + Sync, Fut: Future> + Send, + { + self.run_with_payload( + targets.into_iter().map(|host| (host, ())).collect(), + cancellation, + move |host, (), child| operation(host, child), + ) + .await + } + + /// Executes one operation for every host/payload pair with bounded concurrency. + /// + /// Payloads remain paired with their original target index, allowing callers + /// to fan out distinct requests to the same host without key-based races. + pub async fn run_with_payload( + &self, + targets: Vec<(HostRecord, P)>, + cancellation: CancellationToken, + operation: F, + ) -> FanoutReport + where + P: Send, + T: Send, + E: Send, + F: Fn(HostRecord, P, CancellationToken) -> Fut + Send + Sync, + Fut: Future> + Send, { let operation = Arc::new(operation); let timeout = self.policy.per_target_timeout; let mut outcomes = stream::iter(targets.into_iter().enumerate()) - .map(|(index, host)| { + .map(|(index, (host, payload))| { let operation = Arc::clone(&operation); let child = cancellation.child_token(); async move { let host_id = host.id().clone(); - let future = operation(host, child.clone()); + let future = operation(host, payload, child.clone()); let kind = tokio::select! { () = child.cancelled() => TargetOutcomeKind::Cancelled, result = tokio::time::timeout(timeout, future) => match result { diff --git a/crates/shared/operations/fleet/src/fanout_tests.rs b/crates/shared/operations/fleet/src/fanout_tests.rs index 2e55f14d..ef0bea94 100644 --- a/crates/shared/operations/fleet/src/fanout_tests.rs +++ b/crates/shared/operations/fleet/src/fanout_tests.rs @@ -123,3 +123,43 @@ async fn cancellation_accounts_for_inflight_and_queued_targets() { assert_eq!(report.cancelled_count(), 5); assert_eq!(report.success_count(), 0); } + +#[tokio::test(flavor = "current_thread")] +async fn payload_fanout_preserves_duplicate_host_requests_by_index() { + let host = HostRecord::new(HostId::new("same").unwrap(), HostEndpoint::Local); + let scheduler = FanoutScheduler::new(FanoutPolicy::new(2, Duration::from_secs(1)).unwrap()); + let report = scheduler + .run_with_payload( + vec![(host.clone(), "first"), (host, "second")], + CancellationToken::new(), + |_host, payload, _| async move { Ok::<_, ()>(payload) }, + ) + .await; + + assert_eq!(report.outcomes().len(), 2); + assert!(matches!( + report.outcomes()[0].kind(), + TargetOutcomeKind::Succeeded("first") + )); + assert!(matches!( + report.outcomes()[1].kind(), + TargetOutcomeKind::Succeeded("second") + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn fanout_report_can_be_consumed_without_cloning_results() { + let scheduler = FanoutScheduler::new(FanoutPolicy::new(1, Duration::from_secs(1)).unwrap()); + let report = scheduler + .run( + targets(1), + CancellationToken::new(), + |_host, _| async move { Ok::<_, String>(String::from("owned")) }, + ) + .await; + let outcomes = report.into_outcomes(); + assert!(matches!( + &outcomes[0].kind, + TargetOutcomeKind::Succeeded(value) if value == "owned" + )); +} diff --git a/crates/shared/operations/fleet/src/openssh_driver.rs b/crates/shared/operations/fleet/src/openssh_driver.rs index 9fe62f23..b186f7cb 100644 --- a/crates/shared/operations/fleet/src/openssh_driver.rs +++ b/crates/shared/operations/fleet/src/openssh_driver.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; +use tokio::io::AsyncWriteExt; use tokio_util::sync::CancellationToken; use crate::{ @@ -105,12 +106,27 @@ impl CommandExecutor for OpenSshDriver { let mut command = session.arc_command(request.program().to_owned()); command.args(request.args()); command + .stdin(if request.stdin().is_some() { + openssh::Stdio::piped() + } else { + openssh::Stdio::null() + }) .stdout(openssh::Stdio::piped()) .stderr(openssh::Stdio::piped()); let mut child = command.spawn().await.map_err(|error| FleetError::Command { host: host.id().clone(), message: format!("OpenSSH spawn failed: {error}"), })?; + let input = match request.stdin() { + Some(bytes) => Some(( + child.stdin().take().ok_or_else(|| FleetError::Command { + host: host.id().clone(), + message: "OpenSSH stdin pipe unavailable".into(), + })?, + bytes.to_vec(), + )), + None => None, + }; let stdout = child.stdout().take().ok_or_else(|| FleetError::Command { host: host.id().clone(), message: "OpenSSH stdout pipe unavailable".into(), @@ -128,7 +144,17 @@ impl CommandExecutor for OpenSshDriver { ) .map_err(openssh::Error::ChildIo) }; - let (status, (stdout, stderr)) = tokio::try_join!(child.wait(), streams)?; + let input = async move { + if let Some((mut stdin, bytes)) = input { + stdin + .write_all(&bytes) + .await + .map_err(openssh::Error::ChildIo)?; + stdin.shutdown().await.map_err(openssh::Error::ChildIo)?; + } + Ok::<_, openssh::Error>(()) + }; + let (status, (stdout, stderr), ()) = tokio::try_join!(child.wait(), streams, input)?; Ok::<_, openssh::Error>((status, stdout, stderr)) }; let mut completion = Box::pin(completion); diff --git a/crates/shared/operations/fleet/src/process_driver.rs b/crates/shared/operations/fleet/src/process_driver.rs index 95ecfb94..f669d56e 100644 --- a/crates/shared/operations/fleet/src/process_driver.rs +++ b/crates/shared/operations/fleet/src/process_driver.rs @@ -2,6 +2,7 @@ use std::process::Stdio; use std::time::Duration; use async_trait::async_trait; +use tokio::io::AsyncWriteExt; use tokio::process::Command; use tokio_util::sync::CancellationToken; @@ -37,7 +38,11 @@ impl CommandExecutor for LocalProcessDriver { let mut command = Command::new(request.program()); command .args(request.args()) - .stdin(Stdio::null()) + .stdin(if request.stdin().is_some() { + Stdio::piped() + } else { + Stdio::null() + }) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); @@ -48,6 +53,16 @@ impl CommandExecutor for LocalProcessDriver { host: host.id().clone(), message: format!("spawn failed: {error}"), })?; + let input = match request.stdin() { + Some(bytes) => Some(( + child.stdin.take().ok_or_else(|| FleetError::Command { + host: host.id().clone(), + message: "stdin pipe unavailable".into(), + })?, + bytes.to_vec(), + )), + None => None, + }; let stdout = child.stdout.take().ok_or_else(|| FleetError::Command { host: host.id().clone(), message: "stdout pipe unavailable".into(), @@ -57,13 +72,24 @@ impl CommandExecutor for LocalProcessDriver { message: "stderr pipe unavailable".into(), })?; + let input = async move { + if let Some((mut stdin, bytes)) = input { + stdin.write_all(&bytes).await?; + stdin.shutdown().await?; + } + Ok::<_, std::io::Error>(()) + }; let completion = async { - let (status, (stdout, stderr)) = tokio::try_join!(child.wait(), async { - tokio::try_join!( - drain_bounded(stdout, request.max_stdout_bytes()), - drain_bounded(stderr, request.max_stderr_bytes()) - ) - })?; + let (status, (stdout, stderr), ()) = tokio::try_join!( + child.wait(), + async { + tokio::try_join!( + drain_bounded(stdout, request.max_stdout_bytes()), + drain_bounded(stderr, request.max_stderr_bytes()) + ) + }, + input + )?; Ok::<_, std::io::Error>((status, stdout, stderr)) }; diff --git a/crates/shared/operations/fleet/src/request.rs b/crates/shared/operations/fleet/src/request.rs index e0d11dfd..78df8f25 100644 --- a/crates/shared/operations/fleet/src/request.rs +++ b/crates/shared/operations/fleet/src/request.rs @@ -32,6 +32,14 @@ pub enum RequestError { /// Requested byte limit. bytes: usize, }, + /// Command stdin exceeded the hard ceiling. + #[error("invalid command stdin length: {bytes} bytes; maximum is {max}")] + InvalidStdinLimit { + /// Supplied stdin bytes. + bytes: usize, + /// Hard maximum. + max: usize, + }, /// Transfer byte bound was zero or exceeded the hard ceiling. #[error("invalid transfer limit {bytes}; maximum is {max}")] InvalidTransferLimit { diff --git a/crates/shared/operations/fleet/src/request_tests.rs b/crates/shared/operations/fleet/src/request_tests.rs index 40079a26..eca2309b 100644 --- a/crates/shared/operations/fleet/src/request_tests.rs +++ b/crates/shared/operations/fleet/src/request_tests.rs @@ -31,7 +31,7 @@ fn command_requests_preserve_exec_style_arguments() { fn command_requests_reject_invalid_bounds() { assert!(CommandRequest::new("", Vec::::new(), deadline()).is_err()); assert!( - CommandRequest::new("echo", (0..257).map(|index| index.to_string()), deadline()).is_err() + CommandRequest::new("echo", (0..321).map(|index| index.to_string()), deadline()).is_err() ); assert!(CommandRequest::new("echo", ["hello\0world"], deadline()).is_err()); assert!( diff --git a/crates/shared/operations/fleet/src/transfer.rs b/crates/shared/operations/fleet/src/transfer.rs index b3d293fe..200e31f5 100644 --- a/crates/shared/operations/fleet/src/transfer.rs +++ b/crates/shared/operations/fleet/src/transfer.rs @@ -130,6 +130,18 @@ impl TransferReceipt { self.bytes } + /// Returns the source SHA-256 when recorded. + #[must_use] + pub fn source_sha256(&self) -> Option<&str> { + self.source_sha256.as_deref() + } + + /// Returns the destination SHA-256 when recorded. + #[must_use] + pub fn destination_sha256(&self) -> Option<&str> { + self.destination_sha256.as_deref() + } + /// Returns whether source and destination digests match. #[must_use] pub fn verified(&self) -> bool { diff --git a/crates/shared/operations/fleet/src/transfer_tests.rs b/crates/shared/operations/fleet/src/transfer_tests.rs index baf3660c..90daff31 100644 --- a/crates/shared/operations/fleet/src/transfer_tests.rs +++ b/crates/shared/operations/fleet/src/transfer_tests.rs @@ -16,3 +16,14 @@ fn transfer_contract_preserves_host_identity() { assert_eq!(request.source_host().as_str(), "dookie"); assert_eq!(request.destination_host().as_str(), "squirts"); } + +#[test] +fn transfer_receipt_exposes_verified_digests() { + let digest = "a".repeat(64); + let receipt = TransferReceipt::new(7) + .with_digests(digest.clone(), digest.clone()) + .unwrap(); + assert_eq!(receipt.source_sha256(), Some(digest.as_str())); + assert_eq!(receipt.destination_sha256(), Some(digest.as_str())); + assert!(receipt.verified()); +} diff --git a/crates/shared/operations/infra/README.md b/crates/shared/operations/infra/README.md index adf4c57a..b2a21080 100644 --- a/crates/shared/operations/infra/README.md +++ b/crates/shared/operations/infra/README.md @@ -26,6 +26,11 @@ The mutation surface includes: - verified Docker, container-image, and Compose image pulls; - context-bound verified Docker and Compose image builds; - configuration-bound verified container and Compose replacements; +- bounded non-TTY container exec with direct argv and inspected exit status; +- descriptor-bound allowlisted host execution plus stable partial fanout; +- exact-identity Docker image removal and inventory-bound prune with independent absence checks; +- configuration-bound Compose teardown with optional explicit volume removal; +- digest-verified descriptor-confined file transfer between local or strict-SSH hosts; - descriptor-confined context fingerprints with explicit root, file-count, and byte ceilings; - canonical bounded phase progress and build logs whose delivery failures do not rewrite execution truth; - OCI artifact references and local image-ID/digest verification; @@ -36,8 +41,8 @@ The shared crate does not authorize mutations. Product runtimes must bind a dete ## Feature flags -- `process-driver`: command-backed Compose, process, log, ZFS, lifecycle mutation, artifact pull, context fingerprint, image build, and Compose replacement support; -- `bollard-driver`: local Docker reads, container lifecycle and replacement mutations, and image-pull streams; +- `process-driver`: command-backed Compose, process, log, ZFS, lifecycle mutation, artifact pull, context fingerprint, image build, Compose replacement/teardown, bounded host execution, and descriptor-confined file transfer support; +- `bollard-driver`: local Docker reads, container lifecycle, replacement, non-TTY exec, image-pull streams, image removal, and fixed-scope prune mutations; - `remote-bollard`: strict-SSH Docker Unix-socket forwarding and pooled remote clients; - `linux-filesystem`: Linux `openat2` filesystem inspection. @@ -64,6 +69,16 @@ The default build exposes neutral models, traits, coordinators, and deterministi - container replacement captures and fingerprints image, env, command, entrypoint, labels, volumes, host config, and network attachments before removal; - container replacement rechecks configuration immediately before destructive send and reports the furthest completed stage; - Compose replacement binds normalized configuration and service pre-state, then verifies the exact healthy service set after force-recreate; +- container exec never uses a shell or TTY, crosses the uncertain send boundary only at `start_exec`, and inspects the final exit status; +- host exec admits only a fixed read-oriented command allowlist with typed option grammars; +- host filesystem operands and working directories are opened beneath explicit roots with `O_NOFOLLOW` and passed through inherited `/proc/self/fd` handles; +- fanout preserves deterministic target order, retains partial results, bounds aggregate output, and never recommends blind batch retry; +- image removal binds the resolved local ID/tags/digests and verifies both the requested reference and content ID are absent; +- prune binds the exact candidate inventory, executes `all` in a fixed scope order, and verifies reported deleted identities are absent; +- Compose down binds normalized configuration plus the complete service pre-state and verifies no services remain; +- `remove_volumes=true` is rejected unless `force=true`; +- file transfer binds both host revisions, source bytes/digest, destination pre-state, and destination path before send; +- transfer source reads and destination writes walk explicit roots with `O_NOFOLLOW`, cap content at 16 MiB, and verify destination bytes and SHA-256 independently; - progress sink failures remain bounded metadata and never change backend send or verification truth; - SDK-specific Bollard types never cross the public API. diff --git a/crates/shared/operations/infra/src/bollard_cleanup.rs b/crates/shared/operations/infra/src/bollard_cleanup.rs new file mode 100644 index 00000000..7b49d962 --- /dev/null +++ b/crates/shared/operations/infra/src/bollard_cleanup.rs @@ -0,0 +1,248 @@ +use std::future::Future; +use std::time::Duration; + +use async_trait::async_trait; +use bollard::query_parameters::{ + PruneBuildOptions, PruneContainersOptions, PruneImagesOptions, PruneNetworksOptions, + PruneVolumesOptions, RemoveImageOptionsBuilder, +}; +use soma_fleet::HostRecord; +use soma_ops::{MutationSendState, Timestamp}; +use tokio_util::sync::CancellationToken; + +use crate::{ + BollardReadClient, DockerCleanupMutator, DockerPruneReceipt, DockerPruneRequest, + DockerPruneScopeReceipt, DockerPruneTarget, ImageRemovalReceipt, ImageRemovalRequest, + InfraError, MutationFailure, MutationResult, +}; + +#[async_trait] +impl DockerCleanupMutator for BollardReadClient { + async fn remove_image( + &self, + host: &HostRecord, + request: &ImageRemovalRequest, + cancellation: &CancellationToken, + ) -> MutationResult { + self.validate_host(host).map_err(not_sent)?; + ensure_admitted(request.force, request.deadline, cancellation)?; + let options = RemoveImageOptionsBuilder::default() + .force(request.force) + .build(); + let rows = await_send( + request.deadline, + cancellation, + self.docker() + .remove_image(&request.fingerprint.reference, Some(options), None), + ) + .await?; + let mut deleted = rows + .iter() + .filter_map(|row| row.deleted.clone()) + .collect::>(); + let mut untagged = rows + .iter() + .filter_map(|row| row.untagged.clone()) + .collect::>(); + deleted.sort(); + deleted.dedup(); + untagged.sort(); + untagged.dedup(); + Ok(ImageRemovalReceipt { + send_state: MutationSendState::Sent, + deleted, + untagged, + }) + } + + async fn prune( + &self, + host: &HostRecord, + request: &DockerPruneRequest, + cancellation: &CancellationToken, + ) -> MutationResult { + self.validate_host(host).map_err(not_sent)?; + ensure_admitted(request.force, request.deadline, cancellation)?; + let mut scopes = Vec::new(); + for target in request.fingerprint.target.expanded() { + let scope = self + .prune_scope(*target, request.deadline, cancellation) + .await + .map_err(|failure| { + let completed = scopes + .iter() + .map(|scope: &DockerPruneScopeReceipt| scope.target.as_str()) + .collect::>() + .join(","); + MutationFailure::new( + failure.send_state(), + InfraError::Docker(format!( + "prune scope {} failed after completed scopes [{completed}]: {}", + target.as_str(), + failure.error() + )), + ) + })?; + scopes.push(scope); + } + Ok(DockerPruneReceipt { + send_state: MutationSendState::Sent, + scopes, + }) + } +} + +impl BollardReadClient { + async fn prune_scope( + &self, + target: DockerPruneTarget, + deadline: Timestamp, + cancellation: &CancellationToken, + ) -> MutationResult { + match target { + DockerPruneTarget::Containers => { + let response = await_send( + deadline, + cancellation, + self.docker() + .prune_containers(None::), + ) + .await?; + Ok(scope( + target, + response.containers_deleted.unwrap_or_default(), + response.space_reclaimed, + )) + } + DockerPruneTarget::Images => { + let response = await_send( + deadline, + cancellation, + self.docker().prune_images(None::), + ) + .await?; + let deleted = response + .images_deleted + .unwrap_or_default() + .into_iter() + .filter_map(|row| row.deleted) + .collect(); + Ok(scope(target, deleted, response.space_reclaimed)) + } + DockerPruneTarget::Volumes => { + let response = await_send( + deadline, + cancellation, + self.docker().prune_volumes(None::), + ) + .await?; + Ok(scope( + target, + response.volumes_deleted.unwrap_or_default(), + response.space_reclaimed, + )) + } + DockerPruneTarget::Networks => { + let response = await_send( + deadline, + cancellation, + self.docker().prune_networks(None::), + ) + .await?; + Ok(scope( + target, + response.networks_deleted.unwrap_or_default(), + None, + )) + } + DockerPruneTarget::BuildCache => { + let response = await_send( + deadline, + cancellation, + self.docker().prune_build(None::), + ) + .await?; + Ok(scope( + target, + response.caches_deleted.unwrap_or_default(), + response.space_reclaimed, + )) + } + DockerPruneTarget::All => unreachable!("expanded before execution"), + } + } +} + +fn scope( + target: DockerPruneTarget, + mut deleted: Vec, + reclaimed: Option, +) -> DockerPruneScopeReceipt { + deleted.sort(); + deleted.dedup(); + DockerPruneScopeReceipt { + target, + deleted, + space_reclaimed: reclaimed.unwrap_or_default().max(0) as u64, + } +} + +fn ensure_admitted( + force: bool, + deadline: Timestamp, + cancellation: &CancellationToken, +) -> MutationResult<()> { + if !force { + return Err(not_sent(InfraError::InvalidRequest { + domain: "docker-cleanup", + message: "force=true is required".into(), + })); + } + if cancellation.is_cancelled() { + return Err(not_sent(soma_fleet::FleetError::Cancelled.into())); + } + if deadline <= Timestamp::now() { + return Err(not_sent(soma_fleet::FleetError::DeadlineExceeded.into())); + } + Ok(()) +} + +async fn await_send( + deadline: Timestamp, + cancellation: &CancellationToken, + future: F, +) -> MutationResult +where + F: Future>, +{ + let remaining = deadline + .unix_millis() + .saturating_sub(Timestamp::now().unix_millis()); + if remaining <= 0 { + return Err(not_sent(soma_fleet::FleetError::DeadlineExceeded.into())); + } + tokio::select! { + () = cancellation.cancelled() => Err(MutationFailure::new( + MutationSendState::Unknown, + soma_fleet::FleetError::Cancelled.into(), + )), + () = tokio::time::sleep(Duration::from_millis(remaining as u64)) => Err( + MutationFailure::new( + MutationSendState::Unknown, + soma_fleet::FleetError::DeadlineExceeded.into(), + ) + ), + result = future => result.map_err(|error| MutationFailure::new( + MutationSendState::Unknown, + InfraError::Docker(error.to_string()), + )), + } +} + +fn not_sent(error: InfraError) -> MutationFailure { + MutationFailure::new(MutationSendState::NotSent, error) +} + +#[cfg(test)] +#[path = "bollard_cleanup_tests.rs"] +mod tests; diff --git a/crates/shared/operations/infra/src/bollard_cleanup_tests.rs b/crates/shared/operations/infra/src/bollard_cleanup_tests.rs new file mode 100644 index 00000000..9752e3ed --- /dev/null +++ b/crates/shared/operations/infra/src/bollard_cleanup_tests.rs @@ -0,0 +1,22 @@ +use super::*; + +#[test] +fn cleanup_receipts_sort_and_clamp_backend_values() { + let receipt = scope( + DockerPruneTarget::Containers, + vec!["b".into(), "a".into(), "a".into()], + Some(-1), + ); + assert_eq!(receipt.deleted, ["a", "b"]); + assert_eq!(receipt.space_reclaimed, 0); +} + +#[tokio::test] +async fn expired_cleanup_is_not_sent() { + let cancellation = CancellationToken::new(); + let result = await_send(Timestamp::from_unix_millis(1), &cancellation, async { + Ok::<_, bollard::errors::Error>(()) + }) + .await; + assert_eq!(result.unwrap_err().send_state(), MutationSendState::NotSent); +} diff --git a/crates/shared/operations/infra/src/bollard_exec.rs b/crates/shared/operations/infra/src/bollard_exec.rs new file mode 100644 index 00000000..49df41ee --- /dev/null +++ b/crates/shared/operations/infra/src/bollard_exec.rs @@ -0,0 +1,225 @@ +use std::future::Future; +use std::time::Duration; + +use async_trait::async_trait; +use bollard::container::LogOutput; +use bollard::exec::{StartExecOptions, StartExecResults}; +use bollard::models::ExecConfig; +use futures_util::StreamExt; +use soma_fleet::{HostRecord, TopologyRevision}; +use soma_ops::{MutationSendState, Timestamp}; +use tokio_util::sync::CancellationToken; + +use crate::{ + BollardReadClient, ContainerExecMutator, ContainerExecReceipt, ContainerExecRequest, + InfraError, MutationFailure, MutationResult, +}; + +#[async_trait] +impl ContainerExecMutator for BollardReadClient { + async fn exec_container( + &self, + host: &HostRecord, + request: &ContainerExecRequest, + cancellation: &CancellationToken, + ) -> MutationResult { + self.validate_host(host) + .map_err(|error| MutationFailure::new(MutationSendState::NotSent, error))?; + ensure_before_start(request.deadline(), cancellation)?; + let created = await_pre_start( + request.deadline(), + cancellation, + self.docker().create_exec( + request.container(), + ExecConfig { + cmd: Some(request.command().to_vec()), + user: request.user().map(str::to_owned), + working_dir: request + .working_dir() + .map(|path| path.to_string_lossy().into_owned()), + attach_stdout: Some(true), + attach_stderr: Some(true), + tty: Some(false), + ..Default::default() + }, + ), + ) + .await?; + + let started = await_post_start( + request.deadline(), + cancellation, + self.docker().start_exec( + &created.id, + Some(StartExecOptions { + detach: false, + tty: false, + ..Default::default() + }), + ), + ) + .await?; + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); + let (mut stdout_truncated, mut stderr_truncated) = (false, false); + match started { + StartExecResults::Attached { mut output, .. } => loop { + let timeout = remaining(request.deadline(), MutationSendState::Unknown)?; + let next = tokio::select! { + () = cancellation.cancelled() => return Err(MutationFailure::new( + MutationSendState::Unknown, + soma_fleet::FleetError::Cancelled.into(), + )), + result = tokio::time::timeout(timeout, output.next()) => match result { + Err(_) => return Err(MutationFailure::new( + MutationSendState::Unknown, + soma_fleet::FleetError::DeadlineExceeded.into(), + )), + Ok(value) => value, + } + }; + let Some(frame) = next else { break }; + match frame.map_err(|error| { + MutationFailure::new( + MutationSendState::Unknown, + InfraError::Docker(error.to_string()), + ) + })? { + LogOutput::StdOut { message } | LogOutput::Console { message } => { + stdout_truncated |= + append_bounded(&mut stdout, &message, request.max_stdout_bytes()); + } + LogOutput::StdErr { message } => { + stderr_truncated |= + append_bounded(&mut stderr, &message, request.max_stderr_bytes()); + } + _ => {} + } + }, + StartExecResults::Detached => { + return Err(MutationFailure::new( + MutationSendState::Unknown, + InfraError::Docker( + "container exec unexpectedly detached; completion is unknown".into(), + ), + )); + } + } + let inspected = await_post_start( + request.deadline(), + cancellation, + self.docker().inspect_exec(&created.id), + ) + .await?; + let stdout_text = String::from_utf8_lossy(&stdout); + let stderr_text = String::from_utf8_lossy(&stderr); + Ok(ContainerExecReceipt { + host: host.id().clone(), + topology_revision: TopologyRevision::clone(host.revision()), + container: request.container().to_owned(), + command: request.command().to_vec(), + user: request.user().map(str::to_owned), + working_dir: request.working_dir().map(ToOwned::to_owned), + stdout: stdout_text.into_owned(), + stderr: stderr_text.into_owned(), + exit_code: inspected.exit_code, + truncated: stdout_truncated || stderr_truncated, + encoding_lossy: std::str::from_utf8(&stdout).is_err() + || std::str::from_utf8(&stderr).is_err(), + send_state: MutationSendState::Sent, + }) + } +} + +fn ensure_before_start( + deadline: Timestamp, + cancellation: &CancellationToken, +) -> MutationResult<()> { + if cancellation.is_cancelled() { + return Err(MutationFailure::new( + MutationSendState::NotSent, + soma_fleet::FleetError::Cancelled.into(), + )); + } + remaining(deadline, MutationSendState::NotSent).map(|_| ()) +} + +async fn await_pre_start( + deadline: Timestamp, + cancellation: &CancellationToken, + future: F, +) -> MutationResult +where + F: Future>, +{ + let timeout = remaining(deadline, MutationSendState::NotSent)?; + tokio::select! { + () = cancellation.cancelled() => Err(MutationFailure::new( + MutationSendState::NotSent, + soma_fleet::FleetError::Cancelled.into(), + )), + result = tokio::time::timeout(timeout, future) => match result { + Err(_) => Err(MutationFailure::new( + MutationSendState::NotSent, + soma_fleet::FleetError::DeadlineExceeded.into(), + )), + Ok(Err(error)) => Err(MutationFailure::new( + MutationSendState::NotSent, + InfraError::Docker(error.to_string()), + )), + Ok(Ok(value)) => Ok(value), + } + } +} + +async fn await_post_start( + deadline: Timestamp, + cancellation: &CancellationToken, + future: F, +) -> MutationResult +where + F: Future>, +{ + let timeout = remaining(deadline, MutationSendState::Unknown)?; + tokio::select! { + () = cancellation.cancelled() => Err(MutationFailure::new( + MutationSendState::Unknown, + soma_fleet::FleetError::Cancelled.into(), + )), + result = tokio::time::timeout(timeout, future) => match result { + Err(_) => Err(MutationFailure::new( + MutationSendState::Unknown, + soma_fleet::FleetError::DeadlineExceeded.into(), + )), + Ok(Err(error)) => Err(MutationFailure::new( + MutationSendState::Unknown, + InfraError::Docker(error.to_string()), + )), + Ok(Ok(value)) => Ok(value), + } + } +} + +fn remaining(deadline: Timestamp, send_state: MutationSendState) -> MutationResult { + let millis = deadline + .unix_millis() + .saturating_sub(Timestamp::now().unix_millis()); + if millis <= 0 { + Err(MutationFailure::new( + send_state, + soma_fleet::FleetError::DeadlineExceeded.into(), + )) + } else { + Ok(Duration::from_millis(millis as u64)) + } +} + +fn append_bounded(destination: &mut Vec, bytes: &[u8], limit: usize) -> bool { + let remaining = limit.saturating_sub(destination.len()); + let retained = remaining.min(bytes.len()); + destination.extend_from_slice(&bytes[..retained]); + retained < bytes.len() +} + +#[cfg(test)] +#[path = "bollard_exec_tests.rs"] +mod tests; diff --git a/crates/shared/operations/infra/src/bollard_exec_tests.rs b/crates/shared/operations/infra/src/bollard_exec_tests.rs new file mode 100644 index 00000000..6c69aa9a --- /dev/null +++ b/crates/shared/operations/infra/src/bollard_exec_tests.rs @@ -0,0 +1,27 @@ +use super::*; + +#[test] +fn output_bounding_retains_prefix_and_reports_truncation() { + let mut output = b"ab".to_vec(); + assert!(append_bounded(&mut output, b"cdef", 4)); + assert_eq!(output, b"abcd"); + assert!(append_bounded(&mut output, b"z", 4)); + assert_eq!(output, b"abcd"); +} + +#[test] +fn pre_and_post_start_deadlines_preserve_different_send_states() { + let deadline = Timestamp::from_unix_millis(Timestamp::now().unix_millis() - 1); + assert_eq!( + remaining(deadline, MutationSendState::NotSent) + .unwrap_err() + .send_state(), + MutationSendState::NotSent + ); + assert_eq!( + remaining(deadline, MutationSendState::Unknown) + .unwrap_err() + .send_state(), + MutationSendState::Unknown + ); +} diff --git a/crates/shared/operations/infra/src/bollard_provider.rs b/crates/shared/operations/infra/src/bollard_provider.rs index aea2531c..95d05ed0 100644 --- a/crates/shared/operations/infra/src/bollard_provider.rs +++ b/crates/shared/operations/infra/src/bollard_provider.rs @@ -7,8 +7,9 @@ use soma_fleet::{ConnectionPool, HostEndpoint, HostId, HostRecord, OpenSshConnec use tokio_util::sync::CancellationToken; use crate::{ - BollardReadClient, ContainerRecreateClient, ContainerRecreateClientProvider, - DockerArtifactClient, DockerArtifactClientProvider, DockerClientProvider, DockerMutationClient, + BollardReadClient, ContainerExecClientProvider, ContainerExecMutator, ContainerRecreateClient, + ContainerRecreateClientProvider, DockerArtifactClient, DockerArtifactClientProvider, + DockerCleanupClient, DockerCleanupClientProvider, DockerClientProvider, DockerMutationClient, DockerMutationClientProvider, DockerReadClient, InfraError, InfraResult, }; @@ -98,6 +99,46 @@ impl DockerMutationClientProvider for BollardClientProvider { } } +#[async_trait] +impl DockerCleanupClientProvider for BollardClientProvider { + async fn cleanup_client( + &self, + host: &HostRecord, + cancellation: &CancellationToken, + ) -> InfraResult> { + match self.plan(host)? { + SocketPlan::Local => Ok(Arc::new(BollardReadClient::connect_local(host)?)), + SocketPlan::Remote(socket) => { + let connection = self.pool.get_or_connect(host, cancellation).await?; + Ok(Arc::new( + BollardReadClient::connect_remote(connection, host, socket, cancellation) + .await?, + )) + } + } + } +} + +#[async_trait] +impl ContainerExecClientProvider for BollardClientProvider { + async fn exec_client( + &self, + host: &HostRecord, + cancellation: &CancellationToken, + ) -> InfraResult> { + match self.plan(host)? { + SocketPlan::Local => Ok(Arc::new(BollardReadClient::connect_local(host)?)), + SocketPlan::Remote(socket) => { + let connection = self.pool.get_or_connect(host, cancellation).await?; + Ok(Arc::new( + BollardReadClient::connect_remote(connection, host, socket, cancellation) + .await?, + )) + } + } + } +} + #[async_trait] impl ContainerRecreateClientProvider for BollardClientProvider { async fn recreate_client( diff --git a/crates/shared/operations/infra/src/compose_down.rs b/crates/shared/operations/infra/src/compose_down.rs new file mode 100644 index 00000000..d703a5a5 --- /dev/null +++ b/crates/shared/operations/infra/src/compose_down.rs @@ -0,0 +1,151 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use soma_fleet::{HostId, HostRecord, TopologyRevision}; +use soma_ops::{MutationSendState, OperationId, OperationName, Timestamp, VerificationStatus}; +use tokio_util::sync::CancellationToken; + +use crate::{ + ComposeInspector, ComposeProjectRef, ComposeRecreateFingerprint, ComposeStatus, InfraError, + InfraResult, MutationResult, MutationVerification, +}; + +/// Deadline-bound Compose teardown request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComposeDownRequest { + operation_id: OperationId, + operation: OperationName, + project: ComposeProjectRef, + expected: ComposeRecreateFingerprint, + force: bool, + remove_volumes: bool, + deadline: Timestamp, +} + +impl ComposeDownRequest { + /// Creates a validated teardown request. + pub fn new( + operation_id: OperationId, + operation: OperationName, + project: ComposeProjectRef, + expected: ComposeRecreateFingerprint, + force: bool, + remove_volumes: bool, + deadline: Timestamp, + ) -> InfraResult { + if remove_volumes && !force { + return Err(InfraError::InvalidRequest { + domain: "compose-down", + message: "remove_volumes=true requires force=true".into(), + }); + } + Ok(Self { + operation_id, + operation, + project, + expected, + force, + remove_volumes, + deadline, + }) + } + + /// Returns the operation identity. + #[must_use] + pub fn operation_id(&self) -> &OperationId { + &self.operation_id + } + /// Returns the canonical operation. + #[must_use] + pub fn operation(&self) -> &OperationName { + &self.operation + } + /// Returns the Compose project reference. + #[must_use] + pub const fn project(&self) -> &ComposeProjectRef { + &self.project + } + /// Returns the expected pre-state. + #[must_use] + pub const fn expected(&self) -> &ComposeRecreateFingerprint { + &self.expected + } + /// Returns explicit force confirmation. + #[must_use] + pub const fn force(&self) -> bool { + self.force + } + /// Returns whether named volumes are removed. + #[must_use] + pub const fn remove_volumes(&self) -> bool { + self.remove_volumes + } + /// Returns the execution deadline. + #[must_use] + pub const fn deadline(&self) -> Timestamp { + self.deadline + } +} + +/// Process driver receipt for Compose down. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ComposeDownReceipt { + /// Target host. + pub host: HostId, + /// Exact topology revision. + pub topology_revision: TopologyRevision, + /// Project name. + pub project: String, + /// Whether volume deletion was requested. + pub remove_volumes: bool, + /// Backend send state. + pub send_state: MutationSendState, + /// Bounded stdout. + pub stdout: String, + /// Bounded stderr. + pub stderr: String, + /// Whether output was truncated. + pub output_truncated: bool, +} + +/// Verified Compose teardown outcome. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ComposeDownOutcome { + /// Target host. + pub host: HostId, + /// Exact topology revision. + pub topology_revision: TopologyRevision, + /// Project name. + pub project: String, + /// Service state before teardown. + pub before: ComposeStatus, + /// Service state after teardown. + pub after: ComposeStatus, + /// Whether a nonempty service set or volume deletion was requested. + pub changed: bool, + /// Backend receipt. + pub receipt: ComposeDownReceipt, + /// Verification status. + pub verification_status: VerificationStatus, + /// Verification explanation. + pub verification: MutationVerification, +} + +/// Executes Docker Compose teardown. +#[async_trait] +pub trait ComposeDownMutator: Send + Sync { + /// Performs one shell-free Compose down command. + async fn down_compose( + &self, + host: &HostRecord, + request: &ComposeDownRequest, + cancellation: &CancellationToken, + ) -> MutationResult; +} + +/// Complete Compose client required by teardown verification. +pub trait ComposeDownClient: ComposeInspector + ComposeDownMutator {} +impl ComposeDownClient for T where T: ComposeInspector + ComposeDownMutator {} + +#[cfg(test)] +#[path = "compose_down_tests.rs"] +mod tests; diff --git a/crates/shared/operations/infra/src/compose_down_engine.rs b/crates/shared/operations/infra/src/compose_down_engine.rs new file mode 100644 index 00000000..2bab4362 --- /dev/null +++ b/crates/shared/operations/infra/src/compose_down_engine.rs @@ -0,0 +1,117 @@ +use soma_fleet::HostRecord; +use soma_ops::{MutationSendState, VerificationStatus}; +use tokio_util::sync::CancellationToken; + +use crate::{ + ComposeDownClient, ComposeDownOutcome, ComposeDownRequest, ComposeRecreateFingerprint, + InfraError, MutationFailure, MutationResult, MutationVerification, + compose_recreate_fingerprint, +}; + +/// Verified Docker Compose teardown coordinator. +#[derive(Debug, Clone, Copy, Default)] +pub struct ComposeDownEngine; + +impl ComposeDownEngine { + /// Captures the current normalized Compose config and status fingerprint. + pub async fn inspect( + &self, + client: &dyn ComposeDownClient, + host: &HostRecord, + project: &crate::ComposeProjectRef, + deadline: soma_ops::Timestamp, + cancellation: &CancellationToken, + ) -> crate::InfraResult<(ComposeRecreateFingerprint, crate::ComposeStatus)> { + let config = client.config(host, project, deadline, cancellation).await?; + let status = client + .status(host, project, None, deadline, cancellation) + .await?; + let fingerprint = compose_recreate_fingerprint(&config, &status)?; + Ok((fingerprint, status)) + } + + /// Executes and independently verifies Compose teardown. + pub async fn execute( + &self, + client: &dyn ComposeDownClient, + host: &HostRecord, + request: &ComposeDownRequest, + cancellation: &CancellationToken, + ) -> MutationResult { + admit(request, cancellation)?; + let (current, before) = self + .inspect( + client, + host, + request.project(), + request.deadline(), + cancellation, + ) + .await + .map_err(not_sent)?; + if current != *request.expected() { + return Err(not_sent(InfraError::InvalidRequest { + domain: "compose-down", + message: "Compose config or service state changed after planning".into(), + })); + } + let receipt = client.down_compose(host, request, cancellation).await?; + let after = client + .status( + host, + request.project(), + None, + request.deadline(), + cancellation, + ) + .await + .map_err(|error| MutationFailure::new(receipt.send_state, error))?; + if !after.services.is_empty() { + return Err(MutationFailure::new( + receipt.send_state, + InfraError::InvalidRequest { + domain: "compose-down", + message: "Compose services remain after down".into(), + }, + )); + } + Ok(ComposeDownOutcome { + host: host.id().clone(), + topology_revision: host.revision().clone(), + project: request.project().name().to_owned(), + changed: !before.services.is_empty() || request.remove_volumes(), + before, + after, + receipt, + verification_status: VerificationStatus::Verified, + verification: MutationVerification { + status: "verified".into(), + summary: "Compose status reports no remaining services".into(), + }, + }) + } +} + +fn admit(request: &ComposeDownRequest, cancellation: &CancellationToken) -> MutationResult<()> { + if request.remove_volumes() && !request.force() { + return Err(not_sent(InfraError::InvalidRequest { + domain: "compose-down", + message: "remove_volumes=true requires force=true".into(), + })); + } + if cancellation.is_cancelled() { + return Err(not_sent(soma_fleet::FleetError::Cancelled.into())); + } + if request.deadline() <= soma_ops::Timestamp::now() { + return Err(not_sent(soma_fleet::FleetError::DeadlineExceeded.into())); + } + Ok(()) +} + +fn not_sent(error: InfraError) -> MutationFailure { + MutationFailure::new(MutationSendState::NotSent, error) +} + +#[cfg(test)] +#[path = "compose_down_engine_tests.rs"] +mod tests; diff --git a/crates/shared/operations/infra/src/compose_down_engine_tests.rs b/crates/shared/operations/infra/src/compose_down_engine_tests.rs new file mode 100644 index 00000000..3837247b --- /dev/null +++ b/crates/shared/operations/infra/src/compose_down_engine_tests.rs @@ -0,0 +1,6 @@ +use super::*; + +#[test] +fn teardown_engine_is_zero_sized() { + assert_eq!(std::mem::size_of::(), 0); +} diff --git a/crates/shared/operations/infra/src/compose_down_tests.rs b/crates/shared/operations/infra/src/compose_down_tests.rs new file mode 100644 index 00000000..a8bcc154 --- /dev/null +++ b/crates/shared/operations/infra/src/compose_down_tests.rs @@ -0,0 +1,21 @@ +use super::*; +use soma_ops::{OperationId, OperationName, Timestamp}; + +#[test] +fn volume_removal_requires_force() { + let project = ComposeProjectRef::new("soma", "/srv/soma/compose.yaml").unwrap(); + let expected = + ComposeRecreateFingerprint::new("soma", vec!["api".into()], "a".repeat(64)).unwrap(); + assert!( + ComposeDownRequest::new( + OperationId::new(), + OperationName::new("compose.down").unwrap(), + project, + expected, + false, + true, + Timestamp::from_unix_millis(10), + ) + .is_err() + ); +} diff --git a/crates/shared/operations/infra/src/container_exec.rs b/crates/shared/operations/infra/src/container_exec.rs new file mode 100644 index 00000000..c32d2a7d --- /dev/null +++ b/crates/shared/operations/infra/src/container_exec.rs @@ -0,0 +1,200 @@ +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use soma_fleet::{HostId, HostRecord, TopologyRevision}; +use soma_ops::{MutationSendState, OperationId, OperationName, Timestamp}; +use tokio_util::sync::CancellationToken; + +use crate::{InfraError, InfraResult, MutationResult}; + +const MAX_COMMAND_ARGUMENTS: usize = 256; +const MAX_ARGUMENT_CHARS: usize = 4096; +const MAX_OUTPUT_BYTES: usize = 96 * 1024; + +/// One non-interactive bounded Docker exec request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContainerExecRequest { + operation_id: OperationId, + operation: OperationName, + container: String, + command: Vec, + user: Option, + working_dir: Option, + deadline: Timestamp, +} + +impl ContainerExecRequest { + /// Creates a one-shot non-TTY exec request. + pub fn new( + operation_id: OperationId, + operation: OperationName, + container: impl Into, + command: Vec, + user: Option, + working_dir: Option, + deadline: Timestamp, + ) -> InfraResult { + let container = container.into(); + validate_text("container", &container, 256)?; + if command.is_empty() || command.len() > MAX_COMMAND_ARGUMENTS { + return Err(invalid(format!( + "container exec requires 1-{MAX_COMMAND_ARGUMENTS} command arguments" + ))); + } + for argument in &command { + validate_text("command argument", argument, MAX_ARGUMENT_CHARS)?; + } + if let Some(user) = &user { + validate_text("exec user", user, 256)?; + } + let working_dir = working_dir.map(validate_working_dir).transpose()?; + if deadline <= Timestamp::now() { + return Err(invalid("container exec deadline must be in the future")); + } + Ok(Self { + operation_id, + operation, + container, + command, + user, + working_dir, + deadline, + }) + } + + /// Returns the operation identity. + #[must_use] + pub fn operation_id(&self) -> &OperationId { + &self.operation_id + } + /// Returns the canonical operation name. + #[must_use] + pub fn operation(&self) -> &OperationName { + &self.operation + } + /// Returns the target container identifier. + #[must_use] + pub fn container(&self) -> &str { + &self.container + } + /// Returns direct exec argv. + #[must_use] + pub fn command(&self) -> &[String] { + &self.command + } + /// Returns the optional Docker exec user. + #[must_use] + pub fn user(&self) -> Option<&str> { + self.user.as_deref() + } + /// Returns the optional absolute container working directory. + #[must_use] + pub fn working_dir(&self) -> Option<&Path> { + self.working_dir.as_deref() + } + /// Returns the absolute deadline. + #[must_use] + pub const fn deadline(&self) -> Timestamp { + self.deadline + } + /// Returns the stdout byte ceiling. + #[must_use] + pub const fn max_stdout_bytes(&self) -> usize { + MAX_OUTPUT_BYTES + } + /// Returns the stderr byte ceiling. + #[must_use] + pub const fn max_stderr_bytes(&self) -> usize { + MAX_OUTPUT_BYTES + } +} + +/// Completed non-interactive Docker exec. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContainerExecReceipt { + /// Target host. + pub host: HostId, + /// Exact topology revision. + pub topology_revision: TopologyRevision, + /// Target container. + pub container: String, + /// Direct command argv. + pub command: Vec, + /// Optional exec user. + pub user: Option, + /// Optional container working directory. + pub working_dir: Option, + /// Bounded stdout. + pub stdout: String, + /// Bounded stderr. + pub stderr: String, + /// Docker exec exit code when available. + pub exit_code: Option, + /// Whether either output stream exceeded its ceiling. + pub truncated: bool, + /// Whether UTF-8 replacement was required. + pub encoding_lossy: bool, + /// Backend send state. + pub send_state: MutationSendState, +} + +/// Product-neutral non-interactive Docker exec driver. +#[async_trait] +pub trait ContainerExecMutator: Send + Sync { + /// Executes one direct argv command without a shell or TTY. + async fn exec_container( + &self, + host: &HostRecord, + request: &ContainerExecRequest, + cancellation: &CancellationToken, + ) -> MutationResult; +} + +/// Supplies one host-bound Docker exec client. +#[async_trait] +pub trait ContainerExecClientProvider: Send + Sync { + /// Creates an exec client bound to the exact host revision. + async fn exec_client( + &self, + host: &HostRecord, + cancellation: &CancellationToken, + ) -> InfraResult>; +} + +fn validate_text(field: &'static str, value: &str, max: usize) -> InfraResult<()> { + let count = value.chars().count(); + if count == 0 || count > max || value.as_bytes().contains(&0) { + Err(invalid(format!("invalid {field}"))) + } else { + Ok(()) + } +} + +fn validate_working_dir(path: PathBuf) -> InfraResult { + if !path.is_absolute() + || path + .components() + .any(|part| matches!(part, Component::ParentDir | Component::CurDir)) + || path.to_string_lossy().as_bytes().contains(&0) + { + Err(invalid(format!( + "container working directory must be absolute and normalized: {}", + path.display() + ))) + } else { + Ok(path) + } +} + +fn invalid(message: impl Into) -> InfraError { + InfraError::InvalidRequest { + domain: "container-exec", + message: message.into(), + } +} + +#[cfg(test)] +#[path = "container_exec_tests.rs"] +mod tests; diff --git a/crates/shared/operations/infra/src/container_exec_tests.rs b/crates/shared/operations/infra/src/container_exec_tests.rs new file mode 100644 index 00000000..bce25c1c --- /dev/null +++ b/crates/shared/operations/infra/src/container_exec_tests.rs @@ -0,0 +1,55 @@ +use std::path::Path; + +use soma_ops::{OperationId, OperationName, Timestamp}; + +use super::*; + +fn deadline() -> Timestamp { + Timestamp::from_unix_millis(Timestamp::now().unix_millis() + 10_000) +} + +#[test] +fn container_exec_requests_are_direct_bounded_and_non_tty() { + let request = ContainerExecRequest::new( + OperationId::new(), + OperationName::new("container.exec").unwrap(), + "api", + vec!["printf".into(), "hello".into()], + Some("1000".into()), + Some("/app".into()), + deadline(), + ) + .unwrap(); + assert_eq!(request.command()[0], "printf"); + assert_eq!(request.user(), Some("1000")); + assert_eq!(request.working_dir(), Some(Path::new("/app"))); + assert_eq!(request.max_stdout_bytes(), 96 * 1024); +} + +#[test] +fn invalid_container_exec_shapes_fail_closed() { + assert!( + ContainerExecRequest::new( + OperationId::new(), + OperationName::new("container.exec").unwrap(), + "api", + Vec::new(), + None, + None, + deadline(), + ) + .is_err() + ); + assert!( + ContainerExecRequest::new( + OperationId::new(), + OperationName::new("container.exec").unwrap(), + "api", + vec![String::from("x"); 257], + None, + Some("/app/../root".into()), + deadline(), + ) + .is_err() + ); +} diff --git a/crates/shared/operations/infra/src/docker_cleanup.rs b/crates/shared/operations/infra/src/docker_cleanup.rs new file mode 100644 index 00000000..6b55a3db --- /dev/null +++ b/crates/shared/operations/infra/src/docker_cleanup.rs @@ -0,0 +1,319 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use soma_fleet::{HostId, HostRecord, TopologyRevision}; +use soma_ops::{MutationSendState, OperationId, OperationName, Timestamp}; +use tokio_util::sync::CancellationToken; + +use crate::{ + ContainerReader, DockerTelemetryReader, ImageIdentity, ImageReader, InfraError, InfraResult, + MutationResult, NetworkReader, VolumeReader, +}; + +/// Closed Docker prune scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DockerPruneTarget { + /// Stopped containers. + Containers, + /// Dangling images. + Images, + /// Unused volumes. + Volumes, + /// Unused networks. + Networks, + /// Build cache. + BuildCache, + /// Every supported prune scope in a fixed order. + All, +} + +impl DockerPruneTarget { + /// Parses the canonical schema value. + pub fn parse(value: &str) -> InfraResult { + match value { + "containers" => Ok(Self::Containers), + "images" => Ok(Self::Images), + "volumes" => Ok(Self::Volumes), + "networks" => Ok(Self::Networks), + "buildcache" => Ok(Self::BuildCache), + "all" => Ok(Self::All), + _ => Err(InfraError::InvalidRequest { + domain: "docker-cleanup", + message: format!("unsupported prune target: {value}"), + }), + } + } + + /// Returns the canonical label. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Containers => "containers", + Self::Images => "images", + Self::Volumes => "volumes", + Self::Networks => "networks", + Self::BuildCache => "buildcache", + Self::All => "all", + } + } + + #[cfg(any(feature = "bollard-driver", test))] + pub(crate) fn expanded(self) -> &'static [Self] { + match self { + Self::All => &[ + Self::Containers, + Self::Images, + Self::Volumes, + Self::Networks, + Self::BuildCache, + ], + Self::Containers => &[Self::Containers], + Self::Images => &[Self::Images], + Self::Volumes => &[Self::Volumes], + Self::Networks => &[Self::Networks], + Self::BuildCache => &[Self::BuildCache], + } + } +} + +/// Stable identity bound into an image-removal plan. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImageRemovalFingerprint { + /// Requested reference. + pub reference: String, + /// Resolved local image identity. + pub identity: ImageIdentity, + /// Lowercase SHA-256 over the complete identity. + pub sha256: String, +} + +impl ImageRemovalFingerprint { + /// Builds a deterministic fingerprint. + pub fn new(reference: impl Into, mut identity: ImageIdentity) -> InfraResult { + let reference = validate_text("image reference", reference.into(), 256)?; + identity.repo_tags.sort(); + identity.repo_tags.dedup(); + identity.repo_digests.sort(); + identity.repo_digests.dedup(); + let material = serde_json::to_vec(&(reference.as_str(), &identity)).map_err(|error| { + InfraError::Parse { + domain: "docker-cleanup", + message: error.to_string(), + } + })?; + let sha256 = crate::mutation::sha256_hex(&material); + Ok(Self { + reference, + identity, + sha256, + }) + } +} + +/// Deterministic pre-prune inventory. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DockerPruneFingerprint { + /// Target host. + pub host: HostId, + /// Exact topology revision. + pub topology_revision: TopologyRevision, + /// Requested prune target. + pub target: DockerPruneTarget, + /// Candidate container IDs. + pub containers: Vec, + /// Candidate image IDs. + pub images: Vec, + /// Visible volume names. + pub volumes: Vec, + /// Visible network IDs or names. + pub networks: Vec, + /// Current build-cache bytes when reported. + pub build_cache_bytes: u64, + /// Lowercase SHA-256 over the inventory. + pub sha256: String, +} + +impl DockerPruneFingerprint { + pub(crate) fn finalize(mut self) -> InfraResult { + for values in [ + &mut self.containers, + &mut self.images, + &mut self.volumes, + &mut self.networks, + ] { + values.sort(); + values.dedup(); + } + let material = serde_json::to_vec(&( + &self.host, + &self.topology_revision, + self.target, + &self.containers, + &self.images, + &self.volumes, + &self.networks, + self.build_cache_bytes, + )) + .map_err(|error| InfraError::Parse { + domain: "docker-cleanup", + message: error.to_string(), + })?; + self.sha256 = crate::mutation::sha256_hex(&material); + Ok(self) + } +} + +/// Request to remove one exact local image identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImageRemovalRequest { + /// Operation identity. + pub operation_id: OperationId, + /// Canonical operation. + pub operation: OperationName, + /// Planned image fingerprint. + pub fingerprint: ImageRemovalFingerprint, + /// Explicit destructive confirmation field. + pub force: bool, + /// Absolute execution deadline. + pub deadline: Timestamp, +} + +/// Request to prune one exact inventory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DockerPruneRequest { + /// Operation identity. + pub operation_id: OperationId, + /// Canonical operation. + pub operation: OperationName, + /// Planned prune fingerprint. + pub fingerprint: DockerPruneFingerprint, + /// Explicit destructive confirmation field. + pub force: bool, + /// Absolute execution deadline. + pub deadline: Timestamp, +} + +/// Backend receipt for image removal. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImageRemovalReceipt { + /// Backend send state. + pub send_state: MutationSendState, + /// Deleted content IDs reported by Docker. + pub deleted: Vec, + /// Untagged references reported by Docker. + pub untagged: Vec, +} + +/// Backend receipt for one prune scope. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DockerPruneScopeReceipt { + /// Prune scope. + pub target: DockerPruneTarget, + /// Deleted object identities. + pub deleted: Vec, + /// Reclaimed bytes reported by Docker. + pub space_reclaimed: u64, +} + +/// Complete prune receipt. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DockerPruneReceipt { + /// Backend send state. + pub send_state: MutationSendState, + /// Completed scopes in execution order. + pub scopes: Vec, +} + +/// Verified image-removal result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImageRemovalOutcome { + /// Planned image identity. + pub before: ImageRemovalFingerprint, + /// Whether the image is absent after execution. + pub removed: bool, + /// Backend receipt. + pub receipt: ImageRemovalReceipt, +} + +/// Verified prune result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DockerPruneOutcome { + /// Planned inventory. + pub before: DockerPruneFingerprint, + /// Post-prune inventory. + pub after: DockerPruneFingerprint, + /// Backend receipt. + pub receipt: DockerPruneReceipt, + /// Whether any identity or bytes were reported removed. + pub changed: bool, +} + +/// Product-neutral Docker cleanup mutations. +#[async_trait] +pub trait DockerCleanupMutator: Send + Sync { + /// Removes one image. + async fn remove_image( + &self, + host: &HostRecord, + request: &ImageRemovalRequest, + cancellation: &CancellationToken, + ) -> MutationResult; + + /// Prunes one target scope. + async fn prune( + &self, + host: &HostRecord, + request: &DockerPruneRequest, + cancellation: &CancellationToken, + ) -> MutationResult; +} + +/// Complete Docker cleanup client used by verification engines. +pub trait DockerCleanupClient: + ImageReader + + ContainerReader + + NetworkReader + + VolumeReader + + DockerTelemetryReader + + DockerCleanupMutator +{ +} + +impl DockerCleanupClient for T where + T: ImageReader + + ContainerReader + + NetworkReader + + VolumeReader + + DockerTelemetryReader + + DockerCleanupMutator +{ +} + +/// Host-bound cleanup client provider. +#[async_trait] +pub trait DockerCleanupClientProvider: Send + Sync { + /// Resolves one cleanup client for the exact host revision. + async fn cleanup_client( + &self, + host: &HostRecord, + cancellation: &CancellationToken, + ) -> InfraResult>; +} + +fn validate_text(field: &'static str, value: String, max: usize) -> InfraResult { + let count = value.chars().count(); + if count == 0 || count > max || value.chars().any(char::is_control) { + Err(InfraError::InvalidRequest { + domain: "docker-cleanup", + message: format!("invalid {field}"), + }) + } else { + Ok(value) + } +} + +#[cfg(test)] +#[path = "docker_cleanup_tests.rs"] +mod tests; diff --git a/crates/shared/operations/infra/src/docker_cleanup_engine.rs b/crates/shared/operations/infra/src/docker_cleanup_engine.rs new file mode 100644 index 00000000..fe6a7811 --- /dev/null +++ b/crates/shared/operations/infra/src/docker_cleanup_engine.rs @@ -0,0 +1,300 @@ +use soma_fleet::HostRecord; +use soma_ops::MutationSendState; +use tokio_util::sync::CancellationToken; + +use crate::{ + ContainerListOptions, ContainerState, DockerCleanupClient, DockerPruneFingerprint, + DockerPruneOutcome, DockerPruneRequest, DockerPruneTarget, ImageIdentity, ImageListOptions, + ImageRemovalFingerprint, ImageRemovalOutcome, ImageRemovalRequest, ImageSummary, InfraError, + InfraResult, MutationFailure, MutationResult, +}; + +/// Verified Docker image-removal and prune coordinator. +#[derive(Debug, Clone, Copy, Default)] +pub struct DockerCleanupEngine; + +impl DockerCleanupEngine { + /// Resolves one image reference into a stable local identity. + pub async fn inspect_image( + &self, + client: &dyn DockerCleanupClient, + host: &HostRecord, + reference: &str, + cancellation: &CancellationToken, + ) -> InfraResult { + let images = client + .list_images( + host, + &ImageListOptions { + all: true, + dangling_only: false, + }, + cancellation, + ) + .await?; + let identity = + find_image(&images, reference).ok_or_else(|| InfraError::InvalidRequest { + domain: "docker-cleanup", + message: format!("image not found: {reference}"), + })?; + ImageRemovalFingerprint::new(reference, identity) + } + + /// Captures the deterministic inventory relevant to one prune target. + pub async fn inspect_prune( + &self, + client: &dyn DockerCleanupClient, + host: &HostRecord, + target: DockerPruneTarget, + cancellation: &CancellationToken, + ) -> InfraResult { + let containers = if target_includes(target, DockerPruneTarget::Containers) { + client + .list_containers(host, &ContainerListOptions::default(), cancellation) + .await? + .into_iter() + .filter(|container| { + !matches!( + container.state, + ContainerState::Running + | ContainerState::Paused + | ContainerState::Restarting + | ContainerState::Removing + ) + }) + .filter_map(|container| container.id) + .collect() + } else { + Vec::new() + }; + let images = if target_includes(target, DockerPruneTarget::Images) { + client + .list_images( + host, + &ImageListOptions { + all: true, + dangling_only: true, + }, + cancellation, + ) + .await? + .into_iter() + .map(|image| image.id) + .collect() + } else { + Vec::new() + }; + let volumes = if target_includes(target, DockerPruneTarget::Volumes) { + client + .list_volumes(host, cancellation) + .await? + .into_iter() + .map(|volume| volume.name) + .collect() + } else { + Vec::new() + }; + let networks = if target_includes(target, DockerPruneTarget::Networks) { + client + .list_networks(host, cancellation) + .await? + .into_iter() + .filter_map(|network| network.id.or(network.name)) + .collect() + } else { + Vec::new() + }; + let build_cache_bytes = if target_includes(target, DockerPruneTarget::BuildCache) { + client + .disk_usage(host, cancellation) + .await? + .build_cache + .size_bytes + } else { + 0 + }; + DockerPruneFingerprint { + host: host.id().clone(), + topology_revision: host.revision().clone(), + target, + containers, + images, + volumes, + networks, + build_cache_bytes, + sha256: String::new(), + } + .finalize() + } + + /// Removes one exact planned image and verifies it is absent afterward. + pub async fn remove_image( + &self, + client: &dyn DockerCleanupClient, + host: &HostRecord, + request: &ImageRemovalRequest, + cancellation: &CancellationToken, + ) -> MutationResult { + admit(request.force, request.deadline, cancellation)?; + let current = self + .inspect_image(client, host, &request.fingerprint.reference, cancellation) + .await + .map_err(not_sent)?; + if current != request.fingerprint { + return Err(not_sent(InfraError::InvalidRequest { + domain: "docker-cleanup", + message: "image identity changed after planning".into(), + })); + } + let receipt = client.remove_image(host, request, cancellation).await?; + let images = client + .list_images( + host, + &ImageListOptions { + all: true, + dangling_only: false, + }, + cancellation, + ) + .await + .map_err(|error| MutationFailure::new(receipt.send_state, error))?; + let removed = find_image(&images, &request.fingerprint.reference).is_none() + && images + .iter() + .all(|image| image.id != request.fingerprint.identity.id); + if !removed { + return Err(MutationFailure::new( + receipt.send_state, + InfraError::Docker("removed image remains visible after mutation".into()), + )); + } + Ok(ImageRemovalOutcome { + before: request.fingerprint.clone(), + removed, + receipt, + }) + } + + /// Prunes one exact planned inventory and verifies reported identities are gone. + pub async fn prune( + &self, + client: &dyn DockerCleanupClient, + host: &HostRecord, + request: &DockerPruneRequest, + cancellation: &CancellationToken, + ) -> MutationResult { + admit(request.force, request.deadline, cancellation)?; + let current = self + .inspect_prune(client, host, request.fingerprint.target, cancellation) + .await + .map_err(not_sent)?; + if current != request.fingerprint { + return Err(not_sent(InfraError::InvalidRequest { + domain: "docker-cleanup", + message: "prune inventory changed after planning".into(), + })); + } + let receipt = client.prune(host, request, cancellation).await?; + let after = self + .inspect_prune(client, host, request.fingerprint.target, cancellation) + .await + .map_err(|error| MutationFailure::new(receipt.send_state, error))?; + verify_prune(&receipt, &request.fingerprint, &after) + .map_err(|error| MutationFailure::new(receipt.send_state, error))?; + let changed = receipt + .scopes + .iter() + .any(|scope| !scope.deleted.is_empty() || scope.space_reclaimed > 0); + Ok(DockerPruneOutcome { + before: request.fingerprint.clone(), + after, + receipt, + changed, + }) + } +} + +fn find_image(images: &[ImageSummary], reference: &str) -> Option { + images.iter().find_map(|image| { + let matches = image.id == reference + || image.repo_tags.iter().any(|tag| tag == reference) + || image.repo_digests.iter().any(|digest| digest == reference); + matches.then(|| ImageIdentity { + id: image.id.clone(), + repo_tags: image.repo_tags.clone(), + repo_digests: image.repo_digests.clone(), + }) + }) +} + +fn target_includes(target: DockerPruneTarget, candidate: DockerPruneTarget) -> bool { + target == DockerPruneTarget::All || target == candidate +} + +fn verify_prune( + receipt: &crate::DockerPruneReceipt, + before: &DockerPruneFingerprint, + after: &DockerPruneFingerprint, +) -> InfraResult<()> { + for scope in &receipt.scopes { + if scope.target == DockerPruneTarget::BuildCache { + if scope.space_reclaimed > 0 + && after.build_cache_bytes + > before + .build_cache_bytes + .saturating_sub(scope.space_reclaimed) + { + return Err(InfraError::Docker( + "build-cache usage did not reflect reported reclaimed bytes".into(), + )); + } + continue; + } + let remaining = match scope.target { + DockerPruneTarget::Containers => &after.containers, + DockerPruneTarget::Images => &after.images, + DockerPruneTarget::Volumes => &after.volumes, + DockerPruneTarget::Networks => &after.networks, + DockerPruneTarget::BuildCache | DockerPruneTarget::All => continue, + }; + if scope + .deleted + .iter() + .any(|deleted| remaining.contains(deleted)) + { + return Err(InfraError::Docker(format!( + "{} prune verification still sees a deleted identity", + scope.target.as_str() + ))); + } + } + Ok(()) +} + +fn admit( + force: bool, + deadline: soma_ops::Timestamp, + cancellation: &CancellationToken, +) -> MutationResult<()> { + if !force { + return Err(not_sent(InfraError::InvalidRequest { + domain: "docker-cleanup", + message: "force=true is required".into(), + })); + } + if cancellation.is_cancelled() { + return Err(not_sent(soma_fleet::FleetError::Cancelled.into())); + } + if deadline <= soma_ops::Timestamp::now() { + return Err(not_sent(soma_fleet::FleetError::DeadlineExceeded.into())); + } + Ok(()) +} + +fn not_sent(error: InfraError) -> MutationFailure { + MutationFailure::new(MutationSendState::NotSent, error) +} + +#[cfg(test)] +#[path = "docker_cleanup_engine_tests.rs"] +mod tests; diff --git a/crates/shared/operations/infra/src/docker_cleanup_engine_tests.rs b/crates/shared/operations/infra/src/docker_cleanup_engine_tests.rs new file mode 100644 index 00000000..3aefd140 --- /dev/null +++ b/crates/shared/operations/infra/src/docker_cleanup_engine_tests.rs @@ -0,0 +1,54 @@ +use super::*; +use soma_fleet::{HostEndpoint, HostId, HostRecord}; +use std::collections::BTreeMap; + +fn image(id: &str, tags: &[&str], digests: &[&str]) -> ImageSummary { + let host = HostRecord::new(HostId::new("dookie").unwrap(), HostEndpoint::Local); + ImageSummary { + host: host.id().clone(), + topology_revision: host.revision().clone(), + id: id.into(), + repo_tags: tags.iter().map(|value| (*value).into()).collect(), + repo_digests: digests.iter().map(|value| (*value).into()).collect(), + created_unix_seconds: 0, + size_bytes: 0, + containers: 0, + labels: BTreeMap::new(), + } +} + +#[test] +fn image_lookup_matches_ids_tags_and_digests() { + let images = [image("sha256:a", &["api:v1"], &["api@sha256:b"])]; + assert_eq!(find_image(&images, "sha256:a").unwrap().id, "sha256:a"); + assert_eq!(find_image(&images, "api:v1").unwrap().id, "sha256:a"); + assert_eq!(find_image(&images, "api@sha256:b").unwrap().id, "sha256:a"); + assert!(find_image(&images, "missing").is_none()); +} + +#[test] +fn prune_verification_rejects_reported_identity_that_remains() { + let host = HostRecord::new(HostId::new("dookie").unwrap(), HostEndpoint::Local); + let after = DockerPruneFingerprint { + host: host.id().clone(), + topology_revision: host.revision().clone(), + target: DockerPruneTarget::Images, + containers: Vec::new(), + images: vec!["sha256:a".into()], + volumes: Vec::new(), + networks: Vec::new(), + build_cache_bytes: 0, + sha256: String::new(), + } + .finalize() + .unwrap(); + let receipt = crate::DockerPruneReceipt { + send_state: MutationSendState::Sent, + scopes: vec![crate::DockerPruneScopeReceipt { + target: DockerPruneTarget::Images, + deleted: vec!["sha256:a".into()], + space_reclaimed: 1, + }], + }; + assert!(verify_prune(&receipt, &after, &after).is_err()); +} diff --git a/crates/shared/operations/infra/src/docker_cleanup_tests.rs b/crates/shared/operations/infra/src/docker_cleanup_tests.rs new file mode 100644 index 00000000..4c63c47f --- /dev/null +++ b/crates/shared/operations/infra/src/docker_cleanup_tests.rs @@ -0,0 +1,68 @@ +use super::*; +use soma_fleet::{HostEndpoint, HostId, HostRecord}; + +#[test] +fn prune_targets_are_closed_and_expand_deterministically() { + assert_eq!( + DockerPruneTarget::parse("buildcache").unwrap(), + DockerPruneTarget::BuildCache + ); + assert!(DockerPruneTarget::parse("everything").is_err()); + assert_eq!(DockerPruneTarget::All.expanded().len(), 5); +} + +#[test] +fn image_fingerprint_is_order_independent() { + let left = ImageRemovalFingerprint::new( + "api:v1", + ImageIdentity { + id: "sha256:a".into(), + repo_tags: vec!["api:v1".into(), "api:latest".into()], + repo_digests: vec!["api@sha256:b".into()], + }, + ) + .unwrap(); + let right = ImageRemovalFingerprint::new( + "api:v1", + ImageIdentity { + id: "sha256:a".into(), + repo_tags: vec!["api:latest".into(), "api:v1".into()], + repo_digests: vec!["api@sha256:b".into()], + }, + ) + .unwrap(); + assert_eq!(left.sha256, right.sha256); +} + +#[test] +fn prune_fingerprint_sorts_candidate_sets() { + let host = HostRecord::new(HostId::new("dookie").unwrap(), HostEndpoint::Local); + let fp = DockerPruneFingerprint { + host: host.id().clone(), + topology_revision: host.revision().clone(), + target: DockerPruneTarget::Images, + containers: vec!["b".into(), "a".into()], + images: vec!["z".into(), "z".into()], + volumes: Vec::new(), + networks: Vec::new(), + build_cache_bytes: 0, + sha256: String::new(), + } + .finalize() + .unwrap(); + assert_eq!(fp.containers, ["a", "b"]); + assert_eq!(fp.images, ["z"]); + assert_eq!(fp.sha256.len(), 64); +} + +#[test] +fn cleanup_fingerprints_use_sha2_011_compatible_lowercase_hex() { + let identity = ImageIdentity { + id: "sha256:image".into(), + repo_tags: vec!["app:v1".into()], + repo_digests: vec!["app@sha256:digest".into()], + }; + let fingerprint = ImageRemovalFingerprint::new("app:v1", identity).unwrap(); + assert_eq!(fingerprint.sha256.len(), 64); + assert!(fingerprint.sha256.bytes().all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))); +} diff --git a/crates/shared/operations/infra/src/file_transfer.rs b/crates/shared/operations/infra/src/file_transfer.rs new file mode 100644 index 00000000..dc807f2c --- /dev/null +++ b/crates/shared/operations/infra/src/file_transfer.rs @@ -0,0 +1,173 @@ +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use soma_fleet::{FileTransfer, HostId, HostRecord, TopologyRevision, TransferReceipt}; +use soma_ops::{MutationSendState, OperationId, OperationName, Timestamp}; +use tokio_util::sync::CancellationToken; + +use crate::{FileReadPolicy, InfraError, InfraResult}; + +/// Maximum bytes copied by one canonical file-transfer mutation. +pub const MAX_FILE_TRANSFER_BYTES: u64 = 16 * 1024 * 1024; + +/// Explicit source and destination roots for one host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileTransferPolicy { + source: FileReadPolicy, + destination: FileReadPolicy, +} + +impl FileTransferPolicy { + /// Creates a policy with independent source and destination roots. + pub fn new(source_roots: SI, destination_roots: DI) -> InfraResult + where + SI: IntoIterator, + SP: Into, + DI: IntoIterator, + DP: Into, + { + Ok(Self { + source: FileReadPolicy::new(source_roots)?, + destination: FileReadPolicy::new(destination_roots)?, + }) + } + + #[cfg(any(feature = "process-driver", test))] + pub(crate) fn resolve_source(&self, path: &Path) -> InfraResult<(PathBuf, PathBuf)> { + ensure_named_file(self.source.resolve(path)?) + } + + #[cfg(any(feature = "process-driver", test))] + pub(crate) fn resolve_destination(&self, path: &Path) -> InfraResult<(PathBuf, PathBuf)> { + ensure_named_file(self.destination.resolve(path)?) + } +} + +/// Stable file content identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransferFileIdentity { + /// Absolute path. + pub path: PathBuf, + /// File size. + pub bytes: u64, + /// Lowercase SHA-256. + pub sha256: String, +} + +/// Complete authorization-relevant transfer fingerprint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileTransferFingerprint { + /// Source host. + pub source_host: HostId, + /// Source host revision. + pub source_revision: TopologyRevision, + /// Source file identity. + pub source: TransferFileIdentity, + /// Destination host. + pub destination_host: HostId, + /// Destination host revision. + pub destination_revision: TopologyRevision, + /// Destination absolute path. + pub destination_path: PathBuf, + /// Existing destination identity, when present. + pub destination_before: Option, +} + +/// Deadline-bound transfer request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedFileTransferRequest { + /// Operation identity. + pub operation_id: OperationId, + /// Canonical operation. + pub operation: OperationName, + /// Planned transfer fingerprint. + pub fingerprint: FileTransferFingerprint, + /// Absolute execution deadline. + pub deadline: Timestamp, +} + +/// Verified file-transfer result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerifiedFileTransferOutcome { + /// Planned fingerprint. + pub before: FileTransferFingerprint, + /// Destination identity after transfer. + pub destination_after: TransferFileIdentity, + /// Bytes copied. + pub bytes: u64, + /// Backend send state. + pub send_state: MutationSendState, + /// Whether source and destination digests match. + pub verified: bool, + /// Whether destination content changed. + pub changed: bool, +} + +/// Policy role used while inspecting a transfer path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileTransferPathRole { + /// Source read policy. + Source, + /// Destination write policy. + Destination, +} + +/// Reads file identities for transfer planning and verification. +#[async_trait] +pub trait FileTransferInspector: Send + Sync { + /// Reads one file identity, optionally returning absence. + async fn inspect_transfer_file( + &self, + host: &HostRecord, + path: &Path, + role: FileTransferPathRole, + optional: bool, + cancellation: &CancellationToken, + ) -> InfraResult>; +} + +/// Complete transfer client used by the verified engine. +pub trait VerifiedFileTransferClient: FileTransfer + FileTransferInspector {} +impl VerifiedFileTransferClient for T where T: FileTransfer + FileTransferInspector {} + +#[cfg(any(feature = "process-driver", test))] +fn ensure_named_file((root, relative): (PathBuf, PathBuf)) -> InfraResult<(PathBuf, PathBuf)> { + if relative.as_os_str().is_empty() { + Err(InfraError::InvalidRequest { + domain: "file-transfer", + message: "transfer path must name a file beneath its configured root".into(), + }) + } else { + Ok((root, relative)) + } +} + +#[cfg(any(feature = "process-driver", test))] +pub(crate) fn identity_from_bytes(path: &Path, bytes: &[u8]) -> TransferFileIdentity { + TransferFileIdentity { + path: path.to_path_buf(), + bytes: bytes.len() as u64, + sha256: crate::mutation::sha256_hex(bytes), + } +} + +pub(crate) fn receipt_identity(receipt: &TransferReceipt) -> InfraResult<(&str, &str)> { + let source = receipt + .source_sha256() + .ok_or_else(|| InfraError::InvalidRequest { + domain: "file-transfer", + message: "transfer receipt is missing source digest".into(), + })?; + let destination = receipt + .destination_sha256() + .ok_or_else(|| InfraError::InvalidRequest { + domain: "file-transfer", + message: "transfer receipt is missing destination digest".into(), + })?; + Ok((source, destination)) +} + +#[cfg(test)] +#[path = "file_transfer_tests.rs"] +mod tests; diff --git a/crates/shared/operations/infra/src/file_transfer_engine.rs b/crates/shared/operations/infra/src/file_transfer_engine.rs new file mode 100644 index 00000000..467832c5 --- /dev/null +++ b/crates/shared/operations/infra/src/file_transfer_engine.rs @@ -0,0 +1,174 @@ +use std::path::Path; + +use soma_fleet::{HostRecord, TransferRequest}; +use soma_ops::MutationSendState; +use tokio_util::sync::CancellationToken; + +use crate::file_transfer::receipt_identity; +use crate::{ + FileTransferFingerprint, FileTransferPathRole, InfraError, InfraResult, + MAX_FILE_TRANSFER_BYTES, MutationFailure, MutationResult, VerifiedFileTransferClient, + VerifiedFileTransferOutcome, VerifiedFileTransferRequest, +}; + +/// Verified bounded file-transfer coordinator. +#[derive(Debug, Clone, Copy, Default)] +pub struct FileTransferEngine; + +impl FileTransferEngine { + /// Captures source and destination pre-state. + pub async fn inspect( + &self, + client: &dyn VerifiedFileTransferClient, + source: &HostRecord, + source_path: &Path, + destination: &HostRecord, + destination_path: &Path, + cancellation: &CancellationToken, + ) -> InfraResult { + let source_identity = client + .inspect_transfer_file( + source, + source_path, + FileTransferPathRole::Source, + false, + cancellation, + ) + .await? + .ok_or_else(|| InfraError::InvalidRequest { + domain: "file-transfer", + message: "source file is absent".into(), + })?; + if source_identity.bytes > MAX_FILE_TRANSFER_BYTES { + return Err(InfraError::InvalidRequest { + domain: "file-transfer", + message: format!( + "source exceeds the {MAX_FILE_TRANSFER_BYTES}-byte transfer limit" + ), + }); + } + let destination_before = client + .inspect_transfer_file( + destination, + destination_path, + FileTransferPathRole::Destination, + true, + cancellation, + ) + .await?; + Ok(FileTransferFingerprint { + source_host: source.id().clone(), + source_revision: source.revision().clone(), + source: source_identity, + destination_host: destination.id().clone(), + destination_revision: destination.revision().clone(), + destination_path: destination_path.to_path_buf(), + destination_before, + }) + } + + /// Executes a transfer and independently verifies destination content. + pub async fn execute( + &self, + client: &dyn VerifiedFileTransferClient, + source: &HostRecord, + destination: &HostRecord, + request: &VerifiedFileTransferRequest, + cancellation: &CancellationToken, + ) -> MutationResult { + if cancellation.is_cancelled() { + return Err(not_sent(soma_fleet::FleetError::Cancelled.into())); + } + if request.deadline <= soma_ops::Timestamp::now() { + return Err(not_sent(soma_fleet::FleetError::DeadlineExceeded.into())); + } + let current = self + .inspect( + client, + source, + &request.fingerprint.source.path, + destination, + &request.fingerprint.destination_path, + cancellation, + ) + .await + .map_err(not_sent)?; + if current != request.fingerprint { + return Err(not_sent(InfraError::InvalidRequest { + domain: "file-transfer", + message: "source or destination state changed after planning".into(), + })); + } + let transfer = TransferRequest::new( + source.id().clone(), + request.fingerprint.source.path.clone(), + destination.id().clone(), + request.fingerprint.destination_path.clone(), + MAX_FILE_TRANSFER_BYTES, + request.deadline, + ) + .map_err(soma_fleet::FleetError::from) + .map_err(|error| not_sent(error.into()))?; + let receipt = client + .transfer(source, destination, &transfer, cancellation) + .await + .map_err(|error| MutationFailure::new(MutationSendState::Unknown, error.into()))?; + let destination_after = client + .inspect_transfer_file( + destination, + &request.fingerprint.destination_path, + FileTransferPathRole::Destination, + false, + cancellation, + ) + .await + .map_err(|error| MutationFailure::new(MutationSendState::Sent, error))? + .ok_or_else(|| { + MutationFailure::new( + MutationSendState::Sent, + InfraError::InvalidRequest { + domain: "file-transfer", + message: "destination is absent after transfer".into(), + }, + ) + })?; + let (source_digest, destination_digest) = receipt_identity(&receipt) + .map_err(|error| MutationFailure::new(MutationSendState::Sent, error))?; + let verified = receipt.verified() + && receipt.bytes() == request.fingerprint.source.bytes + && destination_after.bytes == request.fingerprint.source.bytes + && source_digest == request.fingerprint.source.sha256 + && destination_digest == destination_after.sha256 + && source_digest == destination_digest; + if !verified { + return Err(MutationFailure::new( + MutationSendState::Sent, + InfraError::InvalidRequest { + domain: "file-transfer", + message: "source and destination transfer evidence does not match".into(), + }, + )); + } + let changed = request + .fingerprint + .destination_before + .as_ref() + .is_none_or(|before| before.sha256 != destination_after.sha256); + Ok(VerifiedFileTransferOutcome { + before: request.fingerprint.clone(), + destination_after, + bytes: receipt.bytes(), + send_state: MutationSendState::Sent, + verified, + changed, + }) + } +} + +fn not_sent(error: InfraError) -> MutationFailure { + MutationFailure::new(MutationSendState::NotSent, error) +} + +#[cfg(test)] +#[path = "file_transfer_engine_tests.rs"] +mod tests; diff --git a/crates/shared/operations/infra/src/file_transfer_engine_tests.rs b/crates/shared/operations/infra/src/file_transfer_engine_tests.rs new file mode 100644 index 00000000..d16a9ebf --- /dev/null +++ b/crates/shared/operations/infra/src/file_transfer_engine_tests.rs @@ -0,0 +1,6 @@ +use super::*; + +#[test] +fn file_transfer_engine_is_zero_sized() { + assert_eq!(std::mem::size_of::(), 0); +} diff --git a/crates/shared/operations/infra/src/file_transfer_tests.rs b/crates/shared/operations/infra/src/file_transfer_tests.rs new file mode 100644 index 00000000..9b93aff8 --- /dev/null +++ b/crates/shared/operations/infra/src/file_transfer_tests.rs @@ -0,0 +1,31 @@ +use super::*; + +#[test] +fn transfer_policy_requires_named_files_beneath_roots() { + let policy = FileTransferPolicy::new(["/srv/source"], ["/srv/destination"]).unwrap(); + assert!( + policy + .resolve_source(Path::new("/srv/source/file.txt")) + .is_ok() + ); + assert!(policy.resolve_source(Path::new("/srv/source")).is_err()); + assert!( + policy + .resolve_destination(Path::new("/tmp/file.txt")) + .is_err() + ); +} + +#[test] +fn byte_identity_is_deterministic() { + let left = identity_from_bytes(Path::new("/tmp/a"), b"hello"); + let right = identity_from_bytes(Path::new("/tmp/a"), b"hello"); + assert_eq!(left, right); + assert_eq!(left.sha256.len(), 64); +} + +#[test] +fn transfer_identity_uses_sha2_011_compatible_lowercase_hex() { + let identity = identity_from_bytes(std::path::Path::new("/tmp/file"), b"abc"); + assert_eq!(identity.sha256, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); +} diff --git a/crates/shared/operations/infra/src/host_exec.rs b/crates/shared/operations/infra/src/host_exec.rs new file mode 100644 index 00000000..3b3e4a0c --- /dev/null +++ b/crates/shared/operations/infra/src/host_exec.rs @@ -0,0 +1,263 @@ +use std::path::{Component, Path, PathBuf}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use soma_fleet::{HostId, HostRecord, TopologyRevision}; +use soma_ops::{MutationSendState, OperationId, OperationName, Timestamp}; +use tokio_util::sync::CancellationToken; + +use crate::{InfraError, InfraResult, MutationResult}; + +const MAX_COMMAND_ARGUMENTS: usize = 256; +const MAX_ARGUMENT_CHARS: usize = 4096; +const MAX_OUTPUT_BYTES: usize = 96 * 1024; + +/// Closed allowlist of host commands admitted by canonical Synapse execution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HostExecCommand { + /// Concatenate files. + Cat, + /// Read the beginning of files. + Head, + /// Read the end of files. + Tail, + /// Search text with grep. + Grep, + /// Search text with ripgrep. + Rg, + /// List filesystem entries. + Ls, + /// Render a directory tree. + Tree, + /// Count bytes, words, or lines. + Wc, + /// Collapse adjacent duplicate lines. + Uniq, + /// Compare files. + Diff, + /// Read filesystem metadata. + Stat, + /// Identify file types. + File, + /// Summarize filesystem usage. + Du, + /// Report filesystem capacity. + Df, + /// Print the working directory. + Pwd, + /// Print the host name. + Hostname, + /// Print host uptime. + Uptime, + /// Print the effective user. + Whoami, +} + +impl HostExecCommand { + /// Parses one canonical command name. + pub fn parse(value: &str) -> InfraResult { + match value { + "cat" => Ok(Self::Cat), + "head" => Ok(Self::Head), + "tail" => Ok(Self::Tail), + "grep" => Ok(Self::Grep), + "rg" => Ok(Self::Rg), + "ls" => Ok(Self::Ls), + "tree" => Ok(Self::Tree), + "wc" => Ok(Self::Wc), + "uniq" => Ok(Self::Uniq), + "diff" => Ok(Self::Diff), + "stat" => Ok(Self::Stat), + "file" => Ok(Self::File), + "du" => Ok(Self::Du), + "df" => Ok(Self::Df), + "pwd" => Ok(Self::Pwd), + "hostname" => Ok(Self::Hostname), + "uptime" => Ok(Self::Uptime), + "whoami" => Ok(Self::Whoami), + _ => Err(invalid(format!("host command is not allowlisted: {value}"))), + } + } + + /// Returns the executable name. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Cat => "cat", + Self::Head => "head", + Self::Tail => "tail", + Self::Grep => "grep", + Self::Rg => "rg", + Self::Ls => "ls", + Self::Tree => "tree", + Self::Wc => "wc", + Self::Uniq => "uniq", + Self::Diff => "diff", + Self::Stat => "stat", + Self::File => "file", + Self::Du => "du", + Self::Df => "df", + Self::Pwd => "pwd", + Self::Hostname => "hostname", + Self::Uptime => "uptime", + Self::Whoami => "whoami", + } + } +} + +/// One bounded allowlisted host execution request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostExecRequest { + operation_id: OperationId, + operation: OperationName, + command: HostExecCommand, + args: Vec, + working_dir: Option, + deadline: Timestamp, +} + +impl HostExecRequest { + /// Creates a validated request with at most 256 direct arguments. + pub fn new( + operation_id: OperationId, + operation: OperationName, + command: HostExecCommand, + args: Vec, + working_dir: Option, + deadline: Timestamp, + ) -> InfraResult { + if args.len() > MAX_COMMAND_ARGUMENTS { + return Err(invalid(format!( + "host command accepts at most {MAX_COMMAND_ARGUMENTS} arguments" + ))); + } + for argument in &args { + let count = argument.chars().count(); + if count == 0 || count > MAX_ARGUMENT_CHARS || argument.as_bytes().contains(&0) { + return Err(invalid( + "host command arguments must be 1-4096 characters without NUL", + )); + } + } + let working_dir = working_dir.map(validate_absolute_path).transpose()?; + if deadline <= Timestamp::now() { + return Err(invalid("host command deadline must be in the future")); + } + Ok(Self { + operation_id, + operation, + command, + args, + working_dir, + deadline, + }) + } + + /// Returns the operation identity. + #[must_use] + pub fn operation_id(&self) -> &OperationId { + &self.operation_id + } + /// Returns the canonical operation name. + #[must_use] + pub fn operation(&self) -> &OperationName { + &self.operation + } + /// Returns the allowlisted command. + #[must_use] + pub const fn command(&self) -> HostExecCommand { + self.command + } + /// Returns positional arguments. + #[must_use] + pub fn args(&self) -> &[String] { + &self.args + } + /// Returns the optional descriptor-bound working directory. + #[must_use] + pub fn working_dir(&self) -> Option<&Path> { + self.working_dir.as_deref() + } + /// Returns the absolute deadline. + #[must_use] + pub const fn deadline(&self) -> Timestamp { + self.deadline + } + /// Returns the stdout byte ceiling. + #[must_use] + pub const fn max_stdout_bytes(&self) -> usize { + MAX_OUTPUT_BYTES + } + /// Returns the stderr byte ceiling. + #[must_use] + pub const fn max_stderr_bytes(&self) -> usize { + MAX_OUTPUT_BYTES + } +} + +/// Completed bounded host execution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostExecReceipt { + /// Target host. + pub host: HostId, + /// Exact topology revision. + pub topology_revision: TopologyRevision, + /// Executed command. + pub command: HostExecCommand, + /// Positional arguments. + pub args: Vec, + /// Optional descriptor-bound working directory. + pub working_dir: Option, + /// Lossy UTF-8 stdout bounded by policy. + pub stdout: String, + /// Lossy UTF-8 stderr bounded by policy. + pub stderr: String, + /// Process exit code when available. + pub exit_code: Option, + /// Whether either stream exceeded its byte ceiling. + pub truncated: bool, + /// Whether UTF-8 replacement was required. + pub encoding_lossy: bool, + /// Backend send state. + pub send_state: MutationSendState, +} + +/// Product-neutral bounded host command driver. +#[async_trait] +pub trait HostExecMutator: Send + Sync { + /// Executes one allowlisted command through a typed launcher. + async fn exec_host( + &self, + host: &HostRecord, + request: &HostExecRequest, + cancellation: &CancellationToken, + ) -> MutationResult; +} + +fn validate_absolute_path(path: PathBuf) -> InfraResult { + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir | Component::CurDir)) + || path.to_string_lossy().chars().any(char::is_control) + { + Err(invalid(format!( + "working directory must be absolute and normalized: {}", + path.display() + ))) + } else { + Ok(path) + } +} + +fn invalid(message: impl Into) -> InfraError { + InfraError::InvalidRequest { + domain: "host-exec", + message: message.into(), + } +} + +#[cfg(test)] +#[path = "host_exec_tests.rs"] +mod tests; diff --git a/crates/shared/operations/infra/src/host_exec_argv.rs b/crates/shared/operations/infra/src/host_exec_argv.rs new file mode 100644 index 00000000..43e57063 --- /dev/null +++ b/crates/shared/operations/infra/src/host_exec_argv.rs @@ -0,0 +1,284 @@ +#[cfg(any(feature = "process-driver", test))] +use crate::{HostExecCommand, InfraError, InfraResult}; + +#[cfg(any(feature = "process-driver", test))] +pub(crate) fn filesystem_operand_indices( + command: HostExecCommand, + args: &[String], +) -> InfraResult> { + let args = args.iter().map(String::as_str).collect::>(); + let (flags, value_flags): (&[&str], &[&str]) = match command { + HostExecCommand::Cat => ( + &[ + "-A", + "-b", + "-E", + "-n", + "-s", + "-T", + "-v", + "--number", + "--number-nonblank", + "--show-all", + "--show-ends", + "--show-tabs", + "--show-nonprinting", + "--squeeze-blank", + ], + &[], + ), + HostExecCommand::Head => ( + &["-q", "-v", "--quiet", "--silent", "--verbose"], + &["-c", "--bytes", "-n", "--lines"], + ), + HostExecCommand::Tail => ( + &[ + "-f", + "-F", + "-q", + "-v", + "--follow", + "--quiet", + "--silent", + "--verbose", + ], + &["-c", "--bytes", "-n", "--lines", "-s", "--sleep-interval"], + ), + HostExecCommand::Ls => ( + &[ + "-1", + "-A", + "-a", + "-d", + "-h", + "-l", + "-R", + "--all", + "--almost-all", + "--directory", + "--human-readable", + "--recursive", + ], + &[], + ), + HostExecCommand::Tree => (&["-a", "-d", "-f", "-i", "--noreport"], &["-L"]), + HostExecCommand::Stat => ( + &[ + "-f", + "-L", + "-t", + "--dereference", + "--file-system", + "--terse", + ], + &["-c", "--format", "--printf"], + ), + HostExecCommand::File => ( + &["-b", "-L", "-z", "--brief", "--dereference", "--uncompress"], + &[], + ), + HostExecCommand::Du => ( + &[ + "-a", + "-h", + "-s", + "-x", + "--all", + "--human-readable", + "--summarize", + "--one-file-system", + ], + &["-d", "--max-depth"], + ), + HostExecCommand::Diff => ( + &[ + "-a", + "-b", + "-B", + "-i", + "-q", + "-s", + "-u", + "-w", + "--brief", + "--ignore-all-space", + "--ignore-blank-lines", + "--ignore-case", + "--report-identical-files", + "--text", + "--unified", + ], + &[], + ), + HostExecCommand::Wc => ( + &[ + "-c", "-l", "-m", "-w", "--bytes", "--chars", "--lines", "--words", + ], + &[], + ), + HostExecCommand::Uniq => ( + &[ + "-c", + "-d", + "-i", + "-u", + "--count", + "--ignore-case", + "--repeated", + "--unique", + ], + &[ + "-f", + "--skip-fields", + "-s", + "--skip-chars", + "-w", + "--check-chars", + ], + ), + HostExecCommand::Grep | HostExecCommand::Rg => { + return grep_like_operand_indices(command, &args); + } + HostExecCommand::Df + | HostExecCommand::Pwd + | HostExecCommand::Hostname + | HostExecCommand::Uptime + | HostExecCommand::Whoami => { + if args.is_empty() { + return Ok(Vec::new()); + } + return Err(invalid(format!( + "{} does not accept host-exec arguments", + command.as_str() + ))); + } + }; + parse_path_operands(command, &args, flags, value_flags) +} + +#[cfg(any(feature = "process-driver", test))] +fn parse_path_operands( + command: HostExecCommand, + args: &[&str], + flags: &[&str], + value_flags: &[&str], +) -> InfraResult> { + let mut paths = Vec::new(); + let mut index = 0; + let mut options = true; + while index < args.len() { + let argument = args[index]; + if options && argument == "--" { + options = false; + } else if options && argument.starts_with('-') { + if argument.contains('=') + || (!flags.contains(&argument) && !value_flags.contains(&argument)) + { + return Err(invalid(format!( + "unsupported {} option: {argument}", + command.as_str() + ))); + } + if value_flags.contains(&argument) { + index += 1; + if index >= args.len() || args[index].starts_with('-') { + return Err(invalid(format!( + "{} option {argument} requires a value", + command.as_str() + ))); + } + } + } else { + paths.push(index); + } + index += 1; + } + Ok(paths) +} + +#[cfg(any(feature = "process-driver", test))] +fn grep_like_operand_indices(command: HostExecCommand, args: &[&str]) -> InfraResult> { + let flags = [ + "-F", + "-H", + "-I", + "-i", + "-l", + "-n", + "-v", + "-w", + "-x", + "--fixed-strings", + "--files-with-matches", + "--ignore-case", + "--line-number", + "--invert-match", + "--word-regexp", + "--line-regexp", + ]; + let value_flags = [ + "-A", + "-B", + "-C", + "-e", + "-g", + "-m", + "--after-context", + "--before-context", + "--context", + "--glob", + "--max-count", + "--regexp", + ]; + let mut paths = Vec::new(); + let mut index = 0; + let mut options = true; + let mut has_explicit_pattern = false; + let mut positional_pattern_seen = false; + while index < args.len() { + let argument = args[index]; + if options && argument == "--" { + options = false; + } else if options && argument.starts_with('-') { + if argument.contains('=') + || (!flags.contains(&argument) && !value_flags.contains(&argument)) + { + return Err(invalid(format!( + "unsupported {} option: {argument}", + command.as_str() + ))); + } + if value_flags.contains(&argument) { + index += 1; + if index >= args.len() { + return Err(invalid(format!( + "{} option {argument} requires a value", + command.as_str() + ))); + } + has_explicit_pattern |= matches!(argument, "-e" | "--regexp"); + } + } else if !has_explicit_pattern && !positional_pattern_seen { + positional_pattern_seen = true; + } else { + paths.push(index); + } + index += 1; + } + if !has_explicit_pattern && !positional_pattern_seen { + return Err(invalid(format!("{} requires a pattern", command.as_str()))); + } + Ok(paths) +} + +#[cfg(any(feature = "process-driver", test))] +fn invalid(message: impl Into) -> InfraError { + InfraError::InvalidRequest { + domain: "host-exec", + message: message.into(), + } +} + +#[cfg(test)] +#[path = "host_exec_argv_tests.rs"] +mod tests; diff --git a/crates/shared/operations/infra/src/host_exec_argv_tests.rs b/crates/shared/operations/infra/src/host_exec_argv_tests.rs new file mode 100644 index 00000000..b4a5923c --- /dev/null +++ b/crates/shared/operations/infra/src/host_exec_argv_tests.rs @@ -0,0 +1,31 @@ +use super::*; + +#[test] +fn filesystem_operands_follow_typed_command_grammars() { + assert_eq!( + filesystem_operand_indices( + HostExecCommand::Head, + &["-n".into(), "10".into(), "/srv/app.log".into()], + ) + .unwrap(), + vec![2] + ); + assert_eq!( + filesystem_operand_indices(HostExecCommand::Rg, &["needle".into(), "/srv".into()],) + .unwrap(), + vec![1] + ); +} + +#[test] +fn helper_executing_and_unknown_options_fail_closed() { + assert!( + filesystem_operand_indices( + HostExecCommand::Grep, + &["--include-from=/tmp/options".into(), "x".into()], + ) + .is_err() + ); + assert!(filesystem_operand_indices(HostExecCommand::Hostname, &["--help".into()]).is_err()); + assert!(filesystem_operand_indices(HostExecCommand::Rg, &[]).is_err()); +} diff --git a/crates/shared/operations/infra/src/host_exec_many.rs b/crates/shared/operations/infra/src/host_exec_many.rs new file mode 100644 index 00000000..b4404d93 --- /dev/null +++ b/crates/shared/operations/infra/src/host_exec_many.rs @@ -0,0 +1,229 @@ +use std::path::PathBuf; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use soma_fleet::{FanoutPolicy, FanoutScheduler, HostId, HostRecord, TargetOutcomeKind}; +use soma_ops::MutationSendState; +use tokio_util::sync::CancellationToken; + +use crate::{ + HostExecMutator, HostExecReceipt, HostExecRequest, InfraError, MutationFailure, MutationResult, +}; + +/// Terminal classification for one host-exec fanout target. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HostExecTargetStatus { + /// Command completed with exit code zero. + Succeeded, + /// Command failed, returned nonzero, or lost backend certainty. + Failed, + /// Shared cancellation interrupted target accounting. + Cancelled, + /// Fanout target exceeded its per-target ceiling. + TimedOut, +} + +/// Stable-order outcome for one fanout target. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostExecTargetResult { + /// Target host. + pub host: HostId, + /// Optional descriptor-bound working directory. + pub working_dir: Option, + /// Terminal target status. + pub status: HostExecTargetStatus, + /// Completed command receipt when available. + pub receipt: Option, + /// Bounded error text when execution did not succeed. + pub error: Option, + /// Conservative backend send state. + pub send_state: MutationSendState, +} + +/// Complete stable-order host execution fanout outcome. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostExecManyOutcome { + /// Per-target results in normalized request order. + pub results: Vec, + /// Successful target count. + pub succeeded: usize, + /// Failed target count, including nonzero exits. + pub failed: usize, + /// Cancelled target count. + pub cancelled: usize, + /// Timed-out target count. + pub timed_out: usize, + /// Aggregate conservative send state. + pub send_state: MutationSendState, +} + +impl HostExecManyOutcome { + /// Returns whether every target completed with exit code zero. + #[must_use] + pub fn all_succeeded(&self) -> bool { + self.succeeded == self.results.len() + } +} + +/// Bounded stable-order host execution fanout coordinator. +#[derive(Debug, Clone, Copy)] +pub struct HostExecManyEngine { + scheduler: FanoutScheduler, +} + +impl HostExecManyEngine { + /// Creates an engine with explicit concurrency and per-target timeout bounds. + pub fn new(max_concurrency: usize, per_target_timeout: Duration) -> Result { + let policy = FanoutPolicy::new(max_concurrency, per_target_timeout).map_err(|error| { + InfraError::InvalidRequest { + domain: "host-exec-many", + message: error.to_string(), + } + })?; + Ok(Self { + scheduler: FanoutScheduler::new(policy), + }) + } + + /// Executes distinct host/request payloads and retains every terminal outcome. + pub async fn execute( + &self, + client: &dyn HostExecMutator, + targets: Vec<(HostRecord, HostExecRequest)>, + cancellation: CancellationToken, + ) -> MutationResult { + if targets.is_empty() { + return Err(MutationFailure::new( + MutationSendState::NotSent, + InfraError::InvalidRequest { + domain: "host-exec-many", + message: "at least one target is required".into(), + }, + )); + } + if cancellation.is_cancelled() { + return Err(MutationFailure::new( + MutationSendState::NotSent, + soma_fleet::FleetError::Cancelled.into(), + )); + } + let descriptors = targets + .iter() + .map(|(host, request)| { + ( + host.id().clone(), + request.working_dir().map(ToOwned::to_owned), + ) + }) + .collect::>(); + let report = self + .scheduler + .run_with_payload(targets, cancellation, |host, request, child| async move { + client.exec_host(&host, &request, &child).await + }) + .await; + let mut results = Vec::with_capacity(descriptors.len()); + for outcome in report.into_outcomes() { + let (index, host, kind) = outcome.into_parts(); + let working_dir = descriptors[index].1.clone(); + results.push(normalize_target(host, working_dir, kind)); + } + let succeeded = count(&results, HostExecTargetStatus::Succeeded); + let failed = count(&results, HostExecTargetStatus::Failed); + let cancelled = count(&results, HostExecTargetStatus::Cancelled); + let timed_out = count(&results, HostExecTargetStatus::TimedOut); + let send_state = aggregate_send_state(&results); + Ok(HostExecManyOutcome { + results, + succeeded, + failed, + cancelled, + timed_out, + send_state, + }) + } +} + +fn normalize_target( + host: HostId, + working_dir: Option, + kind: TargetOutcomeKind, +) -> HostExecTargetResult { + match kind { + TargetOutcomeKind::Succeeded(receipt) => { + let succeeded = receipt.exit_code == Some(0); + let send_state = receipt.send_state; + HostExecTargetResult { + host, + working_dir, + status: if succeeded { + HostExecTargetStatus::Succeeded + } else { + HostExecTargetStatus::Failed + }, + error: if succeeded { + None + } else { + Some(format!( + "command exited with status {:?}", + receipt.exit_code + )) + }, + receipt: Some(receipt), + send_state, + } + } + TargetOutcomeKind::Failed(failure) => HostExecTargetResult { + host, + working_dir, + status: HostExecTargetStatus::Failed, + receipt: None, + error: Some(failure.error().to_string()), + send_state: failure.send_state(), + }, + TargetOutcomeKind::Cancelled => HostExecTargetResult { + host, + working_dir, + status: HostExecTargetStatus::Cancelled, + receipt: None, + error: Some("target was cancelled after fanout admission".into()), + send_state: MutationSendState::Unknown, + }, + TargetOutcomeKind::TimedOut => HostExecTargetResult { + host, + working_dir, + status: HostExecTargetStatus::TimedOut, + receipt: None, + error: Some("target exceeded its bounded execution timeout".into()), + send_state: MutationSendState::Unknown, + }, + } +} + +fn count(results: &[HostExecTargetResult], status: HostExecTargetStatus) -> usize { + results + .iter() + .filter(|result| result.status == status) + .count() +} + +fn aggregate_send_state(results: &[HostExecTargetResult]) -> MutationSendState { + if results + .iter() + .any(|result| result.send_state == MutationSendState::Unknown) + { + MutationSendState::Unknown + } else if results + .iter() + .any(|result| result.send_state == MutationSendState::Sent) + { + MutationSendState::Sent + } else { + MutationSendState::NotSent + } +} + +#[cfg(test)] +#[path = "host_exec_many_tests.rs"] +mod tests; diff --git a/crates/shared/operations/infra/src/host_exec_many_tests.rs b/crates/shared/operations/infra/src/host_exec_many_tests.rs new file mode 100644 index 00000000..aecc2022 --- /dev/null +++ b/crates/shared/operations/infra/src/host_exec_many_tests.rs @@ -0,0 +1,95 @@ +use std::time::Duration; + +use async_trait::async_trait; +use soma_fleet::{HostEndpoint, HostId}; +use soma_ops::{OperationId, OperationName, Timestamp}; + +use super::*; +use crate::{HostExecCommand, HostExecMutator}; + +struct Fake; + +#[async_trait] +impl HostExecMutator for Fake { + async fn exec_host( + &self, + host: &HostRecord, + request: &HostExecRequest, + _cancellation: &CancellationToken, + ) -> MutationResult { + if host.id().as_str() == "error" { + return Err(MutationFailure::new( + MutationSendState::Unknown, + InfraError::Docker("transport lost".into()), + )); + } + let code = if host.id().as_str() == "nonzero" { + 2 + } else { + 0 + }; + Ok(HostExecReceipt { + host: host.id().clone(), + topology_revision: host.revision().clone(), + command: request.command(), + args: request.args().to_vec(), + working_dir: request.working_dir().map(ToOwned::to_owned), + stdout: host.id().to_string(), + stderr: String::new(), + exit_code: Some(code), + truncated: false, + encoding_lossy: false, + send_state: MutationSendState::Sent, + }) + } +} + +fn target(name: &str, path: &str) -> (HostRecord, HostExecRequest) { + let host = HostRecord::new(HostId::new(name).unwrap(), HostEndpoint::Local); + let request = HostExecRequest::new( + OperationId::new(), + OperationName::new("host.exec_many").unwrap(), + HostExecCommand::Ls, + vec![path.into()], + Some(path.into()), + Timestamp::from_unix_millis(Timestamp::now().unix_millis() + 10_000), + ) + .unwrap(); + (host, request) +} + +#[tokio::test] +async fn fanout_preserves_order_partial_results_and_send_state() { + let outcome = HostExecManyEngine::new(2, Duration::from_secs(1)) + .unwrap() + .execute( + &Fake, + vec![ + target("ok", "/srv/a"), + target("nonzero", "/srv/b"), + target("error", "/srv/c"), + ], + CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(outcome.results[0].host.as_str(), "ok"); + assert_eq!(outcome.results[1].host.as_str(), "nonzero"); + assert_eq!(outcome.results[2].host.as_str(), "error"); + assert_eq!(outcome.succeeded, 1); + assert_eq!(outcome.failed, 2); + assert_eq!(outcome.send_state, MutationSendState::Unknown); + assert!(!outcome.all_succeeded()); +} + +#[tokio::test] +async fn cancellation_before_fanout_is_not_sent() { + let cancellation = CancellationToken::new(); + cancellation.cancel(); + let failure = HostExecManyEngine::new(1, Duration::from_secs(1)) + .unwrap() + .execute(&Fake, vec![target("ok", "/srv")], cancellation) + .await + .unwrap_err(); + assert_eq!(failure.send_state(), MutationSendState::NotSent); +} diff --git a/crates/shared/operations/infra/src/host_exec_policy.rs b/crates/shared/operations/infra/src/host_exec_policy.rs new file mode 100644 index 00000000..40afa83e --- /dev/null +++ b/crates/shared/operations/infra/src/host_exec_policy.rs @@ -0,0 +1,100 @@ +use std::path::{Path, PathBuf}; + +#[cfg(any(feature = "process-driver", test))] +use crate::HostExecRequest; +use crate::{FileReadPolicy, InfraError, InfraResult}; + +const MAX_EXEC_ROOTS: usize = 32; + +/// Explicit read roots used by the typed host command launcher. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostExecPolicy { + files: FileReadPolicy, +} + +impl HostExecPolicy { + /// Creates a host command policy with one to thirty-two absolute roots. + pub fn new(roots: I) -> InfraResult + where + I: IntoIterator, + P: Into, + { + let roots = roots.into_iter().map(Into::into).collect::>(); + if roots.is_empty() || roots.len() > MAX_EXEC_ROOTS { + return Err(invalid(format!( + "host execution requires 1-{MAX_EXEC_ROOTS} read roots" + ))); + } + Ok(Self { + files: FileReadPolicy::new(roots)?, + }) + } + + /// Returns roots in deterministic order. + pub fn roots(&self) -> impl Iterator { + self.files.roots() + } + + #[cfg(any(feature = "process-driver", test))] + pub(crate) fn launcher_plan(&self, request: &HostExecRequest) -> InfraResult { + let path_indices = + crate::host_exec_argv::filesystem_operand_indices(request.command(), request.args())?; + for index in &path_indices { + let path = validate_operand_path(&request.args()[*index])?; + self.files.resolve(path)?; + } + if let Some(path) = request.working_dir() { + self.files.resolve(path)?; + } + let roots = self + .files + .roots() + .map(|root| root.to_string_lossy().into_owned()) + .collect(); + Ok(LauncherPlan { + path_indices, + roots, + working_dir: request + .working_dir() + .map(|path| path.to_string_lossy().into_owned()), + }) + } +} + +#[cfg(any(feature = "process-driver", test))] +pub(crate) struct LauncherPlan { + pub(crate) path_indices: Vec, + pub(crate) roots: Vec, + pub(crate) working_dir: Option, +} + +#[cfg(any(feature = "process-driver", test))] +fn validate_operand_path(value: &str) -> InfraResult<&Path> { + let path = Path::new(value); + if !path.is_absolute() + || path.components().any(|part| { + matches!( + part, + std::path::Component::ParentDir | std::path::Component::CurDir + ) + }) + || value.chars().any(char::is_control) + { + Err(invalid(format!( + "filesystem command operands must be absolute and normalized: {value:?}" + ))) + } else { + Ok(path) + } +} + +fn invalid(message: impl Into) -> InfraError { + InfraError::InvalidRequest { + domain: "host-exec", + message: message.into(), + } +} + +#[cfg(test)] +#[path = "host_exec_policy_tests.rs"] +mod tests; diff --git a/crates/shared/operations/infra/src/host_exec_policy_tests.rs b/crates/shared/operations/infra/src/host_exec_policy_tests.rs new file mode 100644 index 00000000..e23a4cf5 --- /dev/null +++ b/crates/shared/operations/infra/src/host_exec_policy_tests.rs @@ -0,0 +1,45 @@ +use soma_ops::{OperationId, OperationName, Timestamp}; + +use super::*; +use crate::HostExecCommand; + +fn request(args: Vec, working_dir: Option) -> HostExecRequest { + HostExecRequest::new( + OperationId::new(), + OperationName::new("host.exec").unwrap(), + HostExecCommand::Ls, + args, + working_dir, + Timestamp::from_unix_millis(Timestamp::now().unix_millis() + 10_000), + ) + .unwrap() +} + +#[test] +fn launcher_plan_confines_operands_and_working_directory() { + let policy = HostExecPolicy::new(["/srv", "/tmp"]).unwrap(); + let plan = policy + .launcher_plan(&request( + vec!["-l".into(), "/srv/app".into()], + Some("/srv".into()), + )) + .unwrap(); + assert_eq!(plan.path_indices, vec![1]); + assert_eq!(plan.working_dir.as_deref(), Some("/srv")); + assert_eq!(plan.roots, vec!["/srv", "/tmp"]); +} + +#[test] +fn outside_and_relative_operands_fail_before_execution() { + let policy = HostExecPolicy::new(["/srv"]).unwrap(); + assert!( + policy + .launcher_plan(&request(vec!["/etc/passwd".into()], None)) + .is_err() + ); + assert!( + policy + .launcher_plan(&request(vec!["relative".into()], None)) + .is_err() + ); +} diff --git a/crates/shared/operations/infra/src/host_exec_tests.rs b/crates/shared/operations/infra/src/host_exec_tests.rs new file mode 100644 index 00000000..febe92f1 --- /dev/null +++ b/crates/shared/operations/infra/src/host_exec_tests.rs @@ -0,0 +1,52 @@ +use std::path::Path; + +use soma_ops::{OperationId, OperationName, Timestamp}; + +use super::*; + +fn deadline() -> Timestamp { + Timestamp::from_unix_millis(Timestamp::now().unix_millis() + 10_000) +} + +#[test] +fn command_allowlist_is_closed() { + assert_eq!(HostExecCommand::parse("rg").unwrap(), HostExecCommand::Rg); + assert!(HostExecCommand::parse("bash").is_err()); +} + +#[test] +fn requests_bound_argv_paths_and_deadlines() { + let request = HostExecRequest::new( + OperationId::new(), + OperationName::new("host.exec").unwrap(), + HostExecCommand::Ls, + vec!["-l".into(), "/srv".into()], + Some("/srv".into()), + deadline(), + ) + .unwrap(); + assert_eq!(request.max_stdout_bytes(), 96 * 1024); + assert_eq!(request.working_dir(), Some(Path::new("/srv"))); + assert!( + HostExecRequest::new( + OperationId::new(), + OperationName::new("host.exec").unwrap(), + HostExecCommand::Ls, + vec![String::from("x"); 257], + None, + deadline(), + ) + .is_err() + ); + assert!( + HostExecRequest::new( + OperationId::new(), + OperationName::new("host.exec").unwrap(), + HostExecCommand::Ls, + Vec::new(), + Some("/srv/../etc".into()), + deadline(), + ) + .is_err() + ); +} diff --git a/crates/shared/operations/infra/src/lib.rs b/crates/shared/operations/infra/src/lib.rs index 9e781e03..2b9d0b5c 100644 --- a/crates/shared/operations/infra/src/lib.rs +++ b/crates/shared/operations/infra/src/lib.rs @@ -12,25 +12,36 @@ mod build_context; mod compose; mod compose_build; mod compose_build_engine; +mod compose_down; +mod compose_down_engine; mod compose_mutation; mod compose_parse; mod compose_pull; mod compose_pull_engine; mod compose_recreate; mod compose_recreate_engine; +mod container_exec; mod container_mutation; mod container_mutation_engine; mod container_recreate; mod container_recreate_engine; mod docker; +mod docker_cleanup; +mod docker_cleanup_engine; mod docker_provider; mod docker_telemetry; #[cfg(feature = "bollard-driver")] mod docker_telemetry_map; mod error; +mod file_transfer; +mod file_transfer_engine; mod filesystem; mod filesystem_query; mod host; +mod host_exec; +mod host_exec_argv; +mod host_exec_many; +mod host_exec_policy; mod host_system; #[cfg(feature = "process-driver")] mod host_system_parse; @@ -44,9 +55,13 @@ mod process; mod progress_sink; mod zfs; +#[cfg(feature = "bollard-driver")] +mod bollard_cleanup; #[cfg(feature = "bollard-driver")] mod bollard_driver; #[cfg(feature = "bollard-driver")] +mod bollard_exec; +#[cfg(feature = "bollard-driver")] mod bollard_image_pull; #[cfg(feature = "bollard-driver")] mod bollard_mutation; @@ -67,14 +82,20 @@ mod process_compose; #[cfg(feature = "process-driver")] mod process_compose_build; #[cfg(feature = "process-driver")] +mod process_compose_down; +#[cfg(feature = "process-driver")] mod process_compose_mutation; #[cfg(feature = "process-driver")] mod process_compose_pull; #[cfg(feature = "process-driver")] mod process_compose_recreate; #[cfg(feature = "process-driver")] +mod process_file_transfer; +#[cfg(feature = "process-driver")] mod process_filesystem; #[cfg(feature = "process-driver")] +mod process_host_exec; +#[cfg(feature = "process-driver")] mod process_host_system; #[cfg(feature = "process-driver")] mod process_image_build; @@ -99,6 +120,11 @@ pub use compose_build::{ ComposeBuildRequest, ComposeBuiltImage, resolve_compose_build_context, }; pub use compose_build_engine::{ComposeBuildEngine, ComposeBuildServices}; +pub use compose_down::{ + ComposeDownClient, ComposeDownMutator, ComposeDownOutcome, ComposeDownReceipt, + ComposeDownRequest, +}; +pub use compose_down_engine::ComposeDownEngine; pub use compose_mutation::{ ComposeMutationAction, ComposeMutationClient, ComposeMutationEngine, ComposeMutationOutcome, ComposeMutationReceipt, ComposeMutationRequest, ComposeMutator, @@ -114,6 +140,9 @@ pub use compose_recreate::{ compose_recreate_fingerprint, }; pub use compose_recreate_engine::ComposeRecreateEngine; +pub use container_exec::{ + ContainerExecClientProvider, ContainerExecMutator, ContainerExecReceipt, ContainerExecRequest, +}; pub use container_mutation::{ ContainerLifecycleAction, ContainerLifecycleMutator, ContainerLifecycleOutcome, ContainerLifecycleRequest, ContainerMutationReceipt, DockerMutationClient, @@ -131,12 +160,25 @@ pub use docker::{ ContainerSummary, DockerSystemInfo, DockerSystemReader, ImageListOptions, ImageReader, ImageSummary, NetworkReader, NetworkSummary, VolumeReader, VolumeSummary, }; +pub use docker_cleanup::{ + DockerCleanupClient, DockerCleanupClientProvider, DockerCleanupMutator, DockerPruneFingerprint, + DockerPruneOutcome, DockerPruneReceipt, DockerPruneRequest, DockerPruneScopeReceipt, + DockerPruneTarget, ImageRemovalFingerprint, ImageRemovalOutcome, ImageRemovalReceipt, + ImageRemovalRequest, +}; +pub use docker_cleanup_engine::DockerCleanupEngine; pub use docker_provider::{DockerClientProvider, DockerReadClient}; pub use docker_telemetry::{ ContainerLogOptions, ContainerLogs, ContainerStatsSnapshot, DockerDiskUsage, DockerLogStream, DockerTelemetryReader, DockerUsageCategory, }; pub use error::{InfraError, InfraResult}; +pub use file_transfer::{ + FileTransferFingerprint, FileTransferInspector, FileTransferPathRole, FileTransferPolicy, + MAX_FILE_TRANSFER_BYTES, TransferFileIdentity, VerifiedFileTransferClient, + VerifiedFileTransferOutcome, VerifiedFileTransferRequest, +}; +pub use file_transfer_engine::FileTransferEngine; pub use filesystem::{ FileHash, FileKind, FileMetadata, FilePreview, FileReadPolicy, FilesystemInspector, }; @@ -148,6 +190,11 @@ pub use host::{ HostIdentity, HostInspectRequest, HostInspection, HostInspector, HostLoadAverage, HostMemory, LinuxCommandHostInspector, }; +pub use host_exec::{HostExecCommand, HostExecMutator, HostExecReceipt, HostExecRequest}; +pub use host_exec_many::{ + HostExecManyEngine, HostExecManyOutcome, HostExecTargetResult, HostExecTargetStatus, +}; +pub use host_exec_policy::HostExecPolicy; pub use host_system::{ DoctorCheck, DoctorReport, FilesystemUsage, HostSystemInspector, MountInfo, NetworkAddress, NetworkInterface, PortInfo, PortListRequest, PortProtocol, ServiceListRequest, ServiceStatus, @@ -175,8 +222,12 @@ pub use process_compose::CommandComposeInspector; #[cfg(feature = "process-driver")] pub use process_compose_build::CommandComposeBuildMutator; #[cfg(feature = "process-driver")] +pub use process_file_transfer::CommandFileTransfer; +#[cfg(feature = "process-driver")] pub use process_filesystem::CommandFilesystemQueryInspector; #[cfg(feature = "process-driver")] +pub use process_host_exec::CommandHostExec; +#[cfg(feature = "process-driver")] pub use process_host_system::CommandHostSystemInspector; #[cfg(feature = "process-driver")] pub use process_image_build::CommandImageBuildMutator; diff --git a/crates/shared/operations/infra/src/process_compose_down.rs b/crates/shared/operations/infra/src/process_compose_down.rs new file mode 100644 index 00000000..3d304e13 --- /dev/null +++ b/crates/shared/operations/infra/src/process_compose_down.rs @@ -0,0 +1,97 @@ +use async_trait::async_trait; +use soma_fleet::{CommandExecutor, CommandRequest, HostRecord}; +use soma_ops::{MutationSendState, Timestamp}; +use tokio_util::sync::CancellationToken; + +use crate::{ + CommandComposeInspector, ComposeDownMutator, ComposeDownReceipt, ComposeDownRequest, + InfraError, MutationFailure, MutationResult, +}; + +const OUTPUT_LIMIT: usize = 4 * 1024 * 1024; + +#[async_trait] +impl ComposeDownMutator for CommandComposeInspector +where + E: CommandExecutor, +{ + async fn down_compose( + &self, + host: &HostRecord, + request: &ComposeDownRequest, + cancellation: &CancellationToken, + ) -> MutationResult { + ensure_admitted(request.deadline(), cancellation)?; + let mut args = vec![ + "compose".into(), + "-f".into(), + request + .project() + .config_file() + .to_string_lossy() + .into_owned(), + "down".into(), + ]; + if request.remove_volumes() { + args.push("--volumes".into()); + } + let command = CommandRequest::new("docker", args, request.deadline()) + .map_err(soma_fleet::FleetError::from) + .and_then(|command| { + command + .with_output_limits(OUTPUT_LIMIT, OUTPUT_LIMIT) + .map_err(soma_fleet::FleetError::from) + }) + .map_err(|error| { + MutationFailure::new(MutationSendState::NotSent, InfraError::from(error)) + })?; + let output = self + .executor + .execute(host, &command, cancellation) + .await + .map_err(|error| { + MutationFailure::new(MutationSendState::Unknown, InfraError::from(error)) + })?; + if output.exit_code() != Some(0) { + return Err(MutationFailure::new( + MutationSendState::Sent, + InfraError::CommandFailed { + domain: "compose-down", + host: host.id().clone(), + exit_code: output.exit_code(), + stderr: String::from_utf8_lossy(output.stderr()).trim().to_owned(), + }, + )); + } + Ok(ComposeDownReceipt { + host: host.id().clone(), + topology_revision: host.revision().clone(), + project: request.project().name().to_owned(), + remove_volumes: request.remove_volumes(), + send_state: MutationSendState::Sent, + stdout: String::from_utf8_lossy(output.stdout()).trim().to_owned(), + stderr: String::from_utf8_lossy(output.stderr()).trim().to_owned(), + output_truncated: output.truncated(), + }) + } +} + +fn ensure_admitted(deadline: Timestamp, cancellation: &CancellationToken) -> MutationResult<()> { + if cancellation.is_cancelled() { + return Err(MutationFailure::new( + MutationSendState::NotSent, + soma_fleet::FleetError::Cancelled.into(), + )); + } + if deadline <= Timestamp::now() { + return Err(MutationFailure::new( + MutationSendState::NotSent, + soma_fleet::FleetError::DeadlineExceeded.into(), + )); + } + Ok(()) +} + +#[cfg(test)] +#[path = "process_compose_down_tests.rs"] +mod tests; diff --git a/crates/shared/operations/infra/src/process_compose_down_tests.rs b/crates/shared/operations/infra/src/process_compose_down_tests.rs new file mode 100644 index 00000000..d22e0eb0 --- /dev/null +++ b/crates/shared/operations/infra/src/process_compose_down_tests.rs @@ -0,0 +1,58 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use soma_fleet::{ + CommandExecutor, CommandOutput, CommandRequest, FleetResult, HostEndpoint, HostId, HostRecord, +}; +use soma_ops::{OperationId, OperationName, Timestamp}; + +use super::*; +use crate::{ComposeProjectRef, ComposeRecreateFingerprint}; + +#[derive(Default)] +struct MockExecutor(Mutex>); + +#[async_trait] +impl CommandExecutor for MockExecutor { + async fn execute( + &self, + _host: &HostRecord, + request: &CommandRequest, + _cancellation: &CancellationToken, + ) -> FleetResult { + self.0.lock().unwrap().push(request.clone()); + Ok(CommandOutput::new(Vec::new(), Vec::new(), Some(0), false)) + } +} + +#[tokio::test] +async fn compose_down_uses_discrete_volume_argument() { + let executor = Arc::new(MockExecutor::default()); + let inspector = CommandComposeInspector::new(executor.clone()); + let host = HostRecord::new(HostId::new("dookie").unwrap(), HostEndpoint::Local); + let request = ComposeDownRequest::new( + OperationId::new(), + OperationName::new("compose.down").unwrap(), + ComposeProjectRef::new("soma", "/srv/soma/compose.yaml").unwrap(), + ComposeRecreateFingerprint::new("soma", vec!["api".into()], "a".repeat(64)).unwrap(), + true, + true, + Timestamp::from_unix_millis(Timestamp::now().unix_millis() + 10_000), + ) + .unwrap(); + inspector + .down_compose(&host, &request, &CancellationToken::new()) + .await + .unwrap(); + let calls = executor.0.lock().unwrap(); + assert_eq!( + calls[0].args(), + [ + "compose", + "-f", + "/srv/soma/compose.yaml", + "down", + "--volumes" + ] + ); +} diff --git a/crates/shared/operations/infra/src/process_file_transfer.rs b/crates/shared/operations/infra/src/process_file_transfer.rs new file mode 100644 index 00000000..6c7524af --- /dev/null +++ b/crates/shared/operations/infra/src/process_file_transfer.rs @@ -0,0 +1,342 @@ +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; +use base64::Engine; +use soma_fleet::{ + CommandExecutor, CommandRequest, FileTransfer, FleetError, FleetResult, HostId, HostRecord, + TransferLifecycle, TransferReceipt, TransferRequest, +}; +use tokio_util::sync::CancellationToken; + +use crate::file_transfer::identity_from_bytes; +use crate::{ + FileTransferInspector, FileTransferPathRole, FileTransferPolicy, InfraError, InfraResult, + TransferFileIdentity, +}; + +const STDERR_LIMIT: usize = 64 * 1024; +const PY_BOOTSTRAP: &str = + "import base64,sys;exec(compile(base64.b64decode(sys.argv[1]),'','exec'))"; +const READ_SOURCE: &str = r#"import os, stat, sys +root, rel, cap, optional = sys.argv[2], sys.argv[3], int(sys.argv[4]), sys.argv[5] == '1' +parts = [part for part in root.split('/') if part] + [part for part in rel.split('/') if part] +fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY) +try: + for index, part in enumerate(parts): + flags = os.O_RDONLY | os.O_NOFOLLOW + if index < len(parts) - 1: flags |= os.O_DIRECTORY + try: + nxt = os.open(part, flags, dir_fd=fd) + except FileNotFoundError: + if optional: sys.exit(3) + raise + os.close(fd); fd = nxt + meta = os.fstat(fd) + if not stat.S_ISREG(meta.st_mode): raise RuntimeError('path is not a regular file') + if meta.st_size > cap: raise RuntimeError('file exceeds transfer byte limit') + while True: + data = os.read(fd, 65536) + if not data: break + sys.stdout.buffer.write(data) +finally: + os.close(fd) +"#; +const WRITE_SOURCE: &str = r#"import os, sys +root, rel, cap = sys.argv[2], sys.argv[3], int(sys.argv[4]) +parts = [part for part in root.split('/') if part] + [part for part in rel.split('/') if part] +if not parts: raise RuntimeError('destination must name a file') +fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY) +try: + for part in parts[:-1]: + nxt = os.open(part, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY, dir_fd=fd) + os.close(fd); fd = nxt + out = os.open(parts[-1], os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600, dir_fd=fd) + try: + total = 0 + while True: + data = sys.stdin.buffer.read(65536) + if not data: break + total += len(data) + if total > cap: raise RuntimeError('destination exceeded transfer byte limit') + view = memoryview(data) + while view: + written = os.write(out, view) + view = view[written:] + os.fsync(out) + finally: + os.close(out) +finally: + os.close(fd) +"#; + +/// Descriptor-confined local or strict-SSH file transfer driver. +pub struct CommandFileTransfer { + executor: Arc, + policies: BTreeMap, +} + +#[derive(Debug, Clone, Copy)] +struct BoundReadOptions { + role: FileTransferPathRole, + optional: bool, + max_bytes: usize, + deadline: soma_ops::Timestamp, +} + +impl CommandFileTransfer { + /// Creates a transfer driver with no admitted hosts. + #[must_use] + pub fn new(executor: Arc) -> Self { + Self { + executor, + policies: BTreeMap::new(), + } + } + + /// Adds or replaces one host policy. + #[must_use] + pub fn with_policy(mut self, host: HostId, policy: FileTransferPolicy) -> Self { + self.policies.insert(host, policy); + self + } + + fn policy(&self, host: &HostRecord) -> InfraResult<&FileTransferPolicy> { + self.policies + .get(host.id()) + .ok_or_else(|| InfraError::InvalidRequest { + domain: "file-transfer", + message: format!("file transfer is disabled for {}", host.id()), + }) + } + + async fn read_bound( + &self, + host: &HostRecord, + path: &Path, + options: BoundReadOptions, + cancellation: &CancellationToken, + ) -> FleetResult>> { + let policy = self + .policy(host) + .map_err(|error| command_error(host, error))?; + let (root, relative) = match options.role { + FileTransferPathRole::Source => policy.resolve_source(path), + FileTransferPathRole::Destination => policy.resolve_destination(path), + } + .map_err(|error| command_error(host, error))?; + let args = vec![ + "-c".into(), + PY_BOOTSTRAP.into(), + encoded(READ_SOURCE), + root.to_string_lossy().into_owned(), + relative.to_string_lossy().into_owned(), + options.max_bytes.to_string(), + if options.optional { + "1".into() + } else { + "0".into() + }, + ]; + let command = CommandRequest::new("python3", args, options.deadline)? + .with_output_limits(options.max_bytes, STDERR_LIMIT)?; + let output = self.executor.execute(host, &command, cancellation).await?; + if options.optional && output.exit_code() == Some(3) { + return Ok(None); + } + if output.exit_code() != Some(0) || output.truncated() { + return Err(FleetError::Command { + host: host.id().clone(), + message: format!( + "descriptor-bound read failed: {}", + String::from_utf8_lossy(output.stderr()).trim() + ), + }); + } + Ok(Some(output.stdout().to_vec())) + } + + async fn write_bound( + &self, + host: &HostRecord, + path: &Path, + bytes: &[u8], + max_bytes: usize, + deadline: soma_ops::Timestamp, + cancellation: &CancellationToken, + ) -> FleetResult<()> { + let policy = self + .policy(host) + .map_err(|error| command_error(host, error))?; + let (root, relative) = policy + .resolve_destination(path) + .map_err(|error| command_error(host, error))?; + let args = vec![ + "-c".into(), + PY_BOOTSTRAP.into(), + encoded(WRITE_SOURCE), + root.to_string_lossy().into_owned(), + relative.to_string_lossy().into_owned(), + max_bytes.to_string(), + ]; + let command = CommandRequest::new("python3", args, deadline)? + .with_stdin(bytes.to_vec())? + .with_output_limits(STDERR_LIMIT, STDERR_LIMIT)?; + let output = self.executor.execute(host, &command, cancellation).await?; + if output.exit_code() != Some(0) { + return Err(FleetError::Command { + host: host.id().clone(), + message: format!( + "descriptor-bound write failed: {}", + String::from_utf8_lossy(output.stderr()).trim() + ), + }); + } + Ok(()) + } +} + +#[async_trait] +impl FileTransferInspector for CommandFileTransfer { + async fn inspect_transfer_file( + &self, + host: &HostRecord, + path: &Path, + role: FileTransferPathRole, + optional: bool, + cancellation: &CancellationToken, + ) -> InfraResult> { + let deadline = soma_ops::Timestamp::from_unix_millis( + soma_ops::Timestamp::now().unix_millis() + 30_000, + ); + self.read_bound( + host, + path, + BoundReadOptions { + role, + optional, + max_bytes: crate::MAX_FILE_TRANSFER_BYTES as usize, + deadline, + }, + cancellation, + ) + .await + .map(|bytes| bytes.map(|bytes| identity_from_bytes(path, &bytes))) + .map_err(InfraError::from) + } +} + +#[async_trait] +impl FileTransfer for CommandFileTransfer { + async fn transfer( + &self, + source: &HostRecord, + destination: &HostRecord, + request: &TransferRequest, + cancellation: &CancellationToken, + ) -> FleetResult { + if source.id() != request.source_host() || destination.id() != request.destination_host() { + return Err(FleetError::Transfer { + source_host: request.source_host().clone(), + destination_host: request.destination_host().clone(), + message: "transfer host identities do not match request".into(), + }); + } + request.validate_at(soma_ops::Timestamp::now())?; + let max_bytes = usize::try_from(request.max_bytes()).map_err(|_| FleetError::Transfer { + source_host: source.id().clone(), + destination_host: destination.id().clone(), + message: "transfer byte limit does not fit this platform".into(), + })?; + let bytes = self + .read_bound( + source, + request.source_path(), + BoundReadOptions { + role: FileTransferPathRole::Source, + optional: false, + max_bytes, + deadline: request.deadline(), + }, + cancellation, + ) + .await? + .ok_or_else(|| FleetError::Transfer { + source_host: source.id().clone(), + destination_host: destination.id().clone(), + message: "source file is absent".into(), + })?; + let (_lifecycle, mut guard) = TransferLifecycle::start(request); + guard.record_chunk(bytes.len() as u64)?; + let source_identity = identity_from_bytes(request.source_path(), &bytes); + if let Err(error) = self + .write_bound( + destination, + request.destination_path(), + &bytes, + max_bytes, + request.deadline(), + cancellation, + ) + .await + { + let _ = guard.fail(bounded_error(&error)); + return Err(error); + } + let destination_bytes = match self + .read_bound( + destination, + request.destination_path(), + BoundReadOptions { + role: FileTransferPathRole::Destination, + optional: false, + max_bytes, + deadline: request.deadline(), + }, + cancellation, + ) + .await + { + Ok(Some(bytes)) => bytes, + Ok(None) => { + let error = FleetError::Transfer { + source_host: source.id().clone(), + destination_host: destination.id().clone(), + message: "destination is absent after write".into(), + }; + let _ = guard.fail(bounded_error(&error)); + return Err(error); + } + Err(error) => { + let _ = guard.fail(bounded_error(&error)); + return Err(error); + } + }; + let destination_identity = + identity_from_bytes(request.destination_path(), &destination_bytes); + let receipt = TransferReceipt::new(bytes.len() as u64) + .with_digests(source_identity.sha256, destination_identity.sha256)?; + guard.complete(receipt) + } +} + +fn encoded(source: &str) -> String { + base64::engine::general_purpose::STANDARD.encode(source) +} + +fn command_error(host: &HostRecord, error: InfraError) -> FleetError { + FleetError::Command { + host: host.id().clone(), + message: error.to_string(), + } +} + +fn bounded_error(error: &FleetError) -> String { + let text = error.to_string().replace(char::is_control, " "); + text.chars().take(1024).collect() +} + +#[cfg(test)] +#[path = "process_file_transfer_tests.rs"] +mod tests; diff --git a/crates/shared/operations/infra/src/process_file_transfer_tests.rs b/crates/shared/operations/infra/src/process_file_transfer_tests.rs new file mode 100644 index 00000000..5442eedb --- /dev/null +++ b/crates/shared/operations/infra/src/process_file_transfer_tests.rs @@ -0,0 +1,78 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::sync::Arc; + +use soma_fleet::{ + FileTransfer, HostEndpoint, HostId, HostRecord, LocalProcessDriver, TransferRequest, +}; +use soma_ops::Timestamp; + +use super::*; + +fn host() -> HostRecord { + HostRecord::new(HostId::new("dookie").unwrap(), HostEndpoint::Local) +} + +#[tokio::test] +async fn local_transfer_copies_and_verifies_bytes() { + let source_root = tempfile::tempdir().unwrap(); + let destination_root = tempfile::tempdir().unwrap(); + let source = source_root.path().join("source.txt"); + let destination = destination_root.path().join("destination.txt"); + fs::write(&source, b"soma transfer").unwrap(); + let host = host(); + let driver = CommandFileTransfer::new(Arc::new(LocalProcessDriver)).with_policy( + host.id().clone(), + FileTransferPolicy::new([source_root.path()], [destination_root.path()]).unwrap(), + ); + let request = TransferRequest::new( + host.id().clone(), + source.clone(), + host.id().clone(), + destination.clone(), + crate::MAX_FILE_TRANSFER_BYTES, + Timestamp::from_unix_millis(Timestamp::now().unix_millis() + 20_000), + ) + .unwrap(); + let receipt = driver + .transfer(&host, &host, &request, &CancellationToken::new()) + .await + .unwrap(); + assert_eq!(receipt.bytes(), 13); + assert!(receipt.verified()); + assert_eq!(fs::read(destination).unwrap(), b"soma transfer"); +} + +#[tokio::test] +async fn destination_symlink_escape_is_rejected() { + let source_root = tempfile::tempdir().unwrap(); + let destination_root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let source = source_root.path().join("source.txt"); + let destination = destination_root.path().join("destination.txt"); + let secret = outside.path().join("secret.txt"); + fs::write(&source, b"safe").unwrap(); + fs::write(&secret, b"secret").unwrap(); + symlink(&secret, &destination).unwrap(); + let host = host(); + let driver = CommandFileTransfer::new(Arc::new(LocalProcessDriver)).with_policy( + host.id().clone(), + FileTransferPolicy::new([source_root.path()], [destination_root.path()]).unwrap(), + ); + let request = TransferRequest::new( + host.id().clone(), + source, + host.id().clone(), + destination, + crate::MAX_FILE_TRANSFER_BYTES, + Timestamp::from_unix_millis(Timestamp::now().unix_millis() + 20_000), + ) + .unwrap(); + assert!( + driver + .transfer(&host, &host, &request, &CancellationToken::new()) + .await + .is_err() + ); + assert_eq!(fs::read(secret).unwrap(), b"secret"); +} diff --git a/crates/shared/operations/infra/src/process_host_exec.rs b/crates/shared/operations/infra/src/process_host_exec.rs new file mode 100644 index 00000000..91c508ee --- /dev/null +++ b/crates/shared/operations/infra/src/process_host_exec.rs @@ -0,0 +1,173 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use async_trait::async_trait; +use base64::Engine; +use soma_fleet::{CommandExecutor, CommandRequest, HostId, HostRecord}; +use soma_ops::{MutationSendState, Timestamp}; +use tokio_util::sync::CancellationToken; + +use crate::{ + HostExecMutator, HostExecPolicy, HostExecReceipt, HostExecRequest, InfraError, MutationFailure, + MutationResult, +}; + +const PY_BOOTSTRAP: &str = + "import base64,sys;exec(compile(base64.b64decode(sys.argv[1]),'','exec'))"; +const BOUND_EXEC_SOURCE: &str = r#"import json, os, sys +command = sys.argv[2] +cwd = None if sys.argv[3] == 'null' else sys.argv[3] +indices = json.loads(sys.argv[4]) +root_count = int(sys.argv[5]) +roots = sys.argv[6:6 + root_count] +argv = sys.argv[6 + root_count:] +fds = [] +def parts(path): + return [part for part in path.split('/') if part] +def choose(path): + matches = [root for root in roots if path == root or root == '/' or path.startswith(root.rstrip('/') + '/')] + if not matches: + raise PermissionError('path outside configured roots') + root = max(matches, key=lambda value: len(parts(value))) + relative = path[len(root):].lstrip('/') if root != '/' else path.lstrip('/') + return root, relative +def bind(path): + root, relative = choose(path) + fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY) + for part in parts(root) + parts(relative): + next_fd = os.open(part, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=fd) + os.close(fd) + fd = next_fd + os.set_inheritable(fd, True) + fds.append(fd) + return fd +for index in indices: + argv[index] = '/proc/self/fd/' + str(bind(argv[index])) +if cwd is not None: + os.fchdir(bind(cwd)) +os.execvp(command, [command] + argv) +"#; + +/// Process-backed host command driver with explicit per-host read roots. +pub struct CommandHostExec { + executor: Arc, + policies: BTreeMap, +} + +impl CommandHostExec { + /// Creates a driver with no admitted hosts. + #[must_use] + pub fn new(executor: Arc) -> Self { + Self { + executor, + policies: BTreeMap::new(), + } + } + + /// Adds or replaces the execution policy for one host identity. + #[must_use] + pub fn with_policy(mut self, host: HostId, policy: HostExecPolicy) -> Self { + self.policies.insert(host, policy); + self + } +} + +#[async_trait] +impl HostExecMutator for CommandHostExec { + async fn exec_host( + &self, + host: &HostRecord, + request: &HostExecRequest, + cancellation: &CancellationToken, + ) -> MutationResult { + ensure_admitted(request.deadline(), cancellation)?; + let policy = self.policies.get(host.id()).ok_or_else(|| { + MutationFailure::new( + MutationSendState::NotSent, + InfraError::InvalidRequest { + domain: "host-exec", + message: format!("host execution is disabled for {}", host.id()), + }, + ) + })?; + let plan = policy + .launcher_plan(request) + .map_err(|error| MutationFailure::new(MutationSendState::NotSent, error))?; + let source = base64::engine::general_purpose::STANDARD.encode(BOUND_EXEC_SOURCE); + let indices = serde_json::to_string(&plan.path_indices).map_err(|error| { + MutationFailure::new( + MutationSendState::NotSent, + InfraError::Parse { + domain: "host-exec", + message: error.to_string(), + }, + ) + })?; + let mut args = vec![ + "-c".to_owned(), + PY_BOOTSTRAP.to_owned(), + source, + request.command().as_str().to_owned(), + plan.working_dir.unwrap_or_else(|| "null".into()), + indices, + plan.roots.len().to_string(), + ]; + args.extend(plan.roots); + args.extend(request.args().iter().cloned()); + let stdout_limit = request.max_stdout_bytes(); + let stderr_limit = request.max_stderr_bytes(); + let command = CommandRequest::new("python3", args, request.deadline()) + .map_err(soma_fleet::FleetError::from) + .and_then(|command| { + command + .with_output_limits(stdout_limit, stderr_limit) + .map_err(soma_fleet::FleetError::from) + }) + .map_err(|error| { + MutationFailure::new(MutationSendState::NotSent, InfraError::from(error)) + })?; + let output = self + .executor + .execute(host, &command, cancellation) + .await + .map_err(|error| { + MutationFailure::new(MutationSendState::Unknown, InfraError::from(error)) + })?; + let stdout_lossy = String::from_utf8_lossy(output.stdout()); + let stderr_lossy = String::from_utf8_lossy(output.stderr()); + Ok(HostExecReceipt { + host: host.id().clone(), + topology_revision: host.revision().clone(), + command: request.command(), + args: request.args().to_vec(), + working_dir: request.working_dir().map(ToOwned::to_owned), + stdout: stdout_lossy.into_owned(), + stderr: stderr_lossy.into_owned(), + exit_code: output.exit_code(), + truncated: output.truncated(), + encoding_lossy: std::str::from_utf8(output.stdout()).is_err() + || std::str::from_utf8(output.stderr()).is_err(), + send_state: MutationSendState::Sent, + }) + } +} + +fn ensure_admitted(deadline: Timestamp, cancellation: &CancellationToken) -> MutationResult<()> { + if cancellation.is_cancelled() { + return Err(MutationFailure::new( + MutationSendState::NotSent, + soma_fleet::FleetError::Cancelled.into(), + )); + } + if deadline <= Timestamp::now() { + return Err(MutationFailure::new( + MutationSendState::NotSent, + soma_fleet::FleetError::DeadlineExceeded.into(), + )); + } + Ok(()) +} + +#[cfg(test)] +#[path = "process_host_exec_tests.rs"] +mod tests; diff --git a/crates/shared/operations/infra/src/process_host_exec_tests.rs b/crates/shared/operations/infra/src/process_host_exec_tests.rs new file mode 100644 index 00000000..82c76e4a --- /dev/null +++ b/crates/shared/operations/infra/src/process_host_exec_tests.rs @@ -0,0 +1,154 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use soma_fleet::{ + CommandExecutor, CommandOutput, CommandRequest, FleetError, FleetResult, HostEndpoint, HostId, +}; +use soma_ops::{MutationSendState, OperationId, OperationName, Timestamp}; + +use super::*; +use crate::HostExecCommand; + +struct MockExecutor { + result: Mutex>>, + request: Mutex>, +} + +#[async_trait] +impl CommandExecutor for MockExecutor { + async fn execute( + &self, + _host: &HostRecord, + request: &CommandRequest, + _cancellation: &CancellationToken, + ) -> FleetResult { + *self.request.lock().unwrap() = Some(request.clone()); + self.result.lock().unwrap().take().unwrap() + } +} + +fn host() -> HostRecord { + HostRecord::new(HostId::new("dookie").unwrap(), HostEndpoint::Local) +} + +fn request(command: HostExecCommand, args: Vec) -> HostExecRequest { + HostExecRequest::new( + OperationId::new(), + OperationName::new("host.exec").unwrap(), + command, + args, + None, + Timestamp::from_unix_millis(Timestamp::now().unix_millis() + 10_000), + ) + .unwrap() +} + +#[tokio::test] +async fn typed_launcher_uses_discrete_python_arguments_and_retains_output() { + let executor = Arc::new(MockExecutor { + result: Mutex::new(Some(Ok(CommandOutput::new( + b"ok".to_vec(), + Vec::new(), + Some(0), + false, + )))), + request: Mutex::new(None), + }); + let driver = CommandHostExec::new(executor.clone()) + .with_policy(host().id().clone(), HostExecPolicy::new(["/srv"]).unwrap()); + let receipt = driver + .exec_host( + &host(), + &request(HostExecCommand::Cat, vec!["/srv/file".into()]), + &CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(receipt.stdout, "ok"); + assert_eq!(receipt.send_state, MutationSendState::Sent); + let captured = executor.request.lock().unwrap(); + let captured = captured.as_ref().unwrap(); + assert_eq!(captured.program(), "python3"); + assert_eq!(captured.args()[0], "-c"); + assert_eq!(captured.args()[3], "cat"); + assert_eq!( + captured.args().last().map(String::as_str), + Some("/srv/file") + ); +} + +#[tokio::test] +async fn policy_and_executor_failures_preserve_send_truth() { + let executor = Arc::new(MockExecutor { + result: Mutex::new(Some(Err(FleetError::Cancelled))), + request: Mutex::new(None), + }); + let driver = CommandHostExec::new(executor.clone()); + let failure = driver + .exec_host( + &host(), + &request(HostExecCommand::Hostname, Vec::new()), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + assert_eq!(failure.send_state(), MutationSendState::NotSent); + + let driver = driver.with_policy(host().id().clone(), HostExecPolicy::new(["/srv"]).unwrap()); + let failure = driver + .exec_host( + &host(), + &request(HostExecCommand::Hostname, Vec::new()), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + assert_eq!(failure.send_state(), MutationSendState::Unknown); +} + +#[tokio::test] +async fn local_launcher_reads_bound_files_and_refuses_symlink_escape() { + use std::fs; + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let allowed = root.path().join("allowed.txt"); + let escaped = root.path().join("escape.txt"); + let secret = outside.path().join("secret.txt"); + fs::write(&allowed, b"bound").unwrap(); + fs::write(&secret, b"secret").unwrap(); + symlink(&secret, &escaped).unwrap(); + + let driver = CommandHostExec::new(Arc::new(soma_fleet::LocalProcessDriver)).with_policy( + host().id().clone(), + HostExecPolicy::new([root.path()]).unwrap(), + ); + let receipt = driver + .exec_host( + &host(), + &request( + HostExecCommand::Cat, + vec![allowed.to_string_lossy().into_owned()], + ), + &CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(receipt.exit_code, Some(0)); + assert_eq!(receipt.stdout, "bound"); + + let receipt = driver + .exec_host( + &host(), + &request( + HostExecCommand::Cat, + vec![escaped.to_string_lossy().into_owned()], + ), + &CancellationToken::new(), + ) + .await + .unwrap(); + assert_ne!(receipt.exit_code, Some(0)); + assert!(!receipt.stderr.contains("secret")); +} diff --git a/crates/synapse/application/src/lib.rs b/crates/synapse/application/src/lib.rs index ccf32039..925b7a89 100644 --- a/crates/synapse/application/src/lib.rs +++ b/crates/synapse/application/src/lib.rs @@ -19,6 +19,18 @@ mod mutation_build_execute; mod mutation_build_result; mod mutation_compose; mod mutation_dispatch; +mod mutation_exec; +mod mutation_exec_execute; +mod mutation_exec_output; +mod mutation_exec_result; +mod mutation_final; +mod mutation_final_admission; +mod mutation_final_contract; +mod mutation_final_execute; +mod mutation_final_result; +mod mutation_final_transfer_execute; +mod mutation_final_transfer_result; +mod mutation_ports; mod mutation_pull; mod mutation_pull_execute; mod mutation_pull_result; @@ -44,13 +56,24 @@ pub use catalog::SynapseCatalog; pub use diagnostic::DiagnosticProjection; pub use error::CompatibilityError; pub use execution_error::ExecutionError; -pub use mutation_runtime::{ - SynapseBuildPorts, SynapseMutationPorts, SynapseMutationRuntime, SynapseRecreatePorts, +pub use mutation_ports::{ + SynapseBuildPorts, SynapseExecPorts, SynapseFinalPorts, SynapseMutationPorts, + SynapseRecreatePorts, }; +pub use mutation_runtime::SynapseMutationRuntime; pub use normalize::NormalizedOperationRequest; pub use runtime::{SynapseReadPorts, SynapseReadRuntime}; pub use schema::OperationSchemaContract; +#[cfg(test)] +#[path = "../tests/support/mutation_exec_support.rs"] +mod mutation_exec_test_support; +#[cfg(test)] +#[path = "../tests/support/mutation_final_docker_support.rs"] +mod mutation_final_test_docker; +#[cfg(test)] +#[path = "../tests/support/mutation_final_io_support.rs"] +mod mutation_final_test_io; #[cfg(test)] #[path = "../tests/support/mutation_pull_compose.rs"] mod mutation_pull_test_compose; diff --git a/crates/synapse/application/src/mutation_build_tests.rs b/crates/synapse/application/src/mutation_build_tests.rs index fcc87033..ed69e347 100644 --- a/crates/synapse/application/src/mutation_build_tests.rs +++ b/crates/synapse/application/src/mutation_build_tests.rs @@ -111,6 +111,8 @@ fn runtime( compose: compose_builder, }), recreate: None, + exec: None, + final_mutations: None, }) } diff --git a/crates/synapse/application/src/mutation_compose_tests.rs b/crates/synapse/application/src/mutation_compose_tests.rs index 8019ade6..2ce0054d 100644 --- a/crates/synapse/application/src/mutation_compose_tests.rs +++ b/crates/synapse/application/src/mutation_compose_tests.rs @@ -182,6 +182,8 @@ fn runtime(compose: Option>) -> SynapseMutationRu compose_pull: None, builds: None, recreate: None, + exec: None, + final_mutations: None, }, ContainerLifecycleEngine::default(), ComposeMutationEngine::new(MutationVerificationPolicy::new(1, Duration::ZERO).unwrap()), diff --git a/crates/synapse/application/src/mutation_dispatch.rs b/crates/synapse/application/src/mutation_dispatch.rs index 61479b38..e1aadc7b 100644 --- a/crates/synapse/application/src/mutation_dispatch.rs +++ b/crates/synapse/application/src/mutation_dispatch.rs @@ -24,6 +24,10 @@ impl SynapseMutationRuntime { self.plan_build(operation, parameters, context).await } else if crate::mutation_recreate::recreate_operation(operation) { self.plan_recreate(operation, parameters, context).await + } else if crate::mutation_exec::exec_operation(operation) { + self.plan_exec(operation, parameters, context).await + } else if crate::mutation_final_contract::final_operation(operation) { + self.plan_final(operation, parameters, context).await } else { Err(ExecutionError::UnsupportedOperation(operation.clone())) } @@ -115,6 +119,26 @@ impl SynapseMutationRuntime { cancellation, ) .await + } else if crate::mutation_exec::exec_operation(operation) { + self.execute_exec( + operation, + parameters, + context, + plan, + authorization, + cancellation, + ) + .await + } else if crate::mutation_final_contract::final_operation(operation) { + self.execute_final( + operation, + parameters, + context, + plan, + authorization, + cancellation, + ) + .await } else { Err(ExecutionError::UnsupportedOperation(operation.clone())) } diff --git a/crates/synapse/application/src/mutation_dispatch_tests.rs b/crates/synapse/application/src/mutation_dispatch_tests.rs index 42ef844d..91e95f37 100644 --- a/crates/synapse/application/src/mutation_dispatch_tests.rs +++ b/crates/synapse/application/src/mutation_dispatch_tests.rs @@ -6,7 +6,7 @@ use super::*; #[tokio::test(flavor = "current_thread")] async fn unsupported_mutations_fail_before_parameter_or_driver_access() { let runtime = crate::mutation_pull_test_support::runtime(None, None); - let operation = OperationName::new("docker.prune").unwrap(); + let operation = OperationName::new("custom.unsupported_mutation").unwrap(); let error = runtime .plan( &operation, diff --git a/crates/synapse/application/src/mutation_exec.rs b/crates/synapse/application/src/mutation_exec.rs new file mode 100644 index 00000000..24f6da3e --- /dev/null +++ b/crates/synapse/application/src/mutation_exec.rs @@ -0,0 +1,314 @@ +use std::path::PathBuf; + +use serde::Serialize; +use serde_json::Value; +use soma_fleet::{HostId, HostRecord, TopologySnapshot}; +use soma_infra::HostExecCommand; +use soma_ops::{ + OperationContext, OperationName, OperationPlan, PlanStep, PlannedChange, TargetKind, TargetRef, +}; + +use crate::runtime_params::{object, optional_str, required_str, u32_or}; +use crate::{ExecutionError, SynapseMutationRuntime}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ContainerExecSpec { + pub(crate) host: String, + pub(crate) container: String, + pub(crate) command: Vec, + pub(crate) user: Option, + pub(crate) working_dir: Option, + pub(crate) timeout_ms: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct HostExecSpec { + pub(crate) host: String, + pub(crate) command: HostExecCommand, + pub(crate) args: Vec, + pub(crate) working_dir: Option, + pub(crate) timeout_secs: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub(crate) struct HostExecTargetSpec { + pub(crate) host: String, + pub(crate) working_dir: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct HostExecManySpec { + pub(crate) command: HostExecCommand, + pub(crate) args: Vec, + pub(crate) targets: Vec, + pub(crate) timeout_secs: u32, +} + +pub(crate) fn exec_operation(operation: &OperationName) -> bool { + matches!( + operation.as_str(), + "container.exec" | "host.exec" | "host.exec_many" + ) +} + +impl SynapseMutationRuntime { + pub(crate) async fn plan_exec( + &self, + operation: &OperationName, + parameters: &Value, + context: &OperationContext, + ) -> Result { + let spec = self.mutation_spec(operation)?; + self.catalog.validate_parameters(operation, parameters)?; + match operation.as_str() { + "container.exec" => { + let execution = container_spec(parameters)?; + let host = self.resolve_host(&execution.host).await?; + let target = + crate::mutation_runtime::container_target(&host, &execution.container)?; + let digest = digest(&execution)?; + one_target_plan( + operation, + context, + spec, + target, + host.revision().as_str(), + digest, + format!( + "execute {} direct arguments in container {} on {}", + execution.command.len(), + execution.container, + host.id() + ), + ) + } + "host.exec" => { + let execution = host_spec(parameters)?; + let host = self.resolve_host(&execution.host).await?; + let target = host_target(&host)?; + let digest = digest(&execution)?; + one_target_plan( + operation, + context, + spec, + target, + host.revision().as_str(), + digest, + format!( + "execute allowlisted {} on host {}", + execution.command.as_str(), + host.id() + ), + ) + } + "host.exec_many" => { + let execution = host_many_spec(parameters)?; + let snapshot = self.ports.hosts.snapshot().await?; + many_plan(operation, context, spec, &snapshot, &execution) + } + _ => Err(ExecutionError::UnsupportedOperation(operation.clone())), + } + } +} + +fn one_target_plan( + operation: &OperationName, + context: &OperationContext, + spec: &soma_ops::OperationSpec, + target: TargetRef, + topology_revision: &str, + digest: String, + summary: String, +) -> Result { + let change = PlannedChange::new(target.clone(), "exec", summary.clone())? + .with_digests(Some(digest), None); + let step = PlanStep::new(1, operation.clone(), target.clone(), summary)?; + OperationPlan::new( + context.operation_id().clone(), + operation.clone(), + target, + spec.risk(), + spec.reversibility(), + )? + .with_topology_revision(topology_revision)? + .with_change(change)? + .with_prerequisite("the selected execution port is configured and reachable")? + .with_step(step)? + .with_rollback_guidance( + "command execution cannot be automatically rolled back; inspect captured output and reconcile any side effects manually", + ) + .map_err(ExecutionError::from) +} + +fn many_plan( + operation: &OperationName, + context: &OperationContext, + spec: &soma_ops::OperationSpec, + snapshot: &TopologySnapshot, + execution: &HostExecManySpec, +) -> Result { + let target = TargetRef::new(TargetKind::Host, "fanout")? + .with_revision(snapshot.revision().to_string())?; + let mut plan = OperationPlan::new( + context.operation_id().clone(), + operation.clone(), + target, + spec.risk(), + spec.reversibility(), + )? + .with_topology_revision(snapshot.revision().to_string())? + .with_prerequisite("every selected host exists in the same immutable topology snapshot")?; + for (index, selected) in execution.targets.iter().enumerate() { + let id = HostId::new(&selected.host).map_err(|error| ExecutionError::InvalidParameter { + field: "targets.host".into(), + message: error.to_string(), + })?; + let host = snapshot + .get(&id) + .ok_or_else(|| ExecutionError::HostNotFound(selected.host.clone()))?; + let target = host_target(host)?; + let digest = digest(&( + execution.command, + &execution.args, + selected, + execution.timeout_secs, + ))?; + let summary = format!( + "execute allowlisted {} on host {}", + execution.command.as_str(), + host.id() + ); + plan = plan + .with_change( + PlannedChange::new(target.clone(), "exec", summary.clone())? + .with_digests(Some(digest), None), + )? + .with_step(PlanStep::new( + u32::try_from(index + 1).expect("canonical target count is bounded"), + operation.clone(), + target, + summary, + )?)?; + } + plan.with_rollback_guidance( + "do not retry the whole fanout; inspect per-target results and replan only unresolved hosts", + ) + .map_err(ExecutionError::from) +} + +pub(crate) fn container_spec(parameters: &Value) -> Result { + Ok(ContainerExecSpec { + host: required_str(parameters, "host")?.to_owned(), + container: required_str(parameters, "container_id")?.to_owned(), + command: required_string_array(parameters, "command")?, + user: optional_str(parameters, "exec_user")?.map(str::to_owned), + working_dir: optional_str(parameters, "exec_workdir")?.map(PathBuf::from), + timeout_ms: u32_or(parameters, "exec_timeout_ms", 30_000)?, + }) +} + +pub(crate) fn host_spec(parameters: &Value) -> Result { + Ok(HostExecSpec { + host: required_str(parameters, "host")?.to_owned(), + command: parse_host_command(parameters)?, + args: optional_string_array(parameters, "args")?, + working_dir: optional_str(parameters, "path")?.map(PathBuf::from), + timeout_secs: u32_or(parameters, "timeout_secs", 30)?, + }) +} + +pub(crate) fn host_many_spec(parameters: &Value) -> Result { + let values = object(parameters)? + .get("targets") + .and_then(Value::as_array) + .ok_or_else(|| crate::runtime_params::invalid("targets", "expected an array"))?; + let mut targets = values + .iter() + .map(|value| { + let object = value.as_object().ok_or_else(|| { + crate::runtime_params::invalid("targets", "target must be an object") + })?; + let host = object + .get("host") + .and_then(Value::as_str) + .ok_or_else(|| { + crate::runtime_params::invalid("targets.host", "required string is missing") + })? + .to_owned(); + let working_dir = match object.get("path") { + None | Some(Value::Null) => None, + Some(Value::String(path)) => Some(PathBuf::from(path)), + Some(_) => { + return Err(crate::runtime_params::invalid( + "targets.path", + "expected a string", + )); + } + }; + Ok(HostExecTargetSpec { host, working_dir }) + }) + .collect::, ExecutionError>>()?; + targets.sort(); + if targets.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(crate::runtime_params::invalid( + "targets", + "duplicate host/path targets are not allowed", + )); + } + Ok(HostExecManySpec { + command: parse_host_command(parameters)?, + args: optional_string_array(parameters, "args")?, + targets, + timeout_secs: u32_or(parameters, "timeout_secs", 30)?, + }) +} + +fn parse_host_command(parameters: &Value) -> Result { + HostExecCommand::parse(required_str(parameters, "command")?).map_err(ExecutionError::from) +} + +fn required_string_array(parameters: &Value, field: &str) -> Result, ExecutionError> { + let value = object(parameters)? + .get(field) + .ok_or_else(|| crate::runtime_params::invalid(field, "required array is missing"))?; + string_array(value, field) +} + +fn optional_string_array(parameters: &Value, field: &str) -> Result, ExecutionError> { + match object(parameters)?.get(field) { + None | Some(Value::Null) => Ok(Vec::new()), + Some(value) => string_array(value, field), + } +} + +fn string_array(value: &Value, field: &str) -> Result, ExecutionError> { + value + .as_array() + .ok_or_else(|| crate::runtime_params::invalid(field, "expected an array"))? + .iter() + .map(|value| { + value.as_str().map(str::to_owned).ok_or_else(|| { + crate::runtime_params::invalid(field, "array values must be strings") + }) + }) + .collect() +} + +pub(crate) fn host_target(host: &HostRecord) -> Result { + TargetRef::new(TargetKind::Host, host.id().to_string())? + .with_host(host.id().to_string())? + .with_revision(host.revision().to_string()) + .map_err(ExecutionError::from) +} + +pub(crate) fn digest(value: &T) -> Result { + let encoded = serde_json::to_vec(value).map_err(|error| ExecutionError::InvalidParameter { + field: "execution".into(), + message: error.to_string(), + })?; + Ok(crate::runtime_result::digest(&encoded)) +} + +#[cfg(test)] +#[path = "mutation_exec_tests.rs"] +mod tests; diff --git a/crates/synapse/application/src/mutation_exec_execute.rs b/crates/synapse/application/src/mutation_exec_execute.rs new file mode 100644 index 00000000..baf39cc1 --- /dev/null +++ b/crates/synapse/application/src/mutation_exec_execute.rs @@ -0,0 +1,243 @@ +use std::time::Duration; + +use serde_json::Value; +use soma_fleet::HostId; +use soma_infra::{ContainerExecRequest, HostExecManyEngine, HostExecRequest}; +use soma_ops::{AuthorizationEvidence, OperationContext, OperationName, OperationPlan, Timestamp}; +use tokio_util::sync::CancellationToken; + +use crate::mutation_exec::{container_spec, host_many_spec, host_spec}; +use crate::{ExecutionError, SynapseMutationRuntime}; + +impl SynapseMutationRuntime { + #[allow(clippy::too_many_arguments)] + pub(crate) async fn execute_exec( + &self, + operation: &OperationName, + parameters: &Value, + context: &OperationContext, + plan: &OperationPlan, + authorization: &AuthorizationEvidence, + cancellation: &CancellationToken, + ) -> Result { + let started = Timestamp::now(); + self.mutation_spec(operation)?; + self.catalog.validate_parameters(operation, parameters)?; + let current_plan = self.plan_exec(operation, parameters, context).await?; + validate_exec_admission( + operation, + context, + plan, + ¤t_plan, + authorization, + started, + )?; + match operation.as_str() { + "container.exec" => { + let execution = container_spec(parameters)?; + let host = self.resolve_host(&execution.host).await?; + ensure_host_revision(plan, &host)?; + let ports = self.exec_ports(&execution.host)?; + let target = + crate::mutation_runtime::container_target(&host, &execution.container)?; + let deadline = bounded_deadline(context, started, i64::from(execution.timeout_ms)); + let request = ContainerExecRequest::new( + context.operation_id().clone(), + operation.clone(), + execution.container, + execution.command, + execution.user, + execution.working_dir, + deadline, + )?; + let client = match ports.containers.exec_client(&host, cancellation).await { + Ok(client) => client, + Err(error) => { + return self.exec_failure_result( + operation, + context, + target, + started, + soma_ops::MutationSendState::NotSent, + error, + false, + ); + } + }; + match client.exec_container(&host, &request, cancellation).await { + Ok(receipt) => { + self.container_exec_result(operation, context, target, started, receipt) + } + Err(failure) => self.exec_failure_result( + operation, + context, + target, + started, + failure.send_state(), + failure.into_error(), + false, + ), + } + } + "host.exec" => { + let execution = host_spec(parameters)?; + let host = self.resolve_host(&execution.host).await?; + ensure_host_revision(plan, &host)?; + let ports = self.exec_ports(&execution.host)?; + let target = crate::mutation_exec::host_target(&host)?; + let request = HostExecRequest::new( + context.operation_id().clone(), + operation.clone(), + execution.command, + execution.args, + execution.working_dir, + bounded_deadline(context, started, i64::from(execution.timeout_secs) * 1_000), + )?; + match ports.hosts.exec_host(&host, &request, cancellation).await { + Ok(receipt) => { + self.host_exec_result(operation, context, target, started, receipt) + } + Err(failure) => self.exec_failure_result( + operation, + context, + target, + started, + failure.send_state(), + failure.into_error(), + false, + ), + } + } + "host.exec_many" => { + let execution = host_many_spec(parameters)?; + let snapshot = self.ports.hosts.snapshot().await?; + if plan.topology_revision() != Some(snapshot.revision().as_str()) { + return Err(ExecutionError::PlanMismatch( + "fleet topology changed after execution admission".into(), + )); + } + let ports = self.exec_ports("fanout")?; + let mut targets = Vec::with_capacity(execution.targets.len()); + for selected in execution.targets { + let id = HostId::new(&selected.host).map_err(|error| { + ExecutionError::InvalidParameter { + field: "targets.host".into(), + message: error.to_string(), + } + })?; + let host = snapshot + .get(&id) + .cloned() + .ok_or_else(|| ExecutionError::HostNotFound(selected.host.clone()))?; + let request = HostExecRequest::new( + context.operation_id().clone(), + operation.clone(), + execution.command, + execution.args.clone(), + selected.working_dir, + bounded_deadline( + context, + started, + i64::from(execution.timeout_secs) * 1_000, + ), + )?; + targets.push((host, request)); + } + let concurrency = ports.max_fanout_concurrency.clamp(1, 8).min(targets.len()); + let engine = HostExecManyEngine::new( + concurrency, + Duration::from_secs(u64::from(execution.timeout_secs)), + )?; + let target = plan.target().clone(); + match engine + .execute(ports.hosts.as_ref(), targets, cancellation.clone()) + .await + { + Ok(outcome) => { + self.host_exec_many_result(operation, context, target, started, outcome) + } + Err(failure) => self.exec_many_failure_result( + operation, + context, + target, + started, + failure.send_state(), + failure.into_error(), + ), + } + } + _ => Err(ExecutionError::UnsupportedOperation(operation.clone())), + } + } + + fn exec_ports(&self, host: &str) -> Result<&crate::SynapseExecPorts, ExecutionError> { + self.ports + .exec + .as_ref() + .ok_or_else(|| ExecutionError::MutationPortUnavailable { + domain: "exec", + host: host.to_owned(), + }) + } +} + +fn validate_exec_admission( + operation: &OperationName, + context: &OperationContext, + plan: &OperationPlan, + current: &OperationPlan, + authorization: &AuthorizationEvidence, + now: Timestamp, +) -> Result<(), ExecutionError> { + plan.validate_fingerprint()?; + if plan != current { + return Err(ExecutionError::PlanMismatch( + "execution argv, timeout, paths, targets, or topology changed after planning".into(), + )); + } + if plan.verification().is_some() { + return Err(ExecutionError::PlanMismatch( + "exec operations do not support a fabricated verification strategy".into(), + )); + } + if context.deadline().is_some_and(|deadline| deadline <= now) { + return Err(ExecutionError::DeadlineExceeded); + } + if authorization.confirmation_ref().is_none() { + return Err(ExecutionError::ConfirmationRequired); + } + authorization.validate_binding(operation, plan.target(), now, Some(plan.fingerprint()))?; + Ok(()) +} + +fn ensure_host_revision( + plan: &OperationPlan, + host: &soma_fleet::HostRecord, +) -> Result<(), ExecutionError> { + if plan.topology_revision() != Some(host.revision().as_str()) { + Err(ExecutionError::PlanMismatch( + "host topology revision changed after execution admission".into(), + )) + } else { + Ok(()) + } +} + +fn bounded_deadline( + context: &OperationContext, + started: Timestamp, + requested_millis: i64, +) -> Timestamp { + let requested = Timestamp::from_unix_millis( + started + .unix_millis() + .saturating_add(requested_millis.max(1)), + ); + context + .deadline() + .map_or(requested, |deadline| deadline.min(requested)) +} + +#[cfg(test)] +#[path = "mutation_exec_execute_tests.rs"] +mod tests; diff --git a/crates/synapse/application/src/mutation_exec_execute_tests.rs b/crates/synapse/application/src/mutation_exec_execute_tests.rs new file mode 100644 index 00000000..436cba59 --- /dev/null +++ b/crates/synapse/application/src/mutation_exec_execute_tests.rs @@ -0,0 +1,17 @@ +use soma_ops::{OperationContext, Timestamp}; + +use super::*; + +#[test] +fn execution_deadline_uses_the_earliest_bound() { + let started = Timestamp::from_unix_millis(1_000); + let context = OperationContext::new().with_deadline(Timestamp::from_unix_millis(3_000)); + assert_eq!( + bounded_deadline(&context, started, 10_000), + Timestamp::from_unix_millis(3_000) + ); + assert_eq!( + bounded_deadline(&OperationContext::new(), started, 500), + Timestamp::from_unix_millis(1_500) + ); +} diff --git a/crates/synapse/application/src/mutation_exec_output.rs b/crates/synapse/application/src/mutation_exec_output.rs new file mode 100644 index 00000000..b8870e88 --- /dev/null +++ b/crates/synapse/application/src/mutation_exec_output.rs @@ -0,0 +1,113 @@ +use serde_json::{Value, json}; +use soma_infra::{HostExecManyOutcome, HostExecTargetStatus, InfraError}; +use soma_ops::MutationSendState; + +const FANOUT_INLINE_TEXT_BUDGET: usize = 128 * 1024; + +pub(crate) fn exec_output( + exit_code: Option, + stdout: String, + stderr: String, + timed_out: bool, + truncated: bool, +) -> Value { + let mut output = json!({ + "exit_code": exit_code.unwrap_or(-1).clamp(-1, 255), + "timed_out": timed_out, + "truncated": truncated, + }); + if !stdout.is_empty() { + output["stdout"] = Value::String(stdout); + } + if !stderr.is_empty() { + output["stderr"] = Value::String(stderr); + } + output +} + +pub(crate) fn many_output(outcome: &HostExecManyOutcome) -> Value { + let target_count = outcome.results.len().max(1); + let per_stream_budget = (FANOUT_INLINE_TEXT_BUDGET / target_count / 2).clamp(128, 16 * 1024); + let results = outcome + .results + .iter() + .map(|result| { + let target = match &result.working_dir { + Some(path) => format!("{}:{}", result.host, path.display()), + None => result.host.to_string(), + }; + let mut row = json!({ + "target": target, + "ok": result.status == HostExecTargetStatus::Succeeded, + }); + if let Some(receipt) = &result.receipt { + let (stdout, stdout_cut) = truncate_utf8(&receipt.stdout, per_stream_budget); + let (stderr, stderr_cut) = truncate_utf8(&receipt.stderr, per_stream_budget); + row["output"] = exec_output( + receipt.exit_code.map(i64::from), + stdout, + stderr, + false, + receipt.truncated || stdout_cut || stderr_cut, + ); + } else if result.status == HostExecTargetStatus::TimedOut { + row["output"] = exec_output(None, String::new(), String::new(), true, false); + } + if let Some(error) = &result.error { + let code = if result.send_state == MutationSendState::Unknown { + "mutation.uncertain" + } else if error.contains("timeout") { + "operation.timeout" + } else if error.contains("cancel") { + "operation.cancelled" + } else { + "command.failed" + }; + row["diagnostic_codes"] = json!([code]); + } + row + }) + .collect::>(); + json!({ + "results": results, + "success_count": outcome.succeeded, + "failure_count": outcome.failed + outcome.timed_out, + "cancelled_count": outcome.cancelled, + }) +} + +fn truncate_utf8(value: &str, max_bytes: usize) -> (String, bool) { + if value.len() <= max_bytes { + return (value.to_owned(), false); + } + let mut boundary = max_bytes; + while boundary > 0 && !value.is_char_boundary(boundary) { + boundary -= 1; + } + (value[..boundary].to_owned(), true) +} + +pub(crate) fn failure_code( + error: &InfraError, + send_state: MutationSendState, + timed_out: bool, +) -> &'static str { + if timed_out + || matches!( + error, + InfraError::Fleet(soma_fleet::FleetError::DeadlineExceeded) + ) + { + "operation.timeout" + } else if matches!(error, InfraError::Fleet(soma_fleet::FleetError::Cancelled)) { + "operation.cancelled" + } else if send_state == MutationSendState::Unknown { + "mutation.uncertain" + } else { + "internal.failure" + } +} + +#[cfg(test)] +#[path = "mutation_exec_output_tests.rs"] +mod tests; diff --git a/crates/synapse/application/src/mutation_exec_output_tests.rs b/crates/synapse/application/src/mutation_exec_output_tests.rs new file mode 100644 index 00000000..7719a14a --- /dev/null +++ b/crates/synapse/application/src/mutation_exec_output_tests.rs @@ -0,0 +1,17 @@ +use super::*; + +#[test] +fn fanout_text_budget_stays_on_utf8_boundaries() { + let value = "🛰️".repeat(100); + let (trimmed, truncated) = truncate_utf8(&value, 17); + assert!(truncated); + assert!(trimmed.len() <= 17); + assert!(trimmed.is_char_boundary(trimmed.len())); +} + +#[test] +fn exec_output_uses_minus_one_when_exit_is_unknown() { + let output = exec_output(None, String::new(), String::new(), true, false); + assert_eq!(output["exit_code"], -1); + assert_eq!(output["timed_out"], true); +} diff --git a/crates/synapse/application/src/mutation_exec_result.rs b/crates/synapse/application/src/mutation_exec_result.rs new file mode 100644 index 00000000..273ace67 --- /dev/null +++ b/crates/synapse/application/src/mutation_exec_result.rs @@ -0,0 +1,263 @@ +use serde_json::{Value, json}; +use soma_infra::{ContainerExecReceipt, HostExecManyOutcome, HostExecReceipt, InfraError}; +use soma_ops::{ + Diagnostic, DiagnosticSeverity, EvidenceRef, ExecutionMetadata, MutationSendState, + OperationContext, OperationName, OperationResult, OperationStatus, RetryClass, TargetRef, + Timestamp, +}; + +use crate::mutation_exec_output::{exec_output, failure_code, many_output}; +use crate::{ExecutionError, SynapseMutationRuntime}; + +impl SynapseMutationRuntime { + pub(crate) fn container_exec_result( + &self, + operation: &OperationName, + context: &OperationContext, + target: TargetRef, + started: Timestamp, + receipt: ContainerExecReceipt, + ) -> Result { + let output = exec_output( + receipt.exit_code, + receipt.stdout, + receipt.stderr, + false, + receipt.truncated, + ); + self.exec_terminal_result( + operation, + context, + target, + started, + receipt.send_state, + output, + exec_evidence_uri( + "container-exec", + &receipt.host.to_string(), + &receipt.container, + ), + receipt.exit_code == Some(0), + "internal.failure", + ) + } + + pub(crate) fn host_exec_result( + &self, + operation: &OperationName, + context: &OperationContext, + target: TargetRef, + started: Timestamp, + receipt: HostExecReceipt, + ) -> Result { + let output = exec_output( + receipt.exit_code.map(i64::from), + receipt.stdout, + receipt.stderr, + false, + receipt.truncated, + ); + self.exec_terminal_result( + operation, + context, + target, + started, + receipt.send_state, + output, + exec_evidence_uri( + "host-exec", + &receipt.host.to_string(), + receipt.command.as_str(), + ), + receipt.exit_code == Some(0), + "command.failed", + ) + } + + #[allow(clippy::too_many_arguments)] + fn exec_terminal_result( + &self, + operation: &OperationName, + context: &OperationContext, + target: TargetRef, + started: Timestamp, + send_state: MutationSendState, + output: Value, + evidence: String, + succeeded: bool, + failure_code: &'static str, + ) -> Result { + self.catalog.validate_result(operation, &output)?; + let completed = Timestamp::now(); + let status = if succeeded { + OperationStatus::Succeeded + } else { + OperationStatus::Failed + }; + let execution = ExecutionMetadata::new(started, completed, send_state, RetryClass::Never)?; + let mut result = OperationResult::new( + context.operation_id().clone(), + operation.clone(), + target, + status, + execution, + )? + .with_output(output)? + .with_evidence(EvidenceRef::new("logs", evidence)?); + if !succeeded { + result = result.with_diagnostic( + Diagnostic::new( + failure_code, + DiagnosticSeverity::Error, + "command completed without a zero exit status", + )? + .with_next_action( + "inspect captured stdout and stderr, correct the command or target state, and create a new plan before retrying", + )?, + ); + } + result.validate()?; + Ok(result) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn exec_failure_result( + &self, + operation: &OperationName, + context: &OperationContext, + target: TargetRef, + started: Timestamp, + send_state: MutationSendState, + error: InfraError, + timed_out: bool, + ) -> Result { + let output = exec_output(None, String::new(), String::new(), timed_out, false); + self.catalog.validate_result(operation, &output)?; + let code = failure_code(&error, send_state, timed_out); + let completed = Timestamp::now(); + let execution = ExecutionMetadata::new(started, completed, send_state, RetryClass::Never)?; + let result = OperationResult::new( + context.operation_id().clone(), + operation.clone(), + target, + if matches!(error, InfraError::Fleet(soma_fleet::FleetError::Cancelled)) { + OperationStatus::Cancelled + } else { + OperationStatus::Failed + }, + execution, + )? + .with_output(output)? + .with_diagnostic( + Diagnostic::new(code, DiagnosticSeverity::Error, error.to_string())?.with_next_action( + "do not retry blindly; inspect target state and create a fresh execution plan", + )?, + ); + result.validate()?; + Ok(result) + } + + pub(crate) fn host_exec_many_result( + &self, + operation: &OperationName, + context: &OperationContext, + target: TargetRef, + started: Timestamp, + outcome: HostExecManyOutcome, + ) -> Result { + let output = many_output(&outcome); + self.catalog.validate_result(operation, &output)?; + let succeeded = outcome.all_succeeded(); + let execution = ExecutionMetadata::new( + started, + Timestamp::now(), + outcome.send_state, + RetryClass::Never, + )?; + let mut result = OperationResult::new( + context.operation_id().clone(), + operation.clone(), + target, + if succeeded { + OperationStatus::Succeeded + } else { + OperationStatus::Failed + }, + execution, + )? + .with_output(output)? + .with_evidence(EvidenceRef::new( + "logs", + format!("host-exec-many://{}", context.operation_id()), + )?); + if !succeeded { + result = result.with_diagnostic( + Diagnostic::new( + if outcome.send_state == MutationSendState::Unknown { + "mutation.uncertain" + } else { + "command.failed" + }, + DiagnosticSeverity::Error, + format!( + "fanout completed with {} successes, {} failures, {} cancellations, and {} timeouts", + outcome.succeeded, outcome.failed, outcome.cancelled, outcome.timed_out + ), + )? + .with_next_action( + "inspect each target result and create a new plan containing only unresolved hosts", + )?, + ); + } + result.validate()?; + Ok(result) + } + + pub(crate) fn exec_many_failure_result( + &self, + operation: &OperationName, + context: &OperationContext, + target: TargetRef, + started: Timestamp, + send_state: MutationSendState, + error: InfraError, + ) -> Result { + let output = json!({ + "results": [], + "success_count": 0, + "failure_count": 0, + "cancelled_count": 0, + }); + self.catalog.validate_result(operation, &output)?; + let execution = + ExecutionMetadata::new(started, Timestamp::now(), send_state, RetryClass::Never)?; + let result = OperationResult::new( + context.operation_id().clone(), + operation.clone(), + target, + OperationStatus::Failed, + execution, + )? + .with_output(output)? + .with_diagnostic( + Diagnostic::new( + failure_code(&error, send_state, false), + DiagnosticSeverity::Error, + error.to_string(), + )? + .with_next_action( + "no complete target report exists; inspect fleet state and create a fresh fanout plan", + )?, + ); + result.validate()?; + Ok(result) + } +} + +fn exec_evidence_uri(kind: &str, host: &str, target: &str) -> String { + format!("{kind}://{host}/{target}") +} + +#[cfg(test)] +#[path = "mutation_exec_result_tests.rs"] +mod tests; diff --git a/crates/synapse/application/src/mutation_exec_result_tests.rs b/crates/synapse/application/src/mutation_exec_result_tests.rs new file mode 100644 index 00000000..0284ce6b --- /dev/null +++ b/crates/synapse/application/src/mutation_exec_result_tests.rs @@ -0,0 +1,9 @@ +use super::*; + +#[test] +fn evidence_uris_preserve_execution_identity() { + assert_eq!( + exec_evidence_uri("host-exec", "dookie", "rg"), + "host-exec://dookie/rg" + ); +} diff --git a/crates/synapse/application/src/mutation_exec_tests.rs b/crates/synapse/application/src/mutation_exec_tests.rs new file mode 100644 index 00000000..83552562 --- /dev/null +++ b/crates/synapse/application/src/mutation_exec_tests.rs @@ -0,0 +1,221 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use serde_json::json; +use soma_ops::{MutationSendState, OperationStatus}; +use tokio_util::sync::CancellationToken; + +use crate::ExecutionError; +use crate::mutation_exec_test_support::{ + FakeContainerExec, FakeHostExec, container_receipt, op, runtime, +}; +use crate::mutation_pull_test_support::{authorization, context}; + +#[tokio::test] +async fn container_exec_binds_argv_and_returns_canonical_output() { + let container = Arc::new(FakeContainerExec { + receipts: Mutex::new(VecDeque::from([Ok(container_receipt(0))])), + calls: Mutex::new(0), + }); + let hosts = Arc::new(FakeHostExec { + calls: Mutex::new(Vec::new()), + }); + let runtime = runtime(container.clone(), hosts, true); + let operation = op("container.exec"); + let parameters = json!({ + "host":"dookie", + "container_id":"api", + "command":["printf","ok"], + "exec_workdir":"/app", + "exec_timeout_ms":5000 + }); + let ctx = context(); + let plan = runtime.plan(&operation, ¶meters, &ctx).await.unwrap(); + let encoded = serde_json::to_value(&plan).unwrap(); + assert_eq!( + encoded["changes"][0]["before_digest"] + .as_str() + .unwrap() + .len(), + 64 + ); + assert!(encoded.get("verification").is_none()); + let result = runtime + .execute( + &operation, + ¶meters, + &ctx, + &plan, + &authorization(&operation, &plan), + &CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(result.status(), OperationStatus::Succeeded); + assert_eq!(result.output().unwrap()["exit_code"], 0); + assert_eq!(result.output().unwrap()["stdout"], "ok"); + assert_eq!(*container.calls.lock().unwrap(), 1); +} + +#[tokio::test] +async fn host_exec_nonzero_exit_is_a_failed_terminal_result() { + let container = Arc::new(FakeContainerExec { + receipts: Mutex::new(VecDeque::new()), + calls: Mutex::new(0), + }); + let hosts = Arc::new(FakeHostExec { + calls: Mutex::new(Vec::new()), + }); + let runtime = runtime(container, hosts.clone(), true); + let operation = op("host.exec"); + let parameters = json!({ + "host":"bad", + "command":"ls", + "args":["-l","/srv"], + "path":"/srv", + "timeout_secs":5 + }); + let ctx = context(); + let plan = runtime.plan(&operation, ¶meters, &ctx).await.unwrap(); + let result = runtime + .execute( + &operation, + ¶meters, + &ctx, + &plan, + &authorization(&operation, &plan), + &CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(result.status(), OperationStatus::Failed); + assert_eq!(result.mutation_send_state(), MutationSendState::Sent); + assert_eq!(result.output().unwrap()["exit_code"], 2); + assert_eq!(hosts.calls.lock().unwrap().as_slice(), &["bad"]); +} + +#[tokio::test] +async fn host_exec_many_preserves_sorted_partial_results() { + let container = Arc::new(FakeContainerExec { + receipts: Mutex::new(VecDeque::new()), + calls: Mutex::new(0), + }); + let hosts = Arc::new(FakeHostExec { + calls: Mutex::new(Vec::new()), + }); + let runtime = runtime(container, hosts.clone(), true); + let operation = op("host.exec_many"); + let parameters = json!({ + "command":"ls", + "args":["-l"], + "targets":[ + {"host":"lost","path":"/srv/lost"}, + {"host":"alpha","path":"/srv/alpha"}, + {"host":"bad","path":"/srv/bad"} + ], + "timeout_secs":5 + }); + let ctx = context(); + let plan = runtime.plan(&operation, ¶meters, &ctx).await.unwrap(); + assert_eq!(plan.steps().len(), 3); + let result = runtime + .execute( + &operation, + ¶meters, + &ctx, + &plan, + &authorization(&operation, &plan), + &CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(result.status(), OperationStatus::Failed); + assert_eq!(result.mutation_send_state(), MutationSendState::Unknown); + let output = result.output().unwrap(); + assert_eq!(output["results"][0]["target"], "alpha:/srv/alpha"); + assert_eq!(output["results"][1]["target"], "bad:/srv/bad"); + assert_eq!(output["results"][2]["target"], "lost:/srv/lost"); + assert_eq!(output["success_count"], 1); + assert_eq!(output["failure_count"], 2); + assert_eq!(hosts.calls.lock().unwrap().len(), 3); +} + +#[tokio::test] +async fn execution_parameter_drift_rejects_before_driver_send() { + let container = Arc::new(FakeContainerExec { + receipts: Mutex::new(VecDeque::from([Ok(container_receipt(0))])), + calls: Mutex::new(0), + }); + let hosts = Arc::new(FakeHostExec { + calls: Mutex::new(Vec::new()), + }); + let runtime = runtime(container.clone(), hosts, true); + let operation = op("container.exec"); + let planned = json!({ + "host":"dookie", + "container_id":"api", + "command":["printf","ok"] + }); + let changed = json!({ + "host":"dookie", + "container_id":"api", + "command":["printf","changed"] + }); + let ctx = context(); + let plan = runtime.plan(&operation, &planned, &ctx).await.unwrap(); + let error = runtime + .execute( + &operation, + &changed, + &ctx, + &plan, + &authorization(&operation, &plan), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + assert!(matches!(error, ExecutionError::PlanMismatch(_))); + assert_eq!(*container.calls.lock().unwrap(), 0); +} + +#[tokio::test] +async fn absent_execution_ports_fail_closed_before_send() { + let container = Arc::new(FakeContainerExec { + receipts: Mutex::new(VecDeque::new()), + calls: Mutex::new(0), + }); + let hosts = Arc::new(FakeHostExec { + calls: Mutex::new(Vec::new()), + }); + let runtime = runtime(container, hosts, false); + let operation = op("host.exec"); + let parameters = json!({"host":"alpha","command":"hostname"}); + let ctx = context(); + let plan = runtime.plan(&operation, ¶meters, &ctx).await.unwrap(); + let error = runtime + .execute( + &operation, + ¶meters, + &ctx, + &plan, + &authorization(&operation, &plan), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + assert!(matches!( + error, + ExecutionError::MutationPortUnavailable { domain: "exec", .. } + )); +} + +#[test] +fn execution_digest_is_sha2_011_compatible() { + let digest = super::digest(&serde_json::json!({"command": ["true"]})).unwrap(); + assert_eq!(digest.len(), 64); + assert!( + digest + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + ); +} diff --git a/crates/synapse/application/src/mutation_final.rs b/crates/synapse/application/src/mutation_final.rs new file mode 100644 index 00000000..18f5d9bd --- /dev/null +++ b/crates/synapse/application/src/mutation_final.rs @@ -0,0 +1,274 @@ +use serde_json::Value; +use soma_infra::{ComposeDownRequest, DockerPruneTarget}; +use soma_ops::{OperationContext, OperationName, OperationPlan, PlanStep, VerificationStrategy}; +use tokio_util::sync::CancellationToken; + +use crate::mutation_compose::{compose_target, resolve_project}; +use crate::mutation_final_contract::{ + compose_down_change, docker_target, image_target, planning_deadline, prune_change, rmi_change, + transfer_change, transfer_target, +}; +use crate::runtime_params::{bool_or, required_path, required_str}; +use crate::{ExecutionError, SynapseMutationRuntime}; + +impl SynapseMutationRuntime { + pub(crate) async fn plan_final( + &self, + operation: &OperationName, + parameters: &Value, + context: &OperationContext, + ) -> Result { + match operation.as_str() { + "docker.rmi" => self.plan_rmi(operation, parameters, context).await, + "docker.prune" => self.plan_prune(operation, parameters, context).await, + "compose.down" => self.plan_compose_down(operation, parameters, context).await, + "files.transfer" => self.plan_transfer(operation, parameters, context).await, + _ => Err(ExecutionError::UnsupportedOperation(operation.clone())), + } + } + + async fn plan_rmi( + &self, + operation: &OperationName, + parameters: &Value, + context: &OperationContext, + ) -> Result { + let spec = self.mutation_spec(operation)?; + self.catalog.validate_parameters(operation, parameters)?; + let host = self.resolve_host(required_str(parameters, "host")?).await?; + let image = required_str(parameters, "image")?; + let force = bool_or(parameters, "force", false)?; + let target = image_target(&host, image)?; + let cancellation = CancellationToken::new(); + let client = self + .final_ports("docker-cleanup")? + .cleanup + .cleanup_client(&host, &cancellation) + .await?; + let fingerprint = self + .docker_cleanup + .inspect_image(client.as_ref(), &host, image, &cancellation) + .await?; + let change = rmi_change(&host, &fingerprint, force)?; + let step = PlanStep::new( + 1, + operation.clone(), + target.clone(), + format!("remove Docker image {}", fingerprint.identity.id), + )?; + let verification = VerificationStrategy::new( + OperationName::new("docker.images").expect("static operation name"), + "verify the requested reference and resolved image ID are absent", + )?; + OperationPlan::new( + context.operation_id().clone(), + operation.clone(), + target, + spec.risk(), + spec.reversibility(), + )? + .with_topology_revision(host.revision().to_string())? + .with_change(change)? + .with_prerequisite("the exact local image identity remains unchanged")? + .with_prerequisite("dependent containers and tags have been reviewed")? + .with_step(step)? + .with_verification(verification)? + .with_rollback_guidance( + "restore the removed image from its recorded repository digest or rebuild it from a verified source context", + ) + .map_err(ExecutionError::from) + } + + async fn plan_prune( + &self, + operation: &OperationName, + parameters: &Value, + context: &OperationContext, + ) -> Result { + let spec = self.mutation_spec(operation)?; + self.catalog.validate_parameters(operation, parameters)?; + let host = self.resolve_host(required_str(parameters, "host")?).await?; + let prune_target = DockerPruneTarget::parse(required_str(parameters, "prune_target")?)?; + let force = bool_or(parameters, "force", false)?; + let target = docker_target(&host)?; + let cancellation = CancellationToken::new(); + let client = self + .final_ports("docker-cleanup")? + .cleanup + .cleanup_client(&host, &cancellation) + .await?; + let fingerprint = self + .docker_cleanup + .inspect_prune(client.as_ref(), &host, prune_target, &cancellation) + .await?; + let change = prune_change(&host, &fingerprint, force)?; + let step = PlanStep::new( + 1, + operation.clone(), + target.clone(), + format!("prune Docker {} resources", prune_target.as_str()), + )?; + let verification = VerificationStrategy::new( + OperationName::new("docker.df").expect("static operation name"), + "verify reported deleted identities are absent and reclaimed cache bytes are reflected", + )?; + OperationPlan::new( + context.operation_id().clone(), + operation.clone(), + target, + spec.risk(), + spec.reversibility(), + )? + .with_topology_revision(host.revision().to_string())? + .with_change(change)? + .with_prerequisite("the exact prune candidate inventory remains unchanged")? + .with_step(step)? + .with_verification(verification)? + .with_rollback_guidance( + "pruned resources are irreversible; restore images, containers, volumes, networks, or build cache only from independent backups and source definitions", + ) + .map_err(ExecutionError::from) + } + + async fn plan_compose_down( + &self, + operation: &OperationName, + parameters: &Value, + context: &OperationContext, + ) -> Result { + let spec = self.mutation_spec(operation)?; + self.catalog.validate_parameters(operation, parameters)?; + let host = self.resolve_host(required_str(parameters, "host")?).await?; + let project_name = required_str(parameters, "project")?; + let force = bool_or(parameters, "force", false)?; + let remove_volumes = bool_or(parameters, "remove_volumes", false)?; + let target = compose_target(&host, project_name)?; + let ports = self.final_ports("compose-down")?; + let deadline = planning_deadline(context); + let cancellation = CancellationToken::new(); + let project = resolve_project( + ports.compose_down.as_ref(), + &host, + project_name, + deadline, + &cancellation, + ) + .await?; + let (fingerprint, _) = self + .compose_down + .inspect( + ports.compose_down.as_ref(), + &host, + &project, + deadline, + &cancellation, + ) + .await?; + ComposeDownRequest::new( + context.operation_id().clone(), + operation.clone(), + project, + fingerprint.clone(), + force, + remove_volumes, + deadline, + )?; + let change = compose_down_change(&host, &fingerprint, force, remove_volumes)?; + let step = PlanStep::new( + 1, + operation.clone(), + target.clone(), + format!("tear down Compose project {project_name}"), + )?; + let verification = VerificationStrategy::new( + OperationName::new("compose.status").expect("static operation name"), + "verify the project reports no remaining services", + )?; + OperationPlan::new( + context.operation_id().clone(), + operation.clone(), + target, + spec.risk(), + spec.reversibility(), + )? + .with_topology_revision(host.revision().to_string())? + .with_change(change)? + .with_prerequisite("the Compose configuration and complete service set remain unchanged")? + .with_prerequisite("persistent data is recoverable before volume deletion")? + .with_step(step)? + .with_verification(verification)? + .with_rollback_guidance( + "run a separately planned compose.up from the recorded configuration; deleted volumes require backup restoration", + ) + .map_err(ExecutionError::from) + } + + async fn plan_transfer( + &self, + operation: &OperationName, + parameters: &Value, + context: &OperationContext, + ) -> Result { + let spec = self.mutation_spec(operation)?; + self.catalog.validate_parameters(operation, parameters)?; + let source = self + .resolve_host(required_str(parameters, "source_host")?) + .await?; + let destination = self + .resolve_host(required_str(parameters, "dest_host")?) + .await?; + let source_path = required_path(parameters, "source_path")?; + let destination_path = required_path(parameters, "dest_path")?; + let cancellation = CancellationToken::new(); + let fingerprint = self + .file_transfer + .inspect( + self.final_ports("file-transfer")?.transfer.as_ref(), + &source, + &source_path, + &destination, + &destination_path, + &cancellation, + ) + .await?; + let target = transfer_target(&source, &source_path, &destination, &destination_path)?; + let change = transfer_change(&target, &fingerprint)?; + let step = PlanStep::new( + 1, + operation.clone(), + target.clone(), + format!( + "copy {} bytes from {}:{} to {}:{}", + fingerprint.source.bytes, + source.id(), + source_path.display(), + destination.id(), + destination_path.display() + ), + )?; + let verification = VerificationStrategy::new( + OperationName::new("files.compare").expect("static operation name"), + "verify destination bytes and SHA-256 match the source", + )?; + OperationPlan::new( + context.operation_id().clone(), + operation.clone(), + target, + spec.risk(), + spec.reversibility(), + )? + .with_topology_revision(destination.revision().to_string())? + .with_change(change)? + .with_prerequisite("the source and destination pre-state remain unchanged")? + .with_step(step)? + .with_verification(verification)? + .with_rollback_guidance( + "restore the destination from the recorded pre-transfer digest or remove it when no prior destination existed", + ) + .map_err(ExecutionError::from) + } +} + +#[cfg(test)] +#[path = "mutation_final_tests.rs"] +mod tests; diff --git a/crates/synapse/application/src/mutation_final_admission.rs b/crates/synapse/application/src/mutation_final_admission.rs new file mode 100644 index 00000000..ae6baa11 --- /dev/null +++ b/crates/synapse/application/src/mutation_final_admission.rs @@ -0,0 +1,61 @@ +use soma_ops::{AuthorizationEvidence, OperationContext, OperationName, OperationPlan, Timestamp}; + +use crate::ExecutionError; +use crate::mutation_runtime::DEFAULT_MUTATION_DEADLINE_MS; + +pub(crate) fn validate_final_admission( + operation: &OperationName, + context: &OperationContext, + plan: &OperationPlan, + authorization: &AuthorizationEvidence, + expected: &OperationPlan, + now: Timestamp, + idempotent: bool, +) -> Result<(), ExecutionError> { + plan.validate_fingerprint()?; + if plan != expected { + return Err(ExecutionError::PlanMismatch( + "cleanup inventory, Compose state, transfer content, target revision, or operation parameters changed after planning".into(), + )); + } + if context.deadline().is_some_and(|deadline| deadline <= now) { + return Err(ExecutionError::DeadlineExceeded); + } + if idempotent && context.idempotency_key().is_none() { + return Err(ExecutionError::MissingIdempotencyKey); + } + if authorization.confirmation_ref().is_none() { + return Err(ExecutionError::ConfirmationRequired); + } + authorization.validate_binding(operation, plan.target(), now, Some(plan.fingerprint()))?; + Ok(()) +} + +pub(crate) fn validate_final_changes( + plan: &OperationPlan, + expected: &[soma_ops::PlannedChange], +) -> Result<(), ExecutionError> { + if plan.changes() != expected { + return Err(ExecutionError::PlanMismatch( + "authorization-relevant final mutation changes differ from the current state".into(), + )); + } + Ok(()) +} + +pub(crate) fn final_execution_deadline( + context: &OperationContext, + started: Timestamp, +) -> Timestamp { + context.deadline().unwrap_or_else(|| { + Timestamp::from_unix_millis( + started + .unix_millis() + .saturating_add(DEFAULT_MUTATION_DEADLINE_MS), + ) + }) +} + +#[cfg(test)] +#[path = "mutation_final_admission_tests.rs"] +mod tests; diff --git a/crates/synapse/application/src/mutation_final_admission_tests.rs b/crates/synapse/application/src/mutation_final_admission_tests.rs new file mode 100644 index 00000000..6145aa9e --- /dev/null +++ b/crates/synapse/application/src/mutation_final_admission_tests.rs @@ -0,0 +1,11 @@ +use super::*; + +#[test] +fn explicit_deadline_wins_for_final_mutations() { + let deadline = Timestamp::from_unix_millis(Timestamp::now().unix_millis() + 9_000); + let context = OperationContext::new().with_deadline(deadline); + assert_eq!( + final_execution_deadline(&context, Timestamp::now()), + deadline + ); +} diff --git a/crates/synapse/application/src/mutation_final_contract.rs b/crates/synapse/application/src/mutation_final_contract.rs new file mode 100644 index 00000000..c9c49bbc --- /dev/null +++ b/crates/synapse/application/src/mutation_final_contract.rs @@ -0,0 +1,152 @@ +use std::path::Path; + +use soma_fleet::HostRecord; +use soma_infra::{FileTransferFingerprint, ImageRemovalFingerprint}; +use soma_ops::{OperationContext, OperationName, PlannedChange, TargetKind, TargetRef, Timestamp}; + +use crate::mutation_compose::compose_target; +use crate::mutation_runtime::DEFAULT_MUTATION_DEADLINE_MS; +use crate::{ExecutionError, SynapseFinalPorts, SynapseMutationRuntime}; + +impl SynapseMutationRuntime { + pub(crate) fn final_ports( + &self, + domain: &'static str, + ) -> Result<&SynapseFinalPorts, ExecutionError> { + self.ports + .final_mutations + .as_ref() + .ok_or_else(|| ExecutionError::MutationPortUnavailable { + domain, + host: "unresolved".into(), + }) + } +} + +pub(crate) fn final_operation(operation: &OperationName) -> bool { + matches!( + operation.as_str(), + "docker.rmi" | "docker.prune" | "compose.down" | "files.transfer" + ) +} + +pub(crate) fn image_target(host: &HostRecord, image: &str) -> Result { + TargetRef::new(TargetKind::Image, image)? + .with_host(host.id().to_string())? + .with_revision(host.revision().to_string()) + .map_err(ExecutionError::from) +} + +pub(crate) fn docker_target(host: &HostRecord) -> Result { + TargetRef::new(TargetKind::DockerDaemon, host.id().to_string())? + .with_host(host.id().to_string())? + .with_revision(host.revision().to_string()) + .map_err(ExecutionError::from) +} + +pub(crate) fn transfer_target( + source: &HostRecord, + source_path: &Path, + destination: &HostRecord, + destination_path: &Path, +) -> Result { + let parent = TargetRef::new(TargetKind::File, source_path.to_string_lossy())? + .with_host(source.id().to_string())? + .with_revision(source.revision().to_string())?; + TargetRef::new(TargetKind::File, destination_path.to_string_lossy())? + .with_host(destination.id().to_string())? + .with_parent(parent)? + .with_revision(destination.revision().to_string()) + .map_err(ExecutionError::from) +} + +pub(crate) fn rmi_change( + host: &HostRecord, + fingerprint: &ImageRemovalFingerprint, + force: bool, +) -> Result { + Ok(PlannedChange::new( + image_target(host, &fingerprint.reference)?, + if force { "remove_force" } else { "remove" }, + format!( + "remove image {} resolved from {}", + fingerprint.identity.id, fingerprint.reference + ), + )? + .with_digests(Some(fingerprint.sha256.clone()), None)) +} + +pub(crate) fn prune_change( + host: &HostRecord, + fingerprint: &soma_infra::DockerPruneFingerprint, + force: bool, +) -> Result { + Ok(PlannedChange::new( + docker_target(host)?, + if force { "prune_force" } else { "prune" }, + format!( + "prune Docker {} candidate inventory", + fingerprint.target.as_str() + ), + )? + .with_digests(Some(fingerprint.sha256.clone()), None)) +} + +pub(crate) fn compose_down_change( + host: &HostRecord, + fingerprint: &soma_infra::ComposeRecreateFingerprint, + force: bool, + remove_volumes: bool, +) -> Result { + let action = match (force, remove_volumes) { + (_, true) => "down_remove_volumes", + (true, false) => "down_force", + (false, false) => "down", + }; + Ok(PlannedChange::new( + compose_target(host, &fingerprint.project)?, + action, + format!( + "tear down {} services in Compose project {}", + fingerprint.services.len(), + fingerprint.project + ), + )? + .with_digests(Some(fingerprint.sha256.clone()), None)) +} + +pub(crate) fn transfer_change( + target: &TargetRef, + fingerprint: &FileTransferFingerprint, +) -> Result { + Ok(PlannedChange::new( + target.clone(), + "copy_verified", + format!( + "copy {} verified bytes to {}", + fingerprint.source.bytes, + fingerprint.destination_path.display() + ), + )? + .with_digests( + fingerprint + .destination_before + .as_ref() + .map(|identity| identity.sha256.clone()), + Some(fingerprint.source.sha256.clone()), + )) +} + +pub(crate) fn planning_deadline(context: &OperationContext) -> Timestamp { + context.deadline().unwrap_or_else(|| { + Timestamp::from_unix_millis( + Timestamp::now() + .unix_millis() + .saturating_add(DEFAULT_MUTATION_DEADLINE_MS), + ) + }) +} + +#[cfg(test)] +#[path = "mutation_final_contract_tests.rs"] +mod tests; diff --git a/crates/synapse/application/src/mutation_final_contract_tests.rs b/crates/synapse/application/src/mutation_final_contract_tests.rs new file mode 100644 index 00000000..bb7c1659 --- /dev/null +++ b/crates/synapse/application/src/mutation_final_contract_tests.rs @@ -0,0 +1,16 @@ +use super::*; + +#[test] +fn final_operation_set_is_closed() { + for name in [ + "docker.rmi", + "docker.prune", + "compose.down", + "files.transfer", + ] { + assert!(final_operation(&OperationName::new(name).unwrap())); + } + assert!(!final_operation( + &OperationName::new("docker.pull").unwrap() + )); +} diff --git a/crates/synapse/application/src/mutation_final_execute.rs b/crates/synapse/application/src/mutation_final_execute.rs new file mode 100644 index 00000000..46f5956e --- /dev/null +++ b/crates/synapse/application/src/mutation_final_execute.rs @@ -0,0 +1,301 @@ +use serde_json::Value; +use soma_infra::{ComposeDownRequest, DockerPruneRequest, DockerPruneTarget, ImageRemovalRequest}; +use soma_ops::{AuthorizationEvidence, OperationContext, OperationName, OperationPlan, Timestamp}; +use tokio_util::sync::CancellationToken; + +use crate::mutation_compose::{compose_target, resolve_project}; +use crate::mutation_final_admission::{ + final_execution_deadline as execution_deadline, validate_final_admission, + validate_final_changes as validate_changes, +}; +use crate::mutation_final_contract::{ + compose_down_change, docker_target, image_target, prune_change, rmi_change, +}; +use crate::runtime_params::{bool_or, required_str}; +use crate::{ExecutionError, SynapseMutationRuntime}; + +impl SynapseMutationRuntime { + #[allow(clippy::too_many_arguments)] + pub(crate) async fn execute_final( + &self, + operation: &OperationName, + parameters: &Value, + context: &OperationContext, + plan: &OperationPlan, + authorization: &AuthorizationEvidence, + cancellation: &CancellationToken, + ) -> Result { + match operation.as_str() { + "docker.rmi" => { + self.execute_rmi( + operation, + parameters, + context, + plan, + authorization, + cancellation, + ) + .await + } + "docker.prune" => { + self.execute_prune( + operation, + parameters, + context, + plan, + authorization, + cancellation, + ) + .await + } + "compose.down" => { + self.execute_compose_down( + operation, + parameters, + context, + plan, + authorization, + cancellation, + ) + .await + } + "files.transfer" => { + self.execute_transfer( + operation, + parameters, + context, + plan, + authorization, + cancellation, + ) + .await + } + _ => Err(ExecutionError::UnsupportedOperation(operation.clone())), + } + } + + #[allow(clippy::too_many_arguments)] + async fn execute_rmi( + &self, + operation: &OperationName, + parameters: &Value, + context: &OperationContext, + plan: &OperationPlan, + authorization: &AuthorizationEvidence, + cancellation: &CancellationToken, + ) -> Result { + let started = Timestamp::now(); + let spec = self.mutation_spec(operation)?; + self.catalog.validate_parameters(operation, parameters)?; + let host = self.resolve_host(required_str(parameters, "host")?).await?; + let image = required_str(parameters, "image")?; + let force = bool_or(parameters, "force", false)?; + let target = image_target(&host, image)?; + let expected = self.plan_final(operation, parameters, context).await?; + validate_final_admission( + operation, + context, + plan, + authorization, + &expected, + started, + spec.idempotent(), + )?; + let ports = self.final_ports("docker-cleanup")?; + let client = ports.cleanup.cleanup_client(&host, cancellation).await?; + let fingerprint = self + .docker_cleanup + .inspect_image(client.as_ref(), &host, image, cancellation) + .await?; + validate_changes(plan, &[rmi_change(&host, &fingerprint, force)?])?; + let request = ImageRemovalRequest { + operation_id: context.operation_id().clone(), + operation: operation.clone(), + fingerprint, + force, + deadline: execution_deadline(context, started), + }; + match self + .docker_cleanup + .remove_image(client.as_ref(), &host, &request, cancellation) + .await + { + Ok(outcome) => { + self.rmi_outcome_result(operation, context, target, started, spec.retry(), outcome) + } + Err(failure) => self.failure_result( + operation, + context, + target, + started, + failure.send_state(), + spec.retry(), + failure.into_error(), + None, + ), + } + } + + #[allow(clippy::too_many_arguments)] + async fn execute_prune( + &self, + operation: &OperationName, + parameters: &Value, + context: &OperationContext, + plan: &OperationPlan, + authorization: &AuthorizationEvidence, + cancellation: &CancellationToken, + ) -> Result { + let started = Timestamp::now(); + let spec = self.mutation_spec(operation)?; + self.catalog.validate_parameters(operation, parameters)?; + let host = self.resolve_host(required_str(parameters, "host")?).await?; + let target_kind = DockerPruneTarget::parse(required_str(parameters, "prune_target")?)?; + let force = bool_or(parameters, "force", false)?; + let target = docker_target(&host)?; + let expected = self.plan_final(operation, parameters, context).await?; + validate_final_admission( + operation, + context, + plan, + authorization, + &expected, + started, + spec.idempotent(), + )?; + let ports = self.final_ports("docker-cleanup")?; + let client = ports.cleanup.cleanup_client(&host, cancellation).await?; + let fingerprint = self + .docker_cleanup + .inspect_prune(client.as_ref(), &host, target_kind, cancellation) + .await?; + validate_changes(plan, &[prune_change(&host, &fingerprint, force)?])?; + let request = DockerPruneRequest { + operation_id: context.operation_id().clone(), + operation: operation.clone(), + fingerprint, + force, + deadline: execution_deadline(context, started), + }; + match self + .docker_cleanup + .prune(client.as_ref(), &host, &request, cancellation) + .await + { + Ok(outcome) => self.prune_outcome_result( + operation, + context, + target, + started, + spec.retry(), + outcome, + ), + Err(failure) => self.failure_result( + operation, + context, + target, + started, + failure.send_state(), + spec.retry(), + failure.into_error(), + None, + ), + } + } + + #[allow(clippy::too_many_arguments)] + async fn execute_compose_down( + &self, + operation: &OperationName, + parameters: &Value, + context: &OperationContext, + plan: &OperationPlan, + authorization: &AuthorizationEvidence, + cancellation: &CancellationToken, + ) -> Result { + let started = Timestamp::now(); + let spec = self.mutation_spec(operation)?; + self.catalog.validate_parameters(operation, parameters)?; + let host = self.resolve_host(required_str(parameters, "host")?).await?; + let project_name = required_str(parameters, "project")?; + let force = bool_or(parameters, "force", false)?; + let remove_volumes = bool_or(parameters, "remove_volumes", false)?; + let target = compose_target(&host, project_name)?; + let expected = self.plan_final(operation, parameters, context).await?; + validate_final_admission( + operation, + context, + plan, + authorization, + &expected, + started, + spec.idempotent(), + )?; + let ports = self.final_ports("compose-down")?; + let deadline = execution_deadline(context, started); + let project = resolve_project( + ports.compose_down.as_ref(), + &host, + project_name, + deadline, + cancellation, + ) + .await?; + let (fingerprint, _) = self + .compose_down + .inspect( + ports.compose_down.as_ref(), + &host, + &project, + deadline, + cancellation, + ) + .await?; + validate_changes( + plan, + &[compose_down_change( + &host, + &fingerprint, + force, + remove_volumes, + )?], + )?; + let request = ComposeDownRequest::new( + context.operation_id().clone(), + operation.clone(), + project, + fingerprint, + force, + remove_volumes, + deadline, + )?; + match self + .compose_down + .execute(ports.compose_down.as_ref(), &host, &request, cancellation) + .await + { + Ok(outcome) => self.compose_down_outcome_result( + operation, + context, + target, + started, + spec.retry(), + outcome, + ), + Err(failure) => self.failure_result( + operation, + context, + target, + started, + failure.send_state(), + spec.retry(), + failure.into_error(), + None, + ), + } + } +} + +#[cfg(test)] +#[path = "mutation_final_execute_tests.rs"] +mod tests; diff --git a/crates/synapse/application/src/mutation_final_execute_tests.rs b/crates/synapse/application/src/mutation_final_execute_tests.rs new file mode 100644 index 00000000..2e66f5ca --- /dev/null +++ b/crates/synapse/application/src/mutation_final_execute_tests.rs @@ -0,0 +1,6 @@ +use super::*; + +#[test] +fn cleanup_executor_remains_a_focused_impl_surface() { + let _ = std::mem::size_of::(); +} diff --git a/crates/synapse/application/src/mutation_final_result.rs b/crates/synapse/application/src/mutation_final_result.rs new file mode 100644 index 00000000..acf5c93e --- /dev/null +++ b/crates/synapse/application/src/mutation_final_result.rs @@ -0,0 +1,196 @@ +use serde_json::{Value, json}; +use soma_infra::{ComposeDownOutcome, DockerPruneOutcome, ImageRemovalOutcome}; +use soma_ops::{ + EvidenceRef, ExecutionMetadata, OperationContext, OperationName, OperationResult, + OperationStatus, RetryClass, TargetRef, Timestamp, VerificationResult, VerificationStatus, +}; + +use crate::mutation_result::mutation_output; +use crate::{ExecutionError, SynapseMutationRuntime}; + +impl SynapseMutationRuntime { + #[allow(clippy::too_many_arguments)] + pub(crate) fn rmi_outcome_result( + &self, + operation: &OperationName, + context: &OperationContext, + target: TargetRef, + started: Timestamp, + _failure_retry: RetryClass, + outcome: ImageRemovalOutcome, + ) -> Result { + let output = mutation_output( + operation, + outcome.removed, + format!( + "removed Docker image {} resolved from {}", + outcome.before.identity.id, outcome.before.reference + ), + target.revision(), + json!({ + "reference": outcome.before.reference, + "image_id": outcome.before.identity.id, + "repo_tags": outcome.before.identity.repo_tags, + "repo_digests": outcome.before.identity.repo_digests, + "deleted": outcome.receipt.deleted, + "untagged": outcome.receipt.untagged, + "verified_absent": outcome.removed, + }), + ); + self.verified_cleanup_result( + operation, + context, + target, + started, + outcome.receipt.send_state, + output, + "diff", + format!("docker-image-removal://{}", context.operation_id()), + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn prune_outcome_result( + &self, + operation: &OperationName, + context: &OperationContext, + target: TargetRef, + started: Timestamp, + _failure_retry: RetryClass, + outcome: DockerPruneOutcome, + ) -> Result { + let scopes = outcome + .receipt + .scopes + .iter() + .map(|scope| { + json!({ + "target": scope.target, + "deleted_count": scope.deleted.len(), + "space_reclaimed": scope.space_reclaimed, + }) + }) + .collect::>(); + let total_reclaimed = outcome + .receipt + .scopes + .iter() + .map(|scope| scope.space_reclaimed) + .sum::(); + let output = mutation_output( + operation, + outcome.changed, + format!( + "pruned Docker {} resources and reclaimed {} bytes", + outcome.before.target.as_str(), + total_reclaimed + ), + target.revision(), + json!({ + "target": outcome.before.target, + "before_fingerprint": outcome.before.sha256, + "after_fingerprint": outcome.after.sha256, + "before_counts": prune_counts(&outcome.before), + "after_counts": prune_counts(&outcome.after), + "scopes": scopes, + "total_space_reclaimed": total_reclaimed, + }), + ); + self.verified_cleanup_result( + operation, + context, + target, + started, + outcome.receipt.send_state, + output, + "diff", + format!("docker-prune://{}", context.operation_id()), + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn compose_down_outcome_result( + &self, + operation: &OperationName, + context: &OperationContext, + target: TargetRef, + started: Timestamp, + _failure_retry: RetryClass, + outcome: ComposeDownOutcome, + ) -> Result { + let output = mutation_output( + operation, + outcome.changed, + format!( + "Compose project {} was torn down and verified empty", + outcome.project + ), + Some(outcome.topology_revision.as_str()), + json!({ + "host": outcome.host, + "project": outcome.project, + "services_before": outcome.before.services.iter().map(|row| row.service.clone()).collect::>(), + "services_after": outcome.after.services.len(), + "remove_volumes": outcome.receipt.remove_volumes, + "output_truncated": outcome.receipt.output_truncated, + "verification": outcome.verification, + }), + ); + self.verified_cleanup_result( + operation, + context, + target, + started, + outcome.receipt.send_state, + output, + "runtime_state", + format!("compose-down://{}", context.operation_id()), + ) + } + + #[allow(clippy::too_many_arguments)] + fn verified_cleanup_result( + &self, + operation: &OperationName, + context: &OperationContext, + target: TargetRef, + started: Timestamp, + send_state: soma_ops::MutationSendState, + output: Value, + evidence_kind: &'static str, + evidence_reference: String, + ) -> Result { + self.catalog.validate_result(operation, &output)?; + let execution = + ExecutionMetadata::new(started, Timestamp::now(), send_state, RetryClass::Never)?; + let mut result = OperationResult::new( + context.operation_id().clone(), + operation.clone(), + target, + OperationStatus::Succeeded, + execution, + )? + .with_output(output)? + .with_evidence(EvidenceRef::new(evidence_kind, evidence_reference)?); + result = result.with_verification(VerificationResult::new( + VerificationStatus::Verified, + Timestamp::now(), + ))?; + result.validate()?; + Ok(result) + } +} + +fn prune_counts(fingerprint: &soma_infra::DockerPruneFingerprint) -> Value { + json!({ + "containers": fingerprint.containers.len(), + "images": fingerprint.images.len(), + "volumes": fingerprint.volumes.len(), + "networks": fingerprint.networks.len(), + "build_cache_bytes": fingerprint.build_cache_bytes, + }) +} + +#[cfg(test)] +#[path = "mutation_final_result_tests.rs"] +mod tests; diff --git a/crates/synapse/application/src/mutation_final_result_tests.rs b/crates/synapse/application/src/mutation_final_result_tests.rs new file mode 100644 index 00000000..06cbda5e --- /dev/null +++ b/crates/synapse/application/src/mutation_final_result_tests.rs @@ -0,0 +1,24 @@ +use super::*; + +#[test] +fn prune_counts_are_compact_and_complete() { + let fingerprint = soma_infra::DockerPruneFingerprint { + host: soma_fleet::HostId::new("dookie").unwrap(), + topology_revision: soma_fleet::HostRecord::new( + soma_fleet::HostId::new("dookie").unwrap(), + soma_fleet::HostEndpoint::Local, + ) + .revision() + .clone(), + target: soma_infra::DockerPruneTarget::All, + containers: vec!["a".into()], + images: vec!["b".into()], + volumes: vec!["c".into()], + networks: vec!["d".into()], + build_cache_bytes: 9, + sha256: "e".repeat(64), + }; + let counts = prune_counts(&fingerprint); + assert_eq!(counts["containers"], 1); + assert_eq!(counts["build_cache_bytes"], 9); +} diff --git a/crates/synapse/application/src/mutation_final_tests.rs b/crates/synapse/application/src/mutation_final_tests.rs new file mode 100644 index 00000000..aedbcf80 --- /dev/null +++ b/crates/synapse/application/src/mutation_final_tests.rs @@ -0,0 +1,242 @@ +use std::path::PathBuf; + +use soma_fleet::HostRecord; + +use super::*; +use crate::mutation_final_contract::{final_operation, transfer_target}; + +#[test] +fn final_operation_set_is_closed() { + for name in [ + "docker.rmi", + "docker.prune", + "compose.down", + "files.transfer", + ] { + assert!(final_operation(&OperationName::new(name).unwrap())); + } + assert!(!final_operation( + &OperationName::new("docker.pull").unwrap() + )); +} + +#[test] +fn transfer_target_binds_both_host_revisions_and_paths() { + let source = HostRecord::new( + soma_fleet::HostId::new("source").unwrap(), + soma_fleet::HostEndpoint::Local, + ); + let destination = HostRecord::new( + soma_fleet::HostId::new("destination").unwrap(), + soma_fleet::HostEndpoint::Local, + ); + let target = transfer_target( + &source, + &PathBuf::from("/src/file"), + &destination, + &PathBuf::from("/dst/file"), + ) + .unwrap(); + assert_eq!(target.host(), Some("destination")); + assert_eq!(target.parent().unwrap().host(), Some("source")); +} + +use std::sync::Arc; + +use serde_json::json; +use soma_ops::OperationStatus; +use tokio_util::sync::CancellationToken; + +use crate::mutation_final_test_docker::{FakeCleanup, cleanup_image}; +use crate::mutation_final_test_io::{FakeComposeDown, FakeTransfer, op, runtime}; +use crate::mutation_pull_test_support::{authorization, context, host}; + +fn final_runtime( + cleanup: Arc, + compose: Arc, + transfer: Arc, +) -> SynapseMutationRuntime { + runtime(cleanup, compose, transfer, true) +} + +#[tokio::test] +async fn docker_rmi_plans_removes_and_verifies_absence() { + let cleanup = Arc::new(FakeCleanup::new(cleanup_image(&host()))); + let compose = Arc::new(FakeComposeDown::new()); + let transfer = Arc::new(FakeTransfer::new()); + let runtime = final_runtime(cleanup.clone(), compose, transfer); + let operation = op("docker.rmi"); + let parameters = json!({"host":"dookie","image":"app:v1","force":true}); + let context = context(); + let plan = runtime + .plan(&operation, ¶meters, &context) + .await + .unwrap(); + let result = runtime + .execute( + &operation, + ¶meters, + &context, + &plan, + &authorization(&operation, &plan), + &CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(result.status(), OperationStatus::Succeeded); + assert_eq!(result.output().unwrap()["changed"], true); + assert_eq!(cleanup.remove_calls(), 1); +} + +#[tokio::test] +async fn docker_prune_binds_inventory_and_verifies_deleted_images() { + let cleanup = Arc::new(FakeCleanup::new(cleanup_image(&host()))); + let compose = Arc::new(FakeComposeDown::new()); + let transfer = Arc::new(FakeTransfer::new()); + let runtime = final_runtime(cleanup.clone(), compose, transfer); + let operation = op("docker.prune"); + let parameters = json!({"host":"dookie","prune_target":"images","force":true}); + let context = context(); + let plan = runtime + .plan(&operation, ¶meters, &context) + .await + .unwrap(); + let result = runtime + .execute( + &operation, + ¶meters, + &context, + &plan, + &authorization(&operation, &plan), + &CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(result.status(), OperationStatus::Succeeded); + assert_eq!( + result.output().unwrap()["details"]["after_counts"]["images"], + 0 + ); + assert_eq!(cleanup.prune_calls(), 1); +} + +#[tokio::test] +async fn compose_down_binds_service_set_and_verifies_empty_status() { + let cleanup = Arc::new(FakeCleanup::new(cleanup_image(&host()))); + let compose = Arc::new(FakeComposeDown::new()); + let transfer = Arc::new(FakeTransfer::new()); + let runtime = final_runtime(cleanup, compose.clone(), transfer); + let operation = op("compose.down"); + let parameters = json!({"host":"dookie","project":"soma"}); + let context = context(); + let plan = runtime + .plan(&operation, ¶meters, &context) + .await + .unwrap(); + let result = runtime + .execute( + &operation, + ¶meters, + &context, + &plan, + &authorization(&operation, &plan), + &CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(result.status(), OperationStatus::Succeeded); + assert_eq!(result.output().unwrap()["details"]["services_after"], 0); + assert_eq!(compose.calls(), 1); +} + +#[tokio::test] +async fn files_transfer_binds_both_hosts_and_returns_verified_artifact() { + let cleanup = Arc::new(FakeCleanup::new(cleanup_image(&host()))); + let compose = Arc::new(FakeComposeDown::new()); + let transfer = Arc::new(FakeTransfer::new()); + let runtime = final_runtime(cleanup, compose, transfer.clone()); + let operation = op("files.transfer"); + let parameters = json!({ + "source_host":"source", + "source_path":"/source/payload.bin", + "dest_host":"destination", + "dest_path":"/destination/payload.bin" + }); + let context = context(); + let plan = runtime + .plan(&operation, ¶meters, &context) + .await + .unwrap(); + let result = runtime + .execute( + &operation, + ¶meters, + &context, + &plan, + &authorization(&operation, &plan), + &CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(result.status(), OperationStatus::Succeeded); + assert_eq!(result.output().unwrap()["verified"], true); + assert_eq!(result.output().unwrap()["bytes"], 7); + assert_eq!(transfer.calls(), 1); +} + +#[tokio::test] +async fn final_parameter_drift_rejects_before_destructive_send() { + let cleanup = Arc::new(FakeCleanup::new(cleanup_image(&host()))); + let runtime = final_runtime( + cleanup.clone(), + Arc::new(FakeComposeDown::new()), + Arc::new(FakeTransfer::new()), + ); + let operation = op("docker.rmi"); + let parameters = json!({"host":"dookie","image":"app:v1","force":true}); + let context = context(); + let plan = runtime + .plan(&operation, ¶meters, &context) + .await + .unwrap(); + let drifted = json!({"host":"dookie","image":"app:v2","force":true}); + assert!( + runtime + .execute( + &operation, + &drifted, + &context, + &plan, + &authorization(&operation, &plan), + &CancellationToken::new(), + ) + .await + .is_err() + ); + assert_eq!(cleanup.remove_calls(), 0); +} + +#[tokio::test] +async fn absent_final_ports_fail_closed_before_send() { + let cleanup = Arc::new(FakeCleanup::new(cleanup_image(&host()))); + let runtime = runtime( + cleanup.clone(), + Arc::new(FakeComposeDown::new()), + Arc::new(FakeTransfer::new()), + false, + ); + let operation = op("docker.rmi"); + let error = runtime + .plan( + &operation, + &json!({"host":"dookie","image":"app:v1","force":true}), + &context(), + ) + .await + .unwrap_err(); + assert!(matches!( + error, + ExecutionError::MutationPortUnavailable { .. } + )); + assert_eq!(cleanup.remove_calls(), 0); +} diff --git a/crates/synapse/application/src/mutation_final_transfer_execute.rs b/crates/synapse/application/src/mutation_final_transfer_execute.rs new file mode 100644 index 00000000..6c7a552f --- /dev/null +++ b/crates/synapse/application/src/mutation_final_transfer_execute.rs @@ -0,0 +1,96 @@ +use serde_json::Value; +use soma_infra::VerifiedFileTransferRequest; +use soma_ops::{AuthorizationEvidence, OperationContext, OperationName, OperationPlan, Timestamp}; +use tokio_util::sync::CancellationToken; + +use crate::mutation_final_admission::{ + final_execution_deadline, validate_final_admission, validate_final_changes, +}; +use crate::mutation_final_contract::{transfer_change, transfer_target}; +use crate::runtime_params::{required_path, required_str}; +use crate::{ExecutionError, SynapseMutationRuntime}; + +impl SynapseMutationRuntime { + #[allow(clippy::too_many_arguments)] + pub(crate) async fn execute_transfer( + &self, + operation: &OperationName, + parameters: &Value, + context: &OperationContext, + plan: &OperationPlan, + authorization: &AuthorizationEvidence, + cancellation: &CancellationToken, + ) -> Result { + let started = Timestamp::now(); + let spec = self.mutation_spec(operation)?; + self.catalog.validate_parameters(operation, parameters)?; + let source = self + .resolve_host(required_str(parameters, "source_host")?) + .await?; + let destination = self + .resolve_host(required_str(parameters, "dest_host")?) + .await?; + let source_path = required_path(parameters, "source_path")?; + let destination_path = required_path(parameters, "dest_path")?; + let target = transfer_target(&source, &source_path, &destination, &destination_path)?; + let expected = self.plan_final(operation, parameters, context).await?; + validate_final_admission( + operation, + context, + plan, + authorization, + &expected, + started, + spec.idempotent(), + )?; + let ports = self.final_ports("file-transfer")?; + let fingerprint = self + .file_transfer + .inspect( + ports.transfer.as_ref(), + &source, + &source_path, + &destination, + &destination_path, + cancellation, + ) + .await?; + validate_final_changes(plan, &[transfer_change(&target, &fingerprint)?])?; + let request = VerifiedFileTransferRequest { + operation_id: context.operation_id().clone(), + operation: operation.clone(), + fingerprint, + deadline: final_execution_deadline(context, started), + }; + match self + .file_transfer + .execute( + ports.transfer.as_ref(), + &source, + &destination, + &request, + cancellation, + ) + .await + { + Ok(outcome) => { + self.transfer_outcome_result(operation, context, target, started, outcome) + } + Err(failure) => self.transfer_failure_result( + operation, + context, + target, + started, + failure.send_state(), + spec.retry(), + failure.into_error(), + &source_path, + &destination_path, + ), + } + } +} + +#[cfg(test)] +#[path = "mutation_final_transfer_execute_tests.rs"] +mod tests; diff --git a/crates/synapse/application/src/mutation_final_transfer_execute_tests.rs b/crates/synapse/application/src/mutation_final_transfer_execute_tests.rs new file mode 100644 index 00000000..7de4a469 --- /dev/null +++ b/crates/synapse/application/src/mutation_final_transfer_execute_tests.rs @@ -0,0 +1,6 @@ +use super::*; + +#[test] +fn transfer_executor_is_available_as_a_separate_impl_surface() { + let _ = std::mem::size_of::(); +} diff --git a/crates/synapse/application/src/mutation_final_transfer_result.rs b/crates/synapse/application/src/mutation_final_transfer_result.rs new file mode 100644 index 00000000..4e8f95de --- /dev/null +++ b/crates/synapse/application/src/mutation_final_transfer_result.rs @@ -0,0 +1,121 @@ +use std::path::Path; + +use serde_json::json; +use soma_infra::{InfraError, VerifiedFileTransferOutcome}; +use soma_ops::{ + ArtifactRef, Diagnostic, DiagnosticSeverity, EvidenceRef, ExecutionMetadata, MutationSendState, + OperationContext, OperationName, OperationResult, OperationStatus, RetryClass, TargetRef, + Timestamp, VerificationResult, VerificationStatus, +}; + +use crate::{ExecutionError, SynapseMutationRuntime}; + +impl SynapseMutationRuntime { + pub(crate) fn transfer_outcome_result( + &self, + operation: &OperationName, + context: &OperationContext, + target: TargetRef, + started: Timestamp, + outcome: VerifiedFileTransferOutcome, + ) -> Result { + let artifact_uri = format!("soma-artifact://{}", context.operation_id()); + let output = json!({ + "source": outcome.before.source.path.display().to_string(), + "destination": outcome.before.destination_path.display().to_string(), + "bytes": outcome.bytes, + "source_digest": outcome.before.source.sha256, + "destination_digest": outcome.destination_after.sha256, + "verified": outcome.verified, + "artifact": { + "uri": artifact_uri, + "media_type": "application/octet-stream", + "bytes": outcome.bytes, + "sha256": outcome.destination_after.sha256, + "protected": true, + }, + }); + self.catalog.validate_result(operation, &output)?; + let execution = ExecutionMetadata::new( + started, + Timestamp::now(), + outcome.send_state, + RetryClass::Never, + )?; + let artifact = ArtifactRef::new( + format!("soma-artifact://{}", context.operation_id()), + "application/octet-stream", + true, + )? + .with_sha256(outcome.destination_after.sha256.clone())?; + let mut result = OperationResult::new( + context.operation_id().clone(), + operation.clone(), + target, + OperationStatus::Succeeded, + execution, + )? + .with_output(output)? + .with_artifact(artifact) + .with_evidence(EvidenceRef::new( + "artifact", + format!("file-transfer://{}", context.operation_id()), + )?); + result = result.with_verification(VerificationResult::new( + VerificationStatus::Verified, + Timestamp::now(), + ))?; + result.validate()?; + Ok(result) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn transfer_failure_result( + &self, + operation: &OperationName, + context: &OperationContext, + target: TargetRef, + started: Timestamp, + send_state: MutationSendState, + retry: RetryClass, + error: InfraError, + source: &Path, + destination: &Path, + ) -> Result { + let output = json!({ + "source": source.display().to_string(), + "destination": destination.display().to_string(), + "bytes": 0, + "verified": false, + }); + self.catalog.validate_result(operation, &output)?; + let execution = ExecutionMetadata::new(started, Timestamp::now(), send_state, retry)?; + let (code, verification) = if send_state == MutationSendState::Unknown { + ("mutation.uncertain", VerificationStatus::Inconclusive) + } else { + ("verification.failed", VerificationStatus::Failed) + }; + let mut result = OperationResult::new( + context.operation_id().clone(), + operation.clone(), + target, + OperationStatus::Failed, + execution, + )? + .with_output(output)? + .with_diagnostic( + Diagnostic::new(code, DiagnosticSeverity::Error, error.to_string())? + .with_next_action( + "inspect both source and destination digests before planning a selective retry or restoring the prior destination", + )?, + ); + result = + result.with_verification(VerificationResult::new(verification, Timestamp::now()))?; + result.validate()?; + Ok(result) + } +} + +#[cfg(test)] +#[path = "mutation_final_transfer_result_tests.rs"] +mod tests; diff --git a/crates/synapse/application/src/mutation_final_transfer_result_tests.rs b/crates/synapse/application/src/mutation_final_transfer_result_tests.rs new file mode 100644 index 00000000..eaa8978e --- /dev/null +++ b/crates/synapse/application/src/mutation_final_transfer_result_tests.rs @@ -0,0 +1,9 @@ +use super::*; + +#[test] +fn artifact_uri_is_operation_scoped_and_path_independent() { + let context = OperationContext::new(); + let uri = format!("soma-artifact://{}", context.operation_id()); + assert!(uri.starts_with("soma-artifact://")); + assert!(uri.len() < 4096); +} diff --git a/crates/synapse/application/src/mutation_ports.rs b/crates/synapse/application/src/mutation_ports.rs new file mode 100644 index 00000000..fdcccef8 --- /dev/null +++ b/crates/synapse/application/src/mutation_ports.rs @@ -0,0 +1,73 @@ +use std::sync::Arc; + +use soma_fleet::HostRepository; +use soma_infra::{ + BuildContextInspector, ComposeBuildMutator, ComposeDownClient, ComposeMutationClient, + ComposePullClient, ComposeRecreateClient, ContainerExecClientProvider, + ContainerRecreateClientProvider, DockerArtifactClientProvider, DockerCleanupClientProvider, + DockerMutationClientProvider, HostExecMutator, ImageBuildMutator, VerifiedFileTransferClient, +}; + +/// Product-owned privileged build ports. +pub struct SynapseBuildPorts { + /// Descriptor-confined build-context inspector. + pub contexts: Arc, + /// Docker image build driver. + pub image: Arc, + /// Compose build driver. + pub compose: Arc, +} + +/// Product-owned replacement ports. +pub struct SynapseRecreatePorts { + /// Host-bound container replacement client provider. + pub containers: Arc, + /// Compose force-recreate client. + pub compose: Arc, +} + +/// Product-owned bounded execution ports. +pub struct SynapseExecPorts { + /// Host-bound Docker exec client provider. + pub containers: Arc, + /// Allowlisted descriptor-bound host command driver. + pub hosts: Arc, + /// Maximum in-flight fanout targets. + pub max_fanout_concurrency: usize, +} + +/// Product-owned ports for final cleanup and transfer mutations. +pub struct SynapseFinalPorts { + /// Host-bound Docker cleanup client provider. + pub cleanup: Arc, + /// Compose teardown client. + pub compose_down: Arc, + /// Verified bounded file-transfer client. + pub transfer: Arc, +} + +/// Product-owned ports used by canonical Synapse mutations. +pub struct SynapseMutationPorts { + /// Fleet topology source. + pub hosts: Arc, + /// Host-bound Docker mutation client provider. + pub docker: Arc, + /// Optional Compose lifecycle mutation client. + pub compose: Option>, + /// Optional Docker artifact mutation client provider. + pub artifacts: Option>, + /// Optional Compose artifact mutation client. + pub compose_pull: Option>, + /// Optional privileged build ports. + pub builds: Option, + /// Optional destructive replacement ports. + pub recreate: Option, + /// Optional bounded execution ports. + pub exec: Option, + /// Optional final cleanup and transfer ports. + pub final_mutations: Option, +} + +#[cfg(test)] +#[path = "mutation_ports_tests.rs"] +mod tests; diff --git a/crates/synapse/application/src/mutation_ports_tests.rs b/crates/synapse/application/src/mutation_ports_tests.rs new file mode 100644 index 00000000..7a5f4a1b --- /dev/null +++ b/crates/synapse/application/src/mutation_ports_tests.rs @@ -0,0 +1,6 @@ +use super::*; + +#[test] +fn port_bundle_types_remain_product_owned() { + assert!(std::mem::size_of::>() > 0); +} diff --git a/crates/synapse/application/src/mutation_runtime.rs b/crates/synapse/application/src/mutation_runtime.rs index f7a7f1af..66b8818d 100644 --- a/crates/synapse/application/src/mutation_runtime.rs +++ b/crates/synapse/application/src/mutation_runtime.rs @@ -1,14 +1,10 @@ -use std::sync::Arc; - use serde_json::Value; -use soma_fleet::{HostId, HostRecord, HostRepository}; +use soma_fleet::{HostId, HostRecord}; use soma_infra::{ - BuildContextInspector, ComposeBuildEngine, ComposeBuildMutator, ComposeMutationClient, - ComposeMutationEngine, ComposePullClient, ComposePullEngine, ComposeRecreateClient, + ComposeBuildEngine, ComposeDownEngine, ComposeMutationEngine, ComposePullEngine, ComposeRecreateEngine, ContainerLifecycleAction, ContainerLifecycleEngine, - ContainerLifecycleRequest, ContainerRecreateClientProvider, ContainerRecreateEngine, - DockerArtifactClientProvider, DockerMutationClientProvider, ImageBuildEngine, - ImageBuildMutator, ImagePullEngine, + ContainerLifecycleRequest, ContainerRecreateEngine, DockerCleanupEngine, FileTransferEngine, + ImageBuildEngine, ImagePullEngine, }; use soma_ops::{ AccessClass, AuthorizationEvidence, OperationContext, OperationName, OperationPlan, PlanStep, @@ -16,47 +12,12 @@ use soma_ops::{ }; use tokio_util::sync::CancellationToken; +use crate::mutation_ports::SynapseMutationPorts; use crate::runtime_params::required_str; use crate::{ExecutionError, SynapseCatalog}; pub(crate) const DEFAULT_MUTATION_DEADLINE_MS: i64 = 30_000; -/// Product-owned privileged build ports. -pub struct SynapseBuildPorts { - /// Descriptor-confined build-context inspector. - pub contexts: Arc, - /// Docker image build driver. - pub image: Arc, - /// Compose build driver. - pub compose: Arc, -} - -/// Product-owned replacement ports. -pub struct SynapseRecreatePorts { - /// Host-bound container replacement client provider. - pub containers: Arc, - /// Compose force-recreate client. - pub compose: Arc, -} - -/// Product-owned ports used by canonical Synapse mutations. -pub struct SynapseMutationPorts { - /// Fleet topology source. - pub hosts: Arc, - /// Host-bound Docker mutation client provider. - pub docker: Arc, - /// Optional Compose lifecycle mutation client. - pub compose: Option>, - /// Optional Docker artifact mutation client provider. - pub artifacts: Option>, - /// Optional Compose artifact mutation client. - pub compose_pull: Option>, - /// Optional privileged build ports. - pub builds: Option, - /// Optional destructive replacement ports. - pub recreate: Option, -} - /// Canonical Synapse mutation planner and executor. pub struct SynapseMutationRuntime { pub(crate) catalog: &'static SynapseCatalog, @@ -69,6 +30,9 @@ pub struct SynapseMutationRuntime { pub(crate) compose_build: ComposeBuildEngine, pub(crate) container_recreate: ContainerRecreateEngine, pub(crate) compose_recreate: ComposeRecreateEngine, + pub(crate) docker_cleanup: DockerCleanupEngine, + pub(crate) compose_down: ComposeDownEngine, + pub(crate) file_transfer: FileTransferEngine, } impl SynapseMutationRuntime { @@ -86,6 +50,9 @@ impl SynapseMutationRuntime { compose_build: ComposeBuildEngine, container_recreate: ContainerRecreateEngine, compose_recreate: ComposeRecreateEngine, + docker_cleanup: DockerCleanupEngine, + compose_down: ComposeDownEngine, + file_transfer: FileTransferEngine, } } @@ -116,6 +83,9 @@ impl SynapseMutationRuntime { compose_build: ComposeBuildEngine, container_recreate: ContainerRecreateEngine, compose_recreate: ComposeRecreateEngine, + docker_cleanup: DockerCleanupEngine, + compose_down: ComposeDownEngine, + file_transfer: FileTransferEngine, } } @@ -250,7 +220,9 @@ impl SynapseMutationRuntime { && crate::mutation_compose::compose_action(operation).is_err() && !crate::mutation_pull::pull_operation(operation) && !crate::mutation_build::build_operation(operation) - && !crate::mutation_recreate::recreate_operation(operation)) + && !crate::mutation_recreate::recreate_operation(operation) + && !crate::mutation_exec::exec_operation(operation) + && !crate::mutation_final_contract::final_operation(operation)) { return Err(ExecutionError::UnsupportedOperation(operation.clone())); } diff --git a/crates/synapse/application/src/mutation_runtime_tests.rs b/crates/synapse/application/src/mutation_runtime_tests.rs index 86c3c7aa..f31c762c 100644 --- a/crates/synapse/application/src/mutation_runtime_tests.rs +++ b/crates/synapse/application/src/mutation_runtime_tests.rs @@ -181,6 +181,8 @@ fn mutation_runtime(hosts: Arc, client: Arc) -> Synaps compose_pull: None, builds: None, recreate: None, + exec: None, + final_mutations: None, }, soma_infra::ContainerLifecycleEngine::new( MutationVerificationPolicy::new(1, Duration::ZERO).unwrap(), diff --git a/crates/synapse/application/src/runtime_result.rs b/crates/synapse/application/src/runtime_result.rs index fad8cac9..f80f2f7d 100644 --- a/crates/synapse/application/src/runtime_result.rs +++ b/crates/synapse/application/src/runtime_result.rs @@ -138,7 +138,7 @@ fn append_patch(patch: &mut String, prefix: &str, line: &str) { } } -fn digest(value: &[u8]) -> String { +pub(crate) fn digest(value: &[u8]) -> String { Sha256::digest(value) .iter() .map(|byte| format!("{byte:02x}")) diff --git a/crates/synapse/application/tests/support/mutation_exec_support.rs b/crates/synapse/application/tests/support/mutation_exec_support.rs new file mode 100644 index 00000000..6b6c0865 --- /dev/null +++ b/crates/synapse/application/tests/support/mutation_exec_support.rs @@ -0,0 +1,137 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use soma_fleet::{HostEndpoint, HostId, HostRecord, TopologySnapshot}; +use soma_infra::{ + ContainerExecClientProvider, ContainerExecMutator, ContainerExecReceipt, ContainerExecRequest, + HostExecMutator, HostExecReceipt, HostExecRequest, InfraError, InfraResult, MutationResult, +}; +use soma_ops::{MutationSendState, OperationName}; +use tokio_util::sync::CancellationToken; + +use crate::mutation_pull_test_support::{StaticHosts, UnusedLifecycle}; +use crate::{SynapseExecPorts, SynapseMutationPorts, SynapseMutationRuntime}; + +pub(crate) struct FakeContainerExec { + pub(crate) receipts: Mutex>>, + pub(crate) calls: Mutex, +} + +#[async_trait] +impl ContainerExecMutator for FakeContainerExec { + async fn exec_container( + &self, + _host: &HostRecord, + _request: &ContainerExecRequest, + _cancellation: &CancellationToken, + ) -> MutationResult { + *self.calls.lock().unwrap() += 1; + self.receipts.lock().unwrap().pop_front().unwrap() + } +} + +pub(crate) struct FakeContainerExecProvider(pub(crate) Arc); + +#[async_trait] +impl ContainerExecClientProvider for FakeContainerExecProvider { + async fn exec_client( + &self, + _host: &HostRecord, + _cancellation: &CancellationToken, + ) -> InfraResult> { + Ok(self.0.clone()) + } +} + +pub(crate) struct FakeHostExec { + pub(crate) calls: Mutex>, +} + +#[async_trait] +impl HostExecMutator for FakeHostExec { + async fn exec_host( + &self, + host: &HostRecord, + request: &HostExecRequest, + _cancellation: &CancellationToken, + ) -> MutationResult { + self.calls.lock().unwrap().push(host.id().to_string()); + if host.id().as_str() == "lost" { + return Err(soma_infra::MutationFailure::new( + MutationSendState::Unknown, + InfraError::Fleet(soma_fleet::FleetError::RemoteCommandDetached { + host: host.id().clone(), + reason: "connection", + }), + )); + } + let exit_code = if host.id().as_str() == "bad" { 2 } else { 0 }; + Ok(HostExecReceipt { + host: host.id().clone(), + topology_revision: host.revision().clone(), + command: request.command(), + args: request.args().to_vec(), + working_dir: request.working_dir().map(ToOwned::to_owned), + stdout: host.id().to_string(), + stderr: if exit_code == 0 { + String::new() + } else { + "failed".into() + }, + exit_code: Some(exit_code), + truncated: false, + encoding_lossy: false, + send_state: MutationSendState::Sent, + }) + } +} + +pub(crate) fn host(name: &str) -> HostRecord { + HostRecord::new(HostId::new(name).unwrap(), HostEndpoint::Local) +} + +pub(crate) fn container_receipt(exit_code: i64) -> ContainerExecReceipt { + ContainerExecReceipt { + host: HostId::new("dookie").unwrap(), + topology_revision: host("dookie").revision().clone(), + container: "api".into(), + command: vec!["printf".into(), "ok".into()], + user: None, + working_dir: Some("/app".into()), + stdout: "ok".into(), + stderr: String::new(), + exit_code: Some(exit_code), + truncated: false, + encoding_lossy: false, + send_state: MutationSendState::Sent, + } +} + +pub(crate) fn runtime( + container: Arc, + hosts: Arc, + enabled: bool, +) -> SynapseMutationRuntime { + let snapshot = + TopologySnapshot::new([host("dookie"), host("alpha"), host("bad"), host("lost")]).unwrap(); + SynapseMutationRuntime::new(SynapseMutationPorts { + hosts: Arc::new(StaticHosts(snapshot)), + docker: Arc::new(UnusedLifecycle), + compose: None, + artifacts: None, + compose_pull: None, + builds: None, + recreate: None, + exec: enabled.then(|| SynapseExecPorts { + containers: Arc::new(FakeContainerExecProvider(container)), + hosts, + max_fanout_concurrency: 2, + }), + final_mutations: None, + }) +} + +pub(crate) fn op(name: &str) -> OperationName { + OperationName::new(name).unwrap() +} diff --git a/crates/synapse/application/tests/support/mutation_final_docker_support.rs b/crates/synapse/application/tests/support/mutation_final_docker_support.rs new file mode 100644 index 00000000..9c8efc66 --- /dev/null +++ b/crates/synapse/application/tests/support/mutation_final_docker_support.rs @@ -0,0 +1,213 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use soma_fleet::{HostId, HostRecord}; +use soma_infra::{ + ContainerInspect, ContainerListOptions, ContainerLogOptions, ContainerLogs, + ContainerProcessTable, ContainerReader, ContainerStatsSnapshot, ContainerSummary, + DockerCleanupClient, DockerCleanupClientProvider, DockerCleanupMutator, DockerDiskUsage, + DockerPruneReceipt, DockerPruneRequest, DockerPruneScopeReceipt, DockerPruneTarget, + DockerTelemetryReader, ImageListOptions, ImageReader, ImageRemovalReceipt, ImageRemovalRequest, + ImageSummary, InfraError, InfraResult, MutationResult, NetworkReader, NetworkSummary, + VolumeReader, VolumeSummary, +}; +use soma_ops::MutationSendState; +use tokio_util::sync::CancellationToken; + +pub(crate) struct FakeCleanup { + image: ImageSummary, + removed: Mutex, + pruned: Mutex, + remove_calls: Mutex, + prune_calls: Mutex, +} + +impl FakeCleanup { + pub(crate) fn new(image: ImageSummary) -> Self { + Self { + image, + removed: Mutex::new(false), + pruned: Mutex::new(false), + remove_calls: Mutex::new(0), + prune_calls: Mutex::new(0), + } + } + + pub(crate) fn remove_calls(&self) -> usize { + *self.remove_calls.lock().unwrap() + } + + pub(crate) fn prune_calls(&self) -> usize { + *self.prune_calls.lock().unwrap() + } +} + +#[async_trait] +impl ImageReader for FakeCleanup { + async fn list_images( + &self, + _host: &HostRecord, + options: &ImageListOptions, + _cancellation: &CancellationToken, + ) -> InfraResult> { + let absent = if options.dangling_only { + *self.pruned.lock().unwrap() + } else { + *self.removed.lock().unwrap() + }; + Ok((!absent).then(|| self.image.clone()).into_iter().collect()) + } +} + +#[async_trait] +impl ContainerReader for FakeCleanup { + async fn list_containers( + &self, + _host: &HostRecord, + _options: &ContainerListOptions, + _cancellation: &CancellationToken, + ) -> InfraResult> { + Ok(Vec::new()) + } + + async fn inspect_container( + &self, + _host: &HostRecord, + _container: &str, + _cancellation: &CancellationToken, + ) -> InfraResult { + Err(InfraError::Docker("unused cleanup inspect".into())) + } + + async fn top_container( + &self, + _host: &HostRecord, + _container: &str, + _cancellation: &CancellationToken, + ) -> InfraResult { + Err(InfraError::Docker("unused cleanup top".into())) + } +} + +#[async_trait] +impl NetworkReader for FakeCleanup { + async fn list_networks( + &self, + _host: &HostRecord, + _cancellation: &CancellationToken, + ) -> InfraResult> { + Ok(Vec::new()) + } +} + +#[async_trait] +impl VolumeReader for FakeCleanup { + async fn list_volumes( + &self, + _host: &HostRecord, + _cancellation: &CancellationToken, + ) -> InfraResult> { + Ok(Vec::new()) + } +} + +#[async_trait] +impl DockerTelemetryReader for FakeCleanup { + async fn disk_usage( + &self, + host: &HostRecord, + _cancellation: &CancellationToken, + ) -> InfraResult { + Ok(DockerDiskUsage { + host: host.id().clone(), + topology_revision: host.revision().clone(), + layers_size_bytes: 0, + images: Default::default(), + containers: Default::default(), + volumes: Default::default(), + build_cache: Default::default(), + }) + } + + async fn container_logs( + &self, + _host: &HostRecord, + _container: &str, + _options: &ContainerLogOptions, + _cancellation: &CancellationToken, + ) -> InfraResult { + Err(InfraError::Docker("unused cleanup logs".into())) + } + + async fn container_stats( + &self, + _host: &HostRecord, + _container: &str, + _cancellation: &CancellationToken, + ) -> InfraResult { + Err(InfraError::Docker("unused cleanup stats".into())) + } +} + +#[async_trait] +impl DockerCleanupMutator for FakeCleanup { + async fn remove_image( + &self, + _host: &HostRecord, + request: &ImageRemovalRequest, + _cancellation: &CancellationToken, + ) -> MutationResult { + *self.remove_calls.lock().unwrap() += 1; + *self.removed.lock().unwrap() = true; + Ok(ImageRemovalReceipt { + send_state: MutationSendState::Sent, + deleted: vec![request.fingerprint.identity.id.clone()], + untagged: request.fingerprint.identity.repo_tags.clone(), + }) + } + + async fn prune( + &self, + _host: &HostRecord, + request: &DockerPruneRequest, + _cancellation: &CancellationToken, + ) -> MutationResult { + *self.prune_calls.lock().unwrap() += 1; + *self.pruned.lock().unwrap() = true; + Ok(DockerPruneReceipt { + send_state: MutationSendState::Sent, + scopes: vec![DockerPruneScopeReceipt { + target: DockerPruneTarget::Images, + deleted: request.fingerprint.images.clone(), + space_reclaimed: 1024, + }], + }) + } +} + +pub(crate) struct FakeCleanupProvider(pub(crate) Arc); + +#[async_trait] +impl DockerCleanupClientProvider for FakeCleanupProvider { + async fn cleanup_client( + &self, + _host: &HostRecord, + _cancellation: &CancellationToken, + ) -> InfraResult> { + Ok(self.0.clone()) + } +} + +pub(crate) fn cleanup_image(host: &HostRecord) -> ImageSummary { + ImageSummary { + host: HostId::new(host.id().as_str()).unwrap(), + topology_revision: host.revision().clone(), + id: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + repo_tags: vec!["app:v1".into()], + repo_digests: Vec::new(), + created_unix_seconds: 0, + size_bytes: 1024, + containers: 0, + labels: Default::default(), + } +} diff --git a/crates/synapse/application/tests/support/mutation_final_io_support.rs b/crates/synapse/application/tests/support/mutation_final_io_support.rs new file mode 100644 index 00000000..8a191c5a --- /dev/null +++ b/crates/synapse/application/tests/support/mutation_final_io_support.rs @@ -0,0 +1,231 @@ +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use soma_fleet::{ + FileTransfer, FleetResult, HostEndpoint, HostId, HostRecord, TopologySnapshot, TransferReceipt, + TransferRequest, +}; +use soma_infra::{ + ComposeConfig, ComposeDownClient, ComposeDownMutator, ComposeDownReceipt, ComposeInspector, + ComposeLogRequest, ComposeLogs, ComposeProject, ComposeProjectRef, ComposeStatus, + FileTransferInspector, FileTransferPathRole, InfraError, InfraResult, MutationResult, + TransferFileIdentity, VerifiedFileTransferClient, +}; +use soma_ops::{MutationSendState, OperationName, Timestamp}; +use tokio_util::sync::CancellationToken; + +use crate::mutation_final_test_docker::{FakeCleanup, FakeCleanupProvider}; +use crate::mutation_pull_test_support::{StaticHosts, UnusedLifecycle}; +use crate::{SynapseFinalPorts, SynapseMutationPorts, SynapseMutationRuntime}; + +pub(crate) struct FakeComposeDown { + down: Mutex, + calls: Mutex, +} + +impl FakeComposeDown { + pub(crate) fn new() -> Self { + Self { + down: Mutex::new(false), + calls: Mutex::new(0), + } + } + + pub(crate) fn calls(&self) -> usize { + *self.calls.lock().unwrap() + } +} + +#[async_trait] +impl ComposeInspector for FakeComposeDown { + async fn list_projects( + &self, + _host: &HostRecord, + _deadline: Timestamp, + _cancellation: &CancellationToken, + ) -> InfraResult> { + Ok(crate::mutation_recreate_test_support::projects()) + } + + async fn status( + &self, + _host: &HostRecord, + _project: &ComposeProjectRef, + _service: Option<&str>, + _deadline: Timestamp, + _cancellation: &CancellationToken, + ) -> InfraResult { + if *self.down.lock().unwrap() { + let mut status = crate::mutation_recreate_test_support::compose_status("exited"); + status.services.clear(); + Ok(status) + } else { + Ok(crate::mutation_recreate_test_support::compose_status( + "running", + )) + } + } + + async fn config( + &self, + _host: &HostRecord, + _project: &ComposeProjectRef, + _deadline: Timestamp, + _cancellation: &CancellationToken, + ) -> InfraResult { + Ok(crate::mutation_recreate_test_support::compose_config( + "api:v1", + )) + } + + async fn logs( + &self, + _host: &HostRecord, + _project: &ComposeProjectRef, + _request: &ComposeLogRequest, + _cancellation: &CancellationToken, + ) -> InfraResult { + Err(InfraError::InvalidRequest { + domain: "compose-down", + message: "unused final logs".into(), + }) + } +} + +#[async_trait] +impl ComposeDownMutator for FakeComposeDown { + async fn down_compose( + &self, + host: &HostRecord, + request: &soma_infra::ComposeDownRequest, + _cancellation: &CancellationToken, + ) -> MutationResult { + *self.calls.lock().unwrap() += 1; + *self.down.lock().unwrap() = true; + Ok(ComposeDownReceipt { + host: host.id().clone(), + topology_revision: host.revision().clone(), + project: request.project().name().into(), + remove_volumes: request.remove_volumes(), + send_state: MutationSendState::Sent, + stdout: "removed".into(), + stderr: String::new(), + output_truncated: false, + }) + } +} + +pub(crate) struct FakeTransfer { + source: TransferFileIdentity, + destination_before: Option, + transferred: Mutex, + calls: Mutex, +} + +impl FakeTransfer { + pub(crate) fn new() -> Self { + Self { + source: TransferFileIdentity { + path: "/source/payload.bin".into(), + bytes: 7, + sha256: "a".repeat(64), + }, + destination_before: None, + transferred: Mutex::new(false), + calls: Mutex::new(0), + } + } + + pub(crate) fn calls(&self) -> usize { + *self.calls.lock().unwrap() + } +} + +#[async_trait] +impl FileTransferInspector for FakeTransfer { + async fn inspect_transfer_file( + &self, + host: &HostRecord, + path: &Path, + role: FileTransferPathRole, + _optional: bool, + _cancellation: &CancellationToken, + ) -> InfraResult> { + match role { + FileTransferPathRole::Source if host.id().as_str() == "source" => { + Ok(Some(self.source.clone())) + } + FileTransferPathRole::Destination if host.id().as_str() == "destination" => { + if *self.transferred.lock().unwrap() { + Ok(Some(TransferFileIdentity { + path: path.to_path_buf(), + bytes: self.source.bytes, + sha256: self.source.sha256.clone(), + })) + } else { + Ok(self.destination_before.clone()) + } + } + _ => Err(InfraError::InvalidRequest { + domain: "file-transfer", + message: "unexpected fake transfer endpoint".into(), + }), + } + } +} + +#[async_trait] +impl FileTransfer for FakeTransfer { + async fn transfer( + &self, + _source: &HostRecord, + _destination: &HostRecord, + _request: &TransferRequest, + _cancellation: &CancellationToken, + ) -> FleetResult { + *self.calls.lock().unwrap() += 1; + *self.transferred.lock().unwrap() = true; + TransferReceipt::new(self.source.bytes) + .with_digests(self.source.sha256.clone(), self.source.sha256.clone()) + .map_err(Into::into) + } +} + +pub(crate) fn source_host() -> HostRecord { + HostRecord::new(HostId::new("source").unwrap(), HostEndpoint::Local) +} + +pub(crate) fn destination_host() -> HostRecord { + HostRecord::new(HostId::new("destination").unwrap(), HostEndpoint::Local) +} + +pub(crate) fn runtime( + cleanup: Arc, + compose: Arc, + transfer: Arc, + enabled: bool, +) -> SynapseMutationRuntime { + let dookie = crate::mutation_pull_test_support::host(); + SynapseMutationRuntime::new(SynapseMutationPorts { + hosts: Arc::new(StaticHosts( + TopologySnapshot::new([dookie, source_host(), destination_host()]).unwrap(), + )), + docker: Arc::new(UnusedLifecycle), + compose: None, + artifacts: None, + compose_pull: None, + builds: None, + recreate: None, + exec: None, + final_mutations: enabled.then(|| SynapseFinalPorts { + cleanup: Arc::new(FakeCleanupProvider(cleanup)), + compose_down: compose as Arc, + transfer: transfer as Arc, + }), + }) +} + +pub(crate) fn op(name: &str) -> OperationName { + OperationName::new(name).unwrap() +} diff --git a/crates/synapse/application/tests/support/mutation_pull_support.rs b/crates/synapse/application/tests/support/mutation_pull_support.rs index 41a279df..676eae89 100644 --- a/crates/synapse/application/tests/support/mutation_pull_support.rs +++ b/crates/synapse/application/tests/support/mutation_pull_support.rs @@ -228,6 +228,8 @@ pub(crate) fn runtime( compose_pull: compose.map(|client| client as Arc), builds: None, recreate: None, + exec: None, + final_mutations: None, }) } diff --git a/crates/synapse/application/tests/support/mutation_recreate_support.rs b/crates/synapse/application/tests/support/mutation_recreate_support.rs index 7fb7356a..9d8168d4 100644 --- a/crates/synapse/application/tests/support/mutation_recreate_support.rs +++ b/crates/synapse/application/tests/support/mutation_recreate_support.rs @@ -295,5 +295,7 @@ pub(crate) fn runtime( containers: Arc::new(FakeContainerProvider(container)), compose: compose as Arc, }), + exec: None, + final_mutations: None, }) } diff --git a/docs/unify/02-crates/specs/soma-fleet.md b/docs/unify/02-crates/specs/soma-fleet.md index 3039d5d0..6f82772c 100644 --- a/docs/unify/02-crates/specs/soma-fleet.md +++ b/docs/unify/02-crates/specs/soma-fleet.md @@ -25,7 +25,7 @@ soma-ops <- soma-fleet <- soma-infra <- product adapters - `HostEndpoint`, `SshEndpoint`, `HttpEndpoint`, `HostRecord`, and `TopologySnapshot`; - bounded `CommandRequest`, `CommandOutput`, `TransferRequest`, and `TransferReceipt`; - `FleetError`, lifecycle events, and event-sink port; -- stable-order fanout reports with success, failure, cancellation, and timeout states; +- stable-order fanout reports with success, failure, cancellation, and timeout states, including host/payload pairs and duplicate-host requests distinguished by input index; - observable `TransferLifecycle` and RAII `TransferGuard` states. ## Ports @@ -45,11 +45,11 @@ No pool key includes secrets. Identity, config, and known-host file paths are to ## Command semantics -`CommandRequest` contains an executable, discrete arguments, optional absolute local working directory, an absolute deadline, and stdout/stderr byte ceilings. Product command allowlists remain above this crate. +`CommandRequest` contains an executable, discrete arguments, optional absolute local working directory, optional bounded stdin bytes, an absolute deadline, and stdout/stderr byte ceilings. Stdin is capped at 64 MiB and never interpreted as shell syntax. Product command allowlists remain above this crate. -`LocalProcessDriver` uses `tokio::process::Command` without a shell, drains both streams to EOF while retaining bounded prefixes, kills local children on cancellation or timeout, and distinguishes pre-spawn cancellation from in-flight deadline expiry. +`LocalProcessDriver` uses `tokio::process::Command` without a shell, writes optional stdin concurrently with draining both output streams, retains bounded prefixes, kills local children on cancellation or timeout, and distinguishes pre-spawn cancellation from in-flight deadline expiry. -`OpenSshDriver` uses native multiplexing, always configures `KnownHosts::Strict`, supports explicit port/user/identity/config/known-host paths, uses owner-only control directories, passes arguments through escaped `Command::arg` semantics, rejects remote working directories instead of synthesizing shell commands, bounds output and execution permits, and invalidates sessions after transport failures. +`OpenSshDriver` uses native multiplexing, always configures `KnownHosts::Strict`, supports explicit port/user/identity/config/known-host paths, uses owner-only control directories, passes arguments through escaped `Command::arg` semantics, writes optional bounded stdin concurrently with output draining, rejects remote working directories instead of synthesizing shell commands, bounds output and execution permits, and invalidates sessions after transport failures. OpenSSH cannot guarantee termination of a remote process when its local child handle is dropped. Cancellation or timeout after spawn therefore returns `FleetError::RemoteCommandDetached`; callers must treat the remote process as potentially still running. @@ -61,11 +61,11 @@ OpenSSH cannot guarantee termination of a remote process when its local child ha `TransferRequest` uses absolute normalized source and destination paths, a hard byte ceiling, and an absolute deadline. `TransferLifecycle` exposes a cloneable observer while `TransferGuard` records chunks and terminal state. It rejects overflow, overrun, receipt mismatch, duplicate terminal transitions, and invalid failure detail. Dropping a nonterminal guard records `Abandoned`. -Concrete infrastructure-aware file semantics remain in `soma-infra`; implementations consume the `FileTransfer` port and lifecycle guard. +Concrete infrastructure-aware file semantics remain in `soma-infra`; implementations consume the `FileTransfer` port and lifecycle guard. The final Synapse transfer driver uses bounded command stdin to deliver destination bytes without ambient `scp`, shell strings, or untracked temporary files. ## Fanout semantics -`FanoutScheduler` enforces nonzero concurrency and per-target timeout bounds. It uses bounded unordered execution internally but restores original target order. Every admitted target produces exactly one terminal classification. Shared cancellation accounts for running and queued targets rather than dropping them. +`FanoutScheduler` enforces nonzero concurrency and per-target timeout bounds. It supports both one operation per host and distinct host/payload pairs, including repeated host identities whose requests remain separated by their original index. It uses bounded unordered execution internally but restores original target order. Every admitted target produces exactly one terminal classification, reports can be consumed without cloning opaque failures, and shared cancellation accounts for running and queued targets rather than dropping them. ## Forbidden responsibilities @@ -73,6 +73,6 @@ Concrete infrastructure-aware file semantics remain in `soma-infra`; implementat ## Verification evidence -The deterministic suite covers identity and forged revisions, endpoint changes, stale pooled connections, concurrent single-connect initialization, shutdown, argument injection, bounded output, local cancellation/timeout, strict OpenSSH plans, fail-closed SSH working directories, runtime/socket ownership and permissions, file/symlink rejection, transfer overrun/mismatch/cancellation/failure/abandonment, and bounded fanout with partial success, timeout, cancellation, overload prevention, and stable order. +The deterministic suite covers identity and forged revisions, endpoint changes, stale pooled connections, concurrent single-connect initialization, shutdown, argument injection, bounded stdin/output, local cancellation/timeout, strict OpenSSH plans, fail-closed SSH working directories, runtime/socket ownership and permissions, file/symlink rejection, transfer digest exposure, overrun/mismatch/cancellation/failure/abandonment, and bounded fanout with partial success, timeout, cancellation, overload prevention, stable order, duplicate-host payloads, and consuming reports. Live SSH and host-key mismatch tests remain environment-gated product verification; deterministic tests prove the driver can construct only strict-host-key plans. diff --git a/docs/unify/02-crates/specs/soma-infra.md b/docs/unify/02-crates/specs/soma-infra.md index aa26522d..c6d6d7fd 100644 --- a/docs/unify/02-crates/specs/soma-infra.md +++ b/docs/unify/02-crates/specs/soma-infra.md @@ -111,6 +111,23 @@ Optional external drivers: - shell-free `compose up -d --force-recreate` with bounded output; - independent verification of the exact configured running, healthy, zero-exit service set. +### Bounded execution + +- `ContainerExecRequest`, `ContainerExecMutator`, and host-bound `ContainerExecClientProvider`; +- direct non-TTY Docker exec argv with separate 96 KiB stdout and stderr ceilings plus inspected exit status; +- `HostExecCommand`, `HostExecPolicy`, and `CommandHostExec`; +- a fixed read-oriented command allowlist with typed option grammars and helper-executing option rejection; +- explicit per-host read roots, `O_NOFOLLOW` descriptor traversal, inherited `/proc/self/fd` operands, and descriptor-bound working directories; +- `HostExecManyEngine` with bounded concurrency, deterministic target order, conservative aggregate send state, partial results, and a bounded aggregate output envelope. + +### Cleanup, teardown, and transfer + +- `DockerCleanupEngine` binds one resolved image identity or one deterministic prune inventory before send; +- Bollard image removal and fixed-order container/image/volume/network/build-cache prune drivers retain conservative send state and exact deletion receipts; +- `ComposeDownEngine` binds normalized configuration plus the complete service pre-state and verifies no services remain after shell-free teardown; +- `FileTransferEngine` binds both host revisions, source bytes/SHA-256, destination pre-state, and destination path; +- `CommandFileTransfer` supports local or strict-SSH endpoints with descriptor-walking `O_NOFOLLOW` source reads and destination writes, a 16 MiB operation ceiling, lifecycle accounting, and independent destination digest verification. + ## Security properties 1. Every target-specific model is bound to a host and exact topology revision. @@ -142,19 +159,28 @@ Optional external drivers: 27. Container recreate plans bind the exact replacement configuration digest and image-pull choice. 28. Container recreate rechecks the digest immediately before removal and records the furthest completed destructive stage. 29. Compose recreate plans bind normalized configuration and service pre-state, then verify the exact healthy post-state. -30. Arbitrary command execution, file transfer, image deletion, pruning, and Compose down remain outside this slice. +30. Container exec uses direct argv without a shell or TTY, crosses the uncertain send boundary only at `start_exec`, and inspects the final exit status. +31. Host exec admits only the fixed read-oriented command set and typed options; filesystem operands and working directories are descriptor-bound beneath explicit roots. +32. Host fanout preserves deterministic target order, retains every partial result, bounds concurrency and aggregate output, and requires replanning unresolved targets rather than blind batch retry. +33. Image removal plans bind the requested reference plus resolved local ID, tags, and digests; verification requires both reference and content identity absence. +34. Prune plans bind exact candidate identities and build-cache usage; `all` executes a fixed five-scope order and verification rejects any reported identity that remains. +35. Compose down binds normalized configuration plus the complete service set, rejects volume removal without explicit force, and verifies no services remain. +36. File transfer binds both host revisions, source content identity, destination pre-state, and destination path before send. +37. Transfer paths remain beneath explicit source/destination roots, reject symlink escapes with `O_NOFOLLOW`, cap content at 16 MiB, and require matching source/destination SHA-256 evidence. ## Current Synapse adoption -The canonical Synapse runtime delegates all 35 read operations to `soma-fleet` and `soma-infra`. Fourteen of the 21 canonical mutations are now delegated: +The canonical Synapse runtime delegates all 35 read operations and all 21 canonical mutations to `soma-fleet` and `soma-infra`: - `container.start`, `container.stop`, `container.restart`, `container.pause`, and `container.resume`; -- `compose.up` and `compose.restart`; -- `docker.pull`, `container.pull`, and `compose.pull`; -- `docker.build` and `compose.build`; -- `container.recreate` and `compose.recreate`. +- `compose.up`, `compose.down`, `compose.restart`, and `compose.recreate`; +- `docker.pull`, `docker.build`, `docker.rmi`, and `docker.prune`; +- `container.pull`, `container.recreate`, and `container.exec`; +- `compose.pull` and `compose.build`; +- `host.exec` and `host.exec_many`; +- `files.transfer`. -Pull plans bind exact image artifacts. Build plans bind exact context SHA-256 values and output tags. Replacement plans bind exact configuration and service pre-state digests. Execution rechecks mutable inputs before send and independently verifies image identities, replacement containers, or Compose service sets. Seven canonical mutations remain fail-closed. +Pull plans bind exact image artifacts. Build plans bind exact context SHA-256 values and output tags. Replacement and teardown plans bind exact configuration and service pre-state digests. Execution plans bind direct argv, users, paths, timeouts, topology revisions, and normalized fanout target order. Cleanup plans bind exact image or prune inventories. Transfer plans bind both endpoints and content identities. The canonical runtime now covers all 59 operations with no fail-closed catalog gaps. ## Verification @@ -167,6 +193,8 @@ Required gates: - pull progress, delivery-failure, image-reference drift, and artifact-verification tests; - build-context determinism, symlink rejection, context drift, bounded argv/logs, and output-verification tests; - replacement fingerprint drift, pull-choice binding, partial-stage, force-recreate argv, and post-state verification tests; +- direct container argv, pre/post-start send-state, descriptor-bound host operands, symlink escape rejection, nonzero exits, bounded output, and stable partial fanout tests; +- exact image-removal identity, fixed-order prune receipts, candidate drift, Compose down service-set verification, force-gated volume removal, bounded command stdin, real local file copy, digest parity, and destination symlink-escape tests; - stale-host and revision-bound client tests; - filesystem traversal and symlink rejection; - workspace sibling, architecture, pattern, and product-leakage checks. diff --git a/docs/unify/02-crates/specs/synapse-application.md b/docs/unify/02-crates/specs/synapse-application.md index d51752b5..54a075d3 100644 --- a/docs/unify/02-crates/specs/synapse-application.md +++ b/docs/unify/02-crates/specs/synapse-application.md @@ -61,27 +61,27 @@ All 35 canonical read operations execute through this path. 5. Require an idempotency key when the canonical operation contract declares idempotency. 6. Execute through a mutation-capable `soma-infra` port. 7. Preserve `NotSent`, `Sent`, or `Unknown` backend send state. -8. Verify the postcondition through a separate read operation. -9. Build and validate a canonical `OperationResult` with retry policy, verification, diagnostics, and recovery guidance. +8. Verify the postcondition through a separate read operation when the canonical contract declares verification support; execution-only operations retain bounded exit/output evidence instead. +9. Build and validate a canonical `OperationResult` with retry policy, verification when supported, diagnostics, and recovery guidance. ## Implemented mutations -Fourteen of the 21 canonical mutations are implemented: +All 21 canonical mutations are implemented: - `container.start`, `container.stop`, `container.restart`, `container.pause`, and `container.resume`; -- `compose.up` and `compose.restart`; -- `docker.pull`, `container.pull`, and `compose.pull`; -- `docker.build` and `compose.build`; -- `container.recreate` and `compose.recreate`. +- `compose.up`, `compose.down`, `compose.restart`, and `compose.recreate`; +- `docker.pull`, `docker.build`, `docker.rmi`, and `docker.prune`; +- `container.pull`, `container.recreate`, and `container.exec`; +- `compose.pull` and `compose.build`; +- `host.exec` and `host.exec_many`; +- `files.transfer`. -Container lifecycle operations verify through `container.inspect`. Compose lifecycle operations verify through `compose.status`. Pull operations bind exact image references and verify IDs/tags/digests through `docker.images`. Build operations bind source-context SHA-256 values and output tags and verify output identities. Replacement operations bind full container configuration or normalized Compose config/status fingerprints, recheck them before send, and attach diff plus runtime-state evidence. Already-satisfied lifecycle states still return verified no-op results without mutation send. - -The remaining seven mutations fail closed with `UnsupportedOperation`. +Container lifecycle operations verify through `container.inspect`. Compose lifecycle, replacement, and teardown operations verify through `compose.status`. Pull and build operations verify Docker image identities. Replacement operations bind full container configuration or normalized Compose config/status fingerprints. Execution operations bind direct argv, users, paths, timeouts, topology revisions, and normalized fanout target order without fabricating unsupported verification. Cleanup operations bind exact image or prune inventories and verify absence. Transfer binds both hosts, source content, destination pre-state, and verifies destination bytes and SHA-256. Already-satisfied lifecycle states still return verified no-op results without mutation send. The product runtime now executes and validates all 59 canonical operations. ## Verification - all 35 canonical reads execute and validate their result schemas; -- all fourteen implemented mutations plan, authorize, execute, and verify; +- all 21 canonical mutations plan, authorize, and execute; operations that declare verification support also verify independently; - stale topology, wrong target, expired authorization, missing confirmation, and missing idempotency fail before mutation send; - cancellation before admission is `NotSent`; - uncertain Docker failures remain `Unknown` and become failed terminal results; @@ -97,6 +97,13 @@ The remaining seven mutations fail closed with `UnsupportedOperation`. - container replacement preserves the donor configuration surface and records destructive stage progress; - Compose replacement uses shell-free force-recreate and verifies the exact healthy service set; - successful replacements carry diff and runtime-state evidence with explicit recovery guidance; +- execution plans bind direct argv, users, paths, timeouts, and target order, and drift is rejected before send; +- container exec is non-TTY, host exec is descriptor-confined to explicit roots, and fanout retains stable per-target partial results; +- execution outputs remain within canonical inline limits and uncertain targets require selective replanning rather than blind retry; +- image removal and prune plans bind exact inventories, and successful terminal results retain compact diff/runtime-state evidence; +- Compose down binds the complete service pre-state, force-gates volume deletion, and verifies the project is empty; +- file transfer binds both host revisions and both path/content identities, returns protected artifact metadata, and verifies destination digest parity; +- full plan recomputation plus engine-level pre-send inspection rejects final-mutation drift before destructive send; - generated historical input schemas remain closed; - no legacy result projector or imported donor dependency exists; - strict Clippy, warning-free rustdoc, sibling tests, architecture, and pattern gates pass. diff --git a/docs/unify/02-crates/specs/synapse-standalone.md b/docs/unify/02-crates/specs/synapse-standalone.md new file mode 100644 index 00000000..adeb5805 --- /dev/null +++ b/docs/unify/02-crates/specs/synapse-standalone.md @@ -0,0 +1,49 @@ +--- +title: "synapse-standalone" +created: 2026-08-04 +updated: 2026-08-04 +status: implemented +--- + +# Standalone Synapse Product Specification + +## Role + +`apps/synapse` is the product composition and transport boundary for the complete canonical operations engine. It owns configuration, topology materialization, authorization policy, confirmation, activity, status, OpenAPI, CLI, REST, and MCP adapters. It does not own infrastructure behavior and does not link the imported donor runtime. + +## Composition + +One immutable topology snapshot and one routed local-or-strict-SSH command executor feed all concrete drivers. Per-host read, build, execution, source-transfer, and destination-transfer roots remain independent. Docker clients are revision-bound, remote Docker uses private SSH-forwarded sockets, and every surface delegates to `SynapseReadRuntime` or `SynapseMutationRuntime`. + +## Product surfaces + +- Canonical CLI commands: `operations`, `plan`, `run`, `serve`, and `mcp`. +- Optional `legacy flux|scout` request normalization without legacy result projection. +- REST plan and execute endpoints for all 59 operations. +- Three MCP tools: canonical `synapse` plus optional `flux` and `scout` aliases. +- HTTP and stdio MCP transports. +- Catalog-derived OpenAPI containing 59 execute paths and 21 mutation-plan paths. +- Public health/readiness/status and bounded recent activity. + +## Authorization + +Read operations execute after parameter validation. Mutations first produce a deterministic plan. CLI and REST require explicit confirmation, while MCP uses bounded elicitation requiring both `confirm` and `understood`. Product-issued authorization evidence binds operation, target, plan fingerprint, actor, deadline, and idempotency key. Automatic confirmation is disabled by default. + +## Security invariants + +1. No donor runtime module is linked. +2. No legacy result projector exists. +3. Filesystem, build, host-exec, and transfer policies are configured per host. +4. SSH uses strict known-host policy through `soma-fleet`. +5. Protected HTTP routes use a constant-time bearer comparison when configured. +6. Mutation confirmation cannot widen the planned operation or target. +7. Activity is bounded and strips control characters. +8. OpenAPI and MCP schemas derive from the checked-in canonical catalog. + +## Verification + +- all 59 operations are discoverable through CLI, REST/OpenAPI, and MCP; +- 35 reads and 21 mutations delegate to the canonical runtime; +- mutation confirmation returns or displays the exact plan; +- activity, health, readiness, status, and bearer policy tests pass; +- strict Clippy, warning-denied rustdoc, architecture, sibling, pattern, donor-isolation, and release checks pass. diff --git a/docs/unify/05-migration/SYNAPSE-IMPLEMENTATION-PLAN.md b/docs/unify/05-migration/SYNAPSE-IMPLEMENTATION-PLAN.md index 78beccee..d5b56643 100644 --- a/docs/unify/05-migration/SYNAPSE-IMPLEMENTATION-PLAN.md +++ b/docs/unify/05-migration/SYNAPSE-IMPLEMENTATION-PLAN.md @@ -227,7 +227,11 @@ The artifact-pull slice adds `docker.pull`, `container.pull`, and `compose.pull` The verified-build slice adds `docker.build` and `compose.build`. Build contexts are admitted beneath explicit roots, traversed without following symlinks, bounded by file and byte ceilings, and hashed over relative paths, modes, sizes, and regular-file content. Plans bind the exact source-context SHA-256 and output tags. Execution repeats context fingerprinting before send, uses discrete Docker or Compose argv with bounded logs and phase progress, preserves retry-never semantics, and verifies every output tag through the local Docker image store. Successful results carry OCI artifact and source-context evidence. -The replacement slice adds `container.recreate` and `compose.recreate`. Container planning binds a driver-native SHA-256 over image, environment, command, entrypoint, labels, working directory, user, volumes, host configuration, and networks, plus the image-pull choice. Execution rechecks that digest before removal, preserves those fields, records stop/remove/create/start stages, and verifies the replacement is running under the captured name. Compose planning binds normalized configuration and service pre-state; execution uses discrete `compose up -d --force-recreate` argv and verifies the exact healthy service set. Successful results carry diff and runtime-state evidence. Fourteen of 21 mutations are implemented, and seven remain fail-closed. Compose down, image removal/prune, exec, host execution, and file transfer remain later slices. +The replacement slice adds `container.recreate` and `compose.recreate`. Container planning binds a driver-native SHA-256 over image, environment, command, entrypoint, labels, working directory, user, volumes, host configuration, and networks, plus the image-pull choice. Execution rechecks that digest before removal, preserves those fields, records stop/remove/create/start stages, and verifies the replacement is running under the captured name. Compose planning binds normalized configuration and service pre-state; execution uses discrete `compose up -d --force-recreate` argv and verifies the exact healthy service set. Successful results carry diff and runtime-state evidence. + +The bounded-execution slice adds `container.exec`, `host.exec`, and `host.exec_many`. Container exec uses direct non-TTY Docker argv, separate bounded output streams, an uncertainty boundary at `start_exec`, and inspected exit status. Host exec admits only a fixed read-oriented command allowlist with typed options, opens path operands and working directories beneath explicit per-host roots using `O_NOFOLLOW`, and passes inherited descriptor paths to the process without a shell. Fanout binds a normalized target order, limits concurrency, retains stable partial results, and requires selective replanning of unresolved targets. + +The final-mutation slice adds `docker.rmi`, `docker.prune`, `compose.down`, and `files.transfer`. Image removal binds a resolved local image ID/tags/digests and verifies both reference and content identity absence. Prune binds an exact candidate inventory, executes `all` in a fixed five-scope order, retains deletion/reclaimed-byte receipts, and verifies reported identities are absent. Compose down binds normalized configuration and the complete service pre-state, force-gates volume deletion, and verifies no services remain. File transfer binds both host revisions, source bytes/SHA-256, destination pre-state, and destination path; local or strict-SSH copy uses descriptor-walking `O_NOFOLLOW`, bounded command stdin, a 16 MiB operation ceiling, lifecycle accounting, and independent destination digest verification. All 21 mutations and all 59 canonical operations are implemented. ### PR 8: standalone Synapse cutover @@ -245,6 +249,8 @@ Deliverables: - activity, status, readiness, and observability remain product-owned; - old service implementations are removed after canonical coverage and driver conformance are proven. +The native `apps/synapse` product composes every concrete fleet and infrastructure port over one topology snapshot. Canonical CLI, REST, HTTP MCP, and stdio MCP surfaces expose all 59 operations; optional Flux and Scout aliases normalize requests only. Mutations use exact plans, product-issued authorization, CLI/REST confirmation, or bounded MCP elicitation requiring both explicit affirmations. Health, readiness, status, bounded activity, bearer protection, and catalog-derived OpenAPI remain product-owned. The binary has no dependency on the imported donor workspace. + Exit: Synapse's complete test suite, destructive smoke suite, MCPorter suite, OpenAPI drift check, CLI snapshots, npm wrapper tests, and release packaging pass with no donor engine path remaining. ### PR 9: Soma embedded operations diff --git a/docs/unify/09-delivery/IMPLEMENTATION-TRACKER.md b/docs/unify/09-delivery/IMPLEMENTATION-TRACKER.md index 9b2b47a2..05638d12 100644 --- a/docs/unify/09-delivery/IMPLEMENTATION-TRACKER.md +++ b/docs/unify/09-delivery/IMPLEMENTATION-TRACKER.md @@ -73,10 +73,13 @@ No generic `in_progress`. | product-family | 15 | `feat/mutation-artifacts` | `feat/mutation-foundation` | `~/workspace/soma/.worktrees/mutation-artifacts` | Progress-aware verified Docker, container, and Compose image pulls | #293 | parity_verifying | | product-family | 16 | `feat/mutation-builds` | `feat/mutation-artifacts` | `~/workspace/soma/.worktrees/mutation-builds` | Context-bound verified Docker and Compose image builds | #295 | parity_verifying | | product-family | 17 | `feat/mutation-recreate` | `feat/mutation-builds` | `~/workspace/soma/.worktrees/mutation-recreate` | Configuration-bound verified container and Compose replacements | #314 | parity_verifying | +| product-family | 18 | `feat/mutation-exec` | `feat/mutation-recreate` | `~/workspace/soma/.worktrees/mutation-exec` | Bounded container, host, and stable partial fanout execution mutations | #317 | parity_verifying | +| product-family | 19 | `feat/mutation-final` | `feat/mutation-exec` | `~/workspace/soma/.worktrees/mutation-final` | Verified Docker cleanup, Compose teardown, and digest-bound file transfer; completes 59/59 canonical operations | #319 | parity_verifying | +| product-family | 20 | `feat/synapse-standalone-cutover` | `feat/mutation-final` | `~/workspace/soma/.worktrees/synapse-standalone-cutover` | Native standalone CLI, REST, HTTP/stdio MCP, authorization, activity, OpenAPI, and packaging over the complete engine | pending | parity_verifying | Every additional row in a stack must use the branch immediately above it as its PR base until the lower PR merges and the stack is restacked. -Current operations-foundation evidence: extraction spec, code map, domain models, schema contract, Draft 2020-12 JSON Schema, twelve-PR implementation plan, current Synapse donor lock, 49 unit tests, strict Clippy, warning-free rustdoc, external-consumer compile, architecture check, and xtask tests. The next slice now locks all donor-provided legacy semantics for 59 operations with exact scopes, dispatch shapes, parameters, transport/destructive metadata, source provenance, and a deterministic digest. The canonical-classification slice now locks target kind, access, risk, reversibility, planning, progress, cancellation, verification, fanout, retry, idempotency, evidence, requirements, and parameter groups for all 59 operations. The schema-diagnostics slice now binds each operation/version to deterministic parameter and result `SchemaId` values and a validated machine-stable diagnostic vocabulary. The surface-contract slice now locks 59 closed canonical parameter schemas and the complete 33-code projections for CLI exit, HTTP status, MCP error data, event severity, retry, and terminal behavior. The result-schema slice now binds all 59 operations to closed canonical payload schemas across 13 normalized output families. The product-import slice preserves the exact locked Synapse history and all 386 tracked donor files under the temporary `crates/synapse/import` boundary, with donor tests and byte-for-byte verification. The compatibility-adapter slice adds the native `synapse-application` workspace crate. It embeds and cross-validates all five canonical artifacts, owns all 59 legacy bindings, normalizes Flux/Scout requests through closed parameter schemas, validates and projects canonical results, derives closed legacy MCP schemas, and enforces all 33 diagnostic mappings per operation without linking the imported donor workspace. The fleet-foundation slice adds the native `soma-fleet` shared crate with revision-bound topology and pooling, bounded local process execution, strict OpenSSH plans and execution, owner-only forwarding, observable transfer lifecycle, cancellation-aware fanout, and explicit post-spawn remote uncertainty. The infra-foundation slice adds the native `soma-infra` shared crate with typed host inspection, neutral Docker read traits and a revision-bound local Bollard adapter, shell-free Compose listing/status/config, and Linux descriptor-confined filesystem stat/read/hash. The infra-read-expansion slice adds Docker disk usage, bounded one-shot container logs and stats, Compose logs, typed process snapshots, validated syslog/journal/dmesg/auth reads, and structured ZFS pool/dataset/snapshot tables. The canonical-cutover slice completes remote Docker socket composition, descriptor-confined local and SSH filesystem read/tree/find/tail queries, host-system inspection, and container process tables. `SynapseReadRuntime` executes all 35 read operations and validates every canonical result schema. The obsolete legacy JSON/Markdown result projector is deleted because Synapse has no external consumers. The imported donor remains unchanged as historical source material. The catalog contains 21 mutations. The mutation-foundation slice implements container start, stop, restart, pause, and resume, plus Compose up and restart. The artifact-pull slice adds Docker pull, container-image pull, and Compose pull with exact artifact-set plan binding, canonical progress, conservative stream send state, local image-ID/digest verification, OCI artifact references, and runtime-state evidence. The verified-build slice adds Docker and Compose builds with descriptor-confined bounded context hashing, exact context-digest and output-tag plan binding, retry-never command execution, bounded logs and phase progress, pre-send drift rejection, output identity verification, and OCI plus source-context evidence. The replacement slice adds container and Compose recreation with exact pre-state digest binding, container image-pull choice binding, donor-compatible configuration preservation, destructive stage reporting, shell-free force-recreate, independent post-state verification, diff evidence, runtime-state evidence, and recovery guidance. Fourteen mutations are implemented and seven remain fail-closed. +Current operations-foundation evidence: extraction spec, code map, domain models, schema contract, Draft 2020-12 JSON Schema, twelve-PR implementation plan, current Synapse donor lock, 49 unit tests, strict Clippy, warning-free rustdoc, external-consumer compile, architecture check, and xtask tests. The next slice now locks all donor-provided legacy semantics for 59 operations with exact scopes, dispatch shapes, parameters, transport/destructive metadata, source provenance, and a deterministic digest. The canonical-classification slice now locks target kind, access, risk, reversibility, planning, progress, cancellation, verification, fanout, retry, idempotency, evidence, requirements, and parameter groups for all 59 operations. The schema-diagnostics slice now binds each operation/version to deterministic parameter and result `SchemaId` values and a validated machine-stable diagnostic vocabulary. The surface-contract slice now locks 59 closed canonical parameter schemas and the complete 33-code projections for CLI exit, HTTP status, MCP error data, event severity, retry, and terminal behavior. The result-schema slice now binds all 59 operations to closed canonical payload schemas across 13 normalized output families. The product-import slice preserves the exact locked Synapse history and all 386 tracked donor files under the temporary `crates/synapse/import` boundary, with donor tests and byte-for-byte verification. The compatibility-adapter slice adds the native `synapse-application` workspace crate. It embeds and cross-validates all five canonical artifacts, owns all 59 legacy bindings, normalizes Flux/Scout requests through closed parameter schemas, validates and projects canonical results, derives closed legacy MCP schemas, and enforces all 33 diagnostic mappings per operation without linking the imported donor workspace. The fleet-foundation slice adds the native `soma-fleet` shared crate with revision-bound topology and pooling, bounded local process execution, strict OpenSSH plans and execution, owner-only forwarding, observable transfer lifecycle, cancellation-aware fanout, and explicit post-spawn remote uncertainty. The infra-foundation slice adds the native `soma-infra` shared crate with typed host inspection, neutral Docker read traits and a revision-bound local Bollard adapter, shell-free Compose listing/status/config, and Linux descriptor-confined filesystem stat/read/hash. The infra-read-expansion slice adds Docker disk usage, bounded one-shot container logs and stats, Compose logs, typed process snapshots, validated syslog/journal/dmesg/auth reads, and structured ZFS pool/dataset/snapshot tables. The canonical-cutover slice completes remote Docker socket composition, descriptor-confined local and SSH filesystem read/tree/find/tail queries, host-system inspection, and container process tables. `SynapseReadRuntime` executes all 35 read operations and validates every canonical result schema. The obsolete legacy JSON/Markdown result projector is deleted because Synapse has no external consumers. The imported donor remains unchanged as historical source material. The catalog contains 21 mutations. The mutation-foundation slice implements container start, stop, restart, pause, and resume, plus Compose up and restart. The artifact-pull slice adds Docker pull, container-image pull, and Compose pull with exact artifact-set plan binding, canonical progress, conservative stream send state, local image-ID/digest verification, OCI artifact references, and runtime-state evidence. The verified-build slice adds Docker and Compose builds with descriptor-confined bounded context hashing, exact context-digest and output-tag plan binding, retry-never command execution, bounded logs and phase progress, pre-send drift rejection, output identity verification, and OCI plus source-context evidence. The replacement slice adds container and Compose recreation with exact pre-state digest binding, container image-pull choice binding, donor-compatible configuration preservation, destructive stage reporting, shell-free force-recreate, independent post-state verification, diff evidence, runtime-state evidence, and recovery guidance. The bounded-execution slice adds non-TTY container exec plus descriptor-bound allowlisted host execution and stable partial fanout with exact argv/path/timeout/target-order plan binding, bounded output, conservative send-state, and selective recovery guidance. The final-mutation slice adds exact-identity Docker image removal, inventory-bound fixed-order prune, configuration/service-bound Compose teardown, and two-host digest-verified descriptor-confined file transfer. Full plan recomputation and engine-level pre-send inspection reject drift, successful operations attach compact diff/runtime/artifact evidence, and uncertain partial sends require inspection before selective recovery. All 35 reads and all 21 mutations are implemented: 59/59 canonical operations. ## Progress measurement diff --git a/docs/unify/CHECKSUMS.sha256 b/docs/unify/CHECKSUMS.sha256 index e03ac52c..9f0d0f56 100644 --- a/docs/unify/CHECKSUMS.sha256 +++ b/docs/unify/CHECKSUMS.sha256 @@ -18,9 +18,9 @@ da70083feba7fa1e2571c8246e8395a8fc755a9683777cbae6240118403c4c36 02-crates/EXIS 86269131b4f082798c688d3d21bd6e8c12370b28dd9e0bde3436f376438624dd 02-crates/crates.yaml fba1c65abfa1359e2c8b4f3657a1d2a0504bc50ff022898ada264556f4e68fbe 02-crates/specs/soma-collectors.md c84049d26600b70e4bf8540cdce1a96b4f6d4818f2062e1ce58cc9368ebdf7e2 02-crates/specs/soma-crawl.md -da4e2dda9399b508339b687b6c0f35c8bb8f9ec4c742f3ad4e531d1cc512909d 02-crates/specs/soma-fleet.md +cd020c8111d19208042c01397c1d37ee8db50af9fa1e8ada25591c7935d55960 02-crates/specs/soma-fleet.md 4d2e96e353baaf01c56441b42372f3b88e79f94166fe0c0be27bc17ae2a793ec 02-crates/specs/soma-graph.md -a70aba1ed141ce032b9a8d2066b405177c432822f65799f6ffc313284add6435 02-crates/specs/soma-infra.md +b863496b3c2d894a202e1cb868a9598e4cb613d54abc74615a1874d5db730ca1 02-crates/specs/soma-infra.md 7ce58cefb83b4d707c7a72305063ae88ad319cd13501e3efc8e397d93463e5e7 02-crates/specs/soma-ingest.md deca51c7e457617cd6f755e578d43e44b3886e6f2d14c2486a26613e184f6ed8 02-crates/specs/soma-jobs.md 927fc5a8c73a06734bf4106f8321934c1be97bbed494eedfb46212e8616dc4a5 02-crates/specs/soma-ledger.md @@ -35,7 +35,8 @@ f796c131fde802b81eb831f1a7bb0f0d836e5c3c3ac9eafae88fe07bce827850 02-crates/spec 47e8b621ce80ed3e88a85cf5af8b6735376a5a51ffde537856bf6ff14f1d4793 02-crates/specs/soma-sanitize.md 0bbd5c66914ae42aea25b2eb55ee891374a2adb8695cb41a53273c860be1f9b1 02-crates/specs/soma-sources.md e7b16ddd50fc7f7e7d6f3c38f45a064dc65ca8c706cff975a79a88912c8c1645 02-crates/specs/soma-transcript.md -989b68b4fd917c31b3e1a07c0fd999fdd5d23f5815f63ba9bbde9057a038aa59 02-crates/specs/synapse-application.md +e204ad224d3a5f3b179b331115f8a5013772899d5f092949af70ea0ce5cb8845 02-crates/specs/synapse-application.md +16a91dc7703b5e241cfd38c6d543dd621f62eeb3a31921bf2624f95038a27556 02-crates/specs/synapse-standalone.md 610874e8cf11dcdb4de975d83d3353558802a5ac2b9b817da89bf856ca64c941 03-contracts/CITATION-CONTRACT.md bb1907e3ed274b09d81d651e1683a01dbf0ae968f97703250e50346ad015a66b 03-contracts/CONTEXT-QUERY-CONTRACT.md 5e452fc80983aa13b00dd449b18148347829bcbef485509c85a40875a109b3df 03-contracts/DATABASE-CONTRACT.md @@ -85,7 +86,7 @@ a9f0034de2383d1686ab338e58c5e7805b61e093be184c2ad787db444a351d50 05-migration/I 4c2470154e5e509a83e909072141b9ef5f8b545a3095a06c3065964f87359ced 05-migration/SYNAPSE-CODE-MAP.md 2cb7abb2d39afceba4f7f185d7d47d2dbd07173e8c44541b96b66d340de95955 05-migration/SYNAPSE-EXTRACTION-SPEC.md 421b03734b5aef3ea973eb990ac882995475a749fa1822b4f716f75921330c78 05-migration/SYNAPSE-EXTRACTION.md -13d7d66719371abbca9bddd2ed4416d1185e3e14fccd857a5fcc924bdd73d72e 05-migration/SYNAPSE-IMPLEMENTATION-PLAN.md +600509c43bb69a5d31e10c87f9d515a4101e08cfff3ef8a461b3f14c296e0756 05-migration/SYNAPSE-IMPLEMENTATION-PLAN.md fcd36eda521dfdbf154bed742c8ce0f69d5229b0edf0489df960f0ce692f715b 05-migration/VERTICAL-SLICES.md e5bbd147ce3c55b1c3c53d459ed4471186ce476bd1ea402c4b24cc9de6ce4ab1 05-migration/capability-matrix.yaml 063d6b3eb6bfbe945809cba46e7a7fb0933888e1495051f0dff3aa1f6ddf4a0b 05-migration/donor-path-map.yaml @@ -121,14 +122,14 @@ a1d24ba617d98f4cdcfe9fa5e5cca3b765effaacb6e7efa356f8b21d6b881fff 08-adr/0011-se e6bbf8edee67418cb6964d49083a700a290fc26b8757bf5083e585181e6bac01 08-adr/README.md 1fd505bd34429a3db0eb975309c450318928130050f4b57b0878964533e511cc 09-delivery/DEFINITION-OF-DONE.md 7683039a2b5f63310830d3e13d9f52c32a9b2ded2499d26c776722e1f7691baa 09-delivery/DEFINITION-OF-READY.md -9e7e0504cd08ac723df5700de3b313b06232a560b4c9cf81c49eeb464e8c5d7d 09-delivery/IMPLEMENTATION-TRACKER.md +10b7119100b12340a14c9447d6f849eb03aff8b754d973bdd0604ea4f144f26b 09-delivery/IMPLEMENTATION-TRACKER.md c8c7290f4a5309d85d80a14df6d517bf656e73cf367fce36f59d5f90e99e0b73 09-delivery/OPEN-DECISIONS.md b7fda5bb5fa14001abf06f7cbcc56c28a0da94c16e4fa54cfffd39dbaf569950 09-delivery/OWNERSHIP.md e61b0625e137e0cbeb70a8be6859a8a86d06e509b6c6e5b2db23f141f43443f6 09-delivery/PR-TRAIN.md c711b6d188ddc2ba40450444141f780342ccdb1a3b9d75a8f2b5b836ef8bc83b 09-delivery/RISK-REGISTER.md 4ba032b93d702e93c9d45bcb7a875185dc9aa7e32e5e132cbc6be5a407ec1db9 09-delivery/risk-register.yaml 9c6dfdf7267423de1532c8c21c87adccea1e2d018b82d8991273367fe1f921b0 CHANGELOG.md -93366f9235723012953fb671d3af030a04d55277cad802c8e5ae52c599058fd5 MANIFEST.yaml +f20f019280c732839339477b6cd1114c2589cdbd6fd21847081eda08d9e066b6 MANIFEST.yaml 9853bd9f21d7f9c228b2b278f914e3ed9f7800360b960eec751c1c245dd541aa MASTER-SPEC.md 8b935136898acba869ab1de604ebf58dc75443c89af1db8d4fc6a11d92b7555f PACKAGE-LAYOUT.md 9eb6d14e49127a3248d4ece7d324e27879ea3f788929118a837936a4a16ce018 README.md diff --git a/docs/unify/MANIFEST.yaml b/docs/unify/MANIFEST.yaml index 1634c063..f35f0f10 100644 --- a/docs/unify/MANIFEST.yaml +++ b/docs/unify/MANIFEST.yaml @@ -40,7 +40,7 @@ baselines: commit: 8f1bb2efc1a519c9d3b1b5b41ea8bb2ba178011f observed: '2026-07-31' counts: - filesInManifest: 135 + filesInManifest: 136 sharedCrates: 19 adrs: 13 capabilities: 13 @@ -138,16 +138,16 @@ files: sha256: c84049d26600b70e4bf8540cdce1a96b4f6d4818f2062e1ce58cc9368ebdf7e2 type: md - path: 02-crates/specs/soma-fleet.md - bytes: 5573 - sha256: da4e2dda9399b508339b687b6c0f35c8bb8f9ec4c742f3ad4e531d1cc512909d + bytes: 6306 + sha256: cd020c8111d19208042c01397c1d37ee8db50af9fa1e8ada25591c7935d55960 type: md - path: 02-crates/specs/soma-graph.md bytes: 3453 sha256: 4d2e96e353baaf01c56441b42372f3b88e79f94166fe0c0be27bc17ae2a793ec type: md - path: 02-crates/specs/soma-infra.md - bytes: 9209 - sha256: a70aba1ed141ce032b9a8d2066b405177c432822f65799f6ffc313284add6435 + bytes: 12596 + sha256: b863496b3c2d894a202e1cb868a9598e4cb613d54abc74615a1874d5db730ca1 type: md - path: 02-crates/specs/soma-ingest.md bytes: 3147 @@ -206,8 +206,12 @@ files: sha256: e7b16ddd50fc7f7e7d6f3c38f45a064dc65ca8c706cff975a79a88912c8c1645 type: md - path: 02-crates/specs/synapse-application.md - bytes: 5364 - sha256: 989b68b4fd917c31b3e1a07c0fd999fdd5d23f5815f63ba9bbde9057a038aa59 + bytes: 6750 + sha256: e204ad224d3a5f3b179b331115f8a5013772899d5f092949af70ea0ce5cb8845 + type: md +- path: 02-crates/specs/synapse-standalone.md + bytes: 2703 + sha256: 16a91dc7703b5e241cfd38c6d543dd621f62eeb3a31921bf2624f95038a27556 type: md - path: 03-contracts/CITATION-CONTRACT.md bytes: 2416 @@ -406,8 +410,8 @@ files: sha256: 421b03734b5aef3ea973eb990ac882995475a749fa1822b4f716f75921330c78 type: md - path: 05-migration/SYNAPSE-IMPLEMENTATION-PLAN.md - bytes: 25091 - sha256: 13d7d66719371abbca9bddd2ed4416d1185e3e14fccd857a5fcc924bdd73d72e + bytes: 27021 + sha256: 600509c43bb69a5d31e10c87f9d515a4101e08cfff3ef8a461b3f14c296e0756 type: md - path: 05-migration/VERTICAL-SLICES.md bytes: 2297 @@ -550,8 +554,8 @@ files: sha256: 7683039a2b5f63310830d3e13d9f52c32a9b2ded2499d26c776722e1f7691baa type: md - path: 09-delivery/IMPLEMENTATION-TRACKER.md - bytes: 9604 - sha256: 9e7e0504cd08ac723df5700de3b313b06232a560b4c9cf81c49eeb464e8c5d7d + bytes: 11078 + sha256: 10b7119100b12340a14c9447d6f849eb03aff8b754d973bdd0604ea4f144f26b type: md - path: 09-delivery/OPEN-DECISIONS.md bytes: 1994 diff --git a/lefthook.yml b/lefthook.yml index d95d4223..4d92a012 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -34,6 +34,7 @@ # - just conformance → boots a server and runs the official MCP conformance suite; CI/manual only # - cargo xtask patterns → full-repo contract scan; runs in CI and `just soma-check` # - just synapse-product-import-check → locked donor snapshot check; runs in CI/manual only +# - just synapse-standalone-check → native CLI/REST/MCP product and packaging gate; CI/manual only # - just synapse-canonical-read-check → full infrastructure and Synapse read suites; runs in CI/manual only # - just synapse-mutation-check → full lifecycle, artifact, build, replacement, infrastructure, and Synapse mutation suites; CI/manual only # - cargo xtask check-release-versions → PR/main release gate; runs in CI and release scripts diff --git a/xtask/src/test_siblings.rs b/xtask/src/test_siblings.rs index 2b88e86e..29794bd5 100644 --- a/xtask/src/test_siblings.rs +++ b/xtask/src/test_siblings.rs @@ -141,6 +141,7 @@ fn filename(path: &Path) -> String { /// looked. const CHECKED_SRC_ROOTS: &[&str] = &[ "apps/soma/src", + "apps/synapse/src", "crates/shared/codemode/src", "crates/shared/incus-client/src", "crates/shared/mcp/client/src",