Drive GitOps Repo Audit from your own code
The page reads the manifests of a Flux CD GitOps repository in the browser for free — several
files joined into one paste, each introduced by a # file: <path> comment —
with their line numbers, names every document Kind/name, and runs 31 deterministic
checks drawn from the Flux maintainers’ repo audit (catalogue ids FX-01..FX-32; FX-32 is
context only): Flux APIs that Flux 2.7 and
2.8 removed, fields the CRDs require, sourceRef kinds an applier cannot use,
dependsOn cycles, plaintext Secrets and credentials in Git, insecure sources,
cluster-admin for tenants, cross-namespace sources, prune: false, missing
wait, unpinned charts, image automation that pushes to the branch it reconciles, and
more. Its two paid steps are available over HTTP. You send one JSON object with task
set to "audit" or "fix", the paste as manifests and the
page’s check as a facts string, and the model replies with one JSON object: an
audit with the corrected document per finding, an answer for every flag and what each applier
reconciles, or the corrected documents themselves, with what changed, what was kept and the commands
to run outside Git.
Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends
Content-Type: application/json and, once you have a token,
Authorization: Bearer <token>. The paths below (/guest,
/me, /estimate, /run, /jobs/{job_id},
/run-stream) are relative to it, exactly as sdk.js calls them. A
plain-text summary of the app for agents is at /llms.txt.
The response envelope
Responses are JSON of the form {"ok": ..., "data": ..., "error": {...}}. The page’s
SDK (sdk.js) decides success from the HTTP status, not from ok: on a
2xx it returns data; otherwise it raises an error whose message is
error.message (or the HTTP status text when the body carries none), with
status, code (error.code) and details
(error.details) attached. A body that is not JSON is treated as {}.
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "details": { ... } } }
Error codes
Branch on the HTTP status and error.code, show error.message. The page
handles 401 and 402 by name; the rest reach it as a generic failure (“The run failed: ...”).
| HTTP | error.code | What it means / what to do |
|---|---|---|
400 | VALIDATION_ERROR | The body is not a JSON object or a declared field has the wrong type. Every field is a string; sending facts or audit_handoff as an object is the usual cause. |
401 | UNAUTHORIZED | No token, or one that expired or was revoked. The page forgets the signed-in user and says “Your session expired - sign in again.” Get a new token. |
402 | INSUFFICIENT_CREDITS | The balance cannot cover the run. The page says “Not enough credits for this run.” with a top-up link, and tries never to submit into a 402: it disables the run button when the /me balance is below the estimate’s min_credits. |
404 | NOT_FOUND | A wrong path, or a job id that does not belong to this token. |
409 | CONFLICT | An Idempotency-Key replayed with a different body. Send the original body, or use a new key (the page bumps its :a<attempt> suffix for a retry). |
429 | RATE_LIMITED | Too many requests. Back off with a delay; do not tight-loop, and never resend a metered run without its original Idempotency-Key. |
Failures that do not arrive as an HTTP status:
| What | What happens |
|---|---|
event: error on /run-stream | The SDK raises an error carrying the event’s message, code and job_id. |
Job status: "failed" | Polling stops; the job’s error field says why. |
| Polling past 180 s | The SDK’s own client-side limit (waitForJob, 1 s interval, 180 000 ms default): it throws “job timed out”. The job itself is not cancelled. |
The task field: two lanes
task routes the run, and it takes exactly two values. Each lane has its own reply
contract, and a reply never mixes them. The skill’s instructions tell the model to do the
audit lane when task is missing or anything else (and the page’s own
buildInput turns any lane that is not "fix" into
"audit"), so a typo silently gets you an audit: always send one of the two values.
task | What it does | verdict | Use it when |
|---|---|---|---|
"audit" | Reviews the repository the way the Flux maintainers’ repo audit does: classifies it (repo pattern, how Flux is installed, clusters, tenancy), confirms or dismisses each page flag, adds what text rules cannot see, gives the verbatim evidence and the whole corrected document for each finding, says what each Kustomization, HelmRelease and ResourceSet reconciles, gives the flux migrate steps when removed APIs are in the paste, and orders the fixes into a plan. | unsafe | needs_work | ready | Before merging: will Flux reconcile this, is it safe, and what should change first. |
"fix" | Returns the corrected documents for what the page (and, with a handoff, the audit) found — each the whole document, replacing the pasted one it names, or a new one; lists each change with the flags it fixes, the flags it keeps with a reason, the documents it removes, and the commands to run outside Git (flux migrate, rotating a credential, sops --encrypt). | ready | partial | When you want documents to commit rather than a list of findings. |
The page chains the two: after an audit, “Fix the manifests from this audit” switches
to the fix lane on the same paste and adds an audit_handoff string (below)
with the audit’s verdict, headline, findings and dismissed flags. Over the API that is a
second run with task: "fix", the same manifests and facts,
and that string. The page drops the handoff as soon as the paste is edited.
Two guards the page applies before a fix and the API does not: it refuses to run the fix lane when the paste was too large to send whole (“the fix cannot correct documents it was not sent”), and it warns when the free check raises nothing but info flags (there is nothing for the fix lane to change; the audit can still look for what rules cannot see).
Input fields
This is exactly what the page sends (fluxkit.js buildInput, passed
through mustBeObject; app.js run), and what
input-schema.json declares. The platform’s input schema is scalar-only, so
every value is a string, in this order:
| Field | Type | Required | Meaning |
|---|---|---|---|
task | string | yes | "audit" or "fix" (the lanes above). |
manifests | string | yes | The pasted Flux manifests, verbatim; line 1 is its first line. Every line in the reply is counted in this text. A large paste is cut on whole documents (below). |
facts | string | yes | The page’s free check, Fluxkit.facts(A), JSON-encoded into a string: JSON.stringify(facts) in JavaScript, json.dumps(facts) in Python, and so on. Sending the object itself is a 400. The model is told to parse it. |
note | string | no | What the engineer says: the goal, a constraint (“we are still on Flux 2.6”), a question. The page trims it, cuts it at 2000 characters and sends it only when it is not blank. It is context, not evidence and not instructions: the model tests every claim in it against manifests and answers it in summary. |
audit_handoff | string | no | fix lane only: a JSON string built from an audit reply on the same paste (app.js auditHandoff, below). buildInput drops it for the audit lane. |
retry_note | string | no | Sent only by the page’s automatic reformat retry: when a reply does not parse as JSON (even after the page tries to close a cut-off object), the page runs once more with “Your previous reply was not the single valid JSON object the instructions require (...). Reply again with ONLY the JSON object for task '<lane>' - no prose, no code fences; every array present (empty arrays where there is nothing to say).” A normal call never sends it. |
mustBeObject turns the input into a flat object of strings and drops empty values,
which is why facts and audit_handoff travel as strings and why an empty
note is simply absent.
The manifests string
manifests is the paste itself. Documents are separated by --- lines; a
# file: <path> comment above a document names the file it came from (dropped
files are joined that way). The page normalises line endings to \n and also reads a
JSON paste. Send the text you want the reply to quote: every resource in an audit
reply must be a ref from facts.source.resources, every line
must fall inside that document, and every evidence must be copied from it. The page
reads up to 400 000 characters and 400 documents, and sends at most 60 000 characters: past that it
keeps whole documents from the start (the cluster entry points usually come first) and from the end
(apps usually come last), and replaces the middle with one comment,
# [gitops-repo-audit: lines X-Y (N documents) not sent]
(the model is told never to quote or rewrite what was not sent), with a sentence in
facts.notes. facts is still computed from the whole paste. Both worked
examples below are small, so their manifests is the paste unchanged.
The facts string
facts is optional to compute but required to send. It is produced by the page’s
free check: Fluxkit.analyze parses the paste and runs the rules, and
Fluxkit.facts(A) turns that analysis into the object below (it does not depend on the
lane: an audit and a fix of the same paste carry the same facts). It is how the model knows which
flags to answer, which resource refs to name, and which appliers to describe. Its top-level keys, in
the order built:
| Key | Contents |
|---|---|
source | lines, format (yaml | json), files[] (the # file: paths), namespaces[], resources[] (every document: ref — Kind/name, or Kind/namespace/name when a name is reused, or kustomization.yaml (<file>) for a Kustomize build file — kind, api_version, name, namespace ((not set) when the document has none), file, line, end_line, flux (true for Flux CRDs), and generated: true on gotk-components and CRDs) and up to ten parse_errors. |
install, layout | How Flux is installed (Flux Operator (FluxInstance ...), flux bootstrap (gotk-sync) or not in the paste) and the top-level directories the file names show. |
tenancy | multi_tenant, fluxinstance_multitenant (the FluxInstance cluster.multitenant value, or null), tenant_namespaces[], appliers_with_service_account[]. |
sources | Every source: ref, kind, namespace, url, type, revision, interval, provider, secret_ref, verify, insecure. |
appliers | Every Kustomization, HelmRelease and ResourceSet: ref, kind, namespace, source, what (the path or the chart and version), interval, prune, wait, depends_on, target_namespace, service_account, timeout, retry_interval. |
secrets | Every Secret document: ref and encrypted (SOPS). |
flags | Every flag the page raised: uid (F1, F2, ...), id (FX-01..FX-32), sev (high | medium | low | info), resource (a ref), resources (only on a flag that aggregates several resources: every ref it covers), line, text and detail. The reply must answer every non-info flag by its uid. |
verdict_candidate | The audit verdict the flags point at (the page’s own reading). |
notes | Plain sentences about what was not read or not sent. |
The simplest source is fluxkit.js itself: the page loads it from
/fluxkit.js, and it also runs in Node (it sets module.exports).
This script writes the two files the samples read, <stem>-manifests.yaml and
<stem>-facts.json; for both worked examples it reproduces the page’s
manifests and facts exactly (the samples compact the facts file back into
the page’s string byte for byte):
// build-facts.js -- run: node build-facts.js paste.yaml platform (fluxkit.js saved next to it)
const fs = require("fs");
const Fluxkit = require("./fluxkit.js");
const [paste, stem] = process.argv.slice(2);
const A = Fluxkit.analyze(fs.readFileSync(paste, "utf8"));
if (!A.ok) { console.error(A.errors.join("\n")); process.exit(1); }
fs.writeFileSync(stem + "-manifests.yaml", Fluxkit.sentConfig(A).text); // what the page sends as manifests
fs.writeFileSync(stem + "-facts.json", JSON.stringify(Fluxkit.facts(A), null, 2)); // what it sends, compacted, as facts
console.log(A.flags.length + " flags; verdict_candidate " + A.candidate);
Without the page’s check. The platform only checks that facts
is a string, so a minimal object works — "{}", or a hand-built one such as
{"flags": [], "notes": ["No page check was run; read the manifests directly."]}
— and the model still reads manifests. But you lose what the check adds: with no
flags there is nothing to confirm, dismiss, fix or keep, with no
source.resources there are no refs to name documents by, and with no
appliers the audit has no list of appliers to describe. Build it with
fluxkit.js as above when you can.
The audit_handoff string
Only for task: "fix", and only for the paste the audit was about. The page builds it
from the normalised audit reply r (recon.js
normalize, below):
JSON.stringify({ from: "audit", verdict: r.verdict, headline: r.headline,
findings: r.findings.map(f => ({ id: f.id, resource: f.resource, severity: f.severity, title: f.title, flags: f.flags })),
dismissed: r.flag_answers.filter(a => a.status === "dismissed").map(a => ({ flag: a.flag, reason: a.reason })) })
That is {"from": "audit", "verdict", "headline", "findings": [{id, resource, severity, title,
flags}], "dismissed": [{flag, reason}]}. The fix is then expected to change or keep every
finding it lists, and to say in summary which of the audit’s fixes were applied as
written and which were done differently. It is a previous reply, not evidence: the model checks it
against manifests.
The output contracts
The model is instructed (SKILL.md) to reply with one JSON object and
nothing else — no prose, no code fences — with every array of its lane
present ([] when empty). The run’s output text is that object. In both lanes it
must never write a secret value: when the paste holds a credential it says to rotate it, quotes the
key with *** for the value, and refers to a Secret by name. Every page flag whose
sev is not info is answered exactly once — in the audit lane in
flag_answers, in the fix lane in changes or kept — and
an info flag is never reported as a finding. A flag that lists several resources is
one flag, fixed only when every resource it lists is fixed.
task: "audit"
task:"audit".verdict:unsafe(any finding is high) |needs_work(the worst is medium or low) |ready(nothing above low).headline: one sentence.classification: one or two sentences — the repo pattern (monorepo, repo per team, fleet + apps, directory-driven), how Flux is installed, clusters, single- or multi-tenant.findings[]:{id, flags[], resource, line, severity, title, evidence, why, fix, remove[]}.idis a catalogue id (FX-01..FX-31) orother;flagslists the pageuids it confirms ([]for something the page did not flag);resourceis areffromfacts.source.resources, copied exactly — the document the fix changes;lineis the line ofmanifestswith the offending value, inside that document;severityishigh(Flux or the API server rejects it, a reconcile can never succeed, a secret is in Git, TLS is off, or a tenant can take over the cluster) |medium|low;evidenceis one to three lines copied verbatim frommanifests;fixis YAML to paste over the document: the whole corrected document with apiVersion, kind and metadata (or several separated by---, or a new one the paste lacks), keeping its name and namespace, spaces only, and""only for a low finding or a secret that needs rotation first;removelists refs of pasted documents the fix replaces with a new object (usually[]).flag_answers[]:{flag, status, reason}, exactly one per non-info page flag,statusconfirmed(a finding lists that uid) |dismissed(with the reason).appliers[]:{resource, does}, one per applier infacts.appliers, with the ref exactly as written;doessays what it reconciles, from which source, after what, and the effect of any finding.migration[]: the orderedflux migratesteps, only when FX-01 applies; otherwise[].plan[]: the fixes in the order to apply them — what makes Flux accept and reconcile the repo first, then secrets and isolation, then pinning and reliability, then hygiene.questions[](only what the paste cannot answer) andsummary(two to four sentences, answeringnoteif one was sent).
task: "fix"
task:"fix".verdict:ready(nothing but info flags is kept and nothing inquestionsblocks merging) |partial.headline: one sentence.documents[]:{resource, yaml}, one per document changed or added.resourceis the ref of the pasted document it replaces, or"new";yamlis the whole document with apiVersion, kind and metadata, keeping its name and namespace (and its kind, unless the fix replaces the object with one of another kind), spaces only, no literal secret. Unchanged documents are left out.changes[]:{flags[], id, resource, what, why}; a new document is explained by a change naming it.kept[]:{flag, reason}for each flag not fixed (it needs a value outside the paste, a decision, or a step outside Git such as encrypting a Secret).removed[]:{resource, reason}.commands[]: what the engineer runs outside the paste, in order (flux migrate,sops --encrypt, rotating a credential).questions[]andsummary, as in the audit lane; with anaudit_handoff, the summary says which of the audit’s fixes were applied as written.
How the page reads a reply (recon.js parseResult and
normalize): it strips a code fence and parses from the first { to the
last }. A reply is read as the lane that was asked for, unless it names the other lane
and carries that lane’s body (a documents array for fix, an
appliers array for audit). An unknown verdict shows as
needs_work or partial and counts as a disagreement; an unknown
severity becomes medium and counts as one too; ids like FX3
become FX-03; flags keeps only F<n> values; findings
with no resource, evidence and title are dropped; questions is capped at 6,
plan at 14, migration and commands at 12. An audit reply
carries 10 sections (verdict, headline, classification, findings, flag_answers, appliers, migration,
plan, questions, summary) and a fix reply 9 (verdict, headline, documents, changes, kept, removed,
commands, questions, summary), which is what the page counts when it recovers a partial reply.
Worked examples
Both are examples the page ships (the example buttons on the app page; the full
pastes are in /example.js, ids platform and
helm), built exactly as the page builds them:
Fluxkit.mustBeObject(Fluxkit.buildInput(lane, Fluxkit.analyze(text), note)), with a
real model reply for each. Long values are trimmed for display, and each excerpt says what it leaves
out; what is shown is verbatim. Neither paste holds a secret.
Audit: a Flux Operator platform repo onboarding its first tenant
Five files, 12 documents: a FluxInstance, the tenants Kustomization, the
team-checkout namespace, service account and RoleBinding, its GitRepository and Kustomization, image
automation against ECR, and a Redis HelmRelease from a type: oci HelmRepository. The
page’s free check raises 8 flags (6 medium, 1 low, 1 info); its own reading of the verdict is
needs_work. The engineer’s note: “First tenant on the staging cluster. Is
the tenancy set up the way Flux recommends before we add more teams?”
The manifests string, an excerpt: the first 45 of its 191 lines (the
first two documents); the whole string is 3,863 characters ending in a newline. Save the full
platform paste as platform.yaml and run
node build-facts.js platform.yaml platform:
# file: clusters/staging/flux-system/flux-instance.yaml
apiVersion: fluxcd.controlplane.io/v1
kind: FluxInstance
metadata:
name: flux
namespace: flux-system
spec:
distribution:
version: "2.8.x"
registry: "ghcr.io/fluxcd"
components:
- source-controller
- kustomize-controller
- helm-controller
- notification-controller
- image-reflector-controller
- image-automation-controller
cluster:
type: kubernetes
size: medium
multitenant: false
networkPolicy: true
sync:
kind: GitRepository
url: https://github.com/vandrel-labs/platform.git
ref: refs/heads/main
path: clusters/staging
pullSecret: flux-system
---
# file: clusters/staging/tenants.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: tenants
namespace: flux-system
spec:
interval: 30m
retryInterval: 2m
timeout: 5m
wait: true
path: ./tenants/staging
prune: true
sourceRef:
kind: GitRepository
name: flux-system
...
The facts object, decoded — an excerpt: 2 of the 12 resources,
1 of the 2 sources, 1 of the 3 appliers and 3 of the 8 flags (F3..F7 — FX-19, FX-22, FX-18,
FX-20 and FX-26 — are left out). The full string is 7,530 characters; build it with
build-facts.js (it lands in platform-facts.json):
{
"source": {
"lines": 192,
"format": "yaml",
"files": [
"clusters/staging/flux-system/flux-instance.yaml",
"clusters/staging/tenants.yaml",
"tenants/staging/team-checkout.yaml",
"tenants/staging/image-automation.yaml",
"tenants/staging/redis.yaml"
],
"namespaces": [
"flux-system",
"team-checkout"
],
"resources": [
{
"ref": "FluxInstance/flux",
"kind": "FluxInstance",
"api_version": "fluxcd.controlplane.io/v1",
"name": "flux",
"namespace": "flux-system",
"file": "clusters/staging/flux-system/flux-instance.yaml",
"line": 2,
"end_line": 28,
"flux": true
},
{
"ref": "Kustomization/checkout",
"kind": "Kustomization",
"api_version": "kustomize.toolkit.fluxcd.io/v1",
"name": "checkout",
"namespace": "team-checkout",
"file": "tenants/staging/team-checkout.yaml",
"line": 88,
"end_line": 101,
"flux": true
}
],
"parse_errors": []
},
"install": "Flux Operator (FluxInstance FluxInstance/flux)",
"layout": "top-level clusters/, tenants/",
"tenancy": {
"multi_tenant": true,
"fluxinstance_multitenant": false,
"tenant_namespaces": [
"team-checkout"
],
"appliers_with_service_account": []
},
"sources": [
{
"ref": "GitRepository/checkout",
"kind": "GitRepository",
"namespace": "team-checkout",
"url": "https://github.com/vandrel-labs/checkout-deploy",
"type": "",
"revision": "branch=main",
"interval": "5m",
"provider": "",
"secret_ref": "checkout-git",
"verify": "",
"insecure": false
}
],
"appliers": [
{
"ref": "Kustomization/checkout",
"kind": "Kustomization",
"namespace": "team-checkout",
"source": "GitRepository/checkout",
"what": "path ./deploy/staging",
"interval": "10m",
"prune": "true",
"wait": "(not set)",
"depends_on": [],
"target_namespace": "",
"service_account": "",
"timeout": "5m",
"retry_interval": "2m"
}
],
"secrets": [],
"flags": [
{
"uid": "F1",
"id": "FX-21",
"sev": "medium",
"resource": "FluxInstance/flux",
"line": 21,
"text": "FluxInstance/flux does not enable cluster.multitenant, but the paste labels tenant namespaces (team-checkout)",
"detail": "multitenant: true enforces service-account impersonation, disables cross-namespace references and sets a default service account on all controllers."
},
{
"uid": "F2",
"id": "FX-20",
"sev": "medium",
"resource": "Kustomization/checkout",
"line": 88,
"text": "Kustomization/checkout (tenant namespace team-checkout) sets no serviceAccountName",
"detail": "In a multi-tenant repo each tenant's appliers should impersonate a tenant service account with namespace-scoped RBAC; without it they run with the controller's own cluster-wide permissions."
},
{
"uid": "F8",
"id": "FX-32",
"sev": "info",
"resource": "Kustomization/tenants",
"line": 31,
"text": "No Alert or Provider in the paste",
"detail": "Production clusters should send at least error-severity events to a Provider (Slack, Teams, ...), if that is not configured elsewhere."
}
],
"verdict_candidate": "needs_work",
"notes": []
}
The request body the page sends, trimmed for display: the full body is 12,618 characters of compact JSON, and the two long strings are cut here after their first 160 characters, with the cut marked inside the string. The step 4 samples build the untrimmed body from the two saved files.
{
"task": "audit",
"manifests": "# file: clusters/staging/flux-system/flux-instance.yaml\napiVersion: fluxcd.controlplane.io/v1\nkind: FluxInstance\nmetadata:\n name: flux\n namespace: flux-system ... [trimmed for display: 160 of 3,863 characters shown]",
"facts": "{\"source\":{\"lines\":192,\"format\":\"yaml\",\"files\":[\"clusters/staging/flux-system/flux-instance.yaml\",\"clusters/staging/tenants.yaml\",\"tenants/staging/team-checkout ... [trimmed for display: 160 of 7,530 characters shown]",
"note": "First tenant on the staging cluster. Is the tenancy set up the way Flux recommends before we add more teams?"
}
The page sends it with Idempotency-Key: gitops-repo-audit:audit:80389222-314a:a1 (see
step 5). The reply, an excerpt: 2 of the 7 findings (the others are FX-21 on
FluxInstance/flux, FX-19, FX-22, FX-20 on HelmRelease/redis and FX-26),
2 of the 7 flag answers (all 7 are confirmed; F8 is info and needs none), 1 of the 3
appliers, 2 of the 6 plan steps and 1 of the 2 questions:
{
"task": "audit",
"verdict": "needs_work",
"headline": "The repo is fully on current Flux 2.8 APIs and reconciles cleanly, but the first tenant (team-checkout) is not yet isolated the way Flux recommends before more teams are added.",
"classification": "Directory-driven, single-cluster (staging) repo installed via the Flux Operator (FluxInstance/flux): clusters/staging drives Kustomization/tenants into tenants/staging, which onboards team-checkout as its own namespace, RBAC, Git source, Kustomization, image automation and a Helm-deployed Redis. The layout is multi-tenant by directory and labeling (toolkit.fluxcd.io/tenant), but FluxInstance.spec.cluster.multitenant is still false and no tenant applier impersonates a service account yet.",
"findings": [
{
"id": "FX-20",
"flags": [
"F2"
],
"resource": "Kustomization/checkout",
"line": 88,
"severity": "medium",
"title": "Kustomization/checkout has no serviceAccountName",
"evidence": "namespace: team-checkout\nsourceRef:",
"why": "team-checkout already has ServiceAccount/team-checkout and a namespace-scoped RoleBinding to ClusterRole admin (tenants/staging/team-checkout.yaml), but Kustomization/checkout never impersonates it, so kustomize-controller applies ./deploy/staging with its own controller-wide permissions instead of the tenant's namespace-scoped RBAC.",
"fix": "apiVersion: kustomize.toolkit.fluxcd.io/v1\nkind: Kustomization\nmetadata:\n name: checkout\n namespace: team-checkout\nspec:\n interval: 10m\n retryInterval: 2m\n timeout: 5m\n path: ./deploy/staging\n prune: true\n serviceAccountName: team-checkout\n sourceRef:\n kind: GitRepository\n name: checkout\n"
},
{
"id": "FX-18",
"flags": [
"F5"
],
"resource": "HelmRepository/bitnami",
"line": 161,
"severity": "medium",
"title": "HelmRepository/bitnami uses the legacy type: oci mode",
"evidence": "type: oci\nurl: oci://registry-1.docker.io/bitnamicharts",
"why": "type: oci HelmRepository is the deprecated way to pull OCI Helm charts: it cannot be referenced by chartRef, cannot use cosign verification, and has no tag/layer selection. HelmRelease/redis pulls redis from oci://registry-1.docker.io/bitnamicharts through it today, with none of those newer supply-chain controls available.",
"fix": "apiVersion: source.toolkit.fluxcd.io/v1\nkind: OCIRepository\nmetadata:\n name: redis\n namespace: team-checkout\nspec:\n interval: 1h\n url: oci://registry-1.docker.io/bitnamicharts/redis\n ref:\n semver: \"20.x\"\n---\napiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: redis\n namespace: team-checkout\nspec:\n interval: 30m\n timeout: 10m\n serviceAccountName: team-checkout\n chartRef:\n kind: OCIRepository\n name: redis\n install:\n strategy:\n name: RetryOnFailure\n upgrade:\n strategy:\n name: RetryOnFailure\n driftDetection:\n mode: enabled\n values:\n architecture: standalone\n auth:\n existingSecret: redis-auth\n"
}
],
"flag_answers": [
{
"flag": "F2",
"status": "confirmed",
"reason": "Kustomization/checkout's spec (lines 93-101: interval, retryInterval, timeout, path, prune, sourceRef) has no serviceAccountName field."
},
{
"flag": "F5",
"status": "confirmed",
"reason": "HelmRepository/bitnami line 161 sets type: oci."
}
],
"appliers": [
{
"resource": "Kustomization/checkout",
"does": "Reconciles ./deploy/staging from GitRepository/checkout (branch main) every 10m with prune: true. FX-20 (F2): it runs with kustomize-controller's own permissions instead of impersonating ServiceAccount/team-checkout."
}
],
"migration": [],
"plan": [
"Add serviceAccountName: team-checkout to Kustomization/checkout and HelmRelease/redis so both impersonate the namespace-scoped RBAC already created for the tenant (F2, F6).",
"Set FluxInstance/flux spec.cluster.multitenant: true now that the tenant's appliers impersonate, so the operator enforces impersonation and blocks cross-namespace references for this and every future tenant (F1)."
],
"questions": [
"Is AWS Workload Identity (IRSA) already set up for this staging cluster, so ImageRepository/checkout-api can move to spec.provider: aws instead of secretRef ecr-credentials?"
],
"summary": "Not yet: FluxInstance/flux still has cluster.multitenant: false, and neither Kustomization/checkout nor HelmRelease/redis impersonate the ServiceAccount/team-checkout and RoleBinding already created for the tenant, so team-checkout's appliers currently run with the controllers' own cluster-wide permissions rather than the tenant's namespace-scoped RBAC - add serviceAccountName to both, then flip multitenant: true, before onboarding a second team. The rest of the repo is solid: every document is already on current Flux 2.8 APIs (no flux migrate needed) and HelmRelease/redis already uses the modern RetryOnFailure remediation with drift detection on; the remaining gaps - the legacy oci HelmRepository, ECR static credentials, image updates pushed straight to main, and no retryInterval on redis - are medium/low hygiene items, not blockers."
}
Held to the page’s own reconciliation (recon.js reconcile, on this
exact body and the full reply), this reply has 1 disagreement in 18 statements
checked, and it is in the excerpt: the FX-18 fix adds OCIRepository/redis and moves
HelmRelease/redis to chartRef, but leaves remove empty, so
with the fix put back HelmRepository/bitnami is still there and the page still raises
FX-18 on it. The fix needed "remove": ["HelmRepository/bitnami"]. (The page also
notes, as information, that the FX-21 evidence quotes a line from outside
FluxInstance/flux.) This is why the page re-runs every fix, and why you should too.
What the step 5 samples print for the full reply (the Python form; charged is whatever the run cost):
truncated: False charged: ...
needs_work - The repo is fully on current Flux 2.8 APIs and reconciles cleanly, but the first tenant (team-checkout) is not yet isolated the way Flux recommends before more teams are added.
FX-21 FluxInstance/flux 21 medium
FX-20 Kustomization/checkout 88 medium
FX-19 ImageRepository/checkout-api 110 medium
FX-22 ImageUpdateAutomation/checkout 148 medium
FX-18 HelmRepository/bitnami 161 medium
FX-20 HelmRelease/redis 165 medium
FX-26 HelmRelease/redis 171 low
applier Kustomization/tenants
applier Kustomization/checkout
applier HelmRelease/redis
Had you pressed “Fix the manifests from this audit” after this reply, the page would send
the fix lane on the same paste (the same manifests, facts and note) with
this audit_handoff, shown decoded and trimmed to 3 of its 7 findings;
in the body it is the compact JSON.stringify of it (1,306 characters). That body is
14,097 characters and its key would be gitops-repo-audit:fix:4ef41696-3711:a1:
{
"from": "audit",
"verdict": "needs_work",
"headline": "The repo is fully on current Flux 2.8 APIs and reconciles cleanly, but the first tenant (team-checkout) is not yet isolated the way Flux recommends before more teams are added.",
"findings": [
{
"id": "FX-21",
"resource": "FluxInstance/flux",
"severity": "medium",
"title": "FluxInstance does not enforce multi-tenant impersonation",
"flags": [
"F1"
]
},
{
"id": "FX-20",
"resource": "Kustomization/checkout",
"severity": "medium",
"title": "Kustomization/checkout has no serviceAccountName",
"flags": [
"F2"
]
},
{
"id": "FX-19",
"resource": "ImageRepository/checkout-api",
"severity": "medium",
"title": "ImageRepository/checkout-api authenticates to ECR with a static Secret",
"flags": [
"F3"
]
}
],
"dismissed": []
}
Fix: two HelmReleases on helm v2beta2 with charts on * and latest
Four files, 4 documents: HelmRelease/podinfo and HelmRelease/redis on
helm.toolkit.fluxcd.io/v2beta2, their two HelmRepositories on
source.toolkit.fluxcd.io/v1beta2, chart versions "*" and
"latest", and the legacy remediation blocks. The free check raises 8 flags
(4 medium, 3 low, 1 info). This example has no audit before it, so it carries no
audit_handoff. The engineer’s note: “Bring these up to Flux 2.8 practice.
podinfo 6.x and redis 20.x are the lines we run.”
The manifests string, whole: 1,539 characters ending in a newline — save it as helm.yaml and run node build-facts.js helm.yaml podinfo:
# file: apps/base/podinfo/release.yaml
apiVersion: helm.toolkit.fluxcd.io/v2beta2
kind: HelmRelease
metadata:
name: podinfo
namespace: podinfo
spec:
interval: 10m
targetNamespace: podinfo
chart:
spec:
chart: podinfo
version: "*"
sourceRef:
kind: HelmRepository
name: podinfo
install:
createNamespace: true
remediation:
retries: 3
upgrade:
remediation:
retries: 3
remediateLastFailure: true
values:
replicaCount: 2
ingress:
enabled: true
hosts:
- host: podinfo.staging.lumorra-retail.example
paths:
- path: /
pathType: Prefix
---
# file: apps/base/podinfo/repository.yaml
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmRepository
metadata:
name: podinfo
namespace: podinfo
spec:
interval: 1h
url: https://stefanprodan.github.io/podinfo
---
# file: apps/base/redis/release.yaml
apiVersion: helm.toolkit.fluxcd.io/v2beta2
kind: HelmRelease
metadata:
name: redis
namespace: podinfo
spec:
interval: 10m
chart:
spec:
chart: redis
version: "latest"
sourceRef:
kind: HelmRepository
name: bitnami
install:
remediation:
retries: 3
values:
architecture: standalone
auth:
existingSecret: redis-auth
---
# file: apps/base/redis/repository.yaml
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmRepository
metadata:
name: bitnami
namespace: podinfo
spec:
interval: 1h
url: https://charts.bitnami.com/bitnami
The facts object, decoded — an excerpt: 1 of the 4 resources,
1 of the 2 sources, 1 of the 2 appliers and 3 of the 8 flags (F2..F4, F6 and F7 — FX-17,
FX-01, FX-17, FX-26 and FX-25 — are left out). Note how F1 and F5 each cover two resources.
The full string is 5,607 characters (podinfo-facts.json):
{
"source": {
"lines": 77,
"format": "yaml",
"files": [
"apps/base/podinfo/release.yaml",
"apps/base/podinfo/repository.yaml",
"apps/base/redis/release.yaml",
"apps/base/redis/repository.yaml"
],
"namespaces": [
"podinfo"
],
"resources": [
{
"ref": "HelmRelease/podinfo",
"kind": "HelmRelease",
"api_version": "helm.toolkit.fluxcd.io/v2beta2",
"name": "podinfo",
"namespace": "podinfo",
"file": "apps/base/podinfo/release.yaml",
"line": 2,
"end_line": 33,
"flux": true
}
],
"parse_errors": []
},
"install": "not in the paste",
"layout": "top-level apps/",
"tenancy": {
"multi_tenant": false,
"fluxinstance_multitenant": null,
"tenant_namespaces": [],
"appliers_with_service_account": []
},
"sources": [
{
"ref": "HelmRepository/podinfo",
"kind": "HelmRepository",
"namespace": "podinfo",
"url": "https://stefanprodan.github.io/podinfo",
"type": "",
"revision": "(none)",
"interval": "1h",
"provider": "",
"secret_ref": "",
"verify": "",
"insecure": false
}
],
"appliers": [
{
"ref": "HelmRelease/podinfo",
"kind": "HelmRelease",
"namespace": "podinfo",
"source": "HelmRepository/podinfo",
"what": "chart podinfo *",
"interval": "10m",
"prune": "(not set)",
"wait": "(not set)",
"depends_on": [],
"target_namespace": "podinfo",
"service_account": "",
"timeout": "",
"retry_interval": ""
}
],
"secrets": [],
"flags": [
{
"uid": "F1",
"id": "FX-01",
"sev": "medium",
"resource": "HelmRelease/podinfo",
"resources": [
"HelmRelease/podinfo",
"HelmRelease/redis"
],
"line": 2,
"text": "2 resources on helm.toolkit.fluxcd.io/v2beta2 (removed in Flux v2.8)",
"detail": "Current: helm.toolkit.fluxcd.io/v2. Flux v2.8 and later refuse these resources. Run flux migrate -f . --dry-run, then flux migrate -f . and commit; then migrate the cluster objects (flux migrate) before upgrading. Affected: HelmRelease/podinfo, HelmRelease/redis."
},
{
"uid": "F5",
"id": "FX-27",
"sev": "low",
"resource": "HelmRelease/podinfo",
"resources": [
"HelmRelease/podinfo",
"HelmRelease/redis"
],
"line": 2,
"text": "2 appliers have namespace or drift hygiene gaps (HelmRelease/podinfo, HelmRelease/redis)",
"detail": "HelmRelease/podinfo: targetNamespace without storageNamespace, install.createNamespace, no driftDetection.mode: enabled; HelmRelease/redis: no driftDetection.mode: enabled. Namespaces belong to the parent Kustomization, Helm storage beside the release, and production releases should detect drift."
},
{
"uid": "F8",
"id": "FX-32",
"sev": "info",
"resource": "HelmRelease/podinfo",
"line": 2,
"text": "No Alert or Provider in the paste",
"detail": "Production clusters should send at least error-severity events to a Provider (Slack, Teams, ...), if that is not configured elsewhere."
}
],
"verdict_candidate": "needs_work",
"notes": []
}
The request body, trimmed the same way (the full body is 7,961 characters):
{
"task": "fix",
"manifests": "# file: apps/base/podinfo/release.yaml\napiVersion: helm.toolkit.fluxcd.io/v2beta2\nkind: HelmRelease\nmetadata:\n name: podinfo\n namespace: podinfo\nspec:\n inter ... [trimmed for display: 160 of 1,539 characters shown]",
"facts": "{\"source\":{\"lines\":77,\"format\":\"yaml\",\"files\":[\"apps/base/podinfo/release.yaml\",\"apps/base/podinfo/repository.yaml\",\"apps/base/redis/release.yaml\",\"apps/base/re ... [trimmed for display: 160 of 5,607 characters shown]",
"note": "Bring these up to Flux 2.8 practice. podinfo 6.x and redis 20.x are the lines we run."
}
The page sends it with Idempotency-Key: gitops-repo-audit:fix:20a13c99-1f19:a1. The reply,
an excerpt: 2 of the 4 documents (HelmRelease/podinfo is shown
decoded below; HelmRepository/podinfo is left out) and 3 of the 7 changes (every flag
F1..F7 is fixed by one change; kept and removed are empty):
{
"task": "fix",
"verdict": "ready",
"headline": "Migrated all four documents to Flux 2.8 APIs, pinned the podinfo/redis chart versions to the 6.x/20.x lines, and modernized remediation, drift detection and timeouts on both HelmReleases.",
"documents": [
{
"resource": "HelmRelease/redis",
"yaml": "apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: redis\n namespace: podinfo\nspec:\n interval: 10m\n timeout: 5m\n retryInterval: 2m\n driftDetection:\n mode: enabled\n chart:\n spec:\n chart: redis\n version: \"20.x\"\n sourceRef:\n kind: HelmRepository\n name: bitnami\n install:\n strategy:\n name: RetryOnFailure\n values:\n architecture: standalone\n auth:\n existingSecret: redis-auth\n"
},
{
"resource": "HelmRepository/bitnami",
"yaml": "apiVersion: source.toolkit.fluxcd.io/v1\nkind: HelmRepository\nmetadata:\n name: bitnami\n namespace: podinfo\nspec:\n interval: 1h\n url: https://charts.bitnami.com/bitnami\n"
}
],
"changes": [
{
"flags": [
"F4"
],
"id": "FX-17",
"resource": "HelmRelease/redis",
"what": "Pinned chart.spec.version from \"latest\" to \"20.x\" on HelmRelease/redis.",
"why": "The note confirms redis 20.x is the line run in production; 'latest' is not a pinned semver constraint and would resolve differently on every reconcile, including a breaking major."
},
{
"flags": [
"F5"
],
"id": "FX-27",
"resource": "HelmRelease/podinfo",
"what": "Added spec.storageNamespace: podinfo and spec.driftDetection.mode: enabled to HelmRelease/podinfo; added spec.driftDetection.mode: enabled to HelmRelease/redis.",
"why": "podinfo set targetNamespace without a matching storageNamespace, which scatters the Helm release's storage secret from the workload namespace; neither release detected manual drift in the cluster."
},
{
"flags": [
"F7"
],
"id": "FX-25",
"resource": "HelmRelease/podinfo",
"what": "Replaced install.remediation/upgrade.remediation on HelmRelease/podinfo with install.strategy.name: RetryOnFailure and upgrade.strategy.name: RetryOnFailure; replaced install.remediation on HelmRelease/redis with install.strategy.name: RetryOnFailure (redis had no upgrade.remediation block to replace).",
"why": "install/upgrade.remediation is the legacy HelmRelease remediation API; strategy.name: RetryOnFailure is the current field."
}
],
"kept": [],
"removed": [],
"commands": [
"flux migrate -f . --dry-run",
"flux migrate"
],
"questions": [],
"summary": "Per the note, chart versions are pinned to the 6.x (podinfo) and 20.x (redis) lines actually run, and all four documents move off the v2beta2/v1beta2 APIs Flux 2.7/2.8 removed onto helm.toolkit.fluxcd.io/v2 and source.toolkit.fluxcd.io/v1. Both HelmReleases also gained storageNamespace (podinfo) and driftDetection.mode: enabled, a timeout and retryInterval, and the modern install/upgrade.strategy.name: RetryOnFailure remediation in place of the legacy remediation blocks. No secrets are present in this paste, so nothing needed rotation; run flux migrate --dry-run against the full repo (this paste is only two of its apps) before committing and flux migrate against the cluster to convert already-applied objects before upgrading to Flux 2.8."
}
The HelmRelease/podinfo document of the reply, decoded:
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: podinfo
namespace: podinfo
spec:
interval: 10m
timeout: 5m
retryInterval: 2m
targetNamespace: podinfo
storageNamespace: podinfo
driftDetection:
mode: enabled
chart:
spec:
chart: podinfo
version: "6.x"
sourceRef:
kind: HelmRepository
name: podinfo
install:
createNamespace: true
strategy:
name: RetryOnFailure
upgrade:
strategy:
name: RetryOnFailure
values:
replicaCount: 2
ingress:
enabled: true
hosts:
- host: podinfo.staging.lumorra-retail.example
paths:
- path: /
pathType: Prefix
Held to the page’s reconciliation, this reply has 1 disagreement in 17
statements checked: F5 (FX-27) is listed as fixed, but with the documents put back the free check
still raises it on HelmRelease/podinfo — the flag’s detail
names three gaps there (no storageNamespace, install.createNamespace, no
drift detection) and the reply closed two; createNamespace: true is still in the
document. Everything else checks out: every resource is still there, no new high flag, and the
verdict fits.
To see that for yourself — what the page’s “Check the fixed manifests” does
— put the documents back into the paste with fluxkit.js and run the free check
on the result. Save the parsed reply as fix-reply.json:
// apply-fix.js -- run: node apply-fix.js helm.yaml fix-reply.json > fixed-paste.yaml (fluxkit.js next to it)
const fs = require("fs");
const Fluxkit = require("./fluxkit.js");
const A = Fluxkit.analyze(fs.readFileSync(process.argv[2], "utf8"));
const r = JSON.parse(fs.readFileSync(process.argv[3], "utf8")); // the parsed fix reply
const out = Fluxkit.applyDocs(A,
(r.documents || []).map((d) => ({ yaml: d.yaml, ref: d.resource === "new" ? null : d.resource })),
(r.removed || []).map((x) => x.resource));
if (!out) { console.error("a document does not read as Kubernetes YAML"); process.exit(1); }
process.stdout.write(out.text); // the paste with the documents in place
const B = Fluxkit.analyze(out.text); // the free check, run again
console.error(B.flags.filter((f) => f.sev !== "info").map((f) => f.id + " " + f.sev + " " + f.text).join("\n") || "no flags left");
For this reply it writes the 84-line repaired paste and reports the one flag left:
FX-27 low 1 applier has namespace or drift hygiene gaps (HelmRelease/podinfo)
The same function checks one audit finding: pass [{ yaml: f.fix, ref: f.resource }]
and f.remove instead.
What the step 6 samples print for the full reply (the Python form):
truncated: False charged: ...
ready - Migrated all four documents to Flux 2.8 APIs, pinned the podinfo/redis chart versions to the 6.x/20.x lines, and modernized remediation, drift detection and timeouts on both HelmReleases.
change F1 FX-01 HelmRelease/podinfo
change F2 FX-17 HelmRelease/podinfo
change F3 FX-01 HelmRepository/podinfo
change F4 FX-17 HelmRelease/redis
change F5 FX-27 HelmRelease/podinfo
change F6 FX-26 HelmRelease/podinfo
change F7 FX-25 HelmRelease/podinfo
command flux migrate -f . --dry-run
command flux migrate
The page checks the reply against the manifests; the API does not
What comes back from the API is the model’s raw text in output.output. The page
holds it to account in the browser before showing it (recon.js
reconcile, with the facts, the manifests and the handoff it sent). These checks are
client-side: a direct API caller gets none of them.
- Audit: every finding is anchored. Its
idis in the catalogue (FX-32 is never a finding); itsseverityis high, medium or low; itsresourceis a pasted document; itsevidenceis found in that document’s lines ofmanifests(whitespace collapsed; quoted lines that are apart must appear in order; a***matches what comes before it); every citeduidis a non-info flag the page raised; every ref inremoveis pasted. - Audit: every fix is re-run. A fix must parse as YAML with no tabs and be whole Kubernetes documents; a medium or high finding without a fix is a disagreement (except a plaintext secret, FX-05..FX-07). The fix is put back into the paste — replacing the document it names, deleting what
removelists, adding the rest — and the free check runs again: it must clear the flag it answers, introduce no new high flag and no YAML error. - Audit: every flag is answered once, consistently, the verdict fits the severities, every applier in
facts.appliersis described (and nothing else), there is a classification, migration steps whenever FX-01 was raised, the Flux Operator migration guide whenever the repo usesflux bootstrap, and aplanwhenever there is something to fix. - Fix: the result is re-checked. Every document must parse, carry no not-sent marker and keep the name of the resource it replaces; all of them are put back, the
removedones deleted, and the free check runs on the result: every pasted resource survives or is removed with a reason; a new document needs a change that explains it; every non-info flag is fixed or kept exactly once, and a flag claimed as fixed must be gone; no new high flag appears;readyis refused while a flag is kept or the result still raises high or medium flags; a plaintext Secret needscommands; an audit handoff’s findings are all changed or kept. - Both: no secret from the paste is repeated anywhere in the reply.
If you call the API directly, apply your own checks — the evidence against your manifests, every flag answered, each fix or document put back and re-checked (apply-fix.js above, then flux-schema, kustomize build or a dry-run against the cluster) — before you merge anything.
Step 1 — a tiny client helper
One function per language that does what sdk.js does for every call: JSON in,
bearer token if you have one, data out on a 2xx, an error carrying
status, error.code and error.message otherwise. The later
steps assume this helper is in the same file (for Go, Java and C#, the later snippets go inside
main / the top-level statements; Java’s makeInput from step 4 goes
in the class).
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}" # step 2
# api METHOD PATH [extra curl args...] -- prints the whole envelope; pipe to jq
api() {
method="$1"; path="$2"; shift 2
curl -sS -X "$method" "$BASE$path" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" "$@"
}
# success: jq '.data' failure: jq '.error | {code, message}'
import hashlib, json, os, time, urllib.parse
import requests # pip install requests
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # step 2
class ApiError(Exception):
def __init__(self, status, code, message, details=None):
super().__init__(f"{status} {code}: {message}")
self.status, self.code, self.details = status, code, details
def api(method, path, body=None, headers=None):
h = {"Content-Type": "application/json"}
if TOKEN:
h["Authorization"] = "Bearer " + TOKEN
h.update(headers or {})
r = requests.request(method, BASE + path, headers=h,
data=None if body is None else json.dumps(body))
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason, err.get("details"))
return env.get("data")
// fluxcd.mjs -- Node 18+ (global fetch), run with: node fluxcd.mjs
import { readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // step 2
async function api(method, path, body, headers = {}) {
const h = { "Content-Type": "application/json", ...headers };
if (TOKEN) h.Authorization = "Bearer " + TOKEN;
const res = await fetch(BASE + path, {
method,
headers: h,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error((json.error && json.error.message) || res.statusText);
err.status = res.status;
err.code = json.error && json.error.code;
err.details = json.error && json.error.details;
throw err;
}
return json.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const base = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // step 2
type apiError struct {
Status int
Code string
Message string
}
func (e *apiError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func newRequest(method, path string, body any, headers map[string]string) (*http.Request, error) {
var buf []byte
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
buf = b
}
req, err := http.NewRequest(method, base+path, bytes.NewReader(buf))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
return req, nil
}
func errorFrom(res *http.Response, env envelope) error {
e := &apiError{Status: res.StatusCode, Message: http.StatusText(res.StatusCode)}
if env.Error != nil {
e.Code = env.Error.Code
if env.Error.Message != "" {
e.Message = env.Error.Message
}
}
return e
}
// api sends body as JSON and decodes the envelope's data into out (out may be nil).
func api(method, path string, body any, headers map[string]string, out any) error {
req, err := newRequest(method, path, body, headers)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env envelope
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
return errorFrom(res, env)
}
if out != nil && len(env.Data) > 0 {
return json.Unmarshal(env.Data, out)
}
return nil
}
func main() {
// steps 2-6 go here; remove any import a step you skip no longer uses
_, _, _, _, _ = bufio.NewScanner, sha256.Sum256, hex.EncodeToString, url.PathEscape, strings.Index
_ = time.Second
}
// GitopsRepoAudit.java -- Java 17+, needs com.fasterxml.jackson.core:jackson-databind on the classpath
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class GitopsRepoAudit {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN"); // step 2
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static class ApiException extends Exception {
final int status;
final String code;
ApiException(int status, String code, String message) {
super(status + " " + code + ": " + message);
this.status = status;
this.code = code;
}
}
static HttpRequest.Builder request(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)));
if (token != null && !token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
return b;
}
static ApiException errorFrom(int status, JsonNode env) {
JsonNode err = env.path("error");
return new ApiException(status, err.path("code").asText(null), err.path("message").asText("HTTP " + status));
}
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpResponse<String> res = HTTP.send(request(method, path, body, headers).build(), HttpResponse.BodyHandlers.ofString());
JsonNode env;
try { env = JSON.readTree(res.body()); } catch (Exception e) { env = JSON.createObjectNode(); }
if (env == null) env = JSON.createObjectNode();
if (res.statusCode() / 100 != 2) throw errorFrom(res.statusCode(), env);
return env.path("data");
}
public static void main(String[] args) throws Exception {
// steps 2-6 go here (makeInput from step 4 goes next to api())
}
}
require "digest"
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
$token = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # step 2
class ApiError < StandardError
attr_reader :status, :code
def initialize(status, code, message)
super("#{status} #{code}: #{message}")
@status = status
@code = code
end
end
def build_request(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{$token}" if $token && !$token.empty?
headers.each { |k, v| req[k] = v }
req.body = JSON.generate(body) unless body.nil?
[uri, req]
end
def api(method, path, body = nil, headers = {})
uri, req = build_request(method, path, body, headers)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
env = (JSON.parse(res.body) rescue {})
env = {} unless env.is_a?(Hash)
unless res.is_a?(Net::HTTPSuccess)
err = env["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message)
end
env["data"]
end
<?php
// needs the curl extension
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"; // step 2
class ApiError extends Exception {
public $status;
public $apiCode;
public function __construct($status, $code, $message) {
parent::__construct("$status $code: $message");
$this->status = $status;
$this->apiCode = $code;
}
}
function headers_for(array $extra = []): array {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($extra as $k => $v) $h[] = "$k: $v";
return $h;
}
function api(string $method, string $path, $body = null, array $headers = []) {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => headers_for($headers),
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$env = json_decode($raw);
if (!is_object($env)) $env = new stdClass();
if ($status < 200 || $status > 299) {
$e = $env->error ?? new stdClass();
throw new ApiError($status, $e->code ?? null, $e->message ?? "HTTP $status");
}
return $env->data ?? null;
}
// Program.cs -- .NET 6+ console app (implicit usings on) with top-level statements; steps 2-6 (with the MakeInput local function) go ABOVE this class
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
static class Api
{
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"; // step 2
public static readonly HttpClient Http = new HttpClient();
public class ApiException : Exception
{
public int Status;
public string? Code;
public ApiException(int status, string? code, string message) : base($"{status} {code}: {message}")
{
Status = status;
Code = code;
}
}
public static HttpRequestMessage Request(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (body != null) req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
if (!string.IsNullOrEmpty(Token)) req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token);
if (headers != null) foreach (var kv in headers) req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
return req;
}
public static ApiException ErrorFrom(HttpResponseMessage res, JsonNode? env)
{
var err = env?["error"];
return new ApiException((int)res.StatusCode, err?["code"]?.GetValue<string>(),
err?["message"]?.GetValue<string>() ?? res.ReasonPhrase ?? "error");
}
public static async Task<JsonNode?> Call(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
using var req = Request(method, path, body, headers);
using var res = await Http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
JsonNode? env = null;
try { env = JsonNode.Parse(text); } catch (JsonException) { }
if (!res.IsSuccessStatusCode) throw ErrorFrom(res, env);
return env?["data"];
}
}
Step 2 — a token
Two kinds work:
-
Personal — needed for a run. Both lanes are metered in credits, so a run
needs a signed-in user (the page says “The Audit run is metered, so it needs an
account.”) unless the estimate says
sponsor_enabled. Sign in and copy your token from /tokens.html, then export it asSKILLSAFE_TOKEN(the samples fall back to the placeholderYOUR_TOKEN). -
Guest — enough for
/meand/estimate.POST /guestwith{"slug": "gitops-repo-audit"}and noAuthorizationheader, exactly assdk.jsguest()does. The response’sdatacarriestokenandguest_id. Reuse a token you already hold rather than minting a new one each time.
# personal token (needed for a run): copy it from /tokens.html, then
# export SKILLSAFE_TOKEN=... and skip the rest of this block
# guest token (enough for /me and /estimate): no Authorization header on this one call
TOKEN=$(curl -sS -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"gitops-repo-audit"}' | jq -r '.data.token')
echo "guest token: ${TOKEN:0:12}..."
# personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if TOKEN in ("", "YOUR_TOKEN"):
TOKEN = "" # no Authorization header on /guest
guest = api("POST", "/guest", {"slug": "gitops-repo-audit"})
TOKEN = guest["token"]
print("guest", guest.get("guest_id"))
// personal token (needed for a run): paste it from /tokens.html into TOKEN and skip this
if (!TOKEN || TOKEN === "YOUR_TOKEN") {
TOKEN = ""; // no Authorization header on /guest
const guest = await api("POST", "/guest", { slug: "gitops-repo-audit" });
TOKEN = guest.token;
console.log("guest", guest.guest_id);
}
// inside main -- personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if token == "" {
var guest struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
}
if err := api("POST", "/guest", map[string]string{"slug": "gitops-repo-audit"}, nil, &guest); err != nil {
panic(err)
}
token = guest.Token
fmt.Println("guest", guest.GuestID)
}
// inside main -- personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if (token.isEmpty() || token.equals("YOUR_TOKEN")) {
token = ""; // no Authorization header on /guest
JsonNode guest = api("POST", "/guest", Map.of("slug", "gitops-repo-audit"), null);
token = guest.path("token").asText();
System.out.println("guest " + guest.path("guest_id").asText());
}
# personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if $token.empty? || $token == "YOUR_TOKEN"
$token = "" # no Authorization header on /guest
guest = api("POST", "/guest", { slug: "gitops-repo-audit" })
$token = guest["token"]
puts "guest #{guest["guest_id"]}"
end
// personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if ($TOKEN === "" || $TOKEN === "YOUR_TOKEN") {
$TOKEN = ""; // no Authorization header on /guest
$guest = api("POST", "/guest", ["slug" => "gitops-repo-audit"]);
$TOKEN = $guest->token;
echo "guest {$guest->guest_id}\n";
}
// top-level statements (above the Api class) -- personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if (Api.Token == "" || Api.Token == "YOUR_TOKEN")
{
Api.Token = ""; // no Authorization header on /guest
var guest = await Api.Call("POST", "/guest", new { slug = "gitops-repo-audit" });
Api.Token = guest!["token"]!.GetValue<string>();
Console.WriteLine($"guest {guest["guest_id"]}");
}
Step 3 — GET /me
Who the token belongs to and what it can spend. sdk.js documents the shape as
{subject_type, subject_id, credits, profile?}. The page treats the caller as signed
in only when subject_type is "user", shows credits as the
balance (1 credit = $0.0001, so it divides by 10 000 for the dollar figure), and compares it
with the estimate in step 4 before it lets you run.
api GET /me | jq '.data // .error'
me = api("GET", "/me")
print(me["subject_type"], me.get("subject_id"), me.get("credits"))
signed_in = me["subject_type"] == "user"
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, me.credits);
const signedIn = me.subject_type === "user";
var me struct {
SubjectType string `json:"subject_type"`
SubjectID any `json:"subject_id"`
Credits any `json:"credits"`
}
if err := api("GET", "/me", nil, nil, &me); err != nil {
panic(err)
}
fmt.Println(me.SubjectType, me.SubjectID, me.Credits)
signedIn := me.SubjectType == "user"
_ = signedIn
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.path("subject_type").asText() + " " + me.path("subject_id").asText() + " " + me.path("credits"));
boolean signedIn = me.path("subject_type").asText().equals("user");
me = api("GET", "/me")
puts [me["subject_type"], me["subject_id"], me["credits"]].join(" ")
signed_in = me["subject_type"] == "user"
$me = api("GET", "/me");
echo $me->subject_type, " ", $me->subject_id ?? "", " ", $me->credits ?? "", "\n";
$signedIn = $me->subject_type === "user";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me?["subject_type"]} {me?["subject_id"]} {me?["credits"]}");
var signedIn = me?["subject_type"]?.GetValue<string>() == "user";
Step 4 — build the input, then POST /estimate
First a small makeInput that does what buildInput does:
task, then manifests as the pasted YAML text, facts as a
compact JSON string, the note when it is not blank, and — for the fix lane only — the
audit_handoff string when you have one. The samples build the audit
input of the platform example from the two files build-facts.js wrote; step 6 reuses
makeInput for the fix example. Then price it. The body of /estimate
is the same input you will send to /run; no job is created and nothing is charged
— /estimate is free. From the response the page reads:
| Field | Meaning |
|---|---|
hold_credits | A reservation, not a price: the worst case the platform holds while the run is in flight. The page shows it as “reserves N credits ($...) for the Audit run” and adds that you are charged only for what the run actually uses. What you pay is the job’s charged_credits after settlement (step 5), normally less. A balance below the hold is allowed but the reply may be cut short (truncated: true). |
min_credits | The smallest balance that can start a run. Below it the page disables the run button, because the call would be a 402. |
sponsor_enabled | When true, a caller who is not signed in may run; otherwise the page asks them to sign in. |
warnings | Input warnings. They do not refuse the call; the page shows them next to the price, so check them before you run. |
The page re-prices whenever the paste, the note or the lane change. The amounts grow with the size
of manifests and facts, so read them from the call rather than
hard-coding them.
# platform-manifests.yaml and platform-facts.json: written by build-facts.js (see "The facts string").
# --rawfile keeps the YAML byte for byte (jq 1.6+); jq -c turns the facts into ONE compact string.
BODY=$(jq -n --rawfile manifests platform-manifests.yaml --arg facts "$(jq -c . platform-facts.json)" \
--arg note "First tenant on the staging cluster. Is the tenancy set up the way Flux recommends before we add more teams?" \
'{task: "audit", manifests: $manifests, facts: $facts, note: $note}')
# No page check at hand? --arg facts '{}' is accepted as a string, but the reply then has no page flags to answer.
# Fix lane after an audit of the SAME paste: task: "fix", plus
# --arg handoff "$(jq -c . handoff.json)" and audit_handoff: $handoff in the object.
api POST /estimate -d "$BODY" \
| jq '.error // (.data | {hold_credits, min_credits, sponsor_enabled, warnings})'
def make_input(task, manifests_path, facts_path, note=None, handoff=None):
"""The body the page sends: task, manifests (the YAML text), facts as a JSON STRING, an optional note,
and (fix lane only) the audit_handoff string."""
with open(manifests_path, encoding="utf-8", newline="") as f:
manifests = f.read() # the manifests, verbatim
with open(facts_path, encoding="utf-8") as f:
facts = json.load(f) # the page's free check ({} if you have none)
inp = {
"task": task,
"manifests": manifests,
"facts": json.dumps(facts, separators=(",", ":"), ensure_ascii=False), # a STRING, like JSON.stringify
}
n = (note or "").strip()
if n:
inp["note"] = n[:2000] # optional; the page caps it at 2000 chars
if task == "fix" and handoff:
inp["audit_handoff"] = handoff # optional; a JSON string built from an audit reply
return inp
inp = make_input("audit", "platform-manifests.yaml", "platform-facts.json",
"First tenant on the staging cluster. Is the tenancy set up the way Flux recommends before we add more teams?")
est = api("POST", "/estimate", inp)
print("hold", est.get("hold_credits"), "min", est.get("min_credits"),
"sponsored", est.get("sponsor_enabled"), "warnings", est.get("warnings") or [])
// The body the page sends: task, manifests (the YAML text), facts as a JSON STRING, an optional note,
// and (fix lane only) the audit_handoff string.
function makeInput(task, manifestsPath, factsPath, note, handoff) {
const input = {
task,
manifests: readFileSync(manifestsPath, "utf8"), // the manifests, verbatim
facts: JSON.stringify(JSON.parse(readFileSync(factsPath, "utf8"))), // a STRING: the page's free check
};
const n = (note || "").trim();
if (n) input.note = n.length > 2000 ? n.slice(0, 2000) : n; // optional; max 2000 chars
if (task === "fix" && handoff) input.audit_handoff = handoff; // optional; a JSON string
return input;
}
const input = makeInput("audit", "platform-manifests.yaml", "platform-facts.json",
"First tenant on the staging cluster. Is the tenancy set up the way Flux recommends before we add more teams?");
const est = await api("POST", "/estimate", input);
console.log("hold", est.hold_credits, "min", est.min_credits,
"sponsored", est.sponsor_enabled, "warnings", est.warnings || []);
// makeInput builds the body the page sends: task, manifests (the YAML text), facts as a JSON STRING,
// an optional note and (fix lane only) the audit_handoff string.
makeInput := func(task, manifestsPath, factsPath, note, handoff string) map[string]any {
read := func(path string) []byte {
raw, err := os.ReadFile(path)
if err != nil {
panic(err)
}
return raw
}
var facts bytes.Buffer
if err := json.Compact(&facts, read(factsPath)); err != nil { // keeps key order; travels as a STRING
panic(err)
}
in := map[string]any{"task": task, "manifests": string(read(manifestsPath)), "facts": facts.String()}
if n := []rune(strings.TrimSpace(note)); len(n) > 0 {
if len(n) > 2000 {
n = n[:2000] // optional; the page caps it at 2000 chars
}
in["note"] = string(n)
}
if task == "fix" && handoff != "" {
in["audit_handoff"] = handoff // optional; a JSON string built from an audit reply
}
return in
}
input := makeInput("audit", "platform-manifests.yaml", "platform-facts.json",
"First tenant on the staging cluster. Is the tenancy set up the way Flux recommends before we add more teams?", "")
var est struct {
HoldCredits any `json:"hold_credits"`
MinCredits any `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
Warnings []any `json:"warnings"`
}
if err := api("POST", "/estimate", input, nil, &est); err != nil {
panic(err)
}
fmt.Printf("hold %v min %v sponsored %v warnings %v\n", est.HoldCredits, est.MinCredits, est.SponsorEnabled, est.Warnings)
// ---- add to the GitopsRepoAudit class, next to api(): the body the page sends
static Map<String, Object> makeInput(String task, String manifestsPath, String factsPath, String note, String handoff) throws Exception {
Map<String, Object> in = new LinkedHashMap<>();
in.put("task", task);
in.put("manifests", Files.readString(Path.of(manifestsPath))); // the manifests, verbatim
in.put("facts", JSON.writeValueAsString(JSON.readTree(Files.readString(Path.of(factsPath))))); // a STRING, compact
String n = note == null ? "" : note.strip();
if (!n.isEmpty()) in.put("note", n.length() > 2000 ? n.substring(0, 2000) : n); // optional; max 2000 chars
if (task.equals("fix") && handoff != null && !handoff.isEmpty()) in.put("audit_handoff", handoff); // optional
return in;
}
// ---- inside main
Map<String, Object> input = makeInput("audit", "platform-manifests.yaml", "platform-facts.json",
"First tenant on the staging cluster. Is the tenancy set up the way Flux recommends before we add more teams?", null);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println("hold " + est.path("hold_credits") + " min " + est.path("min_credits")
+ " sponsored " + est.path("sponsor_enabled").asBoolean(false) + " warnings " + est.path("warnings"));
# The body the page sends: task, manifests (the YAML text), facts as a JSON STRING, an optional note,
# and (fix lane only) the audit_handoff string.
def make_input(task, manifests_path, facts_path, note = nil, handoff = nil)
input = {
task: task,
manifests: File.read(manifests_path, encoding: "UTF-8"), # the manifests, verbatim
facts: JSON.generate(JSON.parse(File.read(facts_path, encoding: "UTF-8"))) # a STRING, compact
}
n = note.to_s.strip
input[:note] = n[0, 2000] unless n.empty? # optional; max 2000 chars
input[:audit_handoff] = handoff if task == "fix" && handoff && !handoff.empty? # optional
input
end
input = make_input("audit", "platform-manifests.yaml", "platform-facts.json",
"First tenant on the staging cluster. Is the tenancy set up the way Flux recommends before we add more teams?")
est = api("POST", "/estimate", input)
puts "hold #{est["hold_credits"]} min #{est["min_credits"]} " \
"sponsored #{est["sponsor_enabled"]} warnings #{(est["warnings"] || []).inspect}"
// The body the page sends: task, manifests (the YAML text), facts as a JSON STRING, an optional note,
// and (fix lane only) the audit_handoff string.
function make_input(string $task, string $manifestsPath, string $factsPath, string $note = "", string $handoff = ""): array {
// decode to objects (not arrays) so an empty {} stays {} when re-encoded
$facts = json_encode(json_decode(file_get_contents($factsPath)), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$input = ["task" => $task, "manifests" => file_get_contents($manifestsPath), "facts" => $facts]; // all STRINGS
$n = trim($note);
if ($n !== "") $input["note"] = mb_substr($n, 0, 2000); // optional; max 2000 chars
if ($task === "fix" && $handoff !== "") $input["audit_handoff"] = $handoff; // optional
return $input;
}
$input = make_input("audit", "platform-manifests.yaml", "platform-facts.json",
"First tenant on the staging cluster. Is the tenancy set up the way Flux recommends before we add more teams?");
$est = api("POST", "/estimate", $input);
echo "hold ", $est->hold_credits ?? "-", " min ", $est->min_credits ?? "-",
" sponsored ", json_encode($est->sponsor_enabled ?? false),
" warnings ", json_encode($est->warnings ?? []), "\n";
// The body the page sends: task, manifests (the YAML text), facts as a JSON STRING, an optional note,
// and (fix lane only) the audit_handoff string.
static Dictionary<string, string> MakeInput(string task, string manifestsPath, string factsPath, string? note = null, string? handoff = null)
{
var compact = new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
var input = new Dictionary<string, string>
{
["task"] = task,
["manifests"] = File.ReadAllText(manifestsPath), // the manifests, verbatim
["facts"] = JsonNode.Parse(File.ReadAllText(factsPath))!.ToJsonString(compact), // a STRING, compact
};
var n = (note ?? "").Trim();
if (n.Length > 0) input["note"] = n.Length > 2000 ? n[..2000] : n; // optional; max 2000 chars
if (task == "fix" && !string.IsNullOrEmpty(handoff)) input["audit_handoff"] = handoff; // optional
return input;
}
var input = MakeInput("audit", "platform-manifests.yaml", "platform-facts.json",
"First tenant on the staging cluster. Is the tenancy set up the way Flux recommends before we add more teams?");
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"hold {est?["hold_credits"]} min {est?["min_credits"]} " +
$"sponsored {est?["sponsor_enabled"]} warnings {est?["warnings"]?.ToJsonString() ?? "[]"}");
Step 5 — POST /run, then poll the job (audit)
Send the same input to /run with an Idempotency-Key header, using a
personal token. Its data carries a job_id. Poll
GET /jobs/{job_id} the way sdk.js waitForJob does: every
second until status is succeeded or failed, giving up on
the client side after 180 seconds. On the finished job, read:
output.output— the model’s reply as a string: the JSON object from the output contract of the lane you asked for. Parse it yourself (from the first{to the last}). The samples print the verdict and headline, each finding’sid,resource,lineandseverity, then each described applier.truncated—truewhen the reply was cut short because the balance ran out. The page then closes the cut-off JSON, shows what arrived as a partial result (“N of 10 sections recovered” for an audit, of 9 for a fix), and says it was cut short by the available balance.charged_credits— the real cost of the run, as opposed to the estimate’shold_creditsreservation. The page re-reads/meafterwards for the new balance.error— on afailedjob.
Idempotency key. Resending a request with the same key must not start a second
paid run, so derive the key from the input, not from the clock — and include the lane, so an
audit and a fix of the same paste never share a key. The page uses
gitops-repo-audit:<lane>:<inputHash>:a<attempt>, where
<lane> is audit or fix,
<inputHash> is a 32-bit djb2 hash of JSON.stringify(input) in hex, a
dash, and the string’s length in hex (app.js inputHash, over the
input without retry_note), and <attempt> is 1
for a normal run and 2 for the automatic reformat retry (which also adds
retry_note). For the two worked examples that is
gitops-repo-audit:audit:80389222-314a:a1 and
gitops-repo-audit:fix:20a13c99-1f19:a1. The format is the page’s convention,
not a requirement: any stable, unique string does. The samples below use the lane and a SHA-256
prefix.
The page itself uses /run-stream (step 6), not /run; both take the
same body and header.
KEY="gitops-repo-audit:audit:$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-16):a1"
JOB=$(api POST /run -H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')
for i in $(seq 180); do # 1 s interval, 180 s client-side limit
J=$(api GET "/jobs/$JOB")
S=$(printf '%s' "$J" | jq -r '.data.status')
if [ "$S" = "succeeded" ] || [ "$S" = "failed" ]; then break; fi
sleep 1
done
printf '%s' "$J" | jq '.data | {status, truncated, charged_credits, error}'
printf '%s' "$J" | jq -r '.data.output.output' \
| jq '{verdict, headline, findings: [(.findings // [])[] | {id, resource, line, severity}], appliers: [(.appliers // [])[] | .resource]}'
body = json.dumps(inp)
key = "gitops-repo-audit:audit:" + hashlib.sha256(body.encode()).hexdigest()[:16] + ":a1"
job_id = api("POST", "/run", inp, {"Idempotency-Key": key})["job_id"]
deadline = time.time() + 180 # 1 s interval, 180 s client-side limit
while True:
job = api("GET", "/jobs/" + urllib.parse.quote(job_id, safe=""))
if job["status"] in ("succeeded", "failed"):
break
if time.time() > deadline:
raise TimeoutError("job timed out")
time.sleep(1)
if job["status"] == "failed":
raise SystemExit(f"run failed: {job.get('error')}")
print("truncated:", job.get("truncated") is True, "charged:", job.get("charged_credits"))
text = (job.get("output") or {}).get("output") or ""
reply = json.loads(text[text.index("{"): text.rindex("}") + 1])
print(reply["verdict"], "-", reply["headline"])
for f in reply.get("findings", []):
print(f["id"], f["resource"], f.get("line"), f["severity"])
for a in reply.get("appliers", []):
print("applier", a["resource"])
const key = "gitops-repo-audit:audit:" +
createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16) + ":a1";
const { job_id } = await api("POST", "/run", input, { "Idempotency-Key": key });
let job;
const deadline = Date.now() + 180000; // 1 s interval, 180 s client-side limit
for (;;) {
job = await api("GET", "/jobs/" + encodeURIComponent(job_id));
if (job.status === "succeeded" || job.status === "failed") break;
if (Date.now() > deadline) throw new Error("job timed out");
await new Promise((r) => setTimeout(r, 1000));
}
if (job.status === "failed") throw new Error("run failed: " + JSON.stringify(job.error));
console.log("truncated:", job.truncated === true, "charged:", job.charged_credits);
const text = (job.output && job.output.output) || "";
const reply = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
console.log(reply.verdict, "-", reply.headline);
for (const f of reply.findings || []) console.log(f.id, f.resource, f.line, f.severity);
for (const a of reply.appliers || []) console.log("applier", a.resource);
inputJSON, _ := json.Marshal(input)
sum := sha256.Sum256(inputJSON)
key := "gitops-repo-audit:audit:" + hex.EncodeToString(sum[:8]) + ":a1"
var started struct {
JobID string `json:"job_id"`
}
if err := api("POST", "/run", input, map[string]string{"Idempotency-Key": key}, &started); err != nil {
panic(err)
}
type jobT struct {
Status string `json:"status"`
Output *struct {
Output string `json:"output"`
} `json:"output"`
Truncated bool `json:"truncated"`
ChargedCredits any `json:"charged_credits"`
Error any `json:"error"`
}
var job jobT
deadline := time.Now().Add(180 * time.Second) // 1 s interval, 180 s client-side limit
for {
job = jobT{}
if err := api("GET", "/jobs/"+url.PathEscape(started.JobID), nil, nil, &job); err != nil {
panic(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
if time.Now().After(deadline) {
panic("job timed out")
}
time.Sleep(time.Second)
}
if job.Status == "failed" {
panic(fmt.Sprintf("run failed: %v", job.Error))
}
fmt.Println("truncated:", job.Truncated, "charged:", job.ChargedCredits)
text := ""
if job.Output != nil {
text = job.Output.Output
}
var reply map[string]any
if a, b := strings.Index(text, "{"), strings.LastIndex(text, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(text[a:b+1]), &reply); err != nil {
panic(err)
}
}
fmt.Println(reply["verdict"], "-", reply["headline"])
findings, _ := reply["findings"].([]any)
for _, e := range findings {
f, _ := e.(map[string]any)
fmt.Println(f["id"], f["resource"], f["line"], f["severity"])
}
appliers, _ := reply["appliers"].([]any)
for _, e := range appliers {
a, _ := e.(map[string]any)
fmt.Println("applier", a["resource"])
}
byte[] digest = MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(input));
String key = "gitops-repo-audit:audit:" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
String jobId = api("POST", "/run", input, Map.of("Idempotency-Key", key)).path("job_id").asText();
JsonNode job;
long deadline = System.currentTimeMillis() + 180_000; // 1 s interval, 180 s client-side limit
while (true) {
job = api("GET", "/jobs/" + URLEncoder.encode(jobId, StandardCharsets.UTF_8), null, null);
String s = job.path("status").asText();
if (s.equals("succeeded") || s.equals("failed")) break;
if (System.currentTimeMillis() > deadline) throw new RuntimeException("job timed out");
Thread.sleep(1000);
}
if (job.path("status").asText().equals("failed")) throw new RuntimeException("run failed: " + job.path("error"));
System.out.println("truncated: " + job.path("truncated").asBoolean(false) + ", charged: " + job.path("charged_credits"));
String text = job.path("output").path("output").asText("");
JsonNode reply = JSON.readTree(text.substring(text.indexOf('{'), text.lastIndexOf('}') + 1));
System.out.println(reply.path("verdict").asText() + " - " + reply.path("headline").asText());
for (JsonNode f : reply.path("findings")) {
System.out.println(f.path("id").asText() + " " + f.path("resource").asText() + " " + f.path("line") + " " + f.path("severity").asText());
}
for (JsonNode a : reply.path("appliers")) {
System.out.println("applier " + a.path("resource").asText());
}
key = "gitops-repo-audit:audit:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job_id = api("POST", "/run", input, { "Idempotency-Key" => key })["job_id"]
deadline = Time.now + 180 # 1 s interval, 180 s client-side limit
job = nil
loop do
job = api("GET", "/jobs/#{URI.encode_www_form_component(job_id)}")
break if %w[succeeded failed].include?(job["status"])
raise "job timed out" if Time.now > deadline
sleep 1
end
raise "run failed: #{job["error"].inspect}" if job["status"] == "failed"
puts "truncated: #{job["truncated"] == true}, charged: #{job["charged_credits"]}"
text = (job["output"] || {})["output"].to_s
reply = JSON.parse(text[text.index("{")..text.rindex("}")])
puts "#{reply["verdict"]} - #{reply["headline"]}"
(reply["findings"] || []).each { |f| puts "#{f["id"]} #{f["resource"]} #{f["line"].inspect} #{f["severity"]}" }
(reply["appliers"] || []).each { |a| puts "applier #{a["resource"]}" }
$key = "gitops-repo-audit:audit:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$jobId = api("POST", "/run", $input, ["Idempotency-Key" => $key])->job_id;
$deadline = time() + 180; // 1 s interval, 180 s client-side limit
while (true) {
$job = api("GET", "/jobs/" . rawurlencode($jobId));
if (in_array($job->status, ["succeeded", "failed"], true)) break;
if (time() > $deadline) throw new RuntimeException("job timed out");
sleep(1);
}
if ($job->status === "failed") throw new RuntimeException("run failed: " . json_encode($job->error ?? null));
echo "truncated: ", json_encode(($job->truncated ?? false) === true), ", charged: ", $job->charged_credits ?? "-", "\n";
$text = $job->output->output ?? "";
$reply = json_decode(substr($text, strpos($text, "{"), strrpos($text, "}") - strpos($text, "{") + 1));
echo $reply->verdict, " - ", $reply->headline, "\n";
foreach ($reply->findings ?? [] as $f) echo $f->id, " ", $f->resource, " ", json_encode($f->line ?? null), " ", $f->severity, "\n";
foreach ($reply->appliers ?? [] as $a) echo "applier ", $a->resource, "\n";
var digest = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(input)));
var key = "gitops-repo-audit:audit:" + Convert.ToHexString(digest, 0, 8).ToLowerInvariant() + ":a1";
var started = await Api.Call("POST", "/run", input, new Dictionary<string, string> { ["Idempotency-Key"] = key });
var jobId = started!["job_id"]!.GetValue<string>();
JsonNode job;
var deadline = DateTime.UtcNow.AddSeconds(180); // 1 s interval, 180 s client-side limit
while (true)
{
job = (await Api.Call("GET", "/jobs/" + Uri.EscapeDataString(jobId)))!;
var s = job["status"]?.GetValue<string>();
if (s == "succeeded" || s == "failed") break;
if (DateTime.UtcNow > deadline) throw new TimeoutException("job timed out");
await Task.Delay(1000);
}
if (job["status"]?.GetValue<string>() == "failed") throw new Exception($"run failed: {job["error"]?.ToJsonString()}");
var truncated = job["truncated"] is JsonValue t && t.TryGetValue(out bool tb) && tb;
Console.WriteLine($"truncated: {truncated}, charged: {job["charged_credits"]}");
var text = job["output"]?["output"]?.GetValue<string>() ?? "";
var reply = JsonNode.Parse(text[text.IndexOf('{')..(text.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{reply["verdict"]} - {reply["headline"]}");
foreach (var f in reply["findings"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"{f?["id"]} {f?["resource"]} {f?["line"]?.ToJsonString() ?? "null"} {f?["severity"]}");
foreach (var a in reply["appliers"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"applier {a?["resource"]}");
Step 6 — POST /run-stream (Server-Sent Events, fix)
The alternative to step 5, and what the page uses: same kind of body, same
Idempotency-Key header, one request. Run one or the other for a given input, not
both. To show the second lane, the samples build the fix input of the
podinfo example (with its own key) and stream it; to stream the audit input instead, pass the
step 4 input and key. The response is text/event-stream. sdk.js splits
it into frames on a blank line, takes the event name from an event: line (default
message) and concatenates the data: lines, skips frames with no data or
with data that is not JSON, and handles these events:
| Event | Data | What the SDK does |
|---|---|---|
delta | {text} | Passes text to onDelta. |
job | job info | Passes it to onJob (the page uses it to advance its progress display). |
done | the final payload: {job_id, status, charged_credits, output} | Keeps it as the result. |
pending | a payload | Treated exactly like done. If it carries a job_id and no output, poll that job as in step 5. |
error | {message, code, job_id} | After the stream ends, raises an error with those fields. |
If the response is not text/event-stream, it is an ordinary JSON envelope:
the SDK returns its data (an idempotent replay returns this way) or raises the
error on a non-2xx status. Handle both shapes.
From the final payload the page reads output.output (falling back to the
concatenated delta text), truncated and charged_credits.
Browsers receive progress ticks rather than deltas from this endpoint, so the page relies on the
final payload; if the stream fails part-way, it tries to recover a partial reply from whatever
delta text arrived. The samples print the verdict and headline, each change’s flags,
id and resource, each kept flag, each removed resource and each command
to run outside Git, and write every corrected document to fixed-documents.yaml
(joined by ---, the way the page’s “copy all documents” does). Those
documents replace the pasted ones they name; to get the whole repaired paste, put them
back with apply-fix.js (under “Worked examples”) — what the
page’s “Check the fixed manifests” does — and paste the result into the
app page to run the free check on it. To fix a paste you audited, pass the handoff
string as makeInput’s last argument.
# the fix worked example: podinfo-manifests.yaml and podinfo-facts.json from build-facts.js
FBODY=$(jq -n --rawfile manifests podinfo-manifests.yaml --arg facts "$(jq -c . podinfo-facts.json)" \
--arg note "Bring these up to Flux 2.8 practice. podinfo 6.x and redis 20.x are the lines we run." \
'{task: "fix", manifests: $manifests, facts: $facts, note: $note}')
FKEY="gitops-repo-audit:fix:$(printf '%s' "$FBODY" | shasum -a 256 | cut -c1-16):a1"
# -N turns off buffering so frames print as they arrive; tee keeps them for the next lines
api POST /run-stream -N -H "Idempotency-Key: $FKEY" -d "$FBODY" | tee stream.txt
# frames look like:
# event: job
# data: {...}
#
# event: done
# data: {"job_id":"...","status":"...","charged_credits":...,"output":{"output":"{\"task\":\"fix\",...}"}}
# the final reply text, from the done (or pending) frame:
REPLY=$(awk '/^event:/{ev=$2} /^data:/ && (ev=="done" || ev=="pending"){sub(/^data: */,""); print}' stream.txt \
| jq -r '.output.output')
# an idempotent replay answers with a plain JSON envelope instead: REPLY=$(jq -r '.data.output.output' stream.txt)
printf '%s' "$REPLY" | jq '{verdict, headline, changes: [(.changes // [])[] | {flags, id, resource}], kept, removed, commands}'
printf '%s' "$REPLY" | jq -r '[(.documents // [])[] | .yaml | sub("\\s+$"; "")] | join("\n---\n")' > fixed-documents.yaml
# the fix worked example: podinfo-manifests.yaml and podinfo-facts.json from build-facts.js
# (for a fix of an audited paste, pass handoff=json.dumps(...) built as in "The audit_handoff string")
fix_inp = make_input("fix", "podinfo-manifests.yaml", "podinfo-facts.json",
"Bring these up to Flux 2.8 practice. podinfo 6.x and redis 20.x are the lines we run.")
fix_key = "gitops-repo-audit:fix:" + hashlib.sha256(json.dumps(fix_inp).encode()).hexdigest()[:16] + ":a1"
h = {"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN, "Idempotency-Key": fix_key}
done, failure, streamed = None, None, ""
with requests.post(BASE + "/run-stream", headers=h, data=json.dumps(fix_inp), stream=True) as r:
if "text/event-stream" not in r.headers.get("content-type", ""):
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason)
done = env.get("data")
else:
event, data = "message", ""
for line in r.iter_lines(decode_unicode=True):
if line == "": # a blank line ends a frame
if data:
try:
payload = json.loads(data)
except ValueError:
payload = None
if event == "delta" and payload is not None:
streamed += payload.get("text") or ""
elif event == "job":
print("job", payload)
elif event in ("done", "pending"):
done = payload
elif event == "error":
failure = payload
event, data = "message", ""
elif line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
if failure:
raise SystemExit(f"{failure.get('code')}: {failure.get('message')} (job {failure.get('job_id')})")
done = done or {}
full = (done.get("output") or {}).get("output") or streamed
print("truncated:", done.get("truncated") is True, "charged:", done.get("charged_credits"))
result = json.loads(full[full.index("{"): full.rindex("}") + 1])
print(result["verdict"], "-", result["headline"])
for c in result.get("changes", []):
print("change", ",".join(c.get("flags") or []), c.get("id"), c.get("resource"))
for k in result.get("kept", []):
print("kept", k["flag"], "-", k.get("reason"))
for x in result.get("removed", []):
print("removed", x["resource"], "-", x.get("reason"))
for cmd in result.get("commands", []):
print("command", cmd)
with open("fixed-documents.yaml", "w", encoding="utf-8") as f:
f.write("\n---\n".join(d["yaml"].rstrip() for d in result.get("documents", [])) + "\n")
// the fix worked example: podinfo-manifests.yaml and podinfo-facts.json from build-facts.js
// (for a fix of an audited paste, pass the audit_handoff string as the last argument)
const fixInput = makeInput("fix", "podinfo-manifests.yaml", "podinfo-facts.json",
"Bring these up to Flux 2.8 practice. podinfo 6.x and redis 20.x are the lines we run.");
const fixKey = "gitops-repo-audit:fix:" +
createHash("sha256").update(JSON.stringify(fixInput)).digest("hex").slice(0, 16) + ":a1";
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + TOKEN, "Idempotency-Key": fixKey },
body: JSON.stringify(fixInput),
});
let done = null, failure = null, streamed = "";
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((json.error && json.error.message) || res.statusText);
done = json.data;
} else {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message", dataStr = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
let data;
try { data = JSON.parse(dataStr); } catch { continue; }
if (event === "delta") streamed += data.text || "";
else if (event === "job") console.log("job", data);
else if (event === "done" || event === "pending") done = data;
else if (event === "error") failure = data;
}
}
}
if (failure) throw new Error(`${failure.code}: ${failure.message} (job ${failure.job_id})`);
const full = (done && done.output && done.output.output) || streamed;
console.log("truncated:", !!(done && done.truncated), "charged:", done && done.charged_credits);
const result = JSON.parse(full.slice(full.indexOf("{"), full.lastIndexOf("}") + 1));
console.log(result.verdict, "-", result.headline);
for (const c of result.changes || []) console.log("change", (c.flags || []).join(","), c.id, c.resource);
for (const k of result.kept || []) console.log("kept", k.flag, "-", k.reason);
for (const x of result.removed || []) console.log("removed", x.resource, "-", x.reason);
for (const cmd of result.commands || []) console.log("command", cmd);
writeFileSync("fixed-documents.yaml", (result.documents || []).map((d) => d.yaml.trimEnd()).join("\n---\n") + "\n");
// the fix worked example: podinfo-manifests.yaml and podinfo-facts.json from build-facts.js
// (for a fix of an audited paste, pass the audit_handoff string as the last argument)
fixInput := makeInput("fix", "podinfo-manifests.yaml", "podinfo-facts.json",
"Bring these up to Flux 2.8 practice. podinfo 6.x and redis 20.x are the lines we run.", "")
fixJSON, _ := json.Marshal(fixInput)
fixSum := sha256.Sum256(fixJSON)
fixKey := "gitops-repo-audit:fix:" + hex.EncodeToString(fixSum[:8]) + ":a1"
sreq, err := newRequest("POST", "/run-stream", fixInput, map[string]string{"Idempotency-Key": fixKey})
if err != nil {
panic(err)
}
sres, err := http.DefaultClient.Do(sreq)
if err != nil {
panic(err)
}
defer sres.Body.Close()
var done, failure map[string]any
var streamed strings.Builder
if !strings.Contains(sres.Header.Get("Content-Type"), "text/event-stream") {
var env envelope
_ = json.NewDecoder(sres.Body).Decode(&env)
if sres.StatusCode < 200 || sres.StatusCode > 299 {
panic(errorFrom(sres, env))
}
_ = json.Unmarshal(env.Data, &done)
} else {
sc := bufio.NewScanner(sres.Body)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024) // the done frame can be long
event, data := "message", ""
for sc.Scan() {
line := sc.Text()
switch {
case line == "": // a blank line ends a frame
if data != "" {
var p map[string]any
if json.Unmarshal([]byte(data), &p) == nil {
switch event {
case "delta":
if t, ok := p["text"].(string); ok {
streamed.WriteString(t)
}
case "job":
fmt.Println("job", p)
case "done", "pending":
done = p
case "error":
failure = p
}
}
}
event, data = "message", ""
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
}
}
}
if failure != nil {
panic(fmt.Sprintf("%v: %v (job %v)", failure["code"], failure["message"], failure["job_id"]))
}
full := streamed.String()
if out, ok := done["output"].(map[string]any); ok {
if s, ok := out["output"].(string); ok && s != "" {
full = s
}
}
fmt.Println("truncated:", done["truncated"] == true, "charged:", done["charged_credits"])
var result map[string]any
if a, b := strings.Index(full, "{"), strings.LastIndex(full, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(full[a:b+1]), &result); err != nil {
panic(err)
}
}
fmt.Println(result["verdict"], "-", result["headline"])
changes, _ := result["changes"].([]any)
for _, e := range changes {
c, _ := e.(map[string]any)
fl, _ := c["flags"].([]any)
parts := make([]string, 0, len(fl))
for _, u := range fl {
parts = append(parts, fmt.Sprint(u))
}
fmt.Println("change", strings.Join(parts, ","), c["id"], c["resource"])
}
kept, _ := result["kept"].([]any)
for _, e := range kept {
k, _ := e.(map[string]any)
fmt.Println("kept", k["flag"], "-", k["reason"])
}
removed, _ := result["removed"].([]any)
for _, e := range removed {
x, _ := e.(map[string]any)
fmt.Println("removed", x["resource"], "-", x["reason"])
}
commands, _ := result["commands"].([]any)
for _, cmd := range commands {
fmt.Println("command", cmd)
}
documents, _ := result["documents"].([]any)
yamls := make([]string, 0, len(documents))
for _, e := range documents {
d, _ := e.(map[string]any)
y, _ := d["yaml"].(string)
yamls = append(yamls, strings.TrimRight(y, " \t\r\n"))
}
if err := os.WriteFile("fixed-documents.yaml", []byte(strings.Join(yamls, "\n---\n")+"\n"), 0o644); err != nil {
panic(err)
}
// the fix worked example: podinfo-manifests.yaml and podinfo-facts.json from build-facts.js
// (for a fix of an audited paste, pass the audit_handoff string as the last argument)
Map<String, Object> fixInput = makeInput("fix", "podinfo-manifests.yaml", "podinfo-facts.json",
"Bring these up to Flux 2.8 practice. podinfo 6.x and redis 20.x are the lines we run.", null);
byte[] fixDigest = MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(fixInput));
String fixKey = "gitops-repo-audit:fix:" + HexFormat.of().formatHex(fixDigest, 0, 8) + ":a1";
HttpRequest sreq = request("POST", "/run-stream", fixInput, Map.of("Idempotency-Key", fixKey)).build();
HttpResponse<Stream<String>> sres = HTTP.send(sreq, HttpResponse.BodyHandlers.ofLines());
JsonNode done = null, failure = null;
StringBuilder streamed = new StringBuilder();
if (!sres.headers().firstValue("content-type").orElse("").contains("text/event-stream")) {
JsonNode env = JSON.readTree(sres.body().collect(Collectors.joining("\n")));
if (env == null) env = JSON.createObjectNode();
if (sres.statusCode() / 100 != 2) throw errorFrom(sres.statusCode(), env);
done = env.path("data");
} else {
String event = "message";
StringBuilder data = new StringBuilder();
Iterator<String> it = sres.body().iterator();
while (it.hasNext()) {
String line = it.next();
if (line.isEmpty()) { // a blank line ends a frame
if (data.length() > 0) {
JsonNode p = null;
try { p = JSON.readTree(data.toString()); } catch (Exception ignored) { }
if (p != null) {
switch (event) {
case "delta": streamed.append(p.path("text").asText("")); break;
case "job": System.out.println("job " + p); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
default: break;
}
}
}
event = "message";
data.setLength(0);
} else if (line.startsWith("event:")) event = line.substring(6).trim();
else if (line.startsWith("data:")) data.append(line.substring(5).trim());
}
}
if (failure != null) {
throw new RuntimeException(failure.path("code").asText() + ": " + failure.path("message").asText()
+ " (job " + failure.path("job_id").asText() + ")");
}
String full = done == null ? "" : done.path("output").path("output").asText("");
if (full.isEmpty()) full = streamed.toString();
System.out.println("truncated: " + (done != null && done.path("truncated").asBoolean(false))
+ ", charged: " + (done == null ? "-" : done.path("charged_credits").toString()));
JsonNode result = JSON.readTree(full.substring(full.indexOf('{'), full.lastIndexOf('}') + 1));
System.out.println(result.path("verdict").asText() + " - " + result.path("headline").asText());
for (JsonNode c : result.path("changes")) {
List<String> fl = new ArrayList<>();
c.path("flags").forEach(u -> fl.add(u.asText()));
System.out.println("change " + String.join(",", fl) + " " + c.path("id").asText() + " " + c.path("resource").asText());
}
for (JsonNode k : result.path("kept")) {
System.out.println("kept " + k.path("flag").asText() + " - " + k.path("reason").asText());
}
for (JsonNode x : result.path("removed")) {
System.out.println("removed " + x.path("resource").asText() + " - " + x.path("reason").asText());
}
for (JsonNode cmd : result.path("commands")) {
System.out.println("command " + cmd.asText());
}
List<String> yamls = new ArrayList<>();
result.path("documents").forEach(d -> yamls.add(d.path("yaml").asText("").stripTrailing()));
Files.writeString(Path.of("fixed-documents.yaml"), String.join("\n---\n", yamls) + "\n");
# the fix worked example: podinfo-manifests.yaml and podinfo-facts.json from build-facts.js
# (for a fix of an audited paste, pass the audit_handoff string as the last argument)
fix_input = make_input("fix", "podinfo-manifests.yaml", "podinfo-facts.json",
"Bring these up to Flux 2.8 practice. podinfo 6.x and redis 20.x are the lines we run.")
fix_key = "gitops-repo-audit:fix:#{Digest::SHA256.hexdigest(JSON.generate(fix_input))[0, 16]}:a1"
uri, req = build_request("POST", "/run-stream", fix_input, { "Idempotency-Key" => fix_key })
done = failure = nil
streamed = +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
unless res["content-type"].to_s.include?("text/event-stream")
env = (JSON.parse(res.body) rescue {})
unless res.is_a?(Net::HTTPSuccess)
raise ApiError.new(res.code.to_i, env.dig("error", "code"), env.dig("error", "message") || res.message)
end
done = env["data"]
next
end
buf = +""
res.read_body do |chunk|
buf << chunk
while (i = buf.index("\n\n"))
frame = buf.slice!(0, i + 2)
event = "message"
data = +""
frame.split("\n").each do |line|
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") then data << line[5..].strip
end
end
next if data.empty?
payload = (JSON.parse(data) rescue nil)
next if payload.nil?
case event
when "delta" then streamed << payload["text"].to_s
when "job" then puts "job #{payload.inspect}"
when "done", "pending" then done = payload
when "error" then failure = payload
end
end
end
end
end
raise "#{failure["code"]}: #{failure["message"]} (job #{failure["job_id"]})" if failure
done ||= {}
full = (done["output"] || {})["output"].to_s
full = streamed if full.empty?
puts "truncated: #{done["truncated"] == true}, charged: #{done["charged_credits"]}"
result = JSON.parse(full[full.index("{")..full.rindex("}")])
puts "#{result["verdict"]} - #{result["headline"]}"
(result["changes"] || []).each { |c| puts "change #{(c["flags"] || []).join(",")} #{c["id"]} #{c["resource"]}" }
(result["kept"] || []).each { |k| puts "kept #{k["flag"]} - #{k["reason"]}" }
(result["removed"] || []).each { |x| puts "removed #{x["resource"]} - #{x["reason"]}" }
(result["commands"] || []).each { |cmd| puts "command #{cmd}" }
File.write("fixed-documents.yaml", (result["documents"] || []).map { |d| d["yaml"].to_s.rstrip }.join("\n---\n") + "\n")
// the fix worked example: podinfo-manifests.yaml and podinfo-facts.json from build-facts.js
// (for a fix of an audited paste, pass the audit_handoff string as the last argument)
$fixInput = make_input("fix", "podinfo-manifests.yaml", "podinfo-facts.json",
"Bring these up to Flux 2.8 practice. podinfo 6.x and redis 20.x are the lines we run.");
$fixKey = "gitops-repo-audit:fix:" . substr(hash("sha256", json_encode($fixInput)), 0, 16) . ":a1";
$done = null; $failure = null; $streamed = ""; $buf = ""; $ctype = "";
$handle = function (string $event, string $data) use (&$done, &$failure, &$streamed) {
$p = json_decode($data);
if ($p === null) return; // not JSON: skipped, as sdk.js does
switch ($event) {
case "delta": $streamed .= $p->text ?? ""; break;
case "job": echo "job ", json_encode($p), "\n"; break;
case "done": case "pending": $done = $p; break;
case "error": $failure = $p; break;
}
};
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => headers_for(["Idempotency-Key" => $fixKey]),
CURLOPT_POSTFIELDS => json_encode($fixInput, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$ctype) {
if (stripos($line, "content-type:") === 0) $ctype = strtolower(trim(substr($line, 13)));
return strlen($line);
},
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$ctype, $handle) {
$buf .= $chunk;
if (strpos($ctype, "text/event-stream") === false) return strlen($chunk); // plain JSON: parse at the end
while (($i = strpos($buf, "\n\n")) !== false) {
$frame = substr($buf, 0, $i);
$buf = substr($buf, $i + 2);
$event = "message"; $data = "";
foreach (explode("\n", $frame) as $line) {
if (strpos($line, "event:") === 0) $event = trim(substr($line, 6));
elseif (strpos($line, "data:") === 0) $data .= trim(substr($line, 5));
}
if ($data !== "") $handle($event, $data);
}
return strlen($chunk);
},
]);
if (curl_exec($ch) === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if (strpos($ctype, "text/event-stream") === false) {
$env = json_decode($buf) ?? new stdClass();
if ($status < 200 || $status > 299) {
throw new ApiError($status, $env->error->code ?? null, $env->error->message ?? "HTTP $status");
}
$done = $env->data ?? null;
}
if ($failure) throw new RuntimeException(($failure->code ?? "") . ": " . ($failure->message ?? "") . " (job " . ($failure->job_id ?? "") . ")");
$full = $done->output->output ?? "";
if ($full === "") $full = $streamed;
echo "truncated: ", json_encode(($done->truncated ?? false) === true), ", charged: ", $done->charged_credits ?? "-", "\n";
$result = json_decode(substr($full, strpos($full, "{"), strrpos($full, "}") - strpos($full, "{") + 1));
echo $result->verdict, " - ", $result->headline, "\n";
foreach ($result->changes ?? [] as $c) echo "change ", implode(",", $c->flags ?? []), " ", $c->id ?? "", " ", $c->resource ?? "", "\n";
foreach ($result->kept ?? [] as $k) echo "kept ", $k->flag, " - ", $k->reason ?? "", "\n";
foreach ($result->removed ?? [] as $x) echo "removed ", $x->resource, " - ", $x->reason ?? "", "\n";
foreach ($result->commands ?? [] as $cmd) echo "command ", $cmd, "\n";
$yamls = array_map(fn($d) => rtrim($d->yaml ?? ""), $result->documents ?? []);
file_put_contents("fixed-documents.yaml", implode("\n---\n", $yamls) . "\n");
// the fix worked example: podinfo-manifests.yaml and podinfo-facts.json from build-facts.js
// (for a fix of an audited paste, pass the audit_handoff string as the last argument)
var fixInput = MakeInput("fix", "podinfo-manifests.yaml", "podinfo-facts.json",
"Bring these up to Flux 2.8 practice. podinfo 6.x and redis 20.x are the lines we run.");
var fixDigest = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(fixInput)));
var fixKey = "gitops-repo-audit:fix:" + Convert.ToHexString(fixDigest, 0, 8).ToLowerInvariant() + ":a1";
using var sreq = Api.Request("POST", "/run-stream", fixInput, new Dictionary<string, string> { ["Idempotency-Key"] = fixKey });
using var sres = await Api.Http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
JsonNode? done = null, failure = null;
var streamed = new StringBuilder();
if (sres.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
JsonNode? env = null;
try { env = JsonNode.Parse(await sres.Content.ReadAsStringAsync()); } catch (JsonException) { }
if (!sres.IsSuccessStatusCode) throw Api.ErrorFrom(sres, env);
done = env?["data"];
}
else
{
using var reader = new StreamReader(await sres.Content.ReadAsStreamAsync());
var ev = "message";
var data = new StringBuilder();
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.Length == 0) // a blank line ends a frame
{
if (data.Length > 0)
{
JsonNode? p = null;
try { p = JsonNode.Parse(data.ToString()); } catch (JsonException) { }
if (p != null)
{
switch (ev)
{
case "delta": streamed.Append(p["text"]?.GetValue<string>() ?? ""); break;
case "job": Console.WriteLine($"job {p.ToJsonString()}"); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
}
}
}
ev = "message";
data.Clear();
}
else if (line.StartsWith("event:")) ev = line[6..].Trim();
else if (line.StartsWith("data:")) data.Append(line[5..].Trim());
}
}
if (failure != null) throw new Exception($"{failure["code"]}: {failure["message"]} (job {failure["job_id"]})");
var full = done?["output"]?["output"]?.GetValue<string>();
if (string.IsNullOrEmpty(full)) full = streamed.ToString();
var cut = done?["truncated"] is JsonValue tv && tv.TryGetValue(out bool cutShort) && cutShort;
Console.WriteLine($"truncated: {cut}, charged: {done?["charged_credits"]}");
var result = JsonNode.Parse(full[full.IndexOf('{')..(full.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{result["verdict"]} - {result["headline"]}");
foreach (var c in result["changes"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"change {string.Join(",", (c?["flags"]?.AsArray() ?? new JsonArray()).Select(u => u?.ToString()))} {c?["id"]} {c?["resource"]}");
foreach (var k in result["kept"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"kept {k?["flag"]} - {k?["reason"]}");
foreach (var x in result["removed"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"removed {x?["resource"]} - {x?["reason"]}");
foreach (var cmd in result["commands"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"command {cmd}");
var yamls = (result["documents"]?.AsArray() ?? new JsonArray()).Select(d => (d?["yaml"]?.GetValue<string>() ?? "").TrimEnd());
File.WriteAllText("fixed-documents.yaml", string.Join("\n---\n", yamls) + "\n");
What the page does that you now do
- Reads the paste in the browser and builds
manifestsandfactsfrom it (fluxkit.js), cutting a large paste on whole documents; you send the same two strings. - Prices every input with
/estimateand refuses to run belowmin_credits. - Sends each attempt with an input- and lane-derived
Idempotency-Key, and retries once withretry_notewhen a reply does not parse. - Closes a cut-off JSON reply and shows what arrived when
truncatedis true or the stream ends early. - Hands an audit reply to the fix lane as
audit_handoff, only while the paste is unchanged, and refuses a fix of a paste too large to send whole. - Checks the reply against the manifests (
recon.js): resources, lines, evidence, every fix and every corrected document put back and re-run through the free check, flag answers, verdict, appliers and repeated secrets.
Or skip all of this and use GitOps Repo Audit in the browser: the check, the flags, putting a fix back into the paste, and the Markdown and CSV exports are free.