Status: active
What you'll learn: Declare your fleet's desired state in
fleet.yaml, commit to git, let the GitOps reconciler compute the diff against reality, approve through the standard intervention policy, apply. Replaces ad-hoc MCP calls with PR-based change control.Time: ~45 min (mostly diff review)
Builds on: Tutorial 02 (you've authored a module and understand the lifecycle promotion model) and Tutorial 06 (you understand the approval gate on a fleet change, and that a module upgrade is fleet-atomic once applied).
Sets you up for: Tutorial 11 — Multi-region federation — federation peers are enrolled with the
system_sdwan_*actions on nodes the pools declared here materialise;fleet.yamlitself has nosdwankind.
flowchart TD
Repo[(fleet-config git repo)]
Op[Operator] -- "git commit + push" --> Repo
Repo -- "5min cron OR<br/>manual sync trigger" --> RS[RepoSyncService]
RS --> DSP[DesiredStateParser]
DSP --> DE[DiffEngine]
DE -- "vs current platform state" --> Diff[Diff payload:<br/>add / update / delete<br/>across templates / modules /<br/>assignments / pools / platforms]
Diff --> AR[ApprovalRequest<br/>per GitOps Reconciler policy]
AR --> Op2{Operator<br/>approves?}
Op2 -->|yes| App[ApplyService<br/>walks diff in dependency order]
App --> Done[Resources<br/>materialized in DB<br/>+ instances reconcile]
App --> SR[SyncRun status:<br/>success / partial / failed]
Op2 -->|edit| Edit[Adjust YAML +<br/>re-trigger sync]
Edit --> RS
Drift[Drift sensor<br/>every 60s] -- "reality drifts" --> Repo
By the end you'll have your fleet's desired state codified in git, with PR review as the gating mechanism for fleet changes.
fleet.yaml declares the desired state for an Account:
- Templates — the node templates, each bound to a node platform
- Modules — the modules, with their variety and priority
- Assignments — which modules compose each template
- Pools — what should exist: warm instance pools bound to a template,
with sizes, lifecycle class and status (this is the only way
fleet.yamlsays "N nodes of template X" — there is nonodekind) - Platforms —
PlatformDeploymentreplica targets per service role - Provider configs — informational only; credentials are never rotated via GitOps
Nodes and the SDWAN overlay (networks, peers, VIPs) are not in this file — see the schema notes under Step 1.
The reconciler walks this and computes the delta against current
platform state. Each delta becomes an Ai::AgentProposal (with
proposed_changes.source = "gitops"). By default it requires operator
approval; on an auto_apply repo the reconciler auto-approves + applies
non-destructive (create / update) diffs itself (see "Auto-apply" below). The
reconciler opens these proposals directly — there is no
system.gitops_* intervention policy; approval flows through the
standard proposal review queue, not a per-action autonomy policy.
Implementation status (honest, as of 2026-06-03):
| Capability | Status |
|---|---|
Parse fleet.yaml from a git repo |
Shipped (DesiredStateParser) |
| Compute diff against current state | Shipped (DiffEngine) |
| Reconciler opens proposals per change | Shipped (Reconciler) |
| MCP actions: list_repositories / get_repository / register / sync / get_sync_run / get_drift_report / apply_proposal | Shipped (gap remediation slices closed; the two reads under IMP-f07be27ba0b0) |
| Proposal-apply path (post-approval execution) | Shipped for template / module / assignment / pool / platform create+update via system_gitops_apply_proposal, plus an operator-approved assignment destroy; template / module / pool / platform destroy remains a follow-up, provider_config is informational |
Reconciler-driven auto-apply (repository.auto_apply) |
Shipped — auto-approves + applies non-destructive (create / update) diffs, gated by the kill-switch + per-tick cap; destroys always stay manual |
| Drift sensor (alert when reality drifts from git) | Shipped (GitopsDriftSensor, registered in FleetAutonomyService::SENSORS; emits system.gitops.drift_detected) |
| Operator UI for diff review + approval | Partial — generic ApprovalRequest UI works; GitOps-specific drill-in panel forthcoming |
GitOps applies the create/update path for template, module,
assignment, pool and platform — either once an operator approves the proposal, or
automatically on an auto_apply repo (see "Auto-apply"). The
conservative v1 gap is deliberate: a destroy diff is never
auto-applied — the reconciler always leaves it for review — and on
operator approval only an assignment destroy is executed. Destroying a
template, module, pool or platform returns an explicit
not-yet-implemented error, and provider_config diffs are informational
(logged, nothing written), so those resources are removed manually and a
stray fleet.yaml edit can never delete fleet infrastructure unattended.
Set auto_apply: true on the repository to let the reconciler apply diffs
without an operator step. The audit Ai::AgentProposal is still created
first, then auto-approved (impact_assessment.auto_applied = true) and
applied via ApplyService. A diff auto-applies only when all of: the
repo opts in, the change is non-destructive (create / update — never
destroy), the account is not halted (platform kill-switch /
account.ai_suspended?), and it's within the per-tick cap. On a stale
conflict or validation failure the proposal reverts to pending_review for
an operator. Restrict auto_apply: true to repos whose git history is the
trusted change-control gate (multi-reviewer PRs, trusted committers).
Why GitOps: git history is the audit trail; PR review is the change control; reconciler convergence catches drift. Replaces "operator runs imperative commands and hopes the snapshot reflects intent."
| Requirement | How |
|---|---|
| A Gitea repo (or any git remote) for the fleet config | platform.create_gitea_repository |
Permissions: system.modules.read / system.modules.update for the module-shaped platform.* calls; system.gitops.read for the repository reads that carry the credential contract (REST index/show/sync_runs and the system_gitops_get_repository / system_gitops_list_repositories MCP verbs); system.gitops.write for the REST create/update/destroy; system.gitops.sync for the REST sync_now. The other system_gitops_* MCP verbs stay on system.modules.read / .update |
Both families are registered in the system catalog (server/lib/powernode_system/engine.rb). admin holds system.modules.*, system.gitops.read (since IMP-b1191457a091) and — since IMP-e313a4a72309 — system.gitops.write and system.gitops.sync, so a plain admin can drive the whole REST surface. Only system.gitops.reconcile stays worker-only; it gates the worker tick, not an operator action. A grant added to the catalog reaches an ALREADY-INSTALLED deployment at its next boot, when Permissions::RoleGrantReconciler runs — not at deploy time. That reconcile is invoked from the powernode-hub-backend module's rails-start.sh:356-357, the only invocation site there is, so a Rails install started by any other path never gets it |
| A running Powernode platform with at least one Account configured | Default |
| (Optional) Tutorial 02 module authoring done | Helps you understand the templates section of fleet.yaml |
fleet.yaml is a mapping of the resource sections GitOps knows —
templates, modules, assignments, pools, platforms and the
informational provider_configs — plus an optional fleet: defaults block.
Every resource section is itself a mapping of name → attributes: a
list-shaped templates, modules, assignments, pools or platforms
section is refused by the validator (provider_configs is the one section
with no shape rule — DesiredStateParser#parse_section re-keys a list there
by each item's name). The file below is the one the rest of this tutorial
syncs: two templates, three modules, the composition of each template, and
one warm instance pool.
# fleet.yaml
templates:
edge-base:
name: edge-base
description: Hardened Ubuntu edge baseline
node_platform: ubuntu-24.04-amd64-uefi
edge-cdn:
name: edge-cdn
description: Edge CDN node — the edge-base baseline plus nginx
node_platform: ubuntu-24.04-amd64-uefi
modules:
system-base:
name: system-base
variety: subscription
priority: 10
config: {}
security-hardening:
name: security-hardening
variety: config
priority: 20
config: {}
nginx:
name: nginx
variety: config
priority: 50
config:
worker_processes: 4
assignments:
edge-base:system-base:
template: edge-base
module: system-base
edge-base:security-hardening:
template: edge-base
module: security-hardening
edge-cdn:system-base:
template: edge-cdn
module: system-base
edge-cdn:security-hardening:
template: edge-cdn
module: security-hardening
edge-cdn:nginx:
template: edge-cdn
module: nginx
pools:
edge-cdn-tokyo:
name: edge-cdn-tokyo
node_template: edge-cdn
lifecycle_class: ephemeral
status: active
target_size: 2
min_size: 1
max_size: 3What each section resolves to, and what the apply path needs from it:
| Section | Platform model | Keyed by | Apply-time requirements |
|---|---|---|---|
templates |
System::NodeTemplate |
template name | node_platform — the name of a System::NodePlatform already in the account; ApplyService refuses a template create without it, and raises a stale conflict if the name resolves to nothing. AccountBootstrapService seeds four (ubuntu-24.04-lts, ubuntu-24.04-rpi4, ubuntu-24.04-arm64-uefi, ubuntu-24.04-amd64-uefi) — list yours before you commit the file |
modules |
System::NodeModule |
module name | variety ∈ System::NodeModule::VARIETIES — subscription / config / instance. The validator is looser than the model here: DesiredStateValidator::MODULE_VARIETIES also accepts role, which NodeModule and the system_node_modules_variety_check constraint both reject, so a variety: role line passes the schema gate and then fails at apply with Variety is not included in the list. priority must be an integer if present, but see the convergence gaps below — it is not applied |
assignments |
written as System::TemplateModule, diffed against System::NodeModuleAssignment |
the validator requires a <name>:<module> string; DiffEngine reads that key as <node-name>:<module-name> |
template and module — names declared in the two sections above; ApplyService#apply_assignment resolves both by name and reports a stale conflict if either is missing. The two sides do not share an identity — see the convergence gaps below |
pools |
System::InstancePool |
pool name | node_template — a template name from this file; lifecycle_class ∈ ephemeral / spot; status ∈ active / paused / draining / archived; the three sizes as integers |
platforms |
System::PlatformDeployment |
deployment name | node_template plus a service_role; target_replicas an integer (not used above) |
Two things the file deliberately does NOT contain, because GitOps cannot express them:
- There is no
nodekind.ApplyService#apply_diffdispatches template, module, assignment, pool, platform and provider_config, and raisesUnsupportedDiffErrorfor anything else;DiffEnginecomputes diffs for only those kinds. What should exist is declared as apoolsentry — the instance-pool replenisher materialises the pool's members as real nodes — never as a list of hostnames. A Node'slifecycle_classis not GitOps-declarable at all (nor settable through any other API surface; the column is retired). The class lives on theSystem::InstancePool, which is exactly where the example above sets it. See../USE_CASE_MATRIX.md§"Howlifecycle_classis actually set". - There is no
sdwankind. Networks, peers and virtual IPs are still built with thesystem_sdwan_*actions; asdwan:block infleet.yamlis reported as an unknown top-level key and fails the whole sync.
The validator also refuses version: and account: headers — a repository
is bound to its account when it is registered (Step 2), not by a key inside
the file. Unknown top-level keys are collected, not skipped: one stray key
aborts the entire sync, so keep the file to the sections above.
Three v1 convergence gaps. The file above validates, parses and applies,
but DiffEngine and ApplyService do not yet agree on three fields, so those
lines re-open as a proposal on every sync. Apply is idempotent — re-approving
costs nothing and loses nothing — but do not expect a clean diff_count: 0
for them:
- Assignments are diffed node-keyed and applied template-keyed.
DiffEngine#diff_assignmentsbuilds live state fromSystem::NodeModuleAssignment, keyed<node-name>:<module-name>, whileApplyService#apply_assignmentcreates aSystem::TemplateModulefrom the entry'stemplate+modulenames. A<template>:<module>key therefore never matches a live row: applying it composes the template correctly, and the next sync proposes the identicalcreateagain. modules.*.priorityandmodules.*.configare validated and diffed but never written.apply_modulesets name / variety / category on create and description / variety on update — nothing else — so a non-defaultprioritycomes back as anupdatediff each pass.templates.*.node_platformis a name going in andnode_platform_idcoming back.apply_templateresolves the name at create time;diff_templatescompares the storednode_platform_id, which the file does not carry.
Expected outcome: the file passes System::Gitops::DesiredStateValidator
with no errors and DesiredStateParser returns ok?: true — this exact block
is run through both (and through DiffEngine) by
server/spec/docs/gitops_fleet_yaml_tutorial_fidelity_spec.rb, so it cannot
drift from the schema unnoticed. The authoritative schema is the validator
itself, server/app/services/system/gitops/desired_state_validator.rb — read
ALLOWED_TOP_LEVEL and the validate_* methods.
// Create the repo first
platform.create_gitea_repository({
repo_name: "fleet-config",
organization: "<account>", // omit for the personal namespace
private: true
})
// Push fleet.yaml to it via git
// ...
// Register with the platform's reconciler
platform.system_gitops_register_repository({
name: "fleet-config", // required; unique within the account
repo_url: "git@registry.example.com:<account>/fleet-config.git",
branch: "main",
vault_credential_path: "secret/data/powernode/gitops/fleet-deploy-key"
})
// → { repository: { id: "gitops-repo-1", last_status: "pending", ... } }
// ...OR, when policy requires approval (the seeded default):
// → { pending: true, approval_request_id: "...", deferred_operation_id: "..." }This verb is approval-gated (system.gitops_register_repository, seeded
require_approval in GITOPS_RECONCILER_POLICIES): a registered repository is
the input to every later sync and apply, so on the default policy the call
returns pending: true and no repository exists until an operator approves
the request. Approve it in the Autonomy modal (or set the category to
auto_approve for this agent) and the registration runs on replay, returning
the repository shape above. The parameters are validated before the
approval is queued, so a bad repo_url still fails inline rather than parking
a request that could only fail later.
vault_credential_path is a Vault KV path, not a credential id. Store
one of the two shapes at that path, matching your remote:
{ ssh_key } for git@ / ssh://, or { username, password } for
https:// (and plain http://, which takes the same branch) —
RepoSyncService#build_git_env picks by URL scheme. Inline credentials in
repo_url (https://user:pass@...) are rejected at validation.
Expected outcome: the registration is approved and the repo registered (or
pending: true until it is); once registered, the reconciler will pull on the platform's
fixed 5-minute tick and any time system_gitops_sync_repository is invoked.
The interval is not per-repository — GitopsRepository.due_for_sync is
called with a hardcoded 5-minute staleness
(api/v1/system/worker_api/gitops_controller.rb), so there is no registration
parameter that changes it.
platform.system_gitops_sync_repository({
repository_id: "gitops-repo-1" // the GitopsRepository id
})
// → { success: true, data: { repository_id, sync_run_id, ok: true, diff_count,
// proposal_ids, synced_revision, diff_summary, error } }The reconcile runs synchronously inside this call — diff_count,
proposal_ids and diff_summary are already final when it returns, and
sync_run_id is the run this call finalized (Step 4 reads it back). A reconcile
that failed — clone/pull refused, fleet.yaml did not parse, the diff
raised — comes back as { success: false, error: "<reason>", data: { ..., ok: false, sync_run_id } }: branch on success, not on ok or on the shape of
diff_summary, and do not conclude the fleet matches the repository on that
response. On a standby control plane the verb refuses outright with
refusal_code: "standby_control_plane" (retryable: false) and creates no run
— the active plane owns the reconcile. The one success response that carries a
non-nil error is a partial run, where the per-tick proposal cap truncated
the proposal set (diff_count exceeds proposal_ids.length).
The reconciler:
- Pulls latest from
main - Parses
fleet.yamlviaDesiredStateParser - Loads current platform state (templates, modules, assignments, pools, platforms)
- Runs
DiffEngineto compute the delta - Opens
Ai::AgentProposalper change
Step 3 already handed the run id back as data.sync_run_id — pass it straight
to system_gitops_get_sync_run below. REST offers the same trigger, and is
still the only way to LIST past runs: no MCP verb enumerates them.
# Trigger + get the run id in one call (the REST twin of Step 3). Since
# SWEEP-2026-09-03 this endpoint matches the MCP verb: a reconcile that FAILED
# answers 422 with the reason in `error` and the same payload under `details`
# (the run included, so it is still reachable), and a standby control plane
# answers 409 `standby_control_plane` and creates NO run. Branch on the HTTP
# status; it used to answer 2xx for both, which is why the older advice here
# said to read the run's `status` instead.
curl -X POST -H "Authorization: Bearer $JWT" \
http://localhost:3000/api/v1/system/gitops_repositories/gitops-repo-1/sync_now
# 200 → { sync_run: { id, status, error_message, ... }, ok, diff_count, proposal_ids, diff_summary }
# 422 → { success: false, error: "...", details: { sync_run: {...}, ok: false, ... } }
# 409 → { success: false, error: "standby control plane — ...", code: "standby_control_plane" }
# Or list the timeline of past runs
curl -H "Authorization: Bearer $JWT" \
http://localhost:3000/api/v1/system/gitops_repositories/gitops-repo-1/sync_runs
# → { sync_runs: [ { id, status, error_message, diff_count, proposal_ids, ... }, ... ] }
# newest first, 50 most recentplatform.system_gitops_get_sync_run({
sync_run_id: "<run-id>"
})
// → { sync_run: {
// id, gitops_repository_id,
// status, // running | success | failed | partial
// started_at, completed_at, duration_seconds,
// diff_count,
// proposal_ids, // ids only — fetch each proposal to see its payload
// synced_revision,
// diff_summary, // per-kind counts, not a full diff
// error_message
// } }Expected outcome: diff_count + the ids of the per-change proposals
awaiting approval. The run carries a summary, not the diff itself — read the
Ai::AgentProposal rows for per-change payloads, or use
system_gitops_get_drift_report (below) for the diff bodies.
Operator opens /app/approvals UI:
- Reviews each proposal (PR-style summary)
- Optionally edits parts of the plan (e.g., comments out one pool before apply)
- Click Approve on each (or bulk-approve if
Ai::ApprovalRequestUI supports it)
For every applicable kind — template, module, assignment, pool,
platform — apply each approved proposal directly with
system_gitops_apply_proposal; it executes the diff against the DB:
platform.system_gitops_apply_proposal({
proposal_id: "prop-1" // must be in 'approved' status with proposed_changes.source = 'gitops'
})
// → executes the create/update against the DB.
// Errors with stale_conflict if reality drifted after the proposal was opened
// (re-sync to regenerate a fresh proposal, then re-approve).The call itself is approval-gated under the system.gitops_apply_proposal
autonomy category (seeded require_approval on the GitOps Reconciler).
When the resolved policy requires approval, the response is
pending: true with a deferred_operation_id, nothing has been written,
and the apply runs when an operator approves the request — do not retry
it. auto_approve and notify_and_proceed apply inline and answer with
the normal envelope; block refuses. Approving the Ai::AgentProposal (Step 5) says the diff is wanted;
this gate says an agent may write it.
Apply in dependency order: templates → modules → assignments → pools /
platforms (each pool/platform create resolves its node_template by
name, and each assignment resolves its template and module, so a
dependency applied out of order comes back as a stale conflict — re-apply
once the referenced resource exists). Individual nodes and the SDWAN
topology are never in the diff: provision those with the standard actions
(system_create_node, system_sdwan_create_network, …) outside the GitOps
loop.
v1-conservative gap — destroys are never auto-applied.
system_gitops_apply_proposal applies template / module /
assignment / pool / platform create+update, plus an
operator-approved assignment destroy. Destroying a template,
module, pool or platform returns an explicit not-yet-implemented
error and provider_config diffs are informational, so tearing those
down still requires a deliberate manual action and a stray fleet.yaml
edit can never tear down fleet infrastructure unattended:
// A template/module/pool/platform delete is never applied — remove the resource yourself:
platform.system_delete_node({ node_id: "<node-id>" }) // explicit, operator-initiatedYou keep the audit trail + PR review benefits, and create/update converges automatically; only destructive operations stay hands-on by design.
platform.system_gitops_get_sync_run({ sync_run_id })
// → { sync_run: { status: "success", diff_count, proposal_ids, ... } }
// "partial" means the tick hit MAX_PROPOSALS_PER_TICK, NOT that some
// proposals are still awaiting approval — a run's status describes the
// reconcile pass, not whether its proposals were applied.The sync run does not report apply results. To confirm convergence, re-read the drift report below: it recomputes desired-vs-live from scratch.
To make any fleet change:
- Operator clones the fleet-config repo
- Edits
fleet.yaml(add a pool, change a template, assign a module) - Opens a PR
- Team reviews; PR is approved + merged
- Reconciler picks up the change on next tick (or manual sync)
- Operator approves proposals in Powernode UI
- Changes apply
platform.system_gitops_get_drift_report({ repository_id: "gitops-repo-1" })
// → { repository_id, synced_revision, drift: false, diff_count: 0, diffs: [] }
// (when reality matches git; read-only — opens no proposals)diff_count: 0 is reachable for templates (once their node_platform_id is
set), modules with a default priority, pools and platforms. The three
convergence gaps in Step 1 keep the assignment lines — and any module carrying
a priority or config — reporting drift after a successful apply; that is
an engine-side identity mismatch, not an unapplied change.
When drift exists (reality diverges from git — e.g., an operator made an
imperative change), GitopsDriftSensor (registered in the Fleet Autonomy
reconciler, runs every 60s) emits
system.gitops.drift_detected FleetEvents
(the kind is namespaced — filtering on a bare gitops.drift_detected matches
nothing; that bare form is NOT IMPLEMENTED and is named here only as the
mistake to avoid);
the operator must either commit the change back to git or reconcile it away.
# ⚠️ system_gitops_unregister_repository MCP wrapper is aspirational —
# use the REST endpoint directly:
curl -X DELETE http://localhost:3000/api/v1/system/gitops_repositories/<id> \
-H "Authorization: Bearer $JWT"
# → repo removed from reconcile cycle; underlying git repo unaffectedDesiredStateParser fails with "schema validation error" — fleet.yaml
doesn't match the expected schema. The error names the offending key. Read
the rules in app/services/system/gitops/desired_state_validator.rb
(ALLOWED_TOP_LEVEL + the validate_* methods) and the section shapes in
app/services/system/gitops/desired_state_parser.rb.
Diff shows changes you didn't make — drift between platform state and git source-of-truth. Either:
- Commit the drift back to git (accept that imperative changes
happened): edit
fleet.yamlto match current state, commit, push. - Reconcile away the drift (treat git as authoritative): approve the proposals that revert the imperative changes.
Sync fails / SSH credential doesn't resolve — the repository row carries the latest outcome, and the sync run carries the per-run detail. Start with the row:
curl -H "Authorization: Bearer $JWT" \
http://localhost:3000/api/v1/system/gitops_repositories/<id>
# → { gitops_repository: { last_status: "failed", last_error: "RuntimeError: git clone failed: ...", ... },
# recent_runs: [ ... ] }last_status/last_error are written on every terminal path of the
reconcile — the three early returns, the success/partial tail, and the outer
rescue all funnel through one exit that records them — so failed on the
row means the most recent run failed, and a subsequent success clears
last_error back to null. last_status: "pending" now means genuinely never
run, not "failing silently". In the partial case last_error reports the
per-tick proposal cap rather than an error.
On a failure the row's last_synced_at / last_synced_revision /
last_diff_count are deliberately not overwritten — they keep describing
the last successful sync, so the row reads "failing now, last good was
<sha>".
Two notes on what the row does not cover. A standby control plane skips
the whole reconcile and writes nothing here, by design — the active plane owns
this row (over MCP, system_gitops_sync_repository refuses on standby with
refusal_code: "standby_control_plane" rather than reporting a skipped run as
success). And per-diff problems are not repository-level status either: a
proposal that could not be opened is only logged (on the sync run it shows up
implicitly, as diff_count exceeding the number of proposal_ids), and an
auto-apply failure is not recorded on the sync run at all — it comes back on
the sync call's own result, and on the proposal's impact_assessment. For the
full run history, read the runs:
curl -H "Authorization: Bearer $JWT" \
http://localhost:3000/api/v1/system/gitops_repositories/<id>/sync_runs
# → { sync_runs: [ { status: "failed", error_message: "RuntimeError: git clone failed: ...", ... } ] }Changed in IMP-8c1a94b8e1d6. Before this fix the reconciler wrote
last_status/last_erroronly on the success path, so a repository whose every run was failing kept reporting its last success — orpendingforever, if it had never succeeded. Earlier revisions of this tutorial told you not to trustlast_errorand to read the sync run instead. That workaround is retired: the row is now authoritative for the latest outcome.
Two things the platform will not tell you, both worth knowing before you read the error:
-
A Vault miss is reported as a git failure, not a Vault one. This is the READ-FAILURE case specifically — an unreachable Vault, a sealed one, a path that does not exist, a denied policy. A path that resolves to the wrong shape is a different case and fails honestly:
require_creds!raisesCredentialShapeErrornaming the missing key before git runs at all.On a read failure,
RepoSyncService#fetch_vault_credsrescues, logs it, and returnsnil;build_git_envthen falls through to an anonymous clone. Whatever git then says is what lands inerror_message— for anhttps://remote an authentication failure, for agit@remote usuallyHost key verification failed., because the anonymous path sets noGIT_SSH_COMMANDand git falls back to the Rails process's ambient ssh config. The Vault cause appears only in the log, and the two lines are at different levels, so a WARN-only or ERROR-only filter shows you half of it:WARN [Gitops::RepoSync] Vault credential fetch failed: <reason> ERROR [Gitops::RepoSync] <repository-id>: RuntimeError: git clone failed: <sanitized stderr>The second line reads
git fetch failedrather thangit clone failedwhenever the work tree undertmp/gitops/<account>/<repository>already exists, since that path fast-forwards instead of cloning. Grep the backend service log forGitops::RepoSyncat both levels before concluding the deploy key is wrong. -
Check Vault itself first — that part IS exposed. Before suspecting the credential, rule out a sealed or unreachable Vault:
curl -X POST -H "Authorization: Bearer $JWT" \ http://localhost:3000/api/v1/admin_settings/vault/test # → { connected, sealed, initialized, version, latency_ms } # or { connected: false, error: "Cannot reach Vault at <addr>" }
connectedis false whenever Vault is sealed, so asealed: truehere explains every credential failure at once and no per-repository digging is needed. -
Then check the path, which is the other half. A reachable, unsealed Vault still fails the sync if the repository points at a path that does not exist or holds the wrong keys. The REST read echoes the configuration back, so you can see what to probe without leaving the platform:
curl -H "Authorization: Bearer $JWT" \ http://localhost:3000/api/v1/system/gitops_repositories/$REPO_ID # → gitops_repository: { ..., vault_credential_path, required_credential_keys }
required_credential_keysis derived from your remote's URL:ssh_keyforgit@/ssh://;passwordfor anhttps://remote that already names a user (https://bot@host/repo.git);passwordandusernamefor one that does not, because git then prompts for the username first and the askpass shim has to answer it.[]means an anonymous repo with no credential path.nullmeans the opposite and is worth acting on: the repository HAS a credential path but its URL scheme matches neither auth branch, so the sync clones anonymously and ignores the path entirely.The
required_credential_keysvalue is the same setRepoSyncServiceenforces —build_git_envpasses it straight torequire_creds!— so the advertised contract cannot drift from the enforced one.The same read is on the MCP surface (IMP-f07be27ba0b0 — before it,
serialize_gitops_repository's only call site wassystem_gitops_register_repository, so over MCP you saw these fields only on a repository you had just created yourself):platform.system_gitops_list_repositories({}) platform.system_gitops_get_repository({ repository_id: "gitops-repo-1" })
Both MCP verbs return the one projection, so the credential contract cannot drift between them. It is not byte-identical to the REST body above: the MCP projection omits
metadataandupdated_atand renders timestamps as ISO-8601 strings. Every field this section is about —vault_credential_path,required_credential_keys— is on both.The probe below is REST-only, deliberately — it is not mirrored onto MCP. Naming an arbitrary Vault KV path is a different exposure for an agent principal than for an operator in the admin UI, so the read tells you which path a repository uses and the probe stays a human action. Feed the two fields you just read into it:
curl -X POST -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \ -d '{"path":"secret/data/powernode/gitops/fleet-deploy-key","required_keys":["ssh_key"]}' \ http://localhost:3000/api/v1/admin_settings/vault/test # → { connected, sealed, ..., credential_path, path_present, # credential_keys, required_keys, missing_keys, shape_ok }
Read it as:
path_present: false— nothing at that path.shape_ok: falsewithmissing_keys: ["ssh_key"]— the path resolves but not to what this remote needs;credential_keyslists what it does hold.path_present: nullwith apath_error— the read itself failed (a denied policy, say), which is deliberately not reported as an absent path.shape_ok: null— you sent norequired_keys, so the key names are reported but the shape was not judged; that is a declined verdict, not a pass. A blank value counts as missing, matching the sync path.A successful probe also invalidates that path's entry in the Vault read cache, so the next sync reads what the probe just saw rather than re-serving a stale five-minute-old hit. Probe after fixing a payload and the next sync agrees with you.
Three limits worth knowing. The probe reports key names only, never values — by design, and nothing should change that. It requires
admin.settings.security, not theadmin.settings.readthat the plain connectivity check above needs, because naming an arbitrary KV path discloses what lives there. And it deliberately does not participate in the Vault circuit breaker, so repeated probes of an unreadable path cannot trip Vault offline for the rest of the platform. For the values themselves, read out of band as../runbooks/gitops-reconciliation.mddescribes:vault kv get secret/data/powernode/gitops/fleet-deploy-key
Module versions cannot be pinned in fleet.yaml yet — the modules
section carries variety, priority and config and nothing else; there is
no version field in DesiredStateValidator, DiffEngine or ApplyService
(apply_service.rb:33 books versions and file_spec as a later slice). A
node therefore runs whatever version is live for the module, which can shift
under you. Pin with system_promote_module_version / the module's own
promotion controls until GitOps grows the field.
Conflicting concurrent PRs — git's merge mechanics handle these; resolve in PRs before they reach the reconciler. Don't let two operators push competing fleet.yaml versions and expect the reconciler to pick the right one.
- Tutorial 11 — Multi-region federation — the SDWAN
and federation topology, built with the
system_sdwan_*actions. It is not expressible infleet.yaml: there is nosdwankind, so the two surfaces stay separate. ../runbooks/gitops-reconciliation.md— operator runbook: advanced patterns and DR scenarios. Its schema section is older than this page (it still shows aversion:/account:header and lists four kinds) — trustdesired_state_validator.rbover it.../gitops.md— current GitOps reconciler design reference.SMOKE_TEST.md— oncesmoke_test_gitops_reconciler.rblands, it'll exercise this flow at the platform layer.
Last verified: 2026-08-30