Repository navigation
feat(provisioning): declarative resource provisioning and include-aware config checksums - #7921
Conversation
…ning tree Adds the declarative half of the managed-configuration RFC. Managed mode locks config.toml as a whole; this locks resources declared outside it, one at a time, while everything an operator creates at runtime stays editable. LIBREFANG_PROVISIONING_PATH points at a deployment-owned tree whose agents/*.toml files each declare one agent, identified by the manifest name. Boot reconciles the tree into the registry: create, apply, adopt an existing agent under a declared name, or leave an unchanged declaration alone. LIBREFANG_PROVISIONING_PRUNE decides what happens when a declaration leaves the tree — the default releases the agent back to runtime ownership, and only the exact value delete removes it. Both inputs are environment variables rather than KernelConfig fields for the reason the config mode is: a setting that decides what may be written must not be writable through the surface it governs. GET /api/provisioning/status reports each resource's source, applied checksum, on-disk checksum and drift, plus one entry per file the reconcile refused. A malformed manifest never fails the boot.
…manifest default `AgentManifest::name` deserialises to `"unnamed"` when the key is absent, so a file that never declares one was provisioned as an agent called `unnamed`, and a second such file collided with it for no visible reason. The name is the resource identity here, so it now has to be present in the file rather than supplied by the deserialiser, and the trimmed value is written back onto the manifest so the registry lookup and the state-file key cannot disagree.
The checksum on GET /api/config/status was computed over the primary file's bytes alone, so with `include = [...]` an edit to an included file changed the effective configuration and left the checksum identical. An operator comparing it against a ConfigMap's checksum/config annotation to confirm a rollout landed got a false negative, which is why the Kubernetes manifest checker banned `include` in a managed ConfigMap outright. With no include the digest is unchanged, byte for byte, so an existing annotation keeps matching. With includes it becomes the digest of `sha256sum` output over the closure in include order — reproducible as `(cd /etc/librefang && sha256sum config.toml extra.toml) | sha256sum` — and a new `includes` field lists what contributed. `modified_at` now reports the newest modification across the same set. The walk is tolerant where the loader is strict, since this feeds a status endpoint: an absolute, traversing, missing or circular include is skipped rather than raised, so a broken chain shortens the list instead of failing a request. The checker now allows an include whose target is another key of the same ConfigMap and computes the composite annotation, and fails on the cases the daemon could not honour — a target the manifest does not render, a `/`-containing key, an absolute or `..` path, and an include in a file mounted with a subPath.
… the k8s overlay The managed-config overlay now renders agents/*.toml into a second ConfigMap, mounts it read-only under LIBREFANG_PROVISIONING_PATH, and carries a checksum/provisioning annotation so editing a declaration rolls the StatefulSet — the tree is reconciled at boot only, so an edit that does not roll the pod changes nothing at all. The manifest checker gains the matching guard. It fires only when the manifest sets LIBREFANG_PROVISIONING_PATH, and fails the build on the cases the running daemon would otherwise turn into an agent that is silently missing: a declaration that is not valid TOML, one that declares no `name`, a key that is not a `.toml` file, a credential written as a literal value, a mount that does not supply the agents directory or is not read-only, a prune value that reads as intent it does not carry, and a stale checksum annotation. docs/operations/declarative-provisioning.md is the operator-facing contract: the tree layout, the two environment variables and why they are not config keys, the reconcile decision table, what adoption and release mean, what is locked and what deliberately is not, and the known gaps. It also records the answer to the RFC's open question about field-level ownership: ownership is expressed per resource rather than per config field, which is the granularity a deployment has actually asked for.
houko
left a comment
There was a problem hiding this comment.
Reviewed the diff against CLAUDE.md's project conventions (deterministic ordering, config-field completeness, changelog fragment format, route registration/auth, environment-variable-not-config-field pattern for lock-deciding settings). Everything else lines up — one edge-case question left inline on the prune-failure accounting.
Generated by Claude Code
| Action::Prune { .. } => { | ||
| report.pruned += 1; | ||
| if let Some(name) = key.strip_prefix("agent/") { | ||
| match self.agents.registry.find_by_name(name) { | ||
| Some(entry) => { | ||
| if let Err(e) = self.kill_agent(entry.id) { | ||
| tracing::warn!( | ||
| agent = %name, | ||
| "Failed to prune provisioned agent: {e}" | ||
| ); | ||
| } else { | ||
| info!( | ||
| agent = %name, | ||
| "Pruned provisioned agent — its declaration left the tree" | ||
| ); | ||
| } | ||
| } | ||
| None => tracing::debug!( | ||
| agent = %name, | ||
| "Provisioned agent already gone; nothing to prune" | ||
| ), |
There was a problem hiding this comment.
Action::Prune counts report.pruned += 1 and drops the key from next unconditionally, even when self.kill_agent(entry.id) returns Err (e.g. the agent is hand-owned and the kill guard refuses it, per the same guard bulk_delete_agents/kill_agent already special-case elsewhere in this PR).
Two effects of that:
GET /api/provisioning/status'sreport.prunedclaims a resource was pruned when it wasn't — an operator watching that count after settingLIBREFANG_PROVISIONING_PRUNE=deletewould see a wrong success count on a failed delete, with the actual error visible only in the boot logWARN.- Because the key isn't carried into
next.resourceseither, the state file loses this resource's provenance entirely. On the next boot,plan()seesdesiredwithout the key andpreviouswithout the key too — noPrune/Releaseaction is generated for it at all, so a delete that failed once is never retried and the agent quietly becomes an ordinary untracked runtime agent (not reported infailures[], not counted asfailed).
Compare with the Apply/Create failure branch just above, which does push a ProvisioningFailure and bump report.failed instead of report.applied/created. Seems like Action::Prune should follow the same pattern: only increment pruned and drop provenance on success, and on Err push a ProvisioningFailure (bump report.failed) while keeping the previous provenance in next so the next reconcile retries the prune instead of silently giving up.
No test currently exercises a kill_agent failure during prune, so this path isn't covered either way — flagging as a question rather than pushing a fix, since the right retry semantics might be a deliberate call I'm missing.
Generated by Claude Code
… the agent A `kill_agent` failure during a prune still counted as pruned and dropped the provenance record, which left a running agent unlocked — the outcome the `keep` policy produces, reached by a failure rather than by the operator's choice, and with no record left for the next boot to retry from. The record is now kept, the reason is reported through `GET /api/provisioning/status`, and the count lands in `failed` where it belongs.
Deploying librefang-docs with
|
| Latest commit: |
b3f6e9a
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://c1f56838.librefang-docs.pages.dev |
| Branch Preview URL: | https://feat-6695-declarative-provis.librefang-docs.pages.dev |
Deploying librefang with
|
| Latest commit: |
b3f6e9a
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://f57b298e.librefang-7oe.pages.dev |
| Branch Preview URL: | https://feat-6695-declarative-provis.librefang-7oe.pages.dev |
Two hunks in `check-k8s-manifests.py` where both sides changed real things. `check_services` signature: main widened it to take the StatefulSet, and this branch had inserted `expected_config_digest` and `check_provisioning` just above the old one-line definition. Kept this branch's new functions and main's new signature — the old signature would not match the call site main rewrote. The dispatch block: main restructured it into a `try:` with an explicit StatefulSet count and a clearer failure message, and passes `sts` onward even when it is None. Took main's structure and re-added this branch's `check_provisioning(docs, sts, failures)` call inside the same `else` branch, which is the one line this branch had contributed there. `xtask/baselines/openapi.sha256` recomputed from the merged `openapi.json`. Verified: `python3 scripts/tests/test_check_k8s_manifests.py` — 10 tests pass; `ast.parse` accepts the file; `shasum -c` confirms the baseline.
…-provisioning # Conflicts: # xtask/baselines/openapi.sha256
…-provisioning # Conflicts: # xtask/baselines/openapi.sha256
…-provisioning # Conflicts: # xtask/baselines/openapi.sha256
Refs #6695. Lands the second half of @whatnick's RFC — declarative resource provisioning — plus the one design question left open that blocked the first half from being trustworthy.
What was already on
main, re-verified todayThe RFC's status comment predates three merges. Checked against
origin/mainat3624319cc:a0acb2c9), which also settled the lock-or-classify decision onchange_password, the sidecar-channel pair, the extension pair and the MCP routes undermcp_runtime_store = "file".d520adbe):deploy/kubernetes/overlays/managed-config/with a ConfigMap, a read-only mount outside/data, both env vars, achecksum/configrollout annotation, a rollback procedure, and akinde2e job that asserts the 423.config_path_boot.That leaves declarative provisioning, which did not exist in any form, and the
includechecksum question.Declarative resource provisioning
Managed mode locks
config.tomlas a whole. This is the same contract one level down, for resources that do not live in that file.LIBREFANG_PROVISIONING_PATHpoints at a deployment-owned tree:crates/librefang-kernel/src/provisioning.rs— the scan, the state file, andplan(), a pure decision table over plain data so every case is testable without a booted kernel.crates/librefang-kernel/src/kernel/provisioning_ops.rs—apply_provisioning(), called once fromboot_with_config_atafter the registry restore and before the default-assistant fallback, so a deployment that declares its own agents does not also get anassistantit never asked for.name, not the file name.AgentManifest::namedeserialises a missing key to"unnamed", so the reconcile requires the key to be present rather than accepting the deserialiser's default — otherwisenmae = "researcher"provisionsunnamedand the next such typo collides with it invisibly.kubectl applysemantics — id, session and history survive) / no-op on a byte-identical file / recreate one deleted out of band.LIBREFANG_PROVISIONING_PRUNEdecides what happens to an orphan. The default releases it — provenance dropped, agent keeps running and becomes editable again — which is what makes removal reversible: putting the file back re-adopts the same agent instead of colliding with a tombstone. Only the exact worddeleteremoves anything, so a typo can never be the reason an agent is deleted.channels/,workflows/) is reported rather than ignored, so a deployment is told the kind is unsupported instead of believing it provisioned something.source_toml_pathis deliberately not set to its declaring file, sopersist_manifest_to_disknever retries a doomed write against a read-only mount — the failure mode the managed-mode migration write-back was fixed to avoid.Both variables are environment, not
KernelConfigSame reason
LIBREFANG_CONFIG_MODEis: a setting that decides what may be written must not itself be writable through the surface it governs. A[provisioning]key would be settable from the dashboard in mutable mode, and a write that turns provisioning off is a write that unlocks every provisioned resource. No new config field, so nobuild_reload_planclassification and noconfig_schema_goldenchurn.The lock
guard_provisioned_write(routes/mod.rs) is the resource-level counterpart ofguard_config_write— same423 Locked, same envelope,code: "resource_provisioned"because the remedy differs.guard_provisioned_agent(routes/agents/mod.rs) applies it to nine handlers:PATCH /agents/{id},DELETE /agents/{id}, themodel/tools/skills/mcp_servers/channelsPUTs,PATCH /agents/{id}/config,PATCH /agents/{id}/identity, and per item insideDELETE /agents/bulkso one deployment-owned agent does not fail an operator's other deletes.The guard is on the definition, not the agent. Suspend, resume, stop, message, sessions, files and every read stay open — the RFC's "operational actions and mutable runtime state remain usable" criterion, asserted in a test rather than assumed. Everything the tree does not declare stays fully mutable.
GET /api/provisioning/statusPer-resource
source, appliedchecksum, currentsource_checksum,drifted,present, plusfailures[]— the only place a refused file survives after the boot log scrolls. No write route: a provisioning tree is deployment-owned, so it is rollout-only exactly as managed configuration is.Include-aware configuration checksums (RFC design question 6)
GET /api/config/statushashed the primary file's bytes alone, so withinclude = [...]an edit to an included file changed the effective configuration and left the checksum identical — a false negative for the operator using it to confirm a rollout landed, and the stated reasonscripts/check-k8s-manifests.pybannedincludein a managed ConfigMap outright.kindassertion keep matching.sha256sumoutput over the closure in include order, reproducible as(cd /etc/librefang && sha256sum config.toml extra.toml) | sha256sum. A newincludesfield lists what contributed;modified_atnow reports the newest mtime across the same set./-containing key, an absolute or..path, and an include in a file mounted with asubPath.Kubernetes and docs
The managed-config overlay now renders
agents/researcher.tomlinto alibrefang-agentsConfigMap, mounts it read-only at/etc/librefang/provisioning/agents(a ConfigMap exposes keys flat, hence the subdirectory), setsLIBREFANG_PROVISIONING_PATH, and carries achecksum/provisioningannotation. The tree is reconciled at boot only, so an edit that does not roll the pod changes nothing —check_provisioningin the manifest checker enforces that, and additionally fails the build on a declaration that is not valid TOML, declares noname, is not a.tomlkey, assigns a credential a literal value, or is mounted writable or at the wrong path.docs/operations/declarative-provisioning.mdis the operator contract;deploy/kubernetes/README.mdgains a "Provisioned agents" section;docs/operations/managed-config.mdgains the include-checksum semantics.Verification
Every command run locally, in this worktree.
cargo check --workspace --lib— clean.cargo clippy --workspace --all-targets -- -D warnings— clean.cargo test -p librefang-kernel --lib— 1685 passed, 0 failed. Includes 15 newprovisioning::tests::*(the plan decision table, sorted scanning, per-file failures, duplicate names, unsupported subdirectories, state round-trip and malformed-state degradation, the prune-policy resolution rule) and 5 newconfig::tests::*(provenance_checksum_of_a_single_file_is_the_raw_byte_digest,provenance_checksum_covers_included_files,composite_checksum_matches_the_documented_sha256sum_pipeline,source_walk_skips_unsafe_and_missing_includes_instead_of_failing,source_walk_terminates_on_a_circular_include).cargo test -p librefang-api --lib— 1101 passed, 0 failed.cargo test -p librefang-api --test provisioning_test— 13 passed, 0 failed (new file). Named:provisioning_is_disabled_and_inert_without_the_environment_variable,a_declared_agent_is_created_at_boot_and_reported_with_its_provenance,manifest_writes_to_a_provisioned_agent_are_refused_with_the_documented_shape(all nine routes),operational_routes_stay_open_on_a_provisioned_agent,a_runtime_created_agent_stays_writable_beside_a_provisioned_one,editing_a_declaration_after_boot_reports_drift_without_changing_the_applied_checksum,a_malformed_declaration_is_reported_without_failing_the_boot,a_second_boot_over_an_unchanged_tree_changes_nothing,a_changed_declaration_is_applied_to_the_existing_agent_on_the_next_boot,removing_a_declaration_releases_the_agent_under_the_default_prune_policy,removing_a_declaration_deletes_the_agent_under_the_delete_prune_policy,an_existing_agent_is_adopted_when_the_deployment_declares_its_name,a_provisioned_agent_deleted_out_of_band_is_recreated_on_the_next_boot.cargo test -p librefang-api --test config_managed_mode_test— 14 passed (unchanged behaviour, including the checksum-unchanged-across-a-refused-write case).cargo test -p librefang-api --test config_path_unification_test— 2 passed.--test agents_clone_bulk_integration— 27 passed.dead_route_audit_test2 passed,openapi_path_coverage_test1 passed,openapi_spec_test1 passed,config_schema_golden1 passed (schema unchanged — no newKernelConfigfield).openapi.jsonregenerated andxtask/baselines/openapi.sha256rewritten as plainsha256sum openapi.json.kubectl kustomize deploy/kubernetes/{base,overlays/managed-config}piped throughpython3 scripts/check-k8s-manifests.py— both clean (3 and 5 manifests). Each new guard was also exercised against a deliberately broken copy of the overlay and produced the intended message: stalechecksum/provisioning, a declaration with noname, invalid TOML, a literalapi_key, an include naming an unrendered key, an absolute include, and a/-containing include.cargo nextest(not installed on this host) and any live daemon — CI covers the former, the latter is human-only.Deliberately not in this PR
channels/andworkflows/provisioning. Both persist through surfaces this reconcile does not model — channels intoconfig.tomlitself, already covered by the whole-config lock, and workflows into a SQLite-backed registry with its own run state — so neither is a matter of pointing the same scan at another subdirectory. An unrecognised subdirectory is reported as a failure rather than silently skipped, so a deployment that tries one is told.config.toml. The RFC's remaining open design question, now answered rather than deferred: ownership is expressed per resource, which is the granularity a deployment has actually asked for, and layering a second axis over the existingWRITABLE_EXACT_PATHSallowlist would produce a matrix whose interesting cases are all corners. Recorded inmanaged-config.md.I have deliberately not used
Closes #6695— see the issue comment for exactly what is and is not covered.