hecate

module
v0.8.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 25, 2026 License: Apache-2.0

README

⛿ Hecate

The promotion and release-orchestration layer for FluxCD.

Kleidouchos — the key-holder. She stands at the boundary and decides what may pass.

License Go Status

Website · Architecture · Steps · Product plan · Development plan · Permissions · Signing in · Observability · Decisions · Onboarding · Contributing


What

Flux makes a cluster match what is in git. It has no opinion about what should be in git next — so cross-environment promotion is left to hand-rolled CI.

The Flux ecosystem has no promotion category at all. Hecate fills that slot:

within one environment across environments
Argo CD Argo Rollouts (well served)
Flux Flagger Hecate

The model

Four resources. That is the whole API.

Beacon Watches registries, charts, repos — or a Flux Operator ResourceSetInputProvider. Emits a Bundle when something new appears.
Bundle An immutable, content-addressed set of artifact versions. The unit that moves.
Gate An environment, and the threshold a Bundle must cross to enter it.
Passage One attempt to move one Bundle through one Gate.
apiVersion: hecate.dev/v1alpha1
kind: Gate
metadata:
  name: production
spec:
  admits:
    - from: { beacon: podinfo }
      after: [staging]          # only what already cleared staging
      requireApproval: true
  passage:
    steps:
      - uses: git-clone
        with: { repo: https://github.com/acme/fleet.git }
      - uses: set-image
        with:
          path: repo/apps/production/kustomization.yaml
          image: ghcr.io/acme/podinfo   # the version comes from the Bundle
      - uses: git-commit
        as: commit
        with: { message: "promote podinfo to production" }
      - uses: git-push
      - uses: flux-reconcile          # sync now, not at the next interval
        with:
          resources:
            - { kind: GitRepository, name: fleet, namespace: flux-system }
      - uses: flux-wait
        with:
          resources:
            - { kind: Kustomization, name: podinfo, namespace: flux-system }
          expectedRevision: ${{ steps.commit.sha }}
Reusing Flux Operator's discovery

Flux Operator's ResourceSetInputProvider already discovers from GitHub, GitLab, Azure DevOps, AWS CodeCommit, Gitea, OCI, ACR, ECR and GAR. A Beacon can read one instead of doing it again:

watch:
  - provider: { name: podinfo-releases }

It names the provider and nothing else. The filter, the semver range and the limit live there, and the provider already sorts what it exports — so Hecate takes the newest and does not apply a second opinion about what "newest" means.

Discovery without waiting for the interval

A Beacon polls on its own schedule. To make a push land in seconds instead, have CI tell it to look now:

# .github/workflows/release.yml
- name: Tell Hecate to look
  run: |
    curl -fsS -X POST \
      -H "Authorization: Bearer $(cat $ACTIONS_ID_TOKEN_FILE)" \
      https://hecate.acme.example/api/v1alpha1/namespaces/acme/beacons/podinfo/poll

No shared secret and no HMAC to verify: the call is authenticated the way every other call to the API is, by asking Kubernetes to review the bearer token. A cluster configured to trust your CI provider's OIDC issuer accepts its workload token here with nothing added — the same posture Flux v2.9 moved to with OIDC-secured Receivers. The identity needs update on beacons and nothing else, so a CI job that may poke a Beacon still cannot read your Gates.

Asking what is going on
$ hecate status
GATE        STATE     CURRENT      HEALTH   SUMMARY
dev         Idle      podinfo-6b2  Healthy  nothing to do — everything admitted is already in this Gate
staging     Crossing  podinfo-4a1  Healthy  crossing podinfo-6b2 — waiting on flux-wait: Kustomization not yet at the revision
production  Blocked   podinfo-4a1  Healthy  1 Bundle cannot cross: awaiting approval

$ hecate explain production
production is Blocked
1 Bundle cannot cross: awaiting approval

  [AwaitingApproval] awaiting approval
      → hecate approve podinfo-6b2
  waiting:  podinfo-6b2 — awaiting approval

Every read command takes -o table|json|yaml. The structured formats serialise the same value the table is built from, so they cannot drift apart, and the exit code is the same either way — hecate verify -o json still exits 3 on a broken chain, because a format that always exited 0 would break the scripting it exists for.

$ hecate status -o json | jq -r '.[] | select(.state == "Blocked") | .gate'
production
As a Flux CLI plugin

The same binary, reached through the CLI you already have. Flux discovers plugins in ~/.fluxcd/plugins, so installing it is a copy:

$ mkdir -p ~/.fluxcd/plugins
$ cp hecate ~/.fluxcd/plugins/flux-hecate
$ flux plugin list
NAME    VERSION  PATH
hecate  manual   /home/you/.fluxcd/plugins/flux-hecate

$ flux hecate status

It knows how it was reached: flux hecate help offers flux hecate promote, not hecate promote — a binary that is not on your PATH when it lives in the plugin directory.

Completion knows your cluster, not just the verbs — hecate explain <TAB> offers the Gates that exist, hecate abort <TAB> only the crossings still running:

$ source <(hecate completion bash)     # or zsh, or fish
$ hecate man > /usr/share/man/man1/hecate.1
Promoting, and waiting for it
$ hecate promote production --bundle podinfo-6b2 --watch
crossing podinfo-6b2 through production as production-vck6g
  git-clone        Succeeded  checked out fleet at fa90646e
  set-image        Succeeded  pinned ghcr.io/acme/podinfo to 6.14.1
  git-commit       Succeeded  committed 9f8c1a2b
  git-push         Succeeded  pushed 9f8c1a2b to main
  flux-wait        Succeeded  Flux applied the promoted revision to 1 resource(s)

podinfo-6b2 crossed production

--watch exists for its exit code. Without it a CI job learns only that a Passage was opened, which is not the question it asked.

exit meaning
0 it worked
1 you used the command wrong
2 Hecate could not tell you — the cluster, a timeout, a stopped watch
3 an evidence chain is broken (verify)
4 nothing has been recorded (verify)
5 the rules said no — not cleared upstream, a closed window, no approval
6 the crossing ran and did not land

2 and 6 are deliberately different. "The promotion failed" and "I lost sight of the cluster" call for different responses, and only the first is a deployment failure. 5 is different again: it is an answer, not a malfunction.

"Why is nothing crossing?" is otherwise answered by reading four resources and knowing which fields matter. The answer is computed in one place — pkg/ops — so the CLI, the API and the UI cannot come to different conclusions about the same cluster.

Asking through an LLM
$ hecate-mcp -namespace acme                       # stdio, read-only
$ hecate-mcp -namespace acme -listen 127.0.0.1:8085   # streamable HTTP

An MCP server over the same operations layer, so an agent and the CLI cannot come to different conclusions. why_stuck returns typed blockers as structuredContent, which is what makes it something a model reasons over rather than parses out of prose.

It speaks both MCP eras — the stateless 2026-07-28 revision and the older handshake-based ones — because a modern-only server cannot be used by the clients most people are running today (D33).

Over HTTP the version travels in the MCP-Protocol-Version header rather than in _meta, and a browser origin must be named with --allowed-origins — an unlisted one is turned away before its credentials are looked at, because a local server with no origin check can be driven by any page the user visits. There is no authentication of its own: bind loopback, or put a proxy in front and pass --insecure to say the exposure was meant.

It reads by default. --allow-writes --actor <you> adds promote and abort, judged by the same rules as every other path. It can never approve, and there is no flag for it: approval is a segregation-of-duties control, and an agent that could satisfy it would leave the control in every audit trail having stopped meaning anything (D34).

Diagnosis, optionally with a model
$ export HECATE_LLM_URL=http://localhost:11434/v1 HECATE_LLM_MODEL=llama3.2
$ hecate explain production --ai
production is Blocked
1 Bundle cannot cross: awaiting approval

  [AwaitingApproval] awaiting approval
      → hecate approve podinfo-6b2

llama3.2 says:
  The production gate is blocked: podinfo-6b2 is waiting for approval.
  Run `hecate approve podinfo-6b2` to let it through.

Any OpenAI-compatible endpoint — Ollama, llama.cpp, vLLM, or a hosted vendor — because they all speak the same API. The model phrases the diagnosis; it never makes it. It is handed the analysis Hecate already did, everything works identically without one, and nothing it says affects a promotion (D35).

Compliance as a gate, not a report

A Gate can refuse a crossing on evidence. All four of Fides's gates are available as one step — is the artifact compliant, is it approved for this environment, does the environment's policy accept the build, and what does the change gate say:

    steps:
      - uses: evidence-gate
        with:
          gates: [assert, allowlist, policy, change]
          maxRisk: 40

A hold waits for the human to sign off rather than failing the promotion; the other three end it. The evidence judged is the trail CI recorded when it built the image, so the gates see the SBOM and scans that actually exist.

A Gate says which Fides environment it is:

spec:
  evidence:
    fidesEnvironment: 7f3a1c2e-8b41-4d6a-9e2f-0000000009b04   # validated on apply
    credentialsRef: { name: fides }

The trail is tamper-evident, and the check is yours to run rather than ours to claim:

$ hecate verify podinfo-abc123
✓ staging (trail aaaa1111)
  chain valid — 9 attestations, anchored 2026-08-09
✗ production (trail 91b2ffff)
  chain BROKEN at entry 6: content_hash does not match the recorded entry

Exit 3 when a chain is broken, 2 when it could not be checked, 4 when nothing has been recorded — never 0 for any of them.

There is no pipeline object — the graph is implied by what each Gate admits, so it cannot drift out of sync with reality.

Hecate never talks to Flux. A Passage writes to git; Flux syncs; Hecate reads Kustomization / HelmRelease status back. Git is the rendezvous, which keeps Flux authoritative and Hecate removable.

What makes it different

  • Evidence-gated crossings. A crossing is a change gate, and Fides already models one: Bundle → trail, image digest → artifact, verification → attestation, approval → segregation of duties. "Can this ship to prod?" gets answered by evidence rather than merge rights.
  • OpenTelemetry-native. Passage = trace, step = span, and traceID is a first-class API field rather than an annotation bolted on later. Point OTEL_EXPORTER_OTLP_ENDPOINT at your collector and crossings show up there; every other knob is the standard OTEL_* variable, and with none of them set nothing is exported. Every promotion commit carries a traceparent trailer, so one trace spans the CI run, the crossing and the Flux reconciliation. The DORA four and a Grafana dashboard come with it — see observability, which is candid about which two of the four Hecate can measure exactly and which two it approximates.
  • A UI and CLI worth using. Next.js 16 + React 19 + Tailwind v4, matching the Fides portal. The CLI is the product — every UI action has a CLI equivalent and gates return documented exit codes.

Honest health

Most naive Flux health checks are wrong in the same ways. These are all tested:

  • Stale status is the false-green. Ready=True with observedGeneration behind metadata.generation describes the previous revision.
  • Ready=False is not failure. Flux retries forever; we report Progressing until Stalled=True or a tunable deadline passes.
  • Suspended is not progressing. It will never reconcile — waiting is pointless.
  • An unregistered checker is reported, not skipped. Silently ignoring a check you asked for inflates Gate health.
  • A never-reconciled resource is judged by its conditions. Flux writes observedGeneration: -1 there while the conditions are already current, so reading the top-level field reports a source that will never work as one still starting up.

The status these are tested against is captured from a real Flux, not written by hand — the last one on that list was a live bug that only real output revealed.

Repository layout

api/v1alpha1/         Beacon, Bundle, Gate, Passage. The whole API.
pkg/beacon/           Artifact discovery: tag selection, image resolution, controller.
pkg/expr/             ${{ }} evaluation. Sandboxed; no I/O, no arbitrary code.
pkg/flux/             Flux status evaluation. Pure; no client, no I/O.
pkg/gate/             Eligibility, promotion windows, Gate controller.
pkg/health/           Checker interface, registry, and the Flux checker.
pkg/passage/          Step interface, registry, execution engine, Passage controller.
pkg/passage/steps/    Built-in steps.
pkg/metrics/          Delivery metrics: crossings, step durations, Gate health.
pkg/telemetry/        OpenTelemetry wiring, configured entirely by OTEL_*.
ui/                   The web portal. Next.js, built into the API binary.
charts/hecate/        Helm chart. CRDs and RBAC are generated, never hand-edited.
charts/hecate/crds/   Generated CRDs.

Upgrading

CRDs first, then the chart. Helm installs a chart's crds/ once and never touches them again, so upgrading the chart alone runs a new controller against the old API — and the API server prunes unknown fields silently rather than rejecting them.

$ kubectl apply --server-side -f \
    https://github.com/olafkfreund/Hecate/releases/download/v0.1.0/crds.yaml
$ helm upgrade hecate oci://ghcr.io/olafkfreund/charts/hecate --version 0.1.0 \
    --namespace hecate-system

Skipping the first step is a failed rollout rather than a silent behaviour change: the controller refuses to start against CRDs older than itself and names the fields that are missing, while the previous version keeps running.

A crossing already under way survives. Finished steps are not re-run, the running one is re-entered where it was, and the crossing finishes under the rules it started with — an upgrade cannot retroactively change what an in-flight Passage is doing. The details, and how they were measured.

v1alpha1 promises no field stability; that is what alpha means. A stable field list is a v1 deliverable.

Compatibility

Supported Proved by
Flux The three minors Flux itself supports — currently v2.7, v2.8, v2.9 every e2e run, one leg each
Kubernetes N-2 — currently v1.34, v1.35, v1.36 the same three legs, one version each
Hecate The latest two minors nothing yet; there is no release

Each e2e leg pairs one Flux with one Kubernetes, oldest with oldest, so three jobs cover both ranges. The cross product is not tested — new Flux on old Kubernetes is not a combination CI exercises — and the pairing puts together the versions most likely to break rather than pretending to more coverage than nine jobs would buy.

The API is v1alpha1 and can still change incompatibly. Deprecations follow Flux's own rules — three months' notice once a replacement exists, and a removal that fails the rollout rather than silently pruning fields. Read the full policy before depending on it.

Hecate reads Flux resources and never writes them. The exact API versions and status fields it depends on are listed in the Flux compatibility surface — that list is the blast radius of any Flux change.

Cross-namespace references are refused by default, matching Flux's own --no-cross-namespace-refs=true posture. See D11.

We track Flux with CI rather than good intentions: the e2e matrix runs against every supported Flux minor, a weekly job opens an issue when a new Flux minor appears, the controller warns at startup if the cluster serves an API version it does not expect, and pkg/flux tests read status captured from a real Flux, per version. Details in the development plan.

Status

Pre-alpha. The foundations are built and tested; there is no installable release yet. See the development plan for what lands when.

$ go test ./...
ok  github.com/olafkfreund/hecate/api/v1alpha1
ok  github.com/olafkfreund/hecate/pkg/beacon
ok  github.com/olafkfreund/hecate/pkg/flux
ok  github.com/olafkfreund/hecate/pkg/gate
ok  github.com/olafkfreund/hecate/pkg/health
ok  github.com/olafkfreund/hecate/pkg/passage
ok  github.com/olafkfreund/hecate/pkg/passage/steps
ok  github.com/olafkfreund/hecate/pkg/telemetry

353 tests, no cluster required, ~1s. That is the bar: anything testable without a cluster must be. Image resolution is tested against a real in-memory registry rather than a mock.

Development

Everything is pinned in flake.nix — Go, kubectl, Helm, k3d, the Flux CLI, controller-gen — at the versions CI uses.

nix develop          # or: direnv allow

make test            # 353 tests, ~1s, no cluster
make cluster         # k3d in Docker, with Flux installed
make install         # build, push and install the chart into the dev cluster
make e2e             # drive a Bundle through two Gates on a real API server
make ui              # build the web portal into the API binary
make generate        # regenerate CRDs and RBAC after touching api/
make check           # what CI enforces

make cluster pins KUBECONFIG to ./.dev/kubeconfig, so a stray kubectl delete during development can never reach a real cluster.

Full setup, including agenix secrets: docs/ONBOARDING.md.

Contributing

Early enough that design input matters more than code. If you run Flux across more than one environment, open an issue.

Non-obvious decisions are recorded in docs/DECISIONS.md — read it before proposing a structural change; the answer may already be there.

License

Apache 2.0. Not affiliated with the FluxCD project or the CNCF.

Directories

Path Synopsis
api
v1alpha1
Package v1alpha1 contains the Hecate API.
Package v1alpha1 contains the Hecate API.
cmd
apishape command
Command apishape writes the field names the API actually sends, so the browser's copy of those types can be checked against them.
Command apishape writes the field names the API actually sends, so the browser's copy of those types can be checked against them.
hecate command
Command hecate is the operator's side of Hecate.
Command hecate is the operator's side of Hecate.
hecate-api command
Command hecate-api serves Hecate's HTTP API.
Command hecate-api serves Hecate's HTTP API.
hecate-controller command
Command hecate-controller runs the Hecate control plane: the Beacon, Gate and Passage controllers, plus the health-checker and step registries they use.
Command hecate-controller runs the Hecate control plane: the Beacon, Gate and Passage controllers, plus the health-checker and step registries they use.
hecate-mcp command
Command hecate-mcp lets an LLM client ask Hecate what is going on.
Command hecate-mcp lets an LLM client ask Hecate what is going on.
stepschema command
Command stepschema writes a JSON Schema for every step's `with:` block, so a form can be generated from the same struct the step validates against.
Command stepschema writes a JSON Schema for every step's `with:` block, so a form can be generated from the same struct the step validates against.
pkg
api
Package api serves Hecate's HTTP API over pkg/ops.
Package api serves Hecate's HTTP API over pkg/ops.
beacon
Package beacon discovers artifacts and emits Bundles.
Package beacon discovers artifacts and emits Bundles.
crds
Package crds carries the CRDs this build expects the cluster to have, and checks at startup that it does.
Package crds carries the CRDs this build expects the cluster to have, and checks at startup that it does.
expr
Package expr evaluates the `${{ ...
Package expr evaluates the `${{ ...
fides
Package fides talks to a Fides server — the compliance system Hecate records promotions in and asks for permission.
Package fides talks to a Fides server — the compliance system Hecate records promotions in and asks for permission.
flux
Package flux evaluates the health of Flux CD resources (Kustomization, HelmRelease, and anything else following the Flux status conventions).
Package flux evaluates the health of Flux CD resources (Kustomization, HelmRelease, and anything else following the Flux status conventions).
gate
Package gate decides which Bundles may cross a Gate, and drives the crossing.
Package gate decides which Bundles may cross a Gate, and drives the crossing.
git
Package git answers "what credentials apply to this repository?" for everything in Hecate that talks to one.
Package git answers "what credentials apply to this repository?" for everything in Hecate that talks to one.
githubapp
Package githubapp turns a GitHub App's private key into short-lived installation tokens.
Package githubapp turns a GitHub App's private key into short-lived installation tokens.
health
Package health assesses whether what a Gate holds is actually working.
Package health assesses whether what a Gate holds is actually working.
llm
Package llm talks to an OpenAI-compatible chat completions endpoint, and fences the untrusted text Hecate would put in a prompt.
Package llm talks to an OpenAI-compatible chat completions endpoint, and fences the untrusted text Hecate would put in a prompt.
mcp
Package mcp is a Model Context Protocol server: the transport that lets an LLM client ask Hecate questions.
Package mcp is a Model Context Protocol server: the transport that lets an LLM client ask Hecate questions.
metrics
Package metrics publishes Hecate's delivery metrics.
Package metrics publishes Hecate's delivery metrics.
ops
Package ops is the operations layer every human-facing surface sits on: the CLI, the API server, the MCP server and the UI.
Package ops is the operations layer every human-facing surface sits on: the CLI, the API server, the MCP server and the UI.
passage
Package passage runs the steps that move a Bundle through a Gate.
Package passage runs the steps that move a Bundle through a Gate.
passage/steps
Package steps holds Hecate's built-in Passage steps.
Package steps holds Hecate's built-in Passage steps.
provider
Package provider talks to the git hosts' APIs — the part of a promotion that plain git cannot do.
Package provider talks to the git hosts' APIs — the part of a promotion that plain git cannot do.
registry
Package registry is how Hecate authenticates to an OCI registry.
Package registry is how Hecate authenticates to an OCI registry.
telemetry
Package telemetry configures OpenTelemetry tracing from the standard OTEL_* environment, so Hecate drops into an existing collector setup with no Hecate-specific configuration at all.
Package telemetry configures OpenTelemetry tracing from the standard OTEL_* environment, so Hecate drops into an existing collector setup with no Hecate-specific configuration at all.
verify
Package verify answers a different question from pkg/health.
Package verify answers a different question from pkg/health.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL