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) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-21 11:07:00 -04:00
parent b0b54a96fa
commit 4c1a4e5976
4 changed files with 289 additions and 2 deletions

View File

@ -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,

View File

@ -57,10 +57,88 @@ pub struct AppDefinition {
#[serde(default)]
pub interfaces: HashMap<String, AppInterface>,
/// 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<String, serde_yaml::Value>,
}
/// 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<HookStep>,
/// Run before each start (repair/ownership). Reserved; not yet executed.
#[serde(default)]
pub pre_start: Vec<HookStep>,
}
/// 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<String> },
/// 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 = &copy_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 {

View File

@ -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 26 (`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 <suite>`. RPC trigger: auth.login (sets session
+ csrf cookies) → send csrf cookie value as `X-CSRF-Token` header. package.install
needs `{"id":"<app>","dockerImage":"<any-valid-image>"}` (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:

View File

@ -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 `<script src="/nostr-provider.js">` and reload
A manifest `files:` entry writes files on the **host** before create; it cannot
patch a **running** container or copy a host file into it. Without a hook,
migrating indeedhub to the orchestrator ships a broken UI.
## 2. Non-goals / security posture
Per the packaging plan: **NOT arbitrary host scripts.** Hooks are declarative,
allowlisted operations, run against the app's **own** (already manifest-sandboxed)
container. This preserves "no arbitrary privileged execution" while giving a
reviewed escape hatch.
- **No host execution.** `exec` runs *inside the container* (`podman exec`), never
on the host.
- **No arbitrary host reads.** `copy_from_host.src` is **relative to an allowlist
root** (`<data_dir>` and `/opt/archipelago/web-ui`), resolved + canonicalised;
any `..` escape or absolute path outside the allowlist is rejected at validate().
- **Same privileges as the container.** `exec` inherits the container's caps
(already dropped per `security:`), so a hook can't exceed the app's own sandbox.
- **Best-effort + idempotent.** Hooks must be safe to re-run (guard with
`grep -q … || …`). A hook failure is logged, not fatal — matching the legacy
best-effort patch, so a transient hook error never bricks an install.
## 3. Schema (`AppDefinition.hooks`)
```yaml
app:
id: indeedhub
hooks:
post_install: # after the container is created + running, on install
- exec: ["sed", "-i", "/X-Frame-Options/d", "/etc/nginx/conf.d/default.conf"]
- copy_from_host:
src: "web-ui/nostr-provider.js" # relative to allowlist root
dest: "/usr/share/nginx/html/nostr-provider.js"
- exec: ["sh", "-c", "grep -q nostr-provider /etc/nginx/conf.d/default.conf || sed -i 's#</head>#<script src=\"/nostr-provider.js\"></script></head>#' /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<String> },
CopyFromHost { copy_from_host: HostCopy },
}
pub struct HostCopy { pub src: String, pub dest: String }
pub struct LifecycleHooks {
#[serde(default)] pub post_install: Vec<HookStep>,
#[serde(default)] pub pre_start: Vec<HookStep>,
}
```
`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 <container> <args…>` (timeout-bounded).
- `CopyFromHost` → canonicalise `src` against the allowlist roots; reject on
escape; `podman cp <abs-src> <container>:<dest>`.
- 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.