From 4c1a4e59767e007f668f7acceb393b23aeccd99f Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 21 Jun 2026 11:07:00 -0400 Subject: [PATCH] feat(hooks): manifest lifecycle-hooks schema (#20 phase 1) + fix container test literals Add controlled post_install/pre_start hook schema to AppDefinition: LifecycleHooks/HookStep (Exec | CopyFromHost)/HostCopy with allowlist validation (relative src, no '..', absolute container dest, non-empty exec). Re-exported from the crate root. Design: docs/manifest-hooks-design.md. Also add the missing generated_secrets: vec![] field to three pre-existing ContainerConfig test literals (the field was added to the struct in 03a4ee1b but the container crate's own tests were never rerun, so -p archipelago-container failed to compile). cargo test green: 53 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/container/src/lib.rs | 5 +- core/container/src/manifest.rs | 136 +++++++++++++++++++++++++++++++++ docs/PRODUCTION-MASTER-PLAN.md | 47 ++++++++++++ docs/manifest-hooks-design.md | 103 +++++++++++++++++++++++++ 4 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 docs/manifest-hooks-design.md diff --git a/core/container/src/lib.rs b/core/container/src/lib.rs index a3d8c355..45afa02c 100644 --- a/core/container/src/lib.rs +++ b/core/container/src/lib.rs @@ -9,8 +9,9 @@ pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator}; pub use health_monitor::HealthMonitor; pub use manifest::{ AppInterface, AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedFile, - GeneratedSecret, HealthCheck, HostFacts, ManifestError, ResolvedSource, ResourceLimits, - SecretEnv, SecretGenKind, SecretsProvider, SecurityPolicy, Volume, + GeneratedSecret, HealthCheck, HookStep, HostCopy, HostFacts, LifecycleHooks, ManifestError, + ResolvedSource, ResourceLimits, SecretEnv, SecretGenKind, SecretsProvider, SecurityPolicy, + Volume, }; pub use podman_client::{ image_uses_insecure_registry, ContainerState, ContainerStatus, PodmanClient, diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index 62e40082..73f76786 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -57,10 +57,88 @@ pub struct AppDefinition { #[serde(default)] pub interfaces: HashMap, + /// Controlled post-install / pre-start lifecycle hooks. Declarative, + /// allowlisted operations run against the app's OWN container — never the + /// host. See `docs/manifest-hooks-design.md`. + #[serde(default)] + pub hooks: LifecycleHooks, + #[serde(flatten)] pub extensions: HashMap, } +/// Declarative lifecycle hooks for an app. Absent = none (forward-compatible). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct LifecycleHooks { + /// Run once after a successful install, with the container created + running. + #[serde(default)] + pub post_install: Vec, + /// Run before each start (repair/ownership). Reserved; not yet executed. + #[serde(default)] + pub pre_start: Vec, +} + +/// A single controlled hook operation. Each list item is a one-key map, e.g. +/// `- exec: [...]` or `- copy_from_host: { src, dest }`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum HookStep { + /// Run a command vector INSIDE the app's container (`podman exec`). Never on + /// the host; inherits the container's (already dropped) capabilities. + Exec { exec: Vec }, + /// Copy a file from an allowlisted host root into the container. `src` is + /// relative to the allowlist (data dir / web-ui) — no absolute paths, no `..`. + CopyFromHost { + #[serde(rename = "copy_from_host")] + copy_from_host: HostCopy, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HostCopy { + pub src: String, + pub dest: String, +} + +impl LifecycleHooks { + fn validate(&self) -> Result<(), ManifestError> { + for step in self.post_install.iter().chain(self.pre_start.iter()) { + step.validate()?; + } + Ok(()) + } +} + +impl HookStep { + fn validate(&self) -> Result<(), ManifestError> { + match self { + HookStep::Exec { exec } => { + if exec.is_empty() { + return Err(ManifestError::Invalid( + "hooks: exec must be a non-empty command vector".to_string(), + )); + } + } + HookStep::CopyFromHost { copy_from_host } => { + let s = ©_from_host.src; + if s.is_empty() || s.starts_with('/') || s.contains("..") { + return Err(ManifestError::Invalid(format!( + "hooks: copy_from_host.src must be a relative allowlisted path \ + (no leading '/', no '..'), got '{s}'" + ))); + } + if copy_from_host.dest.is_empty() || !copy_from_host.dest.starts_with('/') { + return Err(ManifestError::Invalid(format!( + "hooks: copy_from_host.dest must be an absolute container path, got '{}'", + copy_from_host.dest + ))); + } + } + } + Ok(()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ContainerConfig { /// Pull source. Mutually exclusive with `build`. Exactly one of the two must be present. @@ -657,6 +735,10 @@ impl AppManifest { } } + // Lifecycle hooks: declarative, allowlisted (no host exec, no absolute / + // `..` copy sources). See docs/manifest-hooks-design.md. + self.app.hooks.validate()?; + Ok(()) } } @@ -1072,6 +1154,57 @@ mod tests { use std::fs; use std::path::{Path, PathBuf}; + #[test] + fn hooks_parse_and_validate() { + let yaml = r#" +app: + id: indeedhub + name: IndeedHub + version: 1.0.0 + container: + image: test/indeedhub:1.0.0 + hooks: + post_install: + - exec: ["sed", "-i", "/X-Frame-Options/d", "/etc/nginx/conf.d/default.conf"] + - copy_from_host: + src: "web-ui/nostr-provider.js" + dest: "/usr/share/nginx/html/nostr-provider.js" +"#; + let m = AppManifest::parse(yaml).unwrap(); + assert_eq!(m.app.hooks.post_install.len(), 2); + match &m.app.hooks.post_install[0] { + HookStep::Exec { exec } => assert_eq!(exec[0], "sed"), + _ => panic!("expected exec step"), + } + match &m.app.hooks.post_install[1] { + HookStep::CopyFromHost { copy_from_host } => { + assert_eq!(copy_from_host.dest, "/usr/share/nginx/html/nostr-provider.js") + } + _ => panic!("expected copy_from_host step"), + } + m.validate().unwrap(); + } + + #[test] + fn hooks_reject_absolute_or_traversal_copy_src() { + for bad in ["/etc/passwd", "../../etc/shadow", "web-ui/../../etc/x"] { + let yaml = format!( + "app:\n id: a\n name: a\n version: 1.0.0\n container:\n image: x:y\n \ + hooks:\n post_install:\n - copy_from_host:\n src: \"{bad}\"\n dest: \"/x\"\n" + ); + assert!( + AppManifest::parse(&yaml).is_err(), + "src '{bad}' must be rejected" + ); + } + } + + #[test] + fn hooks_reject_empty_exec() { + let yaml = "app:\n id: a\n name: a\n version: 1.0.0\n container:\n image: x:y\n hooks:\n post_install:\n - exec: []\n"; + assert!(AppManifest::parse(yaml).is_err()); + } + #[test] fn test_manifest_parse() { let yaml = r#" @@ -1546,6 +1679,7 @@ app: }, ], secret_env: vec![], + generated_secrets: vec![], data_uid: None, }; let facts = HostFacts { @@ -1595,6 +1729,7 @@ app: secret_file: "fedimint-gateway-password".to_string(), }, ], + generated_secrets: vec![], data_uid: None, }; let p = MapSecretsProvider { @@ -1630,6 +1765,7 @@ app: key: "BITCOIN_RPC_PASS".to_string(), secret_file: "bitcoin-rpc-password".to_string(), }], + generated_secrets: vec![], data_uid: None, }; let p = MapSecretsProvider { diff --git a/docs/PRODUCTION-MASTER-PLAN.md b/docs/PRODUCTION-MASTER-PLAN.md index 1469764b..e6d2f351 100644 --- a/docs/PRODUCTION-MASTER-PLAN.md +++ b/docs/PRODUCTION-MASTER-PLAN.md @@ -147,6 +147,53 @@ hardening; paid swarm streaming + IndeeHub source (`phase4-streaming-ecash-plan. Meshroller Rust-native mesh AI (`meshroller-integration-design.md`); dual-ecash phases 2–6 (`dual-ecash-design.md`). +## 8b. SESSION STATE + RESUME (2026-06-21, live) + +**Landed + committed on main this session (newest first):** +- `f0c6b79d` immich containers named underscore (immich_server/_postgres/_redis) to + match runtime lifecycle code — fixes package.stop/start/restart. **immich fully + migrated + verified on .228** (manifest-driven stack via orchestrator). +- `b0b54a96` immich lifecycle bats suite (tests/lifecycle/bats/immich.bats). +- `d5ef4573`/`9e6c5370`/`011081d1` immich migration (rename→immich, orchestrator-first). +- `f160e0c4` podman-restart.service enabled at startup (reboot-survival). +- `0860dfac` Services-tab UI (backends→Services, parent icons, categories sub-nav, swipe). +- `220666d3`/`7bfbe8fe` registry-manifest infra phases 1+2 (consume + EMBED_MANIFESTS publish). +- `192238cb` docs consolidation 56→28 + CLAUDE.md. +- `03a4ee1b` generated-secrets system + companion/quadlet fixes. + +**IN FLIGHT — hook capability (#20), phase 1 (schema):** building controlled +post-install hooks so indeedhub/netbird can migrate. Design: `docs/manifest-hooks-design.md`. +- DONE: `LifecycleHooks`/`HookStep`/`HostCopy` types + `hooks` field on AppDefinition + + validate() + re-exports + 3 schema tests (manifest.rs). +- **BLOCKING COMPILE FIX NEEDED:** `cargo test -p archipelago-container` fails — + 3 pre-existing test `ContainerConfig {…}` literals (manifest.rs ~1658/1711/1752) + are missing the `generated_secrets` field (added in 03a4ee1b but the container + crate's own tests were never run since). Add `generated_secrets: vec![],` to each. +- THEN: implement executor `core/archipelago/src/container/hooks.rs` + (run_post_install: podman exec + copy_from_host with allowlist canonicalisation), + wire into orchestrator install (post-create, install-only), tests, commit. + +**NEXT (after #20):** indeedhub migration — author 7 member manifests +(postgres/redis/minio/relay/api/ffmpeg + frontend) on archy-net with container-name +hostnames; frontend carries the `post_install` hook (strip X-Frame-Options, copy +nostr-provider.js, inject script, nginx reload — see `patch_indeedhub_nostr_provider` +in install.rs:68 for exact ops); wire `install_indeedhub_stack` orchestrator-first; +generated_secrets: indeedhub-db-password/indeedhub-jwt/indeedhub-minio-password +(reuse live values); preserve hardcoded AES_MASTER_SECRET literal + minio user +"indeeadmin". Then netbird (assess its setup steps). Then single-container legacy +apps (add to `uses_orchestrator_install_flow` allowlist in install.rs + verify each). +Then the lifecycle gate (#6) — needs harness hardening (#18) + .228 bitcoin synced. + +**Test/deploy facts:** .228 = archi resilience node, UI/RPC pw `password123` (https), +SSH pw `archipelago`. Lifecycle harness runs from .116: `cd tests/lifecycle && +ARCHY_HOST=192.168.1.228 ARCHY_SCHEME=https ARCHY_PASSWORD=password123 +ARCHY_ALLOW_DESTRUCTIVE=1 ./run.sh `. RPC trigger: auth.login (sets session ++ csrf cookies) → send csrf cookie value as `X-CSRF-Token` header. package.install +needs `{"id":"","dockerImage":""}` (dockerImage required even +for stacks). Rust workspace root = `core/`. Linker `undefined hidden symbol` → +rebuild with `CARGO_INCREMENTAL=0`. immich on .228: app_id `immich`, containers +immich_server/immich_postgres/immich_redis, data dir owner 100998:100998. + ## 9. Documentation map (what survives) This master plan is the hub. Authoritative standalone docs (linked above), kept: diff --git a/docs/manifest-hooks-design.md b/docs/manifest-hooks-design.md new file mode 100644 index 00000000..6a6cc9c2 --- /dev/null +++ b/docs/manifest-hooks-design.md @@ -0,0 +1,103 @@ +# Manifest Lifecycle Hooks — Design + +**Status:** design (2026-06-21) · Task #20 · Prereq for migrating complex stacks +(indeedhub, netbird) off legacy Rust installers. + +See `docs/PRODUCTION-MASTER-PLAN.md`, `docs/APP-PACKAGING-MIGRATION-PLAN.md` +("controlled hooks"). + +--- + +## 1. Problem + +Some apps need a step the static manifest can't express: a **post-start container +mutation**. The motivating case is indeedhub's `patch_indeedhub_nostr_provider()`: + +1. `podman exec indeedhub sed -i '/X-Frame-Options/d' /etc/nginx/conf.d/default.conf` + (strip the header so the app loads in our iframe) +2. `podman cp /opt/archipelago/web-ui/nostr-provider.js indeedhub:/usr/share/nginx/html/` +3. patch nginx conf to inject `#' /etc/nginx/conf.d/default.conf"] + - exec: ["nginx", "-s", "reload"] + pre_start: [] # (future) run before each start — repair/ownership +``` + +Types (in `archipelago-container`): +```rust +pub enum HookStep { + Exec { exec: Vec }, + CopyFromHost { copy_from_host: HostCopy }, +} +pub struct HostCopy { pub src: String, pub dest: String } +pub struct LifecycleHooks { + #[serde(default)] pub post_install: Vec, + #[serde(default)] pub pre_start: Vec, +} +``` +`hooks` is `#[serde(default)]` + forward-compatible (absent = no hooks). + +## 4. Execution + +`container::hooks::run_post_install(manifest, container_name, data_dir)`: +- Resolve container name via `compute_container_name`. +- For each step in order: + - `Exec` → `podman exec ` (timeout-bounded). + - `CopyFromHost` → canonicalise `src` against the allowlist roots; reject on + escape; `podman cp :`. +- Log each step; on error, `warn!` and continue (best-effort). + +Called from the orchestrator's install path **after** the container is up +(post-create/health), and gated so it runs on install (not every reconcile). +Validation (`AppManifest::validate`): every `copy_from_host.src` must resolve +inside an allowlist root and contain no `..`; `exec` must be non-empty. + +## 5. indeedhub migration (the payoff) + +With hooks, indeedhub becomes fully manifest-driven: 7 member manifests +(postgres/redis/minio/relay/api/ffmpeg/frontend) + the frontend manifest carries +the `post_install` hook above. `install_indeedhub_stack` becomes orchestrator-first +(like btcpay), legacy as fallback. Same pattern unblocks netbird's setup steps. + +## 6. Phases + +1. **Schema + executor + validation + unit tests** (this design) — `exec` + + `copy_from_host`, allowlist-enforced. +2. **Wire into orchestrator install** (post-create, install-only). +3. **indeedhub**: author member manifests + frontend `post_install` hook; wire + `install_indeedhub_stack` orchestrator-first; live-migrate + verify on .228. +4. **netbird**: assess its setup steps; migrate with hooks. +5. `pre_start` hooks (repair/ownership) if needed.