ops

package
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 Imports: 22 Imported by: 0

Documentation

Overview

Package ops is the operations layer every human-facing surface sits on: the CLI, the API server, the MCP server and the UI.

It exists so that those four do not each answer the same questions separately. The answers that matter are *rules* — what counts as eligible, who may approve, why a Gate is stuck — and three implementations of a rule is three subtly different products. Adapters may format; they may not decide.

Thin over the Kubernetes API, and deliberately not a second data model: reads return the API types themselves. The only shapes defined here are ones that carry genuinely derived information — an explanation is not stored anywhere, which is precisely why it belongs in one place.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func IsNotFound

func IsNotFound(err error) bool

IsNotFound reports whether err is a NotFoundError.

func IsRefused

func IsRefused(err error) bool

IsRefused reports whether err is a RefusedError.

Types

type AuditEntry added in v0.3.0

type AuditEntry struct {
	At   metav1.Time `json:"at"`
	Kind AuditKind   `json:"kind"`
	// Namespace is which one this happened in. Carried on every entry because
	// an audit spanning namespaces has no other way to say, and a Gate name is
	// not unique across them.
	Namespace string `json:"namespace"`
	Gate      string `json:"gate"`
	Bundle    string `json:"bundle,omitempty"`
	// Digest is what actually shipped. The Bundle name is a label; this is the
	// content address, and it is the only field that answers "which bits".
	Digest string `json:"digest,omitempty"`
	// Actor is who caused it. Empty means nobody did — an automatic Gate acting
	// on its own, which is a meaningful answer rather than a missing one.
	Actor   string `json:"actor,omitempty"`
	Passage string `json:"passage,omitempty"`
	// Detail is why, for a refusal, in the words of whatever refused.
	Detail string `json:"detail,omitempty"`
	// Verified records whether a crossing's verification confirmed it worked,
	// which is a different question from whether it completed.
	Verified *bool `json:"verified,omitempty"`
	// Evidence links the entry to the compliance record: the Fides trail, the
	// verdict, and any blockers. This is what turns "it was promoted" into
	// "it was promoted, and here is what said it could be".
	Evidence *v1alpha1.EvidenceRef `json:"evidence,omitempty"`
}

AuditEntry is one thing that happened, in terms an auditor asks about.

type AuditKind added in v0.3.0

type AuditKind string

AuditKind is what happened.

const (
	// AuditCrossed is a Bundle that reached a Gate.
	AuditCrossed AuditKind = "crossed"
	// AuditRefused is a crossing that was attempted and did not complete.
	//
	// The most valuable entry on the page. A trail of everything that shipped
	// is a deployment log; what makes it an audit trail is that it also holds
	// what was stopped, by what, and on whose evidence.
	AuditRefused AuditKind = "refused"
	// AuditRunning is a crossing in progress.
	AuditRunning AuditKind = "running"
	// AuditApproved is a human approving a Bundle for a Gate. Separate from a
	// crossing because approving and crossing are separate acts by separate
	// people — an approval a promoter can grant themselves is not an approval.
	AuditApproved AuditKind = "approved"
)

type Blocker

type Blocker struct {
	// Kind is a stable code, so a caller can branch without parsing prose.
	Kind BlockerKind `json:"kind"`
	// Detail is the human-readable specifics.
	Detail string `json:"detail"`
	// Fix is what would unblock it, when there is a single obvious answer.
	Fix string `json:"fix,omitempty"`
}

Blocker is one reason nothing is crossing.

type BlockerKind

type BlockerKind string

BlockerKind enumerates why a Gate is not crossing. Stable strings: a UI chooses an icon from these and an LLM reasons over them.

const (
	BlockerSuspended     BlockerKind = "Suspended"
	BlockerInvalidSteps  BlockerKind = "InvalidSteps"
	BlockerNoPassage     BlockerKind = "NoPassageTemplate"
	BlockerNoBundles     BlockerKind = "NoBundles"
	BlockerNotApproved   BlockerKind = "AwaitingApproval"
	BlockerUpstream      BlockerKind = "UpstreamNotCleared"
	BlockerWindowClosed  BlockerKind = "WindowClosed"
	BlockerPassageFailed BlockerKind = "PassageFailed"
	BlockerStepWaiting   BlockerKind = "StepWaiting"
	BlockerChangeHeld    BlockerKind = "ChangeHeld"
	BlockerUnhealthy     BlockerKind = "Unhealthy"
	BlockerManual        BlockerKind = "AwaitingRequest"
)

type Day added in v0.4.1

type Day struct {
	// Date is the day in YYYY-MM-DD, in UTC. A chart axis needs a stable label
	// and the server is the only place that knows what "today" means for the
	// data it just counted.
	Date string `json:"date"`
	// Crossed is how many Bundles entered a Gate that day.
	Crossed int `json:"crossed"`
	// Failed is how many Passages ended badly that day.
	//
	// Subject to Passage retention, unlike Crossed — a failure that has been
	// collected is no longer counted. Better than not drawing failures at all,
	// but it means the further back the chart goes the more it flatters.
	Failed int `json:"failed"`
}

Day is one day's crossings and failures.

Built from Gate.status.history rather than from Passages: a Gate keeps a bounded number of Passages and the detail ages out, while history is capped but long-lived and survives the Passage it names. A trend drawn from Passages would quietly shorten itself as retention collected them, which is the one thing a trend must not do.

type Evidence

type Evidence struct {
	Bundle    string `json:"bundle"`
	Namespace string `json:"namespace"`
	// Digest is the artifact all of this is about. Named explicitly because the
	// evidence belongs to the image, not to the Bundle that happens to pin it.
	Digest string `json:"digest,omitempty"`
	// Trail is the Fides trail this evidence lives on. Reported as the bare
	// identifier rather than a link: the portal is a single-page export with
	// no per-trail route to deep-link to, and a link that 404s is worse than
	// an id an auditor can paste.
	Trail string `json:"trail,omitempty"`
	// Gate is which Gate's Fides configuration was used to look this up, so a
	// reader can tell which environment's view they are seeing.
	Gate string `json:"gate,omitempty"`
	// Verdict is the change gate's answer, with its controls, attestation
	// counts, approvals and segregation-of-duties finding.
	Verdict *fides.ChangeVerdict `json:"verdict,omitempty"`
	// Change is the ITSM change request governing this crossing, when one has
	// been checked. Carried alongside the verdict rather than inside it: the
	// verdict is Fides' judgement of the evidence, and this is the ticket a
	// person opens when they disagree with it.
	Change *fides.ChangeRequest `json:"change,omitempty"`
	// ApprovedIn lists the approvals Hecate itself recorded, which is not the
	// same list as Fides' — a Gate that does not use Fides still has approvers,
	// and they belong in the answer to "who allowed it".
	ApprovedIn []v1alpha1.BundleApproval `json:"approvedIn,omitempty"`
	// Unavailable says why there is nothing to show, when there is nothing.
	// A panel that renders empty is indistinguishable from a clean bill of
	// health, and those are opposite answers.
	Unavailable string `json:"unavailable,omitempty"`
}

Evidence is everything Fides holds about one Bundle's artifact, assembled to answer a single question: why was this allowed into production, and who allowed it?

Answering it today means opening Fides, finding the trail, and reading four pages. This is that answer in one object, so a CLI can print it and the UI can show it without the reader leaving the page.

type Explanation

type Explanation struct {
	Gate      string `json:"gate"`
	Namespace string `json:"namespace"`
	State     State  `json:"state"`
	// Summary is one line, suitable on its own.
	Summary string `json:"summary"`
	// Blockers are the specific things standing in the way, most actionable
	// first. Empty when nothing is wrong.
	Blockers []Blocker `json:"blockers,omitempty"`
	// Current is the Bundle in the environment now, if any.
	Current string `json:"current,omitempty"`
	// Eligible names Bundles that could cross right now.
	Eligible []string `json:"eligible,omitempty"`
	// Waiting names Bundles that cannot, with the reason for each.
	Waiting []Waiting `json:"waiting,omitempty"`
	// Health is the Gate's own assessment of what it watches.
	Health v1alpha1.Health `json:"health,omitempty"`
	// Evidence is the change gate's verdict for the crossing in progress, when
	// there is one. Carried whole so a caller can show the risk score next to
	// the reasons rather than parsing them back out of a sentence.
	Evidence *v1alpha1.EvidenceRef `json:"evidence,omitempty"`
}

Explanation is why a Gate is where it is.

The question "why is nothing crossing?" is currently answered by reading four resources by hand and knowing which fields matter. This answers it once, in structure a CLI can print, an API can serialise and a model can reason over — which is why the reasons are a list of typed causes rather than a sentence.

type FluxResource added in v0.6.0

type FluxResource struct {
	Kind      string `json:"kind"`
	Name      string `json:"name"`
	Namespace string `json:"namespace"`
	// Suspended means Flux is not reconciling it.
	//
	// The reason this whole screen exists. A suspended resource is cluster
	// state that git will not restore, so it outlives the debugging session it
	// was created for — and every crossing afterwards appears to succeed while
	// changing nothing, because the step that would have applied it is not
	// running. It is the one Flux state that fails silently.
	Suspended bool `json:"suspended"`
	// Health is what pkg/flux makes of its conditions.
	Health v1alpha1.Health `json:"health"`
	// Detail is the Ready condition's message, or why it is not Ready.
	Detail string `json:"detail,omitempty"`
	// Revision is what Flux has actually applied.
	Revision string `json:"revision,omitempty"`
	// LastHandled is the reconcile request Flux has acted on, so a caller can
	// tell its own "reconcile now" landed rather than watching for any change.
	LastHandled string `json:"lastHandled,omitempty"`
	// Missing means the Gate names it and the cluster does not have it. Not an
	// error: a Gate committed before its Kustomization exists is ordinary, and
	// reporting it as absent beats reporting it as unhealthy.
	Missing bool `json:"missing"`
}

FluxResource is one Flux object a Gate watches, and what is true of it.

type GateSummary added in v0.4.0

type GateSummary struct {
	Name string `json:"name"`
	// Health is the Gate's own report, or Unknown when it has none yet.
	Health v1alpha1.Health `json:"health"`
	// Issues is why the health is what it is. Carried here rather than left to
	// a click, because a board that shows a red dot and no reason sends
	// everyone to the same second page to find out.
	Issues []string `json:"issues,omitempty"`
	// Current is the Bundle in the Gate now.
	Current string `json:"current,omitempty"`
	// Eligible counts what could cross. The names are one page away; the count
	// is what decides whether to look.
	Eligible int `json:"eligible"`
	// Running is the Passage crossing this Gate now, if one is.
	Running string `json:"running,omitempty"`
	// Suspended means the Gate will not admit anything until it is resumed.
	// Reported prominently for the same reason a suspended Beacon is: a
	// suspended Gate looks exactly like a quiet one, and "nothing has shipped
	// all week" is usually this.
	Suspended bool `json:"suspended"`
}

GateSummary is one Gate, reduced to what a board shows.

type NamespaceOverview added in v0.4.0

type NamespaceOverview struct {
	Namespace string        `json:"namespace"`
	Gates     []GateSummary `json:"gates"`
}

NamespaceOverview is one namespace's Gates.

type NotFoundError

type NotFoundError struct {
	Kind      string
	Namespace string
	Name      string
}

NotFoundError is a named resource that is not there.

Distinguished from any other failure because every surface needs to answer differently: the API server owes a 404, the CLI a exit code, and the MCP server a message the model can act on rather than retry.

func (*NotFoundError) Error

func (e *NotFoundError) Error() string

type Ops

type Ops struct {
	Client client.Client
	// Now is the clock, injectable for tests and for judging promotion windows.
	Now func() metav1.Time
	// FidesServer is the default Fides server, used when a Gate names none of
	// its own. Approvals are recorded there so segregation of duties can be
	// evaluated over real identities.
	FidesServer string
	// DialFides is injectable so tests can point at a fake Fides. Nil is the
	// real client.
	DialFides func(fides.Config) (*fides.Client, error)
}

Ops answers questions and performs actions against a cluster.

func New

func New(c client.Client) *Ops

New returns an Ops over the given client.

The Fides server defaults from FIDES_SERVER_URL — the same variable the controller's --fides-server reads — so the CLI, the API server and the MCP server all find it without three flags that have to agree. A Gate's own evidence.serverURL still wins.

func (*Ops) Abort

func (o *Ops) Abort(ctx context.Context, namespace, passageName, actor string) error

Abort asks a running Passage to stop.

It sets spec.abort rather than deleting: the Passage is the record of what happened, and deleting it would erase the evidence that a crossing was started and stopped (D18). The controller marks the remaining steps aborted.

func (*Ops) Approve

func (o *Ops) Approve(ctx context.Context, namespace, bundleName, gateName, actor string) error

Approve records that a human has approved a Bundle for a Gate.

Approval is per Gate, not per Bundle: approving something for staging must not approve it for production, which is the whole point of asking.

func (*Ops) Audit added in v0.3.0

func (o *Ops) Audit(ctx context.Context, namespace string) ([]AuditEntry, error)

Audit reconstructs what has happened in a namespace, newest first.

Built from two sources that outlive each other differently, which is the reason it is not simply a list of Passages.

A Passage is the detailed record — actor, per-step outcome, the evidence verdict, why it stopped — and it is subject to retention: a Gate keeps a bounded number, so the detail ages out. `Gate.status.history` is the durable one, capped but long-lived, and it survives the Passage it names.

So history is the spine and Passages enrich it, and a Passage with no history entry still appears — that is exactly the refused crossing, which never entered the Gate and therefore was never recorded there.

func (*Ops) Beacon

func (o *Ops) Beacon(ctx context.Context, namespace, name string) (*v1alpha1.Beacon, error)

Beacon reads one Beacon.

func (*Ops) Beacons

func (o *Ops) Beacons(ctx context.Context, namespace string) ([]v1alpha1.Beacon, error)

Beacons lists a namespace's Beacons, by name.

func (*Ops) Bundle

func (o *Ops) Bundle(ctx context.Context, namespace, name string) (*v1alpha1.Bundle, error)

Bundle reads one Bundle.

func (*Ops) Bundles

func (o *Ops) Bundles(ctx context.Context, namespace string) ([]v1alpha1.Bundle, error)

Bundles lists a namespace's Bundles, newest first — the order an operator expects, because the question is nearly always about the recent ones.

func (*Ops) Evidence

func (o *Ops) Evidence(ctx context.Context, namespace, bundleName string) (*Evidence, error)

Evidence assembles the compliance record for a Bundle.

**It never fails for want of evidence.** No Gate configured for Fides, no digest pinned, no trail on the artifact — each is a fact about this deployment rather than an error, and each is reported in `unavailable` so the caller can say which. Only a Fides that is configured and then does not answer is an error, because that one is worth retrying.

func (*Ops) Explain

func (o *Ops) Explain(ctx context.Context, namespace, name string) (*Explanation, error)

Explain answers "why is this Gate not crossing anything?".

It composes the rules rather than restating them: eligibility comes from pkg/gate's own judgement, and the window from its own check. A second implementation of either would be a second answer to the same question, which is the failure this package exists to prevent.

func (*Ops) FluxResources added in v0.6.0

func (o *Ops) FluxResources(ctx context.Context, namespace, gateName string) ([]FluxResource, error)

FluxResources is what a Gate watches, and the state of each.

Read from the Gate's own health checks rather than from a list of everything Flux owns in the namespace: the question this answers is "what does this Gate depend on", and a screen listing every Kustomization in the namespace would invite someone to suspend one this Gate has nothing to do with.

func (*Ops) Gate

func (o *Ops) Gate(ctx context.Context, namespace, name string) (*v1alpha1.Gate, error)

Gate reads one Gate.

func (*Ops) Gates

func (o *Ops) Gates(ctx context.Context, namespace string) ([]v1alpha1.Gate, error)

Gates lists a namespace's Gates, by name.

Ordered rather than left in list order: a surface that prints them should print them the same way twice, and a diffable list is worth more than a fast one at this size.

func (*Ops) Namespaces added in v0.2.0

func (o *Ops) Namespaces(ctx context.Context) ([]string, error)

Namespaces lists the namespaces that hold something Hecate manages.

Not "every namespace in the cluster". A picker offering three hundred namespaces of which two are relevant is a worse answer than a text box, and listing all of them needs a cluster-wide read on core Namespaces that a Gate operator has no other reason to hold.

Gates and Beacons are enough to find them all: a Bundle is created in its Beacon's namespace and a Passage in its Gate's, so a namespace holding either of those holds one of these too.

The caller is responsible for removing namespaces the user may not see. This runs with the server's own credentials, so filtering is not optional — it is done in the API layer, where the subject is known (pkg/api).

func (*Ops) Overview added in v0.4.0

func (o *Ops) Overview(ctx context.Context, namespaces []string) (*Overview, error)

Overview assembles the board for the namespaces the caller may read.

The namespaces are passed in rather than discovered here: this runs with the server's own credentials and has no idea who is asking, so deciding what is visible is the API layer's job, where the subject is known. Same division as Namespaces, and for the same reason — an operations layer that filtered by identity would be a second authorisation model.

Two cluster-wide Lists rather than a pair per namespace: the work is the same whether one namespace is visible or forty, and a loop of Lists would make the cost of the page scale with how much of the cluster you are trusted with — which is precisely backwards.

func (*Ops) Passage

func (o *Ops) Passage(ctx context.Context, namespace, name string) (*v1alpha1.Passage, error)

Passage reads one Passage.

func (*Ops) Passages

func (o *Ops) Passages(ctx context.Context, namespace, gate, bundle string) ([]v1alpha1.Passage, error)

Passages lists a namespace's Passages, newest first. Filters are optional: an empty gate or bundle means "any".

func (*Ops) Poll

func (o *Ops) Poll(ctx context.Context, namespace, beaconName string) (string, error)

Poll asks a Beacon to look at its sources now, rather than at its next interval.

**This is the whole of the webhook receiver (#102).** A Beacon already polls immediately when Flux's `reconcile.fluxcd.io/requestedAt` annotation changes, and that path is proven end to end. What a git host could not do was set a Kubernetes annotation — so the missing piece was an HTTP door onto the mechanism, not a mechanism.

It needs no shared secret and no HMAC verification, which is the posture Flux v2.9 moved to with OIDC-secured Receivers. The API server authenticates this call the same way it authenticates every other one, by asking Kubernetes to review the bearer token; a cluster configured to trust a CI provider's OIDC issuer therefore accepts that provider's workload token here with nothing added. A secret nobody stores is a secret nobody leaks.

The token returned is echoed back in `status.lastHandledReconcileAt`, so a caller can tell its own request apart from someone else's.

func (*Ops) Preflight added in v0.7.0

func (o *Ops) Preflight(ctx context.Context, namespace, gateName string) ([]Preflight, error)

Preflight asks the evidence gate about every Bundle eligible for a Gate.

Its own call rather than part of Explain, and that is a cost decision: Explain is loaded on every page view and again on every live update, while this is one Fides round-trip per eligible Bundle. Folding it in would make the Gate page's refresh rate the rate at which Hecate polls Fides.

A Gate with no evidence configuration answers with an empty list rather than an error: most Gates do not gate on evidence, and a screen that reported that as a failure would be wrong about almost every Gate.

func (*Ops) Promote

func (o *Ops) Promote(ctx context.Context, namespace, gateName, bundleName, actor string) (*v1alpha1.Passage, error)

Promote asks a Gate to cross a Bundle, and returns the Passage it opened.

The eligibility rules are the Gate controller's, not a second set: a crossing requested by hand is judged exactly as an automatic one would be. Skipping the check for manual requests is how "promote to production" becomes a way to bypass the pipeline.

func (*Ops) ReconcileFlux added in v0.6.0

func (o *Ops) ReconcileFlux(ctx context.Context, namespace, gateName, kind, name string) (string, error)

ReconcileFlux asks Flux to look at one resource now.

Returns the stamp it wrote, so a caller can match it against status.lastHandledReconcileAt and know its own request was the one that landed rather than watching for any change and hoping.

func (*Ops) SetFluxSuspend added in v0.6.0

func (o *Ops) SetFluxSuspend(
	ctx context.Context, namespace, gateName, kind, name string, suspend bool,
) error

SetFluxSuspend suspends or resumes one Flux resource a Gate watches.

Scoped to the Gate's own resources rather than taking a free-form reference: the API's authorisation is per namespace, and a handler that suspended anything it was named would let a caller who may write in one namespace stop reconciliation in another simply by asking for it.

type Overview added in v0.4.0

type Overview struct {
	// Namespaces holds one entry per namespace with Gates in it, ordered by
	// name so the page does not reshuffle between loads.
	Namespaces []NamespaceOverview `json:"namespaces"`
	// Totals is the whole picture in numbers.
	Totals Totals `json:"totals"`
	// Activity is what crossed and what failed, by day, oldest first.
	Activity []Day `json:"activity"`
}

Overview is every Gate the caller can see, and what is true of them.

The shape answers "is everything okay?" before it answers anything else, which is the question someone opening a dashboard is actually asking. The per-Gate detail is there for the follow-up, and the pages that already exist are there for the one after that.

type Preflight added in v0.7.0

type Preflight struct {
	Bundle string `json:"bundle"`
	// Compliant is whether the evidence gate would let it through.
	Compliant bool `json:"compliant"`
	// Missing is the attestation types the policies wanted and the trail does
	// not have, deduplicated across policies — one missing type failing four
	// policies is one thing to fix, not four.
	Missing []string `json:"missing,omitempty"`
	// Policies names the policies that are not satisfied. Carried alongside
	// Missing rather than instead of it, because they answer different
	// questions: which rule stopped this, and what has to exist for it not to.
	Policies []string `json:"policies,omitempty"`
	// Unknown says why there is no answer, when there is none. A Bundle whose
	// evidence could not be checked is not a Bundle that passed, and rendering
	// the two the same way is how a page starts lying.
	Unknown string `json:"unknown,omitempty"`
}

Preflight is what the evidence gate would say about a Bundle, asked before anyone presses Cross.

The evidence gate already answers this — as a step, inside a Passage, after the crossing has started. So the way to find out a Bundle is missing an attestation is to try to promote it and read the failure:

podtato-head-fa9 → production   Failed
evidence-gate: ... is not compliant: Failing control: segregation-of-duties

That leaves a failed Passage in the record for something nobody could have known, and the record is the product. Asking first costs one call to Fides and changes the failure into a reason the button is not worth pressing.

type RefusedError

type RefusedError struct {
	Action string
	Reason string
}

RefusedError is an action the rules do not allow.

Separate from a failure to perform it: "this Bundle has not cleared staging" is an answer, not an error, and every surface presents the two differently.

func (*RefusedError) Error

func (e *RefusedError) Error() string

type State

type State string

State is the one-word answer to "what is this Gate doing?".

const (
	// StateCrossing means a Passage is running now.
	StateCrossing State = "Crossing"
	// StateReady means a Bundle could cross and is waiting to be asked.
	StateReady State = "Ready"
	// StateBlocked means something must change before anything can cross.
	StateBlocked State = "Blocked"
	// StateIdle means there is nothing to do: nothing new has appeared.
	StateIdle State = "Idle"
	// StateFailed means the last crossing failed and has not been retried.
	StateFailed State = "Failed"
)

type Totals added in v0.4.0

type Totals struct {
	Gates       int `json:"gates"`
	Healthy     int `json:"healthy"`
	Progressing int `json:"progressing"`
	Degraded    int `json:"degraded"`
	Unknown     int `json:"unknown"`
	Suspended   int `json:"suspended"`
	// Eligible is how many Bundles could cross somewhere right now.
	Eligible int `json:"eligible"`
	// Running is how many Passages are in flight.
	Running int `json:"running"`
	// Failed is how many Passages ended badly and are still around to say so.
	// Retention collects Passages (D40), so this is "recently" by definition
	// rather than by a window this code picks.
	Failed int `json:"failed"`
}

Totals counts what matters across everything visible.

type Waiting

type Waiting struct {
	Bundle string `json:"bundle"`
	Reason string `json:"reason"`
	// Kind is the same reason as a stable code, matching BlockerKind's values
	// where they overlap. Without it the approval queue would have to decide
	// what is waiting on a human by matching the prose, and rewording a message
	// would break a caller.
	Kind gate.Code `json:"kind,omitempty"`
}

Waiting is one Bundle that cannot cross, and why.

Jump to

Keyboard shortcuts

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