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:
parent
b0b54a96fa
commit
4c1a4e5976
@ -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,
|
||||
|
||||
@ -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 = ©_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 {
|
||||
|
||||
@ -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 <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:
|
||||
|
||||
103
docs/manifest-hooks-design.md
Normal file
103
docs/manifest-hooks-design.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user