Skip to content

Commit 4b776cd

Browse files
committed
fix(oabctl): address code-review findings on the bundle-restore fix
Follow-up commit on #103, from a multi-angle review pass before merge: - inject_pre_seed_hook: rewritten on toml_edit (format-preserving) instead of raw string append. Previously, *any* existing [hooks.pre_seed] made injection a full no-op -- an operator's own hooks.pre_seed (e.g. this agent's own persistent-state restore, unrelated to the deploy bundle) meant the new bundle.zip was uploaded but never actually wired in, and provision_from_library still reported success. Now merges the zip URI into the existing `sources` array (true no-op only when that exact URI is already present), mirrors pre_seed's own 5-source cap, and bails instead of silently producing invalid TOML when `hooks`/`hooks.pre_seed` is already an inline table (which can't be reopened with a `[table]` header). - provision_from_library: mutates `bundle.files` in place before deriving artifact_objects/zip_bytes/digest from it, instead of deriving them from the pre-injection Bundle. Previously the uploaded zip's own config.toml never got the pre_seed hook, and ProvisionOutcome.digest didn't match what was actually uploaded. - secret_env_vars: attaches the per-secret error context (env var name, namespace/name) to both the "wrong scheme" and "right scheme, malformed body" cases. anyhow's Context impl for Option<T> only fires on None, so the previous `.with_context()??` silently dropped context on the latter. - EcsDriver::apply: preserves the structured ApplyError in the anyhow error's source chain (via .context() on Error::new(e)) instead of flattening it into anyhow::anyhow!(...). A caller can again `.chain().find_map(downcast_ref::<ApplyError>)` to recover `.completed`/`.failed_service` for a partial fleet-apply failure. ApplyError::reconciliation made pub(crate) so driver.rs's test can construct one without a live ECS call. 24 new/changed tests, 131/131 total across oabctl+studio-cp, clippy clean (same 3 pre-existing warnings elsewhere, untouched).
1 parent 80ce50a commit 4b776cd

6 files changed

Lines changed: 219 additions & 34 deletions

File tree

‎crates/oabctl/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ serde_json = "1"
4040
serde_yaml = "0.9"
4141
tokio = { version = "1.40", features = ["full"] }
4242
toml = "0.8"
43+
toml_edit = "0.22"
4344
anyhow = "1.0"
4445
dirs = "6"
4546
rpassword = "7"

‎crates/oabctl/src/apply.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@ impl ApplyError {
111111
}
112112
}
113113

114-
fn reconciliation(
114+
// pub(crate): driver.rs's tests construct one to verify the
115+
// ApplyError -> anyhow::Error conversion preserves it in the source chain.
116+
pub(crate) fn reconciliation(
115117
failed_service: ServiceTarget,
116118
completed: ApplyReport,
117119
source: impl Into<anyhow::Error>,

‎crates/oabctl/src/driver.rs‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ impl<'a> ProvisionDriver for EcsDriver<'a> {
6666
}
6767
crate::apply::apply_manifests(self.aws_config, manifests, &ecs_opts)
6868
.await
69-
.map_err(|e| anyhow::anyhow!("apply failed [{:?}]: {e}", e.kind))
69+
.map_err(ecs_apply_error_to_anyhow)
7070
}
7171

7272
async fn scale(&self, namespace: &str, name: &str, size: i32) -> Result<()> {
@@ -93,3 +93,54 @@ impl<'a> ProvisionDriver for EcsDriver<'a> {
9393
.await
9494
}
9595
}
96+
97+
/// Convert `apply_manifests`'s structured error into an `anyhow::Error`
98+
/// while keeping the `ApplyError` itself in the source chain — a caller can
99+
/// still `err.chain().find_map(anyhow::Error::downcast_ref::<apply::ApplyError>)`
100+
/// to recover `.completed`/`.failed_service` for a partial fleet-apply
101+
/// failure, same as callers could before `ProvisionDriver` existed (when
102+
/// `studio_api::provision` used `.context(..)` directly on `apply_manifests`'s
103+
/// result). A prior version of this flattened `e` into `anyhow::anyhow!(...)`,
104+
/// which silently dropped that recovery path — extracted as its own function
105+
/// so the conversion is unit-testable without a live ECS call.
106+
fn ecs_apply_error_to_anyhow(e: crate::apply::ApplyError) -> anyhow::Error {
107+
let kind = e.kind;
108+
anyhow::Error::new(e).context(format!("apply failed [{kind:?}]"))
109+
}
110+
111+
#[cfg(test)]
112+
mod tests {
113+
use super::*;
114+
use crate::apply::{ApplyAction, AppliedService, ApplyError, ApplyErrorKind, ApplyReport, ServiceTarget};
115+
116+
#[test]
117+
fn ecs_apply_error_to_anyhow_preserves_downcast_and_partial_progress() {
118+
let completed = ApplyReport {
119+
services: vec![AppliedService {
120+
namespace: "prod".to_string(),
121+
name: "orca".to_string(),
122+
resource_name: "oab-prod-orca".to_string(),
123+
action: ApplyAction::Updated,
124+
webhook_urls: vec![],
125+
warnings: vec![],
126+
}],
127+
};
128+
let failed = ServiceTarget {
129+
namespace: "prod".to_string(),
130+
name: "mira".to_string(),
131+
ecs_service_name: "oab-prod-mira".to_string(),
132+
};
133+
let source = ApplyError::reconciliation(failed.clone(), completed.clone(), anyhow::anyhow!("boom"));
134+
135+
let err = ecs_apply_error_to_anyhow(source);
136+
137+
assert!(err.to_string().contains("Reconciliation"));
138+
let recovered = err
139+
.chain()
140+
.find_map(|e| e.downcast_ref::<ApplyError>())
141+
.expect("ApplyError must survive in the source chain");
142+
assert_eq!(recovered.kind, ApplyErrorKind::Reconciliation);
143+
assert_eq!(recovered.failed_service, Some(failed));
144+
assert_eq!(recovered.completed, completed);
145+
}
146+
}

‎crates/oabctl/src/k8s_driver.rs‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,22 @@ fn secret_env_vars(m: &OABServiceManifest) -> Result<Vec<EnvVar>> {
105105
.secrets
106106
.iter()
107107
.map(|(env_name, value)| {
108+
// parse_k8s_secret_uri returns Option<Result<..>>: None for the
109+
// wrong scheme, Some(Err(..)) for the right scheme but a
110+
// malformed body (e.g. missing '#'). anyhow's Context impl for
111+
// Option<T> only fires on None, so a chained `.with_context()??`
112+
// here would silently drop this context on the Some(Err(..))
113+
// path — attach it explicitly to both instead.
114+
let context = || {
115+
format!(
116+
"spec.secrets['{env_name}'] for k8s runtime must use \
117+
k8s-secret://<secret-name>#<key> (got '{value}') — '{}/{}'",
118+
m.metadata.namespace, m.metadata.name
119+
)
120+
};
108121
let (secret_name, key) = crate::secrets::parse_k8s_secret_uri(value)
109-
.with_context(|| {
110-
format!(
111-
"spec.secrets['{env_name}'] for k8s runtime must use \
112-
k8s-secret://<secret-name>#<key> (got '{value}') — '{}/{}'",
113-
m.metadata.namespace, m.metadata.name
114-
)
115-
})??;
122+
.ok_or_else(|| anyhow::anyhow!(context()))?
123+
.with_context(context)?;
116124
Ok(EnvVar {
117125
name: env_name.clone(),
118126
value_from: Some(EnvVarSource {
@@ -384,6 +392,17 @@ mod tests {
384392
assert!(err.to_string().contains("k8s-secret://"));
385393
}
386394

395+
#[test]
396+
fn build_deployment_attributes_malformed_k8s_secret_ref_to_its_env_var() {
397+
// Right scheme, malformed body (missing #key) — must still name the
398+
// offending spec.secrets entry, not just the bare parser error.
399+
let m = k8s_manifest(None, &[("DISCORD_BOT_TOKEN", "k8s-secret://oab-orca")]);
400+
let err = build_deployment(&m).unwrap_err();
401+
let msg = err.to_string();
402+
assert!(msg.contains("DISCORD_BOT_TOKEN"), "must name the env var: {msg}");
403+
assert!(msg.contains("prod/orca"), "must name the agent: {msg}");
404+
}
405+
387406
#[test]
388407
fn build_deployment_sets_image_command_and_env() {
389408
let m = k8s_manifest(None, &[]);

‎crates/oabctl/src/studio_api.rs‎

Lines changed: 125 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -76,28 +76,85 @@ pub fn bundle_zip_uri(bucket: &str, namespace: &str, name: &str) -> String {
7676
format!("s3://{bucket}/artifacts/{namespace}/{name}/{BUNDLE_ZIP_FILENAME}")
7777
}
7878

79-
/// Append a `[hooks.pre_seed]` section to `config_toml`'s bytes, pointing at
80-
/// `zip_uri` — see [`bundle_zip_uri`] for why this is the actual bundle-restore
81-
/// mechanism. A no-op (returns `config_toml` unchanged) if the config already
82-
/// declares `[hooks.pre_seed]`: an operator who hand-authored one keeps
83-
/// control, this never silently overrides it. Appends rather than
84-
/// re-serializing the whole document, so existing comments/formatting in the
85-
/// operator's authored `config.toml` survive untouched — same "verbatim,
86-
/// don't round-trip through a parser" principle `save_bindings_text` already
87-
/// applies to `fleets.toml`.
79+
/// `pre_seed`'s own cap on `hooks.pre_seed.sources` length
80+
/// (`openab_core::pre_seed::MAX_SOURCES`) — mirrored here so a full sources
81+
/// list fails loudly at inject time instead of silently at the deployed
82+
/// agent's next boot.
83+
const PRE_SEED_MAX_SOURCES: usize = 5;
84+
85+
/// Add `zip_uri` to `config_toml`'s `hooks.pre_seed.sources`, creating
86+
/// `[hooks.pre_seed]` if absent — see [`bundle_zip_uri`] for why this is the
87+
/// actual bundle-restore mechanism. Uses `toml_edit` (format-preserving), so
88+
/// an operator's existing comments/layout survive; only the `sources` array
89+
/// (and, if needed, the `[hooks]`/`[hooks.pre_seed]` headers) are touched.
90+
///
91+
/// Idempotent — a true no-op (byte-for-byte unchanged) only when `zip_uri`
92+
/// is *already in* the sources list. Critically, this is **not** the same as
93+
/// "a `[hooks.pre_seed]` section already exists": an operator's own
94+
/// unrelated `hooks.pre_seed` (e.g. modeled on this fleet's own persistent
95+
/// state restore) gets `zip_uri` appended to its sources, not silently left
96+
/// alone — the earlier version of this function treated *any* existing
97+
/// `hooks.pre_seed` as "already handled" and skipped injection entirely,
98+
/// which meant a template that already used pre_seed for something else
99+
/// silently never got the new bundle wired in at all.
100+
///
101+
/// Bails (doesn't guess) when `hooks` or `hooks.pre_seed` already exists but
102+
/// isn't a table (e.g. an inline table `hooks = { restart = "always" }` —
103+
/// TOML forbids reopening a key already closed by inline-table syntax with a
104+
/// `[hooks.pre_seed]` header, so blindly appending would silently produce
105+
/// invalid TOML that only fails at the deployed agent's boot), or when
106+
/// `sources` is already at `pre_seed`'s own 5-entry cap.
88107
pub fn inject_pre_seed_hook(config_toml: &[u8], zip_uri: &str) -> Result<Vec<u8>> {
89108
let text = std::str::from_utf8(config_toml).context("config.toml is not valid UTF-8")?;
90-
let parsed: toml::Value = text.parse().context("config.toml is not valid TOML")?;
91-
if parsed.get("hooks").and_then(|h| h.get("pre_seed")).is_some() {
109+
let mut doc: toml_edit::DocumentMut = text.parse().context("config.toml is not valid TOML")?;
110+
111+
let hooks: &mut dyn toml_edit::TableLike = table_like_entry(doc.as_table_mut(), "hooks")?;
112+
let pre_seed = table_like_entry(hooks, "pre_seed")?;
113+
114+
let sources_item = pre_seed
115+
.entry("sources")
116+
.or_insert(toml_edit::value(toml_edit::Array::new()));
117+
let sources = sources_item
118+
.as_array_mut()
119+
.with_context(|| "config.toml's hooks.pre_seed.sources is not an array".to_string())?;
120+
121+
if sources.iter().any(|v| v.as_str() == Some(zip_uri)) {
92122
return Ok(config_toml.to_vec());
93123
}
94-
let quoted_uri = toml::Value::String(zip_uri.to_string());
95-
let mut out = text.to_string();
96-
if !out.is_empty() && !out.ends_with('\n') {
97-
out.push('\n');
124+
if sources.len() >= PRE_SEED_MAX_SOURCES {
125+
anyhow::bail!(
126+
"config.toml's hooks.pre_seed.sources already has {PRE_SEED_MAX_SOURCES} entries \
127+
(openab's own limit) — cannot add the bundle zip source '{zip_uri}' without \
128+
exceeding it; trim an existing source first"
129+
);
130+
}
131+
sources.push(zip_uri);
132+
133+
Ok(doc.to_string().into_bytes())
134+
}
135+
136+
/// Get-or-create `parent[key]` as a table-like entry (regular `[table]` or
137+
/// inline `{ ... }` both qualify structurally, but only a regular table can
138+
/// safely receive a further nested `[table.child]` header — see
139+
/// [`inject_pre_seed_hook`]'s doc for why an inline table bails instead of
140+
/// silently producing invalid TOML).
141+
fn table_like_entry<'a>(
142+
parent: &'a mut dyn toml_edit::TableLike,
143+
key: &str,
144+
) -> Result<&'a mut dyn toml_edit::TableLike> {
145+
if let Some(existing) = parent.get(key) {
146+
if existing.is_inline_table() {
147+
anyhow::bail!(
148+
"config.toml's `{key}` is an inline table — cannot add a nested `[{key}.*]` \
149+
section to it; use a regular `[{key}]` table instead"
150+
);
151+
}
98152
}
99-
out.push_str(&format!("\n[hooks.pre_seed]\nsources = [{quoted_uri}]\n"));
100-
Ok(out.into_bytes())
153+
parent
154+
.entry(key)
155+
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()))
156+
.as_table_like_mut()
157+
.with_context(|| format!("config.toml's `{key}` key is not a table"))
101158
}
102159

103160
/// Outcome of pushing a bundle: which bucket it landed in and how many objects.
@@ -317,18 +374,66 @@ mod tests {
317374
}
318375

319376
#[test]
320-
fn inject_pre_seed_hook_is_a_noop_when_already_present() {
321-
let config = b"[hooks.pre_seed]\nsources = [\"s3://other/bucket.zip\"]\n";
377+
fn inject_pre_seed_hook_is_a_true_noop_only_when_source_already_present() {
378+
let config = b"[hooks.pre_seed]\nsources = [\"s3://bucket/artifacts/prod/orca/bundle.zip\"]\n";
322379
let out = inject_pre_seed_hook(config, "s3://bucket/artifacts/prod/orca/bundle.zip").unwrap();
323-
// unchanged, byte-for-byte — an operator's own hook is never overridden
380+
// unchanged, byte-for-byte — this exact source is already wired, a
381+
// second redeploy of the same agent shouldn't touch the file at all
324382
assert_eq!(out, config);
325383
}
326384

385+
#[test]
386+
fn inject_pre_seed_hook_appends_to_an_operators_own_unrelated_pre_seed() {
387+
// The bug this replaced: an operator's own hooks.pre_seed (e.g. for
388+
// this agent's own persistent-state restore, unrelated to the deploy
389+
// bundle) used to make injection silently no-op entirely, so the new
390+
// bundle was never wired in at all. It must be added alongside.
391+
let config = b"[hooks.pre_seed]\nsources = [\"s3://other/state.zip\"]\n";
392+
let out = inject_pre_seed_hook(config, "s3://bucket/artifacts/prod/orca/bundle.zip").unwrap();
393+
let text = String::from_utf8(out).unwrap();
394+
assert!(text.contains("s3://other/state.zip"), "operator's own source must survive: {text}");
395+
assert!(text.contains("s3://bucket/artifacts/prod/orca/bundle.zip"), "new source must be added: {text}");
396+
let reparsed: toml::Value = text.parse().expect("valid toml");
397+
let sources = reparsed["hooks"]["pre_seed"]["sources"].as_array().unwrap();
398+
assert_eq!(sources.len(), 2);
399+
}
400+
401+
#[test]
402+
fn inject_pre_seed_hook_rejects_more_than_five_sources() {
403+
let config = br#"[hooks.pre_seed]
404+
sources = ["s3://a", "s3://b", "s3://c", "s3://d", "s3://e"]
405+
"#;
406+
let err = inject_pre_seed_hook(config, "s3://new").unwrap_err();
407+
assert!(err.to_string().contains('5'));
408+
}
409+
327410
#[test]
328411
fn inject_pre_seed_hook_rejects_invalid_toml() {
329412
assert!(inject_pre_seed_hook(b"not = [valid", "s3://x/bundle.zip").is_err());
330413
}
331414

415+
#[test]
416+
fn inject_pre_seed_hook_rejects_inline_table_hooks() {
417+
let config = b"hooks = { restart = \"always\" }\n";
418+
let err = inject_pre_seed_hook(config, "s3://x/bundle.zip").unwrap_err();
419+
assert!(err.to_string().contains("inline table"));
420+
}
421+
422+
#[test]
423+
fn inject_pre_seed_hook_rejects_inline_table_pre_seed() {
424+
let config = b"[hooks]\npre_seed = { target = \"~\" }\n";
425+
let err = inject_pre_seed_hook(config, "s3://x/bundle.zip").unwrap_err();
426+
assert!(err.to_string().contains("inline table"));
427+
}
428+
429+
#[test]
430+
fn inject_pre_seed_hook_preserves_comments_and_layout() {
431+
let config = b"# a comment worth keeping\n[agent]\nname = \"orca\" # inline comment\n";
432+
let out = inject_pre_seed_hook(config, "s3://x/bundle.zip").unwrap();
433+
let text = String::from_utf8(out).unwrap();
434+
assert!(text.starts_with("# a comment worth keeping\n[agent]\nname = \"orca\" # inline comment\n"));
435+
}
436+
332437
#[test]
333438
fn manifest_round_trips_bundle_from() {
334439
// A manifest carrying bundleFrom parses into spec.bundle_from; one without

‎crates/studio-cp/src/lib.rs‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -719,7 +719,7 @@ pub async fn provision_from_library(
719719
overlay: Option<&str>,
720720
image_override: Option<&str>,
721721
) -> anyhow::Result<ProvisionOutcome> {
722-
let bundle = studio_compose::compose_named(library, template, overlay)
722+
let mut bundle = studio_compose::compose_named(library, template, overlay)
723723
.map_err(|e| anyhow::anyhow!("compose failed: {e}"))?;
724724
let image = image_override
725725
.filter(|s| !s.is_empty())
@@ -731,14 +731,21 @@ pub async fn provision_from_library(
731731
// into config.toml *before* upload. See oabctl::studio_api::bundle_zip_uri
732732
// for why this — not the loose-file artifact_objects prefix — is what
733733
// actually gets restored on the deployed agent at boot.
734+
//
735+
// Patch `bundle.files` itself (not a derived copy) *before* deriving
736+
// artifact_objects/zip_bytes/digest from it, so all three agree on the
737+
// same, actually-uploaded content — deriving them from the bundle
738+
// pre-patch (as an earlier version of this function did) meant the
739+
// uploaded zip's own config.toml lacked the hook, and the reported
740+
// digest didn't match what was actually deployed.
734741
let bucket = oabctl::resolve_bucket(aws_config, None).await?;
735-
let mut objects = bundle.artifact_objects(namespace, name);
736742
let zip_uri = oabctl::studio_api::bundle_zip_uri(&bucket, namespace, name);
737-
let config_key = format!("{}/config.toml", studio_compose::artifacts_prefix(namespace, name));
738-
match objects.iter_mut().find(|(key, _)| *key == config_key) {
739-
Some((_, bytes)) => *bytes = oabctl::studio_api::inject_pre_seed_hook(bytes, &zip_uri)?,
743+
match bundle.files.get_mut("config.toml") {
744+
Some(bytes) => *bytes = oabctl::studio_api::inject_pre_seed_hook(bytes, &zip_uri)?,
740745
None => anyhow::bail!("composed bundle for {namespace}/{name} has no config.toml — cannot wire hooks.pre_seed"),
741746
}
747+
748+
let mut objects = bundle.artifact_objects(namespace, name);
742749
let zip_key = format!(
743750
"{}/{}",
744751
studio_compose::artifacts_prefix(namespace, name),

0 commit comments

Comments
 (0)