feat(enabled): apply-time reachability warning + end-to-end journey

- §4.7 warning: `apply` now reports when an enabled route or trigger is
  bound to a disabled script — deployed but unreachable (route 404s,
  trigger won't fire). A warning, not an error: it's valid desired state,
  the operator just shouldn't be surprised by the silent 404.
- E2E journey (`tests/enabled.rs`): apply an active script and confirm
  `/api/v1/execute/{id}` 200s; flip `enabled = false` in the manifest and
  re-apply → 404; re-enable → 200. Exercises the whole path: manifest →
  diff → apply → runtime honoring.

Tested: manager-core lib 367 + cli bins 31 + 15 project-tool journeys
(incl. the new enabled e2e) green; clippy -D warnings clean; versioning
gate passes (migration 0045).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-23 20:10:13 +02:00
parent bfab7c781d
commit 8c805a07d0
3 changed files with 168 additions and 0 deletions

View File

@@ -469,6 +469,10 @@ impl ApplyService {
validate_email_secrets_present(bundle, &current.secret_names)?;
let plan = compute_diff(&current, bundle);
let mut report = ApplyReport::default();
// §4.7 warning: an enabled binding pointing at a disabled script is
// deployed-but-unreachable. Not an error (it's valid desired state),
// but surfaced so the operator isn't surprised by a silent 404.
report.warnings.extend(disabled_target_warnings(bundle));
let bundle_scripts: HashMap<String, &BundleScript> = bundle
.scripts
@@ -1422,6 +1426,42 @@ fn reject_reserved_path(path: &str) -> Result<(), ApplyError> {
Ok(())
}
/// §4.7 reachability warnings: an *enabled* route or trigger bound to a
/// script the manifest marks *disabled* is deployed but unreachable (the
/// route 404s, the trigger won't fire). Valid desired state, so a warning —
/// not an error — keyed on the bundle alone.
fn disabled_target_warnings(bundle: &Bundle) -> Vec<String> {
let disabled: HashSet<&str> = bundle
.scripts
.iter()
.filter(|s| !s.enabled)
.map(|s| s.name.as_str())
.collect();
if disabled.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
for r in &bundle.routes {
if r.enabled && disabled.contains(r.script.as_str()) {
out.push(format!(
"route `{}` is enabled but its script `{}` is disabled — it will 404",
route_key(r.method.as_deref(), r.host_kind, &r.host, r.path_kind, &r.path),
r.script
));
}
}
for t in &bundle.triggers {
if disabled.contains(t.script()) {
out.push(format!(
"{} trigger targets disabled script `{}` — it will not fire",
t.kind_str(),
t.script()
));
}
}
out
}
/// Per-kind structural validation for one desired trigger — kept at parity
/// with the interactive trigger API so `apply` can't write a trigger the
/// dashboard would have rejected. Pure (no live state), so it's unit-tested
@@ -2105,6 +2145,33 @@ mod tests {
);
}
#[test]
fn warns_on_enabled_binding_to_disabled_script() {
let mut b = empty_bundle();
let mut s = bundle_script("h", "x");
s.enabled = false;
b.scripts = vec![s];
b.routes = vec![BundleRoute {
script: "h".into(),
method: None,
host_kind: HostKind::Any,
host: String::new(),
host_param_name: None,
path_kind: PathKind::Exact,
path: "/h".into(),
dispatch_mode: DispatchMode::Sync,
enabled: true,
}];
let w = disabled_target_warnings(&b);
assert!(
w.iter().any(|m| m.contains("will 404")),
"expected a 404 reachability warning: {w:?}"
);
// No warning when the script is active.
b.scripts[0].enabled = true;
assert!(disabled_target_warnings(&b).is_empty());
}
#[test]
fn trigger_diff_create_noop_delete() {
let s = script("h", "x");