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
co-authored by Claude Opus 4.8
parent b0b54a96fa
commit 4c1a4e5976
4 changed files with 289 additions and 2 deletions
+3 -2
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,
+136
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 {