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:
co-authored by
Claude Opus 4.8
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 {
|
||||
|
||||
Reference in New Issue
Block a user