cluster

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Oct 29, 2026 License: Apache-2.0 Imports: 47 Imported by: 0

Documentation

Overview

Package cluster owns the render-KCL → kubectl-apply → wait-rollouts pipeline that `forge env deploy`, `forge cluster reload`, and the deploy phase of `forge env up` all execute. Before this package existed, the pipeline was duplicated across three call sites:

  • runDeploy (internal/cli/deploy.go)
  • runDevClusterReload (internal/cli/dev_cluster.go)
  • reconcileCluster (internal/cli/up.go)

All three drove the same kubectl invocations against the same KCL renderer; they differed only in the pre-flight (context guard vs context pin), the dev-cluster bootstrap (deploy-only), and the per-call defaults (prune, host-skip, one-shot Job wait).

This package intentionally does NOT own:

  • the kubectl-context guard (verifyKubectlContext) — that's the deploy command's affordance and applies to non-dev envs the dev cluster reload doesn't reach;
  • the k3d cluster bootstrap (ensureDevCluster, buildAndPushLocal) — that's a deploy-time concern for the dev env that the reload deliberately skips;
  • the typed KCLEntities schema (still in internal/cli/).

The shape mirrors internal/hostlaunch: a small Opts struct expressing the differences between call sites, plus a single Apply entry point and the kubectl/KCL helpers exported for callers that need them piecewise.

Package cluster — helm-as-a-RENDERER for declarative platform deps.

THE MODEL: helm is a RENDERER, not an installer. A forge.HelmChart is a platform dependency declared in the SAME env Bundle as the app. forge runs `helm template <chart> --version <v> --values <vals> -n <ns> --skip-crds` to expand the chart to a manifest list, stamps every rendered manifest with `app.kubernetes.io/name = <name>`, and folds those manifests into the SAME render → kubectl-apply → wait pipeline every other forge manifest flows through (Apply, below). There is NO `helm install`, NO helm-managed release, NO imperative installer.

SELECTION IS `--target`. Because each chart's manifests carry the chart's `name` as their `app.kubernetes.io/name` GROUP, the SAME exclusive `--target` axis (SelectManifestsByGroup) selects them with no new tag — a chart is just another manifest group:

forge env deploy <env> --target <name>   # render+apply ONLY this group
forge env deploy <env>                   # apply EVERYTHING (every group +
                                     # every declared platform dep)

APPLY ORDERING (the one real problem). A chart's controllers reference CRDs that must exist + be Established first. The chart's bundled STANDARD Gateway-API CRDs (group gateway.networking.k8s.io) are excluded from the render (`--skip-crds`) because the chart's copy is often older / experimental-channel and trips the `safe-upgrades` ValidatingAdmissionPolicy, making the install self-deny. forge SUPPLIES that group itself at a pinned version (the caller fetches the pinned standard Gateway API CRDs / cert-manager's CRDs at the matching chart version and hands them in as HelmChartSpec.CRDs).

`--skip-crds` is necessary but NOT sufficient. Envoy Gateway 1.9 moved the safe-upgrades ValidatingAdmissionPolicy itself from the CRD bundle into the chart's TEMPLATES, which --skip-crds does not touch — so the chart would render a second copy of a resource forge already applies from its pinned bundle. gatewayAPIChartValues turns that template off for any chart whose Gateway API CRDs forge supplies, keeping ONE source for the whole group.

But a chart ALSO ships its OWN, non-Gateway-API CRDs the controller needs — envoy-gateway's eight `gateway.envoyproxy.io` CRDs the controller starts informers on; `--skip-crds` would drop those too and the controller crashloops on cache-sync. So forge ADDITIONALLY renders the chart `--include-crds` (chartOwnCRDs) and supplies the chart's NON-standard- Gateway-API CRDs alongside the pinned bundle. Net CRD set = pinned standard Gateway-API + the chart's own CRDs.

Apply applies that combined CRD set (+ the chart's synthesized Namespace, which `helm template` never emits) FIRST and waits until the CRDs are Established before the chart's controller manifests; then waits for the chart's Deployments to be Available before the chart's riding manifests (the cert-manager webhook would reject ClusterIssuers until then). See applyCRDsThenRest, RenderHelmChart, waitChartDeploymentsAvailable.

Index

Constants

View Source
const (
	ChartOwnerLabel          = "forge.dev/chart-owner"
	PruneAnnotation          = "kustomize.toolkit.fluxcd.io/prune"
	PruneDisabled            = "disabled"
	ChartInventoryLabel      = "forge.dev/chart-inventory"
	ChartInventoryOwnerLabel = "forge.dev/chart-inventory-owner"
)

Chart ownership and inventory keys. PruneAnnotation/PruneDisabled use the Flux spelling so the direct and reconciler delivery paths share one guard.

View Source
const (
	// CRDOwnerLabel names the (project, env) that rendered a CRD. It is the
	// prune's ownership proof; see the file comment.
	CRDOwnerLabel = "forge.dev/crd-owner"
	// AppManagedByLabel is the standard managed-by key. forge's own value is
	// "forge".
	AppManagedByLabel = "app.kubernetes.io/managed-by"
)
View Source
const (
	// DeployPhaseAnnotation is the Job metadata annotation that places a
	// one-shot Job relative to the workloads in the same apply.
	DeployPhaseAnnotation = release.AnnotationDeployPhase
	// DeployPhasePreRollout is applied, and required to COMPLETE, before any
	// workload in the same apply is applied. The default.
	DeployPhasePreRollout = release.DeployPhasePreRollout
	// DeployPhasePostRollout is applied with the workloads and awaited after
	// their rollouts, for a Job that needs this release's workloads running.
	DeployPhasePostRollout = release.DeployPhasePostRollout
)

The phase vocabulary and the classifier live in pkg/release (release.ClassifyPhase), because a Flux-applied bundle expresses the same phases as trees and the two appliers must not classify differently.

View Source
const AppNameLabel = "app.kubernetes.io/name"

AppNameLabel is the KCL-declared GROUP key — the single `--target` selector. The group is ALWAYS the SERVICE: forge's KCL renderer stamps it with the service/component name on workloads / RBAC (`_managed_labels`, kcl/lib/services.k, kcl/lib/rbac.k), gateways/routes, and on a service's owned manifests (`Service.manifests`, which inherit THAT service's name). Env-shared resources that belong to no service — Namespace, ConfigMap, RuntimeClass, NetworkPolicy, and bundle-level `additional_manifests` — carry NO `app.kubernetes.io/name`. There is no synthetic env/namespace group.

SelectManifestsByGroup is therefore a pure mechanical include filter — keep iff group ∈ targets — with no Go-side "always keep shared infra" policy. An ungrouped manifest matches no service `--target`, so it applies only on a bare `forge env deploy` (no `--target`). To make a manifest ride a service's `--target`, declare it on that service's `manifests`.

View Source
const ClusterRoutingLabel = "forge.dev/cluster"

ClusterRoutingLabel is the FIRST-CLASS per-manifest cluster-attribution key. forge's KCL gateway/route builders stamp it (`forge.dev/cluster: k3d-<name>`) when an ingress entity (Gateway / HTTPRoute / GRPCRoute) declares `cluster = <forge.Cluster>` — the builder denormalizes the referenced Cluster's kubectl CONTEXT (`k3d-<name>`) into the label, which is exactly the value GroupScope.Cluster (== forge.K8sCluster.cluster, the kubectl context) is matched against. ScopeManifestsToGroup reads it directly: a manifest carrying this label routes to the named cluster ONLY, no `app.kubernetes.io/name` indirection. It is the replacement for the older label-piggyback trick (stamping an unrelated service's app label so the manifest rode that service's group routing). A manifest WITHOUT this label still routes by app label exactly as before, so existing consumers are unaffected.

View Source
const DefaultRolloutTimeout = 5 * time.Minute

DefaultRolloutTimeout bounds each resource's readiness wait when a policy does not set one. Five minutes covers a cold cloud rollout that pulls a fresh image on a new node; the previous 60s did not.

View Source
const KeepFinishedJobs = 3

KeepFinishedJobs is how many finished Jobs of one base name an apply leaves behind, the current release's included. It is fixed, not a knob: the plan's object_removed detail and the deploy skills state this number, so a per-env override would have to reach them too, and nothing has needed one.

View Source
const OwnerLabel = "forge.dev/owner"

OwnerLabel names the (project, env) whose apply sent an object. It is the prune's ownership proof; see the file comment. The value is CRDOwner's.

View Source
const WorkloadLabel = "forge.dev/workload"

WorkloadLabel is forge's OWN routing key. It names the workload that owns a manifest forge did not build itself — a `RenderedWorkload.manifests` entry (kcl/lib/labels.k owner_labels) or a helm chart's output (stampAppLabel) — and it takes priority over AppNameLabel when present. See ManifestGroup.

It exists because AppNameLabel is not forge's to take. Chart output and hand-written controllers set `app.kubernetes.io/name` for their own reasons, most often as the key a Service or DaemonSet SELECTOR matches on. Forcing it to the owning workload's name breaks those selectors; deferring to it lets the manifest's own label pick which `--target` ships it (control-plane's kata pre-pull DaemonSet fell out of `--target kata-prepull` that way). A key nothing else reads resolves both without choosing a loser.

Objects forge's own builders render carry no WorkloadLabel: their AppNameLabel already is the workload, and adding a second label to every pod template would roll every Deployment in every project for no routing gain.

Variables

View Source
var ErrAdmissionNotEffective = errors.New("admission policy did not admit the secret stores")

ErrAdmissionNotEffective is the cause wrapped by a failed admission barrier.

View Source
var ErrExternalSecretNotSynced = errors.New("ExternalSecret did not sync")

ErrExternalSecretNotSynced is the cause wrapped by a failed ExternalSecret gate, for callers that grade it with errors.Is.

View Source
var ErrImageCheckAuthDenied = errors.New("image existence could not be confirmed (registry denied the lookup)")

ErrImageCheckAuthDenied marks an image check the registry refused with an auth-class denial (denied / unauthorized / 403 forbidden). Unlike a transport error, this DID reach the registry — it just wouldn't answer whether the manifest exists. On a private registry (ghcr.io private packages, GCP Artifact Registry) a genuinely-MISSING image returns exactly this denial, so treating it as inconclusive would let the gate silently pass a missing image and ImagePullBackOff in prod. The preflight therefore BLOCKS on it (cannot confirm the image is present) with a message naming both causes — image not pushed, OR the deploy host lacks pull creds — and the --skip-preflight escape. Wrap it with %w to carry the underlying reason.

View Source
var ErrImageCheckInconclusive = errors.New("image existence could not be verified")

ErrImageCheckInconclusive marks an image check that could not reach the registry at all — a transport-class failure (DNS, connection refused, i/o timeout, docker daemon down). That says nothing about the image: the CLUSTER may still pull it fine, so the preflight treats it as a non-blocking warning ("couldn't verify image X: <reason>; proceeding") rather than a confirmed miss. Wrap it with %w to carry the underlying reason.

View Source
var ErrImageNotFound = errors.New("the registry has no such image")

ErrImageNotFound marks a CONFIRMED miss: the registry answered, and its answer was "no such manifest". Wrap it with %w to carry what the registry said, so a blocked deploy shows the evidence rather than just the ref.

View Source
var ErrNothingApplied = errors.New("nothing was applied")

ErrNothingApplied marks a refusal that was decided before any object was sent to a cluster. A deploy that fails with it changed nothing, which is a different fact from "failed part-way; some of it may be live".

Functions

func Apply

func Apply(ctx context.Context, opts ApplyOpts) error

Apply runs the render-KCL → kubectl-apply → wait-rollouts pipeline. It is the single entry point for the three call sites this package collapses. Behavior matches the pre-extraction `runDeploy` / `runDevClusterReload` shapes exactly (including stdout framing, warning messages, and ordering); per-call differences are expressed through ApplyOpts fields.

Apply is ApplyNoWait followed by WaitRollouts of that one apply. A caller deploying to SEVERAL clusters must not call it once per cluster: see ApplyNoWait.

func ApplyHelmChart added in v0.1.45

func ApplyHelmChart(ctx context.Context, kctx string, spec HelmChartSpec) error

ApplyHelmChart renders and applies ONE platform dep into one cluster, CRDs first and Established-gated — the same pipeline a declared `forge.HelmChart` flows through on a `--target` deploy.

WHY A SINGLE-CHART ENTRY POINT EXISTS. Every other chart apply starts from an env's rendered Bundle, which is right for a platform dep the env declares. The cluster phase installs a declared chart through this function too, rather than its own helm shell-out, which is what keeps the two identical — in particular the CRD-first ordering: an object applied before its CRD is Established fails on a kind the apiserver has never registered.

`quiet` is false: this is an install a human asked for, and the per-resource kubectl lines are the evidence it happened.

func ApplySecrets added in v0.1.45

func ApplySecrets(ctx context.Context, kctx, namespace, fieldManager, manifests string) error

ApplySecrets server-side-applies a Secret manifest stream under an explicit field manager, and prints nothing from it: the apply's own per-object lines would name Secrets, and a caller summarises by count instead.

Conflicts are forced, as in KubectlApply: forge is the declared source of these values, and the Secrets it takes over are exactly the ones a previous writer (a hand-run `forge env secrets sync`, or an older reconciler applying a redaction marker) already owns fields of.

func ApplySnapshot added in v0.1.46

func ApplySnapshot(ctx context.Context, s release.Snapshot, clusterName string, opts ApplyOpts) error

ApplySnapshot applies only recorded complete snapshot bytes through the normal config/CRD/pre-rollout/rollout actuator. It never evaluates KCL, renders Helm, provisions secrets, or prunes based on today's checkout. Cluster selection is explicit and must name a recorded destination. A failure does not claim that the complete snapshot was applied.

func CRDOwner added in v0.1.45

func CRDOwner(project, env string) string

CRDOwner is the CRDOwnerLabel value for a (project, env): "<project>.<env>", sanitized to a legal label value. Empty when either half is unknown, which disables both the stamp and the prune — an owner that cannot be named cannot be proven.

func ChartOwner added in v0.1.45

func ChartOwner(project, env string) string

ChartOwner names the project/env that owns the chart, or disables ownership when either component is unknown, using the same spelling as CRDOwner.

func CollectCertManagerSecretNames added in v0.1.45

func CollectCertManagerSecretNames(manifests string) map[string]struct{}

CollectCertManagerSecretNames returns the set of Secret NAMES that cert-manager Certificates in the stream will materialise — each Certificate's `spec.secretName`.

A Certificate is a PROVIDER of its Secret, exactly as a `kind: Secret` document is. cert-manager issues the certificate and writes the Secret when it reconciles the Certificate, in the same apply pass that creates the pods mounting it, so demanding the Secret exist beforehand makes a first deploy of any cert-manager-backed workload unsatisfiable — the thing that provisions it is the very deploy being blocked.

This surfaced on the vendored cloudnative-pg barman-cloud plugin, whose bundle renders two Certificates and the Deployment mounting their `barman-cloud-{client,server}-tls` Secrets. The gate reported both as undeclared mounts and told the operator to declare a KubeconfigSecret or ExternalSecret — advice that is wrong for a cert-manager Secret, whose bytes cert-manager owns and would overwrite. `forge env deploy e2e` was unrunnable without --skip-preflight, which disables every other check too.

Matched on the cert-manager.io API GROUP (any version), so a bundle on v1alpha2 or a future v2 is recognised without a forge change, while an unrelated CRD that happens to be called Certificate is not.

func CollectExternalSecretsNames added in v1.0.0

func CollectExternalSecretsNames(manifests string) map[string]struct{}

CollectExternalSecretsNames returns the set of Secret NAMES that external-secrets.io ExternalSecrets in the stream will materialise: each one's `spec.target.name`, else its own name (ESO writes the Secret named like the ExternalSecret when no target name is given).

An ExternalSecret is a PROVIDER of its Secret exactly as a cert-manager Certificate is: the operator writes the Secret when it reconciles the object, in the same apply pass that creates the pods mounting it, so demanding the Secret exist beforehand makes a first deploy unsatisfiable. This is what a bootstrap source needs: the OpenBao component renders the ExternalSecrets that deliver its own seal key and database DSN, and a store that mounts them would otherwise be refused by its own render.

Matched on the external-secrets.io API GROUP (any version).

func CollectRenderedSecretNames

func CollectRenderedSecretNames(manifests string) map[string]struct{}

CollectRenderedSecretNames returns the set of Secret NAMES rendered as `kind: Secret` documents in the manifest stream — the in-stream half of the supply. These satisfy a demand directly: forge applies them in the same deploy, so a mount of one resolves on first schedule. Malformed documents are skipped (best-effort, mirroring the other manifest scanners in this package).

func DeploymentsInUnits added in v0.1.46

func DeploymentsInUnits(manifests string, units []string) []string

DeploymentsInUnits names the Deployments of the stream that belong to one of units, sorted. The rollout wait excludes them: they are the hub's to roll.

func DropDeliveryUnits added in v0.1.46

func DropDeliveryUnits(manifests string, units []string) string

DropDeliveryUnits removes from a `---`-separated stream every document that belongs to one of units, and returns the rest byte-identical. A document that does not parse is kept: nothing can prove it belongs to a skipped unit, and dropping what cannot be read would silently ship less than the render holds. An empty units is a no-op.

func EnsureNamespace

func EnsureNamespace(ctx context.Context, kctx, namespace string) error

EnsureNamespace idempotently creates the target namespace so resources scoped to it (e.g. dotenv-projected Secrets) can be applied BEFORE the main manifest stream — which is where the Namespace object itself is rendered. Without this, the first thing the deploy applies (the secret_provider Secrets) lands before the Namespace exists and fails "namespaces \"…\" not found". The full manifest apply later re-applies the Namespace with its labels (server-side apply is idempotent), so this early create is a pure ordering fix, not a competing owner.

Uses `kubectl create --dry-run=client -o yaml | kubectl apply` so a pre-existing namespace is a no-op rather than an AlreadyExists error.

func ExternalSecretsIn added in v1.0.0

func ExternalSecretsIn(manifests string) map[string]struct{}

ExternalSecretsIn lists the ExternalSecrets a manifest stream applies, as "<namespace>/<name>". It is how the store gate knows which declared Secrets THIS deploy sends, after a --target filter.

func ExtractManifests added in v0.1.45

func ExtractManifests(kclOutput []byte) (string, error)

ExtractManifests is the manifest stream a render's KCL output becomes on its way to kubectl: `output.manifests`, with every forge.dev record expanded through pkg/deploy, as `---`-separated YAML. It is exported so tests and tooling can assert against exactly what is applied, not against an intermediate.

ONE ENTRYPOINT. main.k ends with `output = forge.render(bundle)`, and the applyable stream is `output.manifests`. A top-level `manifests` var is an ERROR, not a fallback: the stream carries Workload records only forge can expand, so a second top-level stream that a bare `kcl run -S manifests | kubectl apply` could consume would be a path that looks right and silently is not. Any other public top-level var warns.

func FormatPreflightReport

func FormatPreflightReport(r PreflightResult) string

FormatPreflightReport renders the grouped, actionable failure report. The shape is deliberately scannable: one block per missing Secret, then the missing-images block, then the remediation footer.

func FormatStoreFindings added in v1.0.0

func FormatStoreFindings(f StoreFindings) string

FormatStoreFindings renders the BLOCKING findings as one block, or "" when none block.

func FormatUndeclaredSecretMounts

func FormatUndeclaredSecretMounts(misses []UndeclaredSecretMount) string

FormatUndeclaredSecretMounts renders the back-propagated, actionable failure for one or more undeclared Secret mounts. The message names WHO mounts the Secret and what the author must declare to provide it — the exact shape the task specifies:

service "workspace-controller" mounts Secret "cp-daemon-kubeconfig" but
nothing declares it (no rendered Secret, KubeconfigSecret, or
ExternalSecret) — it will FailedMount. Declare a
forge.KubeconfigSecret/ExternalSecret or remove the mount.

func JobsWithPrefix added in v1.0.0

func JobsWithPrefix(manifests, prefix string) []string

JobsWithPrefix lists the Jobs a manifest stream applies whose name starts with prefix, as "<namespace>/<name>". The OpenBao converge Job is content-addressed (<name>-converge-<hash>), so a stream that carries one is a deploy that is about to (re)converge the store.

func KubectlApply

func KubectlApply(ctx context.Context, kctx, manifests string) error

KubectlApply pipes the rendered YAML document stream into `kubectl [--context <kctx>] apply --server-side --force-conflicts -f -`. Stdout/stderr are inherited so the user sees the per-resource `created`/`configured`/`unchanged` lines kubectl emits. kctx (when non-empty) targets a specific kubectl context for this command only.

--force-conflicts is unconditional and deliberate: forge is the declarative source of truth, so its Server-Side Apply field manager always wins. Without it, any resource previously touched by a plain `kubectl apply` (manager `kubectl-client-side-apply`, common after manual debugging or an older bootstrap) makes SSA abort the whole deploy with "Apply failed with N conflicts ... conflicts with kubectl-client-side-apply" / `exit status 1`. Forcing forge to take ownership of those fields overrides the stale manager and keeps the deploy idempotent. (--force-conflicts is an SSA-only flag — it has no effect without --server-side, which we always pass.)

An empty kctx is a HARD ERROR, never a fall-through to kubectl's current/default context. The target cluster is declarative — forge.K8sCluster.cluster in the env's KCL IS the context — so an empty value here means some group failed to carry its declared cluster. Applying to whatever context happens to be active is the footgun where an unrelated tool (e.g. `k3d cluster create`, which silently flips current-context) makes a deploy land in the WRONG cluster. Writes must fail LOUDLY instead. (Reads /waits via KubectlArgs may still default; only the destructive apply is gated here.)

func KubectlApplyNamespaced added in v0.1.45

func KubectlApplyNamespaced(ctx context.Context, kctx, namespace, manifests string) error

KubectlApplyNamespaced is KubectlApply with an explicit DEFAULT namespace for the objects in the stream that do not name one themselves: it passes `-n <namespace>` to kubectl, which is the namespace kubectl assigns to any namespaced object whose `metadata.namespace` is absent.

WHY THIS EXISTS — the `default`-namespace bug. `helm template -n <ns>` renders a chart FOR a namespace, but a chart is not obliged to stamp `metadata.namespace` onto its output, and many do not: Helm's own install path supplies the namespace from the client at apply time, so the field is redundant there and charts legitimately omit it. Rendering such a chart and applying it with NO namespace flag sends every namespace-less object to kubectl's default namespace — `default` — regardless of what the chart declared.

That is not hypothetical, and it is chart-specific rather than universal:

fluxcd-community/flux2 2.19.1   40 objects rendered, 0 carry metadata.namespace
envoyproxy/gateway-helm v1.7.2  18 objects rendered, 12 carry it; the 6 that
                                do not are all cluster-scoped, so they need none

So Flux's six controllers landed in `default` while `flux-system` sat empty, and Envoy — declared the same way, in the same bundle, for months — was unaffected the whole time, because its templates stamp the field. A chart "working" therefore proved nothing about the next one; the namespace has to come from forge's declaration, not from the chart's generosity.

An empty namespace reproduces the old behaviour exactly (no flag passed), which is what every non-chart caller wants: forge's own KCL render stamps `metadata.namespace` on everything it emits.

WHY `-n` REMAINS THE BACKSTOP. Render-time stamping (stampChartNamespace) makes the stream self-describing, but it can only stamp objects whose scope forge can prove offline: a built-in namespaced kind, or a kind whose CRD is in hand. Stamping a namespace onto a cluster-scoped object is an error, and a chart's own CRDs DEFINE new kinds, so for a kind forge cannot resolve only the apiserver knows. `-n` is how kubectl asks it: measured, the flag is applied to namespaced objects and IGNORED for cluster-scoped ones (a bare ClusterRole applied with `-n` comes back with `metadata.namespace` empty). So the stamp covers what it can prove, `-n` covers the rest, and neither needs a forge-side kind table that can drift out of date.

A doc that DECLARES its own namespace keeps it: kubectl refuses to relocate it — `the namespace from the provided object "kube-system" does not match the namespace "flux-system"` — and it fails the whole stream rather than applying some of it. So those docs are separated out and applied WITHOUT the flag (see splitByTargetNamespace), which both honours the chart's intent and keeps the mismatch from breaking the deploy.

func KubectlArgs

func KubectlArgs(kctx string, args ...string) []string

KubectlArgs prepends `--context <kctx>` to a kubectl argument list when kctx is non-empty, and returns the args unchanged otherwise. Threading the context PER COMMAND (rather than mutating the global active context via `kubectl config use-context`) is what makes concurrent multi-cluster `forge env deploy` safe — two deploys sharing one kubeconfig can target different clusters without racing on the single global context. An empty kctx means "use kubectl's current/default context", the unchanged single-cluster behaviour.

`--context` is a global kubectl flag, so it's valid as the leading argument before any subcommand (apply / wait / rollout / get / …).

func ListManagedDeployments

func ListManagedDeployments(ctx context.Context, kctx, namespace string) ([]string, error)

ListManagedDeployments returns the names of every forge-owned Deployment in the namespace (filtered by the `app.kubernetes.io/managed-by=forge` label). This is the authoritative list for rollout-watching — it covers shared-binary `<project>-<svc>` names, per-service `<svc>` names, operator and worker deployments, and anything packs add, without forge having to guess naming schemes per scaffold mode.

func LocalImageRef

func LocalImageRef(ref string) bool

LocalImageRef reports whether an image ref targets a LOCAL registry (k3d / registry.localhost / localhost:<port> / 127.0.0.1). The deploy path uses it as the default SkipImageRef so a local dev loop isn't failed by an image that only exists in the in-cluster registry the manifest checker can't reach the same way. Best-effort: a missing local image still surfaces as an ImagePullBackOff, but local dev iterates fast and a hard preflight failure there is more friction than value.

func ManifestGroup added in v0.1.45

func ManifestGroup(labels map[string]string) string

ManifestGroup is the deploy GROUP a manifest belongs to — the value `--target` selects on and multi-cluster routing attributes by. WorkloadLabel when the manifest carries one, else AppNameLabel, else "" (an env-shared object that belongs to no workload).

It is the ONE reader of that fact. SelectManifestsByGroup, ScopeManifestsToGroup, `forge env render --list`, the `--target` vocabulary and doctor's routing model all call it, so the name `--list` prints for an object is by construction the name `--target` accepts and routes by.

func ManifestGroups added in v0.1.45

func ManifestGroups(manifests string) []string

ManifestGroups returns every distinct non-empty ManifestGroup in a `---`-separated stream, sorted. It is the manifest half of the `--target` vocabulary: every name an object in the rendered env is attributed to.

func ObjectOwner added in v1.0.0

func ObjectOwner(project, env string) string

ObjectOwner is the OwnerLabel value for a (project, env), "" when either half is unknown. An owner that cannot be named cannot be proven, so "" disables both the stamp and the prune.

func PartitionConfigManifests

func PartitionConfigManifests(manifests string) (config, rest string)

PartitionConfigManifests splits a `---`-separated multi-doc YAML stream into (config, rest): config holds the documents whose `kind` is one of configFirstKinds (Namespace, ConfigMap, Secret) in their original relative order, rest holds everything else (also in order). Empty / whitespace-only docs are dropped from both halves. A doc that doesn't parse as YAML is conservatively placed in rest — it can't be confirmed config, and rest is the pass that always runs.

This is the ordering primitive behind Apply's two-pass apply: config is applied (and kubectl returns) before rest, so a workload in rest never schedules ahead of the ConfigMap/Secret it references.

func PlanCharts added in v0.1.46

func PlanCharts(ctx context.Context, envContext string, specs []HelmChartSpec) release.ChartPlan

PlanCharts observes what applying specs to their clusters would change. envContext is the env's primary kubectl context, which a chart that declares no cluster of its own applies to.

A chart it cannot observe is reported as unplanned rather than left out: the plan must never read "this chart does not change" for one it could not read.

func Preflight

func Preflight(ctx context.Context, opts PreflightOpts) error

Preflight runs the deployability checks and returns a grouped, actionable error when anything is missing — or nil to proceed. It is safe under --dry-run (a pure read-only check that applies nothing). When there is nothing to check (no Secret refs and no checkable images) it is a no-op.

Checks run concurrently: one GetSecretKeys per DISTINCT Secret and one ImageExists per DISTINCT image, in parallel, so the happy path adds one round-trip's latency rather than one-per-ref.

func Prune

func Prune(ctx context.Context, kctx, manifests, namespace string) error

Prune deletes every forge-managed Deployment in namespace that is NOT in the rendered manifest stream. The managed-by guard comes from the kubectl label filter inside ListManagedDeployments — only resources carrying `app.kubernetes.io/managed-by=forge` are eligible for prune. This invariant protects user-applied Deployments living alongside forge-owned ones in the same namespace.

An empty desired set (no Deployments at all in the render) is treated as a misuse case (almost certainly the user pointed at the wrong env dir) and prune is skipped rather than wiping every forge-managed Deployment in the namespace.

Errors during the list or per-Deployment delete are returned to the caller (which logs them as warnings rather than failing the whole deploy — pruning is a maintenance step, not a correctness gate).

func PruneCRDs added in v0.1.45

func PruneCRDs(ctx context.Context, kctx, manifests, owner string) (pruned, keptInUse []string, err error)

PruneCRDs deletes this owner's CRDs that the stream no longer renders and that have no remaining instances. It returns the CRDs it deleted and the ones it kept because they still have instances. Delete failures are reported on stdout and do not stop the rest; a failed LIST is an error.

func PrunePDBs added in v0.1.45

func PrunePDBs(ctx context.Context, kctx, manifests, namespace string) error

PrunePDBs deletes forge-labelled PodDisruptionBudgets for workloads in this render whose PDB the render no longer carries (a workload dropping to one replica stops rendering its PDB, and apply never deletes). Only PDBs are touched, and only those labelled managed-by=forge.

func PruneSupersededJobs added in v0.1.46

func PruneSupersededJobs(ctx context.Context, kctx, manifests, namespace, env string) ([]string, error)

PruneSupersededJobs deletes the finished, superseded Jobs described in the file comment, keeping the newest KeepFinishedJobs finished Jobs of each base name. It returns the names it deleted.

func RecoverChartLock added in v0.1.46

func RecoverChartLock(ctx context.Context, kctx, owner, chart, uid, rv, confirmation string) error

RecoverChartLock removes exactly the lock an operator inspected. Confirmation must name its holder after the operator has terminated/fenced that writer and its children on every host. It never checks an age or automatically recovers. UID and resourceVersion prevent this approval being reused for a newer lock.

func RedactSecretValues added in v0.1.45

func RedactSecretValues(stream string) string

RedactSecretValues replaces every value in a `kind: Secret` document of a manifest stream with a placeholder. A dry run is read by humans and pasted into tickets and CI logs, so it shows a Secret's shape (its name and keys), never its content.

func RenderHelmChart

func RenderHelmChart(ctx context.Context, spec HelmChartSpec) (string, error)

RenderHelmChart expands one HelmChart to its manifest stream, with every rendered manifest stamped `app.kubernetes.io/name = spec.Name` so the SAME exclusive `--target` axis (SelectManifestsByGroup) selects it. This is the whole bridge from "a declared platform dep" to "manifests in the normal apply stream": the result joins render output and flows through the same filter → apply → wait pipeline as the app.

The chart's CRDs (HelmChartSpec.CRDs) are NOT included here — they are applied first by Apply (CRDs → wait Established → rest); RenderHelmChart returns only the `--skip-crds` controller/RBAC/Service manifests.

func RenderManifests

func RenderManifests(ctx context.Context, mainK, imageTag, namespace, env string, envCfgKV map[string]string, imageDigests map[string]string) (string, error)

RenderManifests renders the project's KCL for env into a Kubernetes manifest document, applying the given image tag, namespace, per-env config overrides, and image digest pins. It runs from the project root so deploy-as-data file reads resolve.

func RenderedDeploymentNames

func RenderedDeploymentNames(manifests string) []string

RenderedDeploymentNames extracts the `metadata.name` of every `kind: Deployment` document in a `---`-separated YAML stream. Used by Prune to compute the desired set against which the namespace's actual forge-managed Deployments are diffed.

Malformed documents are skipped (callers get a best-effort list).

func RenderedJobNames

func RenderedJobNames(manifests string) []string

RenderedJobNames extracts the `metadata.name` of every `kind: Job` document in a `---`-separated YAML stream. This is the authoritative source for the one-shot-Job wait set: forge waits on whatever Jobs the deploy actually applies, regardless of how they entered the bundle.

Deriving from an entity list instead was fragile in both directions. It only saw Jobs that round-tripped through the typed `forge.CronJob` -> `output.cronjobs` contract, so it MISSED a `schedule==""` Job that never surfaced there (the real-launch gap where forge rolled the workloads without blocking on the migrate Job) and any raw `kind: Job` from `additional_manifests` — and it INVENTED names that were never applied once one-shot Jobs were named by spec hash (see oneShotWaitSet). The rendered manifests are what kubectl actually applies, so deriving from them closes both holes at once.

Malformed documents are skipped (callers get a best-effort list).

func ScopeManifestsToGroup

func ScopeManifestsToGroup(manifests string, scope GroupScope) string

ScopeManifestsToGroup filters a `---`-separated env manifest stream down to the documents that belong on ONE deploy group's cluster. Routing is DECLARED-CLUSTER-ONLY: a manifest lands on the cluster of its OWNING service, identified by the service's `app.kubernetes.io/name` label. There is no "primary cluster" and no most-services heuristic — forge stamps that label on every workload AND on every per-service owned manifest (forge.Service.manifests, which is also how an image-less infra service pins env-level resources — Namespace, Gateways, CRDs — to a specific declared cluster). KCL still renders the whole env as one stream; this filter routes each doc to the cluster its owner declares.

FIRST-CLASS cluster attribution takes priority over the app-label rule below: a manifest carrying the `forge.dev/cluster` label (stamped by forge's gateway/route builders when an ingress entity declares `cluster = "<name>"`) is routed by that label DIRECTLY — kept iff the label equals scope.Cluster, dropped otherwise — with no `app.kubernetes.io/name` indirection. This is the explicit replacement for the old trick of piggybacking an unrelated service's app label. When scope.Cluster is empty (the label-only routing path), the first-class match is skipped and the doc falls through to the app-label rule. A manifest WITHOUT the routing label always uses the app-label rule.

The ownership rule, document by document (by its `app.kubernetes.io/name` label `a`):

  • a ∈ scope.OwnApps → KEEP. This group's own service or its owned manifests (Deployment / Service / per-service RBAC / HPA / a CRD or infra resource attached via forge.Service.manifests — all carry the owning service's app label).
  • a ∈ scope.OtherApps → DROP. Owned by a DIFFERENT cluster's group — never apply it here (this is what stops cross-contamination: a secondary cluster never receives the primary's services, CRDs, or gateways, so it can't hard-fail on a CRD it doesn't have).
  • a != "" but in NEITHER set → KEEP. An app-labelled doc whose owner isn't in any group should not occur once every service is grouped; keeping (rather than dropping or routing by guess) is the safe, non-heuristic default.
  • a == "" and kind == Namespace → KEEP. Every cluster needs its namespace (a workload can't apply into a missing namespace), and the namespace is genuinely env-wide, so it is replicated to every group.
  • a == "" (any other unlabeled doc — an env-level resource the user did NOT attribute to a cluster, e.g. a RuntimeClass, which forge.render leaves unstamped because every cluster running the class needs it) → KEEP. The deploy layer never PICKS a cluster for an unattributed resource; it replicates the genuinely-shared ones rather than guess a primary. To pin such a resource to ONE cluster, declare it on an image-less infra service's `manifests` so it carries that service's app label and routes via OwnApps/OtherApps above.

A doc that doesn't parse as YAML is conservatively KEPT (it can't be confirmed as another cluster's, and silently swallowing it is worse than letting kubectl reject genuinely-broken YAML).

Empty / whitespace-only docs are dropped. The single-cluster path never reaches here (ApplyOpts.ClusterScope stays nil), so this is a no-op for the common case and multi-cluster envs are the only behaviour change.

func SelectManifestsByGroup

func SelectManifestsByGroup(manifests string, targets []string) string

SelectManifestsByGroup is the ONE uniform, mechanical, EXCLUSIVE `--target` filter: it keeps a `---`-separated multi-doc YAML stream's documents iff their KCL-declared service GROUP (`app.kubernetes.io/name`) is in targets, and DROPS every other document. The rule, doc by doc:

  • KEEP iff `metadata.labels[app.kubernetes.io/name]` ∈ targets.
  • DROP otherwise — including a doc whose group is NOT targeted AND a doc with NO group label.

EXCLUSIVE means EXACTLY that: nothing is kept implicitly. The group is always the service, and there is no synthetic group for shared infra: the env-shared manifests (Namespace, ConfigMap, RuntimeClass, NetworkPolicy) and bundle-level additional_manifests carry NO group, so a service `--target` DROPS them — they apply only on a bare deploy. A shared resource an app needs is kept under that app's `--target` ONLY if it was declared on the service's `manifests` (so it inherits the service group) in KCL. The decision of WHICH manifests a `--target` includes is thus entirely KCL-declared data; this function only filters on it. Callers pass targets only when non-empty — an empty/whole-env apply never calls this (Apply keeps everything when Targets is empty).

A doc that doesn't parse as YAML carries no readable group and is DROPPED like any ungrouped doc: under exclusive targeting "I can't read its group" means "it isn't in the target set". Empty / whitespace-only docs are dropped.

func ServesKind

func ServesKind(served map[string]struct{}, group, kind string) bool

ServesKind reports whether served (from a ServedKindChecker) contains the (group, kind). Centralizes the key shape so call sites and the live checker agree.

func SplitManifestDocs added in v0.1.45

func SplitManifestDocs(manifests string) []string

SplitManifestDocs splits a `---`-separated multi-doc YAML manifest stream into its individual documents, trimming surrounding whitespace and dropping empty ones.

Exported so callers OUTSIDE this package (the `forge env render` printer) walk the stream document by document through the same splitter the routing filters use, rather than re-deriving the delimiter. A second splitter that disagreed about where a document ends would attribute the wrong bytes to the wrong cluster — the precise failure the render printer exists to prevent.

func StampCRDOwnership added in v0.1.45

func StampCRDOwnership(manifests, owner string) string

StampCRDOwnership labels every CustomResourceDefinition in the stream with CRDOwnerLabel=owner, and with managed-by=forge unless the author set one. Other documents pass through byte-identical. An empty owner is a no-op.

func StampOwnership added in v1.0.0

func StampOwnership(manifests, owner string) string

StampOwnership labels every document in the stream with OwnerLabel=owner except CRDs and Secrets, and passes the rest through unchanged. Only metadata.labels is written: never a selector or a pod template, so no workload rolls. An empty owner is a no-op.

func WaitJobComplete

func WaitJobComplete(ctx context.Context, kctx, name, namespace string) error

WaitJobComplete blocks until the named Job in namespace reaches `condition=complete`. Timeout is 5m — Jobs in this lane are deploy-time migrations / backfills, which routinely run for minutes.

func WaitJobCompleteTimeout added in v0.1.45

func WaitJobCompleteTimeout(ctx context.Context, kctx, name, namespace string, timeout time.Duration) error

WaitJobCompleteTimeout is WaitJobComplete under the policy's budget.

`kubectl wait --for=condition=complete` does NOT return when the Job fails — a failed Job satisfies `condition=failed`, not `complete`, so this would otherwise sit out the whole timeout on a migration that crashed in its first second. Waiting on both conditions and reporting which one fired turns "5 minutes then a timeout" into an immediate, accurate failure.

func WaitRollout

func WaitRollout(ctx context.Context, kctx, name, namespace string) error

WaitRollout blocks until the named Deployment reaches a healthy rollout state, with a 60s timeout (down from 120s — dev iteration is the dominant path, and a failing rollout almost always means the image won't pull / the pod won't start, not that 120s of patience would have rescued it).

On timeout, automatically dumps a short diagnostic burst so the developer doesn't have to context-switch to a separate kubectl shell to figure out WHY it's stuck. The dump covers:

  • The non-Ready pod's `kubectl describe` Events tail (image-pull errors, scheduling failures, readiness probe failures).
  • Recent namespace events (`kubectl get events`) so cluster-level issues (admission webhooks, missing ConfigMaps, etc.) show up.
  • Pod log tail when the pod is at least pulled, captures CrashLoopBackOff reasons like "NATS_URL is required".

Diagnostics are best-effort — any kubectl invocation failure is swallowed so the wait error itself remains the primary signal.

func WaitRolloutTimeout added in v0.1.45

func WaitRolloutTimeout(ctx context.Context, kctx, name, namespace string, timeout time.Duration) error

WaitRolloutTimeout is WaitRollout with an explicit per-resource budget, so a policy can give a cold cloud rollout the minutes it needs without making the local inner loop wait that long to learn the same thing.

func WaitRollouts added in v0.1.45

func WaitRollouts(ctx context.Context, pending ...*PendingRollout) error

WaitRollouts waits on every pending rollout and grades them as ONE result.

Nothing is applied here: by the time this runs every cluster in the deploy has its manifests and is converging concurrently, so a dependency between clusters resolves while forge waits rather than expiring because it was never sent.

Every cluster's Deployments are awaited as one sequence ordered by RolloutPolicy.Order, so `--rollout-order` means the same thing across clusters as within one; the one-shot Jobs follow. Each resource is graded by its own apply's policy, exactly as Apply grades it: RolloutWait records every failure on every cluster and fails, RolloutWarn reports and succeeds, FailFast stops at the first failure anywhere. There is no rollback either way — what was applied stays applied, and recovery is to roll forward.

nil entries (a dry run, a skip-mode apply, a platform-only apply) have nothing to wait for and are ignored. One pending rollout returns exactly the error Apply always has; several return a *RolloutFailedError when more than one of them failed.

func WarnBlockingPDBs added in v0.1.45

func WarnBlockingPDBs(manifests string)

WarnBlockingPDBs prints one warning per PDB CheckPDBs flags.

func WithKCLRunner added in v1.0.0

func WithKCLRunner(ctx context.Context, run KCLRunner) context.Context

WithKCLRunner scopes a renderer to one command, never a process-global hook.

Types

type AdoptionProbe added in v1.0.0

type AdoptionProbe interface {
	// ForeignStore reports the workload named name in namespace on kctx that
	// forge did not install: a Deployment or StatefulSet that carries no
	// forge.dev/component-owner label. found=false when there is none, or it is
	// a forge component's.
	ForeignStore(ctx context.Context, kctx, namespace, name string) (kind string, found bool, err error)
}

AdoptionProbe is the one extra read the adoption warning makes. It is a separate, consumer-declared interface so a probe that cannot answer it (a fake, a host-only env) simply does not implement it and the warning is not raised: an advisory never fails a deploy.

type AppliedOverride added in v0.1.45

type AppliedOverride struct {
	Key       string `json:"key"`
	Kind      string `json:"kind"`
	Name      string `json:"name"`
	Namespace string `json:"namespace,omitempty"`
	Cluster   string `json:"cluster,omitempty"`
}

AppliedOverride is one override that landed, for the "overrides applied" summary `forge env render` prints and its --json carries. It records the KEY the author wrote next to the object it actually resolved to, because the whole class of mistake here is a key that matches something other than what its author pictured.

func ExtractManifestsWithOverrides added in v0.1.45

func ExtractManifestsWithOverrides(kclOutput []byte) (string, []AppliedOverride, error)

ExtractManifestsWithOverrides is ExtractManifests, additionally reporting which Bundle.overrides landed and on what. Only the surfaces that SHOW the overrides need the second return (`forge env render`'s summary and its --json); every other caller wants the stream and uses ExtractManifests.

func RenderManifestsWithOverrides added in v0.1.45

func RenderManifestsWithOverrides(ctx context.Context, mainK, imageTag, namespace, env string, envCfgKV map[string]string, imageDigests map[string]string) (string, []AppliedOverride, error)

RenderManifestsWithOverrides is the one render: `forge env render`, `forge env deploy` and the preflight all reach KCL through it, so they cannot disagree about what the env declares. It additionally reports which Bundle.overrides landed — what `forge env render` summarizes.

func (AppliedOverride) Object added in v0.1.45

func (a AppliedOverride) Object() string

Object renders the resolved target the way the summary prints it.

type ApplyOpts

type ApplyOpts struct {
	// MainK is the path to deploy/kcl/<env>/main.k — the KCL entrypoint
	// that renders the cluster manifests. Required.
	MainK string

	// ImageTag is the value bound to KCL's `image_tag` -D variable.
	// Required (callers default to gitShortSHA at the call site).
	ImageTag string

	// ImageDigests is the per-image content-addressed digest map bound to
	// KCL's `image_digests` -D variable. When set, each rendered service's
	// manifest image resolves to ITS image's digest rather than the env-wide
	// ImageTag — the structural fix for a multi-image env pinning every
	// service to one digest.
	//
	// Keys are BOTH the bare image name (`reliant`) and the tag-qualified
	// name the build pushed (`reliant:e2e`), because the render looks up
	// whichever one the workload declares: an unpinned image takes the env
	// tag and is keyed bare, while an image that pins its own tag must match
	// a digest captured for THAT tag or stay unpinned. A tag-pinned image
	// renders as `<image>:<tag>@sha256:…`.
	//
	// May be nil/empty (the no-digest path), in which case every image stays
	// on ImageTag, byte-identical to before.
	ImageDigests map[string]string

	// Namespace is the namespace this apply WAITS in and prunes: the
	// rollout wait lists Deployments and Jobs here, and a Job naming no
	// namespace of its own is waited for here. Required.
	//
	// It is NOT a render input when Render is set. A deploy spanning several
	// namespaces applies one group per (cluster, namespace), and re-rendering
	// the env once per group with that group's namespace bound to KCL's
	// `namespace` option changes what the env DECLARES — every override and
	// URL keyed on the env namespace — so a group's render could disagree
	// with `forge env render` about which objects exist. Without Render (the
	// single-group callers: cluster reload, a direct apply) it is also the
	// namespace the env is rendered with.
	Namespace string

	// Render, when set, supplies the env's manifest stream instead of
	// RenderManifests(Namespace, …). A deploy renders the env ONCE, with the
	// env namespace `forge env render` uses, and every group's apply
	// partitions that one stream by cluster (ClusterScope) — the groups
	// differ in where they apply and wait, never in what the env declares.
	Render func(ctx context.Context) (string, error)

	// EnvConfigKV is the per-env config map projected as additional
	// `-D key=value` bindings to KCL. May be nil — the dev cluster
	// reload doesn't project per-env config (it would force a re-deploy
	// pipeline rebuild, defeating the inner-loop purpose).
	EnvConfigKV map[string]string

	// DryRun skips kubectl apply and prints the rendered manifests
	// instead. With DryRunFramed, the output is wrapped in
	// "--- Generated Manifests (dry-run) ---" / "--- End Manifests ---"
	// markers (the forge env deploy convention). Without it, raw manifests
	// are printed (the forge cluster reload convention).
	DryRun       bool
	DryRunFramed bool

	// Prune deletes forge-managed Deployments in the namespace that the
	// just-applied KCL render no longer produces. Opt-in — pruning is
	// destructive (see deploy.go's pruneOrphanDeployments docstring).
	Prune bool

	// Project is the forge project name. With Env it names the owner of
	// the CRDs this apply renders (CRDOwner), which is what makes CRD
	// pruning provable. Empty disables the CRD stamp and prune.
	Project string
	// PruneCRDs deletes the CRDs this (project, env) previously rendered
	// and no longer does, provided no custom resource of them remains —
	// see crd_prune.go. It runs only on a FULL apply: never with Targets,
	// never on a dry run. The env up and deploy paths set it, because a
	// CRD the render dropped is otherwise served forever.
	PruneCRDs bool

	// Rollout governs what the post-apply wait DOES, and — critically —
	// whether a rollout that never becomes ready fails the deploy.
	//
	// The zero value is RolloutPolicy{}, which Normalize() turns into the
	// safe default: wait for every managed Deployment, and FAIL if any of
	// them does not become ready. See RolloutPolicy.
	Rollout RolloutPolicy

	// Quiet suppresses the section-header banners ("Applying
	// manifests...", "Waiting for rollouts...") and emits the matching
	// per-resource warnings in the bare format ("Warning: <msg>" with
	// no leading indent) — the shape `forge cluster reload` used
	// pre-extraction. Off by default; the deploy and up call sites
	// keep the framed banners.
	Quiet bool

	// Env is the environment name (e.g. "dev", "dev-host", "prod")
	// passed to KCL as `-D env=<env>`. User main.k files can read it via
	// `option("env")` to conditionally include manifests — typical use
	// is skipping in-cluster infra (NATS, Temporal, LiteLLM) on dev-host
	// envs where docker-compose provides the same services.
	Env string

	// Context, when non-empty, is the kubectl context every kubectl
	// invocation in the apply/wait path runs against — passed as
	// `--context <ctx>` per command rather than mutating the global
	// active context (`kubectl config use-context`). This is what makes
	// concurrent multi-cluster `forge env deploy` safe: two deploys sharing
	// one kubeconfig but targeting different clusters no longer race on
	// the single global context. Empty = use kubectl's current/default
	// context (unchanged for single-cluster users).
	Context string

	// Targets, when non-empty, is the EXCLUSIVE set of KCL-declared service
	// GROUPs (`app.kubernetes.io/name` values) this apply keeps. The whole
	// env bundle is still rendered (KCL renders the env as a unit), but after
	// RenderManifests and before KubectlApply the multi-doc YAML is filtered
	// to EXACTLY the manifests whose group ∈ Targets — and EXACTLY the helm
	// charts whose Name ∈ Targets are rendered. The filter is purely
	// mechanical: it includes nothing implicitly. There is no shared-base
	// auto-keep, and no synthetic group: the env-shared manifests (Namespace,
	// ConfigMap, RuntimeClass, NetworkPolicy) and bundle-level
	// `additional_manifests` carry NO group, so they apply ONLY on a bare
	// deploy and are NEVER selected by a service Target. To make a manifest
	// ride a service's Target, declare it on that service's `manifests`.
	// Empty Targets means "apply EVERYTHING" — every manifest and every
	// declared platform dep (the full declarative reconcile). See
	// SelectManifestsByGroup and selectHelmChartsByGroup.
	Targets []string

	// HelmCharts are the env's declared platform dependencies, rendered
	// with helm-as-a-RENDERER (helm template --skip-crds) and folded into
	// THIS apply stream. Each is a renderable whose NAME is its GROUP — the
	// SAME exclusive Targets filter selects it: a chart is rendered + applied
	// iff (no Targets) OR (its Name ∈ Targets), the identical rule every other
	// manifest obeys (each chart's manifests are stamped
	// `app.kubernetes.io/name = Name`). There is NO chart opt-in special case
	// — a bare `forge env deploy <env>` (no Targets) reconciles every declared
	// platform dep too. Apply renders each selected chart, stamps every
	// manifest with its group, and applies it in CRD-first order (the chart's
	// forge-supplied CRDs → wait Established → the chart's controllers) so the
	// apply leaves CRDs Established + controllers Deployed. Empty => no
	// platform deps. See helm.go.
	HelmCharts []HelmChartSpec

	// ClusterScope, when non-nil, scopes the rendered env bundle to ONE
	// deploy group's cluster before applying — declared-cluster-only
	// multi-cluster routing. KCL renders the whole env as a unit (every
	// service's manifests in one stream), but each manifest must land ONLY
	// on the cluster of its OWNING service (identified by its
	// `app.kubernetes.io/name` label), and no other cluster may receive it.
	// Without this, every group applied the entire bundle to its own
	// `--context`, so a two-cluster env cross-contaminated both clusters
	// (the secondary got the whole stack and hard-failed on missing CRDs).
	// ScopeManifestsToGroup does the per-doc partition by owner; nil leaves
	// the stream untouched (the single-cluster path, byte-identical to the
	// pre-scoping behaviour). See ScopeManifestsToGroup for the ownership
	// rule.
	//
	// nil does NOT mean "write everything to Context": an object stamped
	// for a different cluster (ClusterRoutingLabel) is refused, scoped or
	// not — see refuseForeignClusterDocs.
	ClusterScope *GroupScope

	// OnStream, when non-nil, is handed the FINAL manifest stream — after
	// the --target filter and the multi-cluster scope, with every rendered
	// chart folded in — immediately before it is either applied or printed
	// as a dry run.
	//
	// This is the only place that stream exists. A caller cannot reproduce
	// it by re-rendering: it would have to re-apply both filters and
	// re-template every chart, and a second implementation of that is free
	// to disagree with this one about what ships. Reporting it from here
	// means a `--dry-run` preview and a real apply describe the same bytes
	// by construction.
	//
	// Called for dry runs too, BEFORE the early return — previewing what
	// would be applied is the primary use. Purely observational: the
	// callback's result is ignored and it cannot affect the apply.
	OnStream func(manifests string)

	// SkipUnits are the delivery units whose objects this apply LEAVES to the
	// hub: Bundle.hub_converges, resolved by the caller. The objects are
	// dropped from what is sent and their Deployments from what is awaited,
	// and nothing else changes: pruning still reads the full stream, because
	// pruning from a filtered one would delete every skipped Deployment as an
	// orphan. See delivery_unit_filter.go.
	SkipUnits []string

	// OnRollout, when non-nil, receives one observation per resource whose
	// readiness was awaited, as each verdict arrives.
	//
	// Apply's own return value stays the aggregate error, which is what
	// fails the deploy; this reports the per-resource detail that the
	// aggregate necessarily loses — in particular WHICH resource, and
	// whether its budget expired rather than a failure being observed. See
	// rollout_observer.go for why that third state cannot be reconstructed
	// from the outside.
	//
	// Not called under RolloutSkip: forge asks the cluster nothing in that
	// mode, so it has nothing to report, and inventing "not waited" entries
	// here would mean synthesizing observations Apply never made.
	OnRollout func(RolloutObservation)
}

ApplyOpts expresses the differences between the three existing call sites. Every field has a sensible zero value so callers that don't care about a knob can leave it unset.

type CRDState added in v1.0.0

type CRDState struct {
	Present bool
	// Owner is the forge.dev/component-owner label ("" when forge did not
	// install it).
	Owner string
	// Versions are the versions the CRD SERVES.
	Versions []string
}

CRDState is what a live CustomResourceDefinition looks like.

type ComponentOwnerError added in v1.0.0

type ComponentOwnerError struct {
	Component, Cluster, Holder, Wanted string
}

ComponentOwnerError is a refusal to install a component another owner holds.

func (*ComponentOwnerError) Error added in v1.0.0

func (e *ComponentOwnerError) Error() string

type ConfigMapGetter

type ConfigMapGetter interface {
	GetConfigMapKeys(ctx context.Context, kctx, namespace, name string) (keys map[string]struct{}, exists bool, err error)
}

ConfigMapGetter resolves which keys exist on a ConfigMap in a target cluster, mirroring SecretGetter. exists=false means the ConfigMap is absent; an error is a genuine lookup failure that aborts the preflight.

type CredentialedImageArchChecker

type CredentialedImageArchChecker interface {
	WithDockerConfigDir(dir string) ImageArchChecker
}

CredentialedImageArchChecker is the ImageArchChecker analogue of CredentialedImageChecker.

type CredentialedImageChecker

type CredentialedImageChecker interface {
	// WithDockerConfigDir returns an ImageChecker that runs `docker` with
	// DOCKER_CONFIG=dir, so private-registry lookups authenticate with the
	// cluster's pull creds materialised there.
	WithDockerConfigDir(dir string) ImageChecker
}

CredentialedImageChecker is an OPTIONAL capability an ImageChecker / ImageArchChecker may implement: given a docker config dir (a directory holding a config.json with the cluster's pull creds), it returns a variant of itself that authenticates registry lookups with those creds. The preflight uses it to RETRY an auth-denied lookup from the CLUSTER's perspective — turning a local-daemon "auth denied" (a false negative for an image the cluster can pull) into a TRUE existence/arch verdict. A checker that does not implement this is used as-is (no credentialed retry).

type DockerImageArchChecker

type DockerImageArchChecker struct {
	// DockerConfigDir overrides DOCKER_CONFIG for the docker invocation. Empty
	// inherits the process environment.
	DockerConfigDir string
}

DockerImageArchChecker is the live ImageArchChecker: it reads an image's advertised architecture(s) via `docker manifest inspect <ref>` against the registry. It parses BOTH shapes the command can return:

  • a manifest LIST / OCI index — `.manifests[].platform.architecture`, one entry per platform (a multi-arch image). "unknown" attestation entries (buildx provenance/SBOM) are dropped — they aren't runnable.
  • a single image manifest — the top-level `.architecture` field.

A lookup that can't reach the registry (transport failure, image absent, daemon down) is returned as ErrImageCheckInconclusive so the gate WARNS rather than blocking — a mismatch can only be asserted on a known arch.

DockerConfigDir mirrors RegistryImageChecker: set it (via WithDockerConfigDir) to read the manifest with the CLUSTER's pull creds when the local daemon lacks access to a private registry.

func (DockerImageArchChecker) ImageArchitectures

func (c DockerImageArchChecker) ImageArchitectures(ctx context.Context, ref string) ([]string, error)

ImageArchitectures returns the distinct architectures ref advertises. See the type doc for the two manifest shapes handled.

func (DockerImageArchChecker) WithDockerConfigDir

func (c DockerImageArchChecker) WithDockerConfigDir(dir string) ImageArchChecker

WithDockerConfigDir returns a copy that runs docker with DOCKER_CONFIG=dir. Implements CredentialedImageArchChecker.

type ExternalSecretSyncError added in v1.0.0

type ExternalSecretSyncError struct {
	Context  string
	Failures []secretSyncFailure
	// Held names what was withheld, for the closing line.
	Held string
}

ExternalSecretSyncError is a deploy stopped at the ExternalSecret gate: at least one ExternalSecret did not sync, so what comes after it was not applied. It is a type so a caller (and a test) can tell "the gate held" from every other apply failure without parsing prose.

func (*ExternalSecretSyncError) Error added in v1.0.0

func (e *ExternalSecretSyncError) Error() string

func (*ExternalSecretSyncError) Unwrap added in v1.0.0

func (e *ExternalSecretSyncError) Unwrap() error

type GroupScope

type GroupScope struct {
	// Cluster is THIS group's cluster name (forge.K8sCluster.cluster). It
	// is the value a manifest's first-class `forge.dev/cluster` routing
	// label is matched against: a manifest carrying that label lands on
	// this group iff the label equals Cluster, and is dropped otherwise —
	// no app-label indirection. Empty disables the first-class match (the
	// stream is routed purely by OwnApps/OtherApps, the pre-existing
	// behaviour). See ClusterRoutingLabel and ScopeManifestsToGroup.
	Cluster string

	// OwnApps is the set of `app.kubernetes.io/name` values belonging to
	// THIS group's services — their workloads (Deployment / Service /
	// per-service RBAC / HPA) AND the raw manifests those services own (a
	// CRD, env-level infra pinned to this cluster via an image-less infra
	// service). All carry the service's app label. These land on this
	// group's cluster.
	OwnApps map[string]struct{}

	// OtherApps is the set of app-name labels owned by OTHER k8s groups —
	// services that target a DIFFERENT cluster. These are dropped from this
	// group so a manifest never lands on a cluster its owner doesn't declare.
	OtherApps map[string]struct{}
}

GroupScope describes how to filter the env's rendered manifest stream down to ONE deploy group's cluster. It is the input to ScopeManifestsToGroup, applied by Apply before the kubectl apply when ApplyOpts.ClusterScope is set.

Routing is DECLARED-CLUSTER-ONLY: there is no "primary cluster". A manifest lands on the cluster of its OWNING service, identified by the service's `app.kubernetes.io/name` label, which forge stamps on every workload AND on every per-service owned manifest (forge.Service.manifests). See ScopeManifestsToGroup for the precise per-document keep/drop rule.

type HelmChartSpec

type HelmChartSpec struct {
	// Name is the platform dep's NAME — the `--target` selector and the
	// `app.kubernetes.io/name` stamped on every rendered manifest.
	Name string
	// Chart is the chart name for a repo chart (e.g. "cert-manager").
	// Empty for an OCI chart (OCI carries the full ref).
	Chart string
	// Repo is the chart-repo URL (e.g. "https://charts.jetstack.io").
	// Mutually exclusive with OCI.
	Repo string
	// OCI is the OCI chart ref (e.g.
	// "oci://docker.io/envoyproxy/gateway-helm"). Mutually exclusive with Repo.
	OCI string
	// Version is the pinned chart version (e.g. "v1.20.1").
	Version string
	// Namespace is the namespace to render the chart into
	// (`helm template -n <namespace>`).
	Namespace string
	// Values is the helm values overlay (the `--values` file content),
	// passed through verbatim from the KCL `values` dict.
	Values map[string]any
	// CRDs is the forge-supplied CRD manifest YAML applied FIRST (before
	// the chart's controllers) and waited until Established. The caller
	// fetches it (pinned standard Gateway API CRDs / cert-manager CRDs at
	// the chart version). Empty when the chart needs no forge CRDs.
	CRDs string
	// CRDBundle NAMES the forge-supplied bundle CRDs holds the content of
	// ("gateway-api", "cert-manager", or empty). CRDs alone cannot answer
	// "which bundle is this?" — it is megabytes of YAML — and the render
	// has to know, because a chart whose Gateway API CRDs forge supplies
	// must also be told not to render the resources that ship ALONGSIDE
	// those CRDs upstream. See gatewayAPIChartValues.
	CRDBundle string
	// Manifests is the consumer-declared raw manifest YAML (a `---`-joined
	// stream) that rides this chart's `--target`: the cluster-scoped
	// instances a chart's controller reconciles but the chart doesn't ship
	// (the `eg` GatewayClass, cert-manager ClusterIssuers). Applied AFTER
	// the chart's controllers (so the controller is up before its
	// instances), stamped with the chart's app-label like the chart's own
	// output, so they ride the chart's GROUP under the exclusive `--target`
	// filter (selected iff no targets, or the chart's Name ∈ targets — the
	// same rule as the chart itself). Empty when the chart carries none.
	Manifests string
	// Cluster, when non-empty, is the kubectl CONTEXT this chart installs
	// into, overriding the env's primary cluster for this chart alone. It
	// is the denormalized `.context` of the `forge.Cluster` the chart's KCL
	// `cluster` field references (the CLI resolves the reference and
	// validates it against the env's declared clusters before building this
	// spec — see cli.helmChartSpecsFromEntities).
	//
	// THE GAP THIS CLOSES. An operator must be installed where the custom
	// resources it reconciles land, and in a multi-cluster env that is not
	// the primary cluster. control-plane's dev installs CNPG while its
	// managed-database tier renders `postgresql.cnpg.io/v1 Cluster` objects
	// into the cp-daemon cluster; with the operator on the control-plane
	// cluster the CR apply fails `no matches for kind "Cluster" in version
	// "postgresql.cnpg.io/v1"`.
	//
	// Empty => the env's primary cluster, so every pre-existing chart
	// declaration applies through the identical argv it did before.
	Cluster string

	// NamespaceLabels and NamespaceAnnotations are written onto the Namespace
	// forge creates for the chart (helm template never emits it). The
	// Namespace rides the chart's server-side apply, so a re-apply sets
	// exactly the declared keys, drops a key that is no longer declared (the
	// same field manager owned it), and leaves every key another manager set.
	//
	// PRECEDENCE: the labels forge stamps on that Namespace are forge's, and
	// a declaration may not set them (forgeOwnedNamespaceLabel). That is
	// enforced by REFUSING the spec in RenderHelmChart, as the KCL schema
	// refuses the declaration, rather than by picking a winner. Either
	// silent outcome is wrong: if forge's value won, the declared one would
	// vanish, and if the declared one won, forge's routing and ownership
	// stamps would change without anyone deciding to change them.
	NamespaceLabels      map[string]string
	NamespaceAnnotations map[string]string
	// Owner is the (project, env) that applies this chart — ChartOwner's
	// value. Every object the chart renders is stamped with it
	// (ChartOwnerLabel), and the chart's inventory is recorded per owner. The
	// prune after a successful apply deletes only objects that an earlier
	// apply recorded in that inventory and that still carry the stamp
	// (chart_prune.go). Empty disables the stamp, the inventory and the prune:
	// an owner that cannot be named cannot be proven.
	Owner string
	// Prune enables that prune for this chart. The zero value is OFF so a
	// spec built by hand never deletes anything by accident; the KCL
	// declaration defaults it to on.
	Prune bool
	// Patches are merge patches applied to the objects `helm template`
	// rendered, keyed `Kind/name` (the Bundle.overrides grammar, minus the
	// cluster qualifier). They reach what a chart's values cannot — a field
	// the chart does not template, such as a pod's runtimeClassName. Every
	// key must match exactly one rendered object.
	Patches map[string]any
}

HelmChartSpec is one declared platform dependency, resolved from the KCL `output.helm_charts` projection plus the caller-fetched CRD bundle. The CLI (internal/cli) builds these from KCLEntities and fetches CRDs via the existing pinned-CRD machinery (internal/cli/dev_cluster_ingress.go), keeping this package free of the templates/cache dependency.

type ImageArchChecker

type ImageArchChecker interface {
	ImageArchitectures(ctx context.Context, ref string) (archs []string, err error)
}

ImageArchChecker reports the architecture(s) an image ref advertises — the `architecture` field of a single-platform manifest, or every platform's architecture in a multi-arch manifest index. A deploy GATE compares this to the TARGET cluster's declared node arch and BLOCKS on mismatch, turning the runtime `exec format error` (an amd64 node trying to run an arm64 binary — the 2026-06-24 cross-env incident) into a pre-apply failure.

Contract mirrors ImageChecker's "don't block on a blind spot, don't pass on a confirmed problem" discipline:

  • (archs, nil) — the image's architectures, resolved. Compare + gate.
  • (nil, err) where errors.Is(err, ErrImageCheckInconclusive) — the arch could not be read (transport failure, image absent — already reported by the existence check, daemon down). The arch is UNKNOWN, so the gate WARNS and proceeds rather than false-failing. Mismatch can only be asserted on a KNOWN arch.
  • (nil, err) for anything else — a genuine checker failure surfaced by the caller.

type ImageChecker

type ImageChecker interface {
	ImageExists(ctx context.Context, ref string) (exists bool, err error)
}

ImageChecker reports whether an image ref is resolvable in its registry.

The outcomes are deliberately distinct, because a deploy GATE must not block on its own blind spots — but it equally must not SILENTLY PASS an image it could not confirm is present:

  • (true, nil) — the image is present. Proceed.
  • (false, err) where errors.Is(err, ErrImageNotFound) — the image is CONFIRMED absent (the registry answered 404 / MANIFEST_UNKNOWN). err carries the registry's answer, which the report prints: a block that names only the ref leaves nobody able to tell what the registry said. (false, nil) means the same thing from a checker with no answer to carry. BLOCK the deploy — after one recheck (see checkOneImage).
  • (false, err) where errors.Is(err, ErrImageCheckAuthDenied) — the registry refused the lookup with an auth-class denial (denied / unauthorized / 403 forbidden). The image's presence is UNKNOWN, and on a private prod registry an auth-denied manifest lookup is exactly what a genuinely-missing image looks like. BLOCK (cannot confirm), with an actionable message — but the operator can --skip-preflight after verifying. Failing open here is what produces ImagePullBackOff in prod.
  • (false, err) where errors.Is(err, ErrImageCheckInconclusive) — the check could not reach the registry AT ALL (DNS / connection refused / i/o timeout / docker daemon down). That is a transport problem, not a statement about the image, and the CLUSTER may still pull fine, so the preflight WARNS and PROCEEDS rather than false-failing a present image.
  • (_, err) for any other error — a genuine checker failure the caller surfaces (aborts the preflight).

type IncompleteApplyError added in v0.1.45

type IncompleteApplyError struct {
	// Rendered is how many identifiable objects the manifest stream held.
	Rendered int
	// Confirmed is how many of those the apply reported acting on.
	Confirmed int
	// Missing names the objects with no line in the apply output, as
	// `kind/name`, in render order, capped at maxMissingObjectsReported.
	Missing []string
	// Truncated is how many missing objects were elided from Missing.
	Truncated int
}

IncompleteApplyError says the apply exited 0 while fewer objects reached the cluster than were rendered, and names the ones that did not.

It is a distinct type because the deploy pipeline treats it as the same CLASS of failure as a rollout that never became ready: fatal under the default RolloutWait policy, a warning under RolloutWarn. See RolloutPolicy.noteApplyFailure.

func (*IncompleteApplyError) Error added in v0.1.45

func (e *IncompleteApplyError) Error() string

type KCLRunner added in v1.0.0

type KCLRunner func(ctx context.Context, projectDir, source, env string, options []string) ([]byte, error)

KCLRunner is the environment evaluation boundary. The CLI supplies its isolated kcl eval transport; library callers retain the embedded renderer.

type KubectlConfigMapGetter

type KubectlConfigMapGetter struct{}

KubectlConfigMapGetter is the live ConfigMapGetter: it reads a ConfigMap's `.data` keys from the target cluster via `kubectl --context <ctx> get configmap <name> -n <ns> -o json`, mirroring KubectlSecretGetter (same declared-context discipline, same not-found → exists=false handling).

func (KubectlConfigMapGetter) GetConfigMapKeys

func (KubectlConfigMapGetter) GetConfigMapKeys(ctx context.Context, kctx, namespace, name string) (map[string]struct{}, bool, error)

GetConfigMapKeys returns the keys of the named ConfigMap's `.data`.

type KubectlPullCredsResolver

type KubectlPullCredsResolver struct{}

KubectlPullCredsResolver is the live PullCredsResolver: it reads the `.dockerconfigjson` of each named imagePullSecret from the TARGET cluster (via `kubectl --context <ctx> get secret <name> -n <ns> -o json`) and MERGES their `auths` maps into a single docker config.json document. That document is what the image-verification path materialises into a temp DOCKER_CONFIG so `docker manifest inspect` authenticates with the cluster's pull creds.

A secret that is absent, isn't a dockerconfigjson Secret, or has an unparseable blob is SKIPPED (best-effort) rather than failing the resolve — the goal is to recover creds when we can, never to turn a credential gap into a hard preflight error (that would regress envs that worked before). Only a genuine kubectl failure (misconfig / unreachable apiserver) surfaces as an error. Returns (nil, nil) when no secret yielded any auths.

func (KubectlPullCredsResolver) ResolveDockerConfig

func (KubectlPullCredsResolver) ResolveDockerConfig(ctx context.Context, kctx, namespace string, secretNames []string) ([]byte, error)

ResolveDockerConfig implements PullCredsResolver.

type KubectlRunningImages added in v0.1.45

type KubectlRunningImages struct{}

KubectlRunningImages is the live RunningImageLister: one `kubectl get pods --all-namespaces -o json` against the context.

func (KubectlRunningImages) RunningImages added in v0.1.45

func (KubectlRunningImages) RunningImages(ctx context.Context, kctx string) (map[string]string, error)

RunningImages lists the images running containers in kctx use.

type KubectlSecretGetter

type KubectlSecretGetter struct{}

KubectlSecretGetter is the live SecretGetter: it reads a Secret's `.data` KEY NAMES from the target cluster, threading the DECLARED context per command (the same per-command --context discipline the rest of the apply path uses, so the check never trusts the ambient context). A not-found Secret returns exists=false rather than an error so the preflight reports it cleanly.

KEYS ONLY, NEVER VALUES. The output is a go-template that prints each key and nothing else (secretKeysTemplate), so a value never reaches this process — not in a buffer, not in an error string, not in a log. `-o json` (and `-o jsonpath={.data}`) would hand forge every value just to read the map's keys; a presence check has no business holding them.

func (KubectlSecretGetter) GetSecretKeys

func (KubectlSecretGetter) GetSecretKeys(ctx context.Context, kctx, namespace, name string) (map[string]struct{}, bool, error)

GetSecretKeys returns the keys of the named Secret's `.data` in namespace.

type KubectlSecretValueGetter

type KubectlSecretValueGetter struct{}

KubectlSecretValueGetter is the live SecretValueGetter: it reads a Secret's `.data` and base64-decodes each value, for the cross-secret byte-match check. Same per-command --context discipline and not-found → exists=false handling as KubectlSecretGetter.

func (KubectlSecretValueGetter) GetSecretValues

func (KubectlSecretValueGetter) GetSecretValues(ctx context.Context, kctx, namespace, name string) (map[string][]byte, bool, error)

GetSecretValues returns the decoded `.data` values of the named Secret.

type KubectlServedKinds

type KubectlServedKinds struct{}

KubectlServedKinds is the live ServedKindChecker: it enumerates the resource types the target cluster's API server serves via `kubectl --context <ctx> api-resources --no-headers -o wide` and keys each by servedKindKey(group, kind). This is the cluster's discovery surface — a kind absent from it has no installed CRD (the GRPCRoute / Gateway API channel footgun), so the preflight can block before the apply that would otherwise fail `no matches for kind`.

The DECLARED context is threaded per command (the same --context discipline the rest of the apply path uses). A non-zero exit is a genuine discovery failure (kubectl not configured, RBAC denial, unreachable apiserver) returned as an error so the gate aborts rather than asserting a kind is missing against an unknown served set.

func (KubectlServedKinds) ServedKinds

func (KubectlServedKinds) ServedKinds(ctx context.Context, kctx string) (map[string]struct{}, error)

ServedKinds implements ServedKindChecker. It parses `kubectl api-resources` output, whose columns are NAME [SHORTNAMES] APIVERSION NAMESPACED KIND. The APIVERSION column is the group/version ("apps/v1", "gateway.networking.k8s.io/ v1") or a bare version ("v1") for core; the KIND column is the last field. We split the group off APIVERSION and key on (group, kind).

type KubectlStoreProbe added in v1.0.0

type KubectlStoreProbe struct {
	// PresenceFn answers Presence. Nil answers every name as present, so a probe
	// built without a store client checks nothing it cannot check.
	PresenceFn func(ctx context.Context, kctx string, read StoreRead) (map[string]bool, error)
}

KubectlStoreProbe reads the cluster with kubectl. Presence is not a cluster read, so it is injected: the CLI supplies the one that talks to the stores.

func (KubectlStoreProbe) CRD added in v1.0.0

func (KubectlStoreProbe) CRD(ctx context.Context, kctx, name string) (CRDState, error)

CRD implements StoreProbe.

func (KubectlStoreProbe) ConvergeSucceeded added in v1.0.0

func (KubectlStoreProbe) ConvergeSucceeded(ctx context.Context, kctx, namespace, name string) (bool, bool, error)

ConvergeSucceeded implements StoreProbe.

func (KubectlStoreProbe) ForeignStore added in v1.0.0

func (KubectlStoreProbe) ForeignStore(ctx context.Context, kctx, namespace, name string) (string, bool, error)

ForeignStore implements AdoptionProbe.

func (KubectlStoreProbe) Presence added in v1.0.0

func (p KubectlStoreProbe) Presence(ctx context.Context, kctx string, read StoreRead) (map[string]bool, error)

Presence implements StoreProbe.

type KubectlVolumeResize added in v1.0.0

type KubectlVolumeResize struct{}

KubectlVolumeResize is the VolumeResizeChecker over kubectl: it reads the live StatefulSet, its claims and the StorageClasses, and asks stsresize.Decide, the decision the resize Job's script and the direct-apply recovery make, so the three cannot disagree about what is refusable.

func (KubectlVolumeResize) Judge added in v1.0.0

func (KubectlVolumeResize) Judge(ctx context.Context, kctx string, desired *appsv1.StatefulSet) error

Judge implements VolumeResizeChecker.

type ManifestGVK

type ManifestGVK struct {
	APIVersion string
	Kind       string
	// Name is metadata.name of the document (best-effort, "" when absent) —
	// used only to make the missing-CRD report actionable.
	Name string
}

ManifestGVK is the GroupVersionKind of a rendered manifest document — the (apiVersion, kind) pair `kubectl apply` keys a resource on — paired with the document's name so the preflight report can point at WHICH manifest needs a missing CRD. APIVersion is the raw `apiVersion:` value ("apps/v1", "v1", "gateway.networking.k8s.io/v1"); Kind is the raw `kind:` value.

func CollectManifestGVKs

func CollectManifestGVKs(manifests string) []ManifestGVK

CollectManifestGVKs walks a `---`-separated multi-doc YAML manifest stream and returns the GroupVersionKind of every TOP-LEVEL document — the (apiVersion, kind, name) `kubectl apply` will create a resource for. Only the document's OWN apiVersion/kind is collected (not nested template kinds): the cluster must serve the resource type forge actually applies, and a pod template's `kind` is an embedded field, never an applied object. Documents missing apiVersion or kind (a List wrapper, a malformed doc, a YAML comment- only chunk) are skipped — there's nothing to gate. The result preserves document order and may contain duplicate GVKs (the served-kind check de-dupes).

type ManifestRefs

type ManifestRefs struct {
	// Secrets maps a Secret, located where the referencing pod resolves it,
	// to the set of keys referenced from it. A key of "" (present in the set)
	// means a whole-Secret reference (envFrom secretRef, a volume mount, an
	// imagePullSecret) — verify existence only.
	//
	// Only REQUIRED references are here: every consumer that reads Secrets
	// as demand (the live check, the render-time supply gate, the secrets
	// lint) reads this map. A reference marked `optional: true` is in
	// OptionalSecrets instead.
	Secrets map[ResourceRef]map[string]struct{}
	// OptionalSecrets is Secrets for references marked `optional: true`.
	// Such a reference has two modes. While the Secret is ABSENT it demands
	// nothing: kubelet starts the pod without it, so no gate may report it.
	// Once the Secret is PRESENT it is a real input of the pod: the pod
	// template is pinned to it like any other (secret_pins.go), and the live
	// check reads the Secret and warns about a referenced key it lacks.
	OptionalSecrets map[ResourceRef]map[string]struct{}
	// ConfigMaps maps a ConfigMap, located like Secrets, to the set of keys
	// referenced from it. A key of "" means a whole-ConfigMap reference
	// (envFrom configMapRef or a whole-ConfigMap volume mount) — verify
	// existence only. A missing ConfigMap key fails a pod identically to a
	// missing Secret key (CreateContainerConfigError). Required references
	// only, like Secrets.
	ConfigMaps map[ResourceRef]map[string]struct{}
	// OptionalConfigMaps is ConfigMaps for `optional: true` references, with
	// OptionalSecrets' two modes (a ConfigMap pins nothing).
	OptionalConfigMaps map[ResourceRef]map[string]struct{}
	// Images is the set of distinct container image refs in the bundle.
	Images map[string]struct{}
	// ImagePullSecrets is the set of distinct Secrets referenced via a pod
	// spec's imagePullSecrets[].name. These are the credentials the CLUSTER
	// uses to pull private images — the image-verification path resolves
	// their .dockerconfigjson from the target cluster and uses it to
	// authenticate an otherwise auth-denied registry lookup, so a present
	// private image isn't false-flagged just because the LOCAL docker daemon
	// lacks creds for that registry. A subset of Secrets (which records every
	// Secret reference shape); kept distinct here because only
	// imagePullSecrets carry registry credentials.
	ImagePullSecrets map[ResourceRef]struct{}
}

ManifestRefs is the set of external references a rendered manifest bundle depends on at schedule time: the Secret / ConfigMap (name, key) pairs its containers project into env or mount, and the distinct container images it runs.

func CollectManifestRefs

func CollectManifestRefs(manifests string, place RefPlacement) ManifestRefs

CollectManifestRefs walks a `---`-separated multi-doc YAML manifest stream and collects every Secret reference (secretKeyRef, envFrom secretRef, secret-backed volumes, projected secret sources, imagePullSecrets), every ConfigMap reference (configMapKeyRef, envFrom configMapRef, configMap-backed volumes, projected configMap sources), and every container image. It recurses the whole document tree rather than hard-coding pod-spec paths, so it picks references up uniformly across Deployments, StatefulSets, DaemonSets, Jobs, CronJobs, Pods, and any nested template — including init containers and CronJob/Job pod templates. Malformed documents are skipped (best-effort, mirroring the other manifest scanners in this package).

Each Secret / ConfigMap reference is recorded where it resolves: the referencing document's namespace (place.Namespace when it declares none), on every cluster place attributes the document to.

type ObjectRef added in v1.0.0

type ObjectRef struct{ Kind, Namespace, Name string }

ObjectRef names one rendered object. An empty Namespace on a namespaced kind matches that name in any namespace, the conservative reading for an object the render did not place.

func ObjectRefsOf added in v1.0.0

func ObjectRefsOf(stream string) []ObjectRef

ObjectRefsOf lists the objects of a rendered stream, for a caller that plans a prune without applying.

type OpenBaoExpectation added in v1.0.0

type OpenBaoExpectation struct {
	Name      string
	Namespace string
	// Installs is true when this deploy applies the component, so its converge
	// Job is about to be created and has not run yet.
	Installs bool
}

OpenBaoExpectation is the in-cluster store a Bundle runs.

type OwnedKeep added in v1.0.0

type OwnedKeep struct {
	Cluster, Kind, Namespace, Name, Why string
}

OwnedKeep is an owned object the prune found and will not delete, and why.

func PruneOwned added in v1.0.0

func PruneOwned(ctx context.Context, kctx, rendered, owner string) (pruned []string, kept []OwnedKeep, err error)

PruneOwned deletes the objects this (project, env) stamped that the stream no longer renders. It returns what it deleted and what it kept. A candidate set over the cap deletes nothing.

type OwnedPruneReport added in v1.0.0

type OwnedPruneReport struct {
	Delete []liveChartObject
	Kept   []OwnedKeep
}

OwnedPruneReport is what a prune deletes or would delete, and what it keeps.

func PlanOwnedPrune added in v1.0.0

func PlanOwnedPrune(ctx context.Context, kctx string, desired []ObjectRef, owner string) (OwnedPruneReport, error)

PlanOwnedPrune is PruneOwned's decision without its effect: what a deploy of desired to kctx would delete, and what it would keep. desired is the set of objects the deploy renders for that cluster.

func (OwnedPruneReport) OwnedPruneChanges added in v1.0.0

func (r OwnedPruneReport) OwnedPruneChanges(kctx string) []release.OwnedRemoval

OwnedPruneChanges is a report as the plan's removals.

type PDBFinding added in v0.1.45

type PDBFinding struct {
	Name      string
	Namespace string
	Reason    string
	Fix       string
}

PDBFinding is one PodDisruptionBudget that can never allow a voluntary disruption, so every node drain that touches its pods blocks forever.

func CheckPDBs added in v0.1.45

func CheckPDBs(manifests string) []PDBFinding

CheckPDBs reports every PodDisruptionBudget in a rendered manifest stream that can never permit a disruption: maxUnavailable 0, minAvailable 100%, or an integer minAvailable at or above the replica count of the Deployment / StatefulSet it selects. Replica-dependent checks need the selected workload in the same stream; a PDB selecting nothing we can see is judged only on the replica-independent rules.

func (PDBFinding) String added in v0.1.45

func (f PDBFinding) String() string

type PendingRollout added in v0.1.45

type PendingRollout struct {
	// contains filtered or unexported fields
}

PendingRollout is one apply's outstanding rollout: everything the wait needs to know about what that apply sent, and where. ApplyNoWait produces it and WaitRollouts consumes it; there is nothing to construct by hand.

func ApplyNoWait added in v0.1.45

func ApplyNoWait(ctx context.Context, opts ApplyOpts) (*PendingRollout, error)

ApplyNoWait is Apply without the rollout wait: it renders, selects, scopes and applies — the pre-rollout Job gate included — and returns what is left to await as a PendingRollout. Hand it, with every other cluster's, to ONE WaitRollouts call.

WHY THE WAIT IS SEPARABLE. One env may span several clusters, and a workload on one can be unable to become ready until a workload on another is running (control-plane's workspace-proxy-bridge cannot pass readiness until workspace-proxy, on the daemon cluster, answers it). Applying and awaiting each cluster in turn deadlocks that env on every fresh deploy: the first cluster's wait expires on a dependency the second cluster has not been sent yet, the deploy fails, and the second cluster is never applied at all. Kubernetes converges concurrently — RolloutPolicy.Order is documented as a wait ordering, not an apply ordering, for exactly this reason — so the deploy applies every cluster first and then waits on all of them together.

The pre-rollout gate stays INSIDE the apply, per cluster: it holds back a cluster's own workloads until that cluster's migration Jobs complete, which is an ordering within one apply, not a wait on convergence.

A nil PendingRollout with a nil error means there is nothing to wait for (a dry run, a platform-only apply); WaitRollouts accepts it.

func (*PendingRollout) WithLabel added in v0.1.45

func (p *PendingRollout) WithLabel(label string) *PendingRollout

WithLabel names the pending rollout in WaitRollouts' error, e.g. `k8s-cluster deploy (ns=…, cluster=…)`, so a verdict over several clusters says which cluster each failure belongs to. Nil-safe: ApplyNoWait returns nil when there is nothing to wait for.

type PreRolloutJobError added in v0.1.45

type PreRolloutJobError struct {
	// Context is the kubectl context the Jobs ran in, threaded into the
	// commands the message prints so they can be pasted as-is.
	Context  string
	Failures []PreRolloutJobFailure
	// Kind names the gate in the message: "pre-rollout" when empty, "secret
	// store" for the store component's own Jobs.
	Kind string
}

PreRolloutJobError is a deploy stopped at the pre-rollout gate: at least one pre-rollout Job did not complete, so no workload in the group was applied.

It is a type, not a string, so a caller (and a test) can tell "the gate held" apart from every other apply failure without parsing prose.

func (*PreRolloutJobError) Error added in v0.1.45

func (e *PreRolloutJobError) Error() string

Error names every Job, says what did NOT happen, and gives the exact commands to see why and to retry. A migration that failed at 3am is diagnosed from this message, so it carries the commands rather than a hint to go and find them.

type PreRolloutJobFailure added in v0.1.45

type PreRolloutJobFailure struct {
	Job       string
	Namespace string
	// State distinguishes a Job that positively failed from one whose
	// budget expired while it was still running.
	State   RolloutState
	Timeout time.Duration
	Err     error
}

PreRolloutJobFailure is one Job that did not complete.

type PreflightOpts

type PreflightOpts struct {
	// Manifests is the rendered `---`-separated YAML stream about to be
	// applied. Refs are collected from it.
	Manifests string

	// Context is the DECLARED kubectl context (forge.K8sCluster.cluster)
	// the Secret checks run against — the SAME context the apply targets,
	// never the ambient one. Empty disables the Secret check (host-only /
	// compose env with nothing to verify against a cluster).
	Context string

	// Namespace is the deploy's target namespace: where a rendered object
	// that declares no metadata.namespace is applied, and so where ITS
	// Secret / ConfigMap references resolve. An object that declares a
	// namespace resolves its references there instead.
	Namespace string

	// DocumentContexts attributes each rendered document to the kubectl
	// contexts it is applied to, for an env that spans several clusters, so
	// every reference is checked on the cluster its pod actually runs on. It
	// must be the deploy's own router (ScopeManifestsToGroup), not a second
	// model of it. Nil — a single-cluster env — checks every reference on
	// Context. See RefPlacement.Contexts.
	DocumentContexts func(doc string) []string

	// Secrets resolves Secret keys against the target cluster.
	Secrets SecretGetter

	// ConfigMaps resolves ConfigMap keys against the target cluster. Like
	// Secrets, the check runs only when this and Context are both set.
	ConfigMaps ConfigMapGetter

	// ServedKinds resolves which resource types the target cluster serves, so
	// the CRD preflight can BLOCK a deploy that renders a kind (e.g. GRPCRoute)
	// whose CRD / Gateway API channel isn't installed — before the partial
	// apply that otherwise errors `no matches for kind` mid-rollout. The check
	// runs only when this and Context are both set, and only gates NON-CORE
	// kinds (core kinds like Deployment/Service are served by every cluster, so
	// gating them would false-positive on a discovery blind spot). Nil disables
	// the CRD gate (local dev / nothing to verify).
	ServedKinds ServedKindChecker

	// VolumeResize judges every StatefulSet the bundle renders against the
	// live one, so a change to its volumeClaimTemplates that no resize can
	// converge (a shrink, a changed storage class, a claim whose class cannot
	// expand) BLOCKS before anything is applied. Runs only with a Context.
	// Nil disables the check. See VolumeResizeChecker.
	VolumeResize VolumeResizeChecker

	// Images checks image existence against the registry.
	Images ImageChecker

	// ImageRecheckDelay is how long to wait before the one recheck a miss or
	// an inconclusive lookup gets (see checkOneImage). A registry having a bad
	// minute answers wrongly in bursts, so a little space between the looks
	// is what makes the second one independent. Zero rechecks immediately.
	ImageRecheckDelay time.Duration

	// RunningImages lists the images the target cluster is RUNNING right
	// now. An image a live container is running cannot be "missing": the
	// registry's not-found (or refusal) for it is downgraded to a warning
	// that names both facts, instead of blocking a deploy whose image is
	// demonstrably pullable. Consulted only when an image would otherwise
	// block, and only with a Context. Nil disables the downgrade.
	RunningImages RunningImageLister

	// ImageArch reports an image's advertised architecture(s) for the arch
	// gate. Nil disables the gate entirely (the existence check still runs).
	ImageArch ImageArchChecker

	// PullCreds resolves the CLUSTER's registry pull credentials (the
	// .dockerconfigjson of the bundle's imagePullSecrets) from the target
	// cluster. When set AND the image checker implements
	// CredentialedImageChecker AND the bundle declares imagePullSecrets, an
	// AUTH-DENIED registry lookup from the LOCAL docker daemon is RETRIED with
	// those creds, so a present private image the cluster can pull yields a TRUE
	// verdict instead of a false BLOCK. Nil (or no imagePullSecrets, or creds
	// not resolvable) leaves the existing local-daemon behaviour unchanged — the
	// auth-denied lookup still BLOCKS (fail-safe, no regression). Each pull
	// Secret is resolved where the pod that names it reads it — its
	// namespace, on its cluster — and the results are merged.
	PullCreds PullCredsResolver

	// TargetArch is the DECLARED node architecture of the target cluster
	// (GOARCH form: "amd64" / "arm64"), resolved from the env's KCL
	// `deploy.Cluster.platform`. When set AND ImageArch is configured, each
	// checked image's architectures are compared to it and a mismatch BLOCKS
	// the deploy (the exec-format-error gate). EMPTY means the env hasn't
	// declared a platform yet — the gate is INERT (WARN-don't-block) so envs
	// that predate the platform field (incl. the local e2e path) are never
	// false-failed.
	TargetArch string

	// SkipImageRef, when non-nil, returns true for image refs that should
	// NOT be checked (e.g. local k3d / registry.localhost refs in a dev
	// loop). A nil func checks every image (both existence AND arch).
	SkipImageRef func(ref string) bool

	// RequiredSecrets are the env's DECLARED external Secret prerequisites
	// (forge.ExternalSecret) — out-of-band Secrets the deploy depends on but
	// forge does NOT create. UNLIKE the secretKeyRef check above (which is
	// driven by what the rendered manifests reference, each in its
	// referencing object's namespace), a declared prereq names no consumer
	// and carries its OWN namespace (cert-manager's `cloudflare-api-token`
	// lives in the `cert-manager` namespace, not the deploy namespace), so
	// each is checked in its declared namespace. A
	// declared-required-but-absent Secret/key BLOCKS — this is the whole
	// point: it converts "render green, then ACME hangs silently" into a
	// fail-fast pre-apply block. Verified only when opts.Secrets is configured
	// (a SecretGetter against the live target). Empty => no declared prereqs.
	RequiredSecrets []RequiredSecret

	// RequiredSecretContexts are the kubectl contexts in which the declared
	// prerequisites must ALL exist. An environment can deploy one render to
	// several clusters, and a workload scheduled onto the secondary needs its
	// Secret THERE — checking only the primary passes a deploy that then dies
	// at rollout with CreateContainerConfigError, which is the same class of
	// late, opaque failure this preflight exists to prevent.
	//
	// Unlike the manifest-driven secretKeyRef check, this one is NOT gated to
	// remote clusters. That gate exists because forge applies its own projected
	// Secrets moments after the preflight, so checking them locally would
	// false-fail the inner loop. A forge.ExternalSecret is BY DEFINITION one
	// forge does not create — the provisioning is out-of-band — so the
	// rationale does not apply, and skipping it locally removed the check
	// exactly where the local dev loop needed it.
	//
	// Empty falls back to Context, preserving single-cluster behaviour.
	RequiredSecretContexts []string

	// SecretValues, when set, resolves a Secret's full .data value bytes
	// (base64-decoded) for the cross-secret BYTE-MATCH check: ExternalSecrets
	// sharing a `value_group` must carry IDENTICAL bytes under their keys. A
	// drifted copy (same group, different bytes — a half-rotated credential)
	// is caught here before it ships. Nil => the byte-match compare is skipped
	// (the KCL schema still enforces that a group's members declare the same
	// KEY SET; only the live byte equality needs cluster reads).
	SecretValues SecretValueGetter

	// ProvisionedByDeploy are the Secrets THIS deploy creates AFTER the
	// preflight runs, so the live existence check must not demand them
	// up-front. Today that is exactly the forge.KubeconfigSecret declarations
	// forge MINTS on the deploy path (a declared service_account).
	//
	// Without this the gate blocks on its own output: forge refuses to deploy
	// because a Secret is absent, and the deploy it refuses is the one that
	// would have created it. There is no order of operations that fixes it —
	// minting first would grant a credential on a deploy the gate then
	// rejects, and under --dry-run the mint writes nothing at all, so the
	// Secret is absent by construction.
	//
	// This is the same reasoning that already skips the local-cluster Secret
	// check: a Secret forge itself applies moments later is not a missing
	// dependency, it is a not-yet-executed step of this deploy.
	//
	// The set must be DERIVED from the provisioning step rather than
	// re-described beside it, or the two drift and the gate silently stops
	// covering a Secret nobody creates. The caller builds it from the same
	// filter the mint phase iterates.
	//
	// Scope is deliberately narrow: a Secret in here is exempt from the live
	// existence check ONLY. It still counts as DEMAND for every other gate,
	// and a Secret forge does not provision — including a KubeconfigSecret
	// that COPIES a kubeconfig rather than minting one — is not in this set
	// and still BLOCKS when absent.
	ProvisionedByDeploy []ProvisionedSecret

	// SecretSupply is the env's bundle-internal Secret SUPPLY for the
	// RENDER-TIME back-propagation gate (CheckSecretSupply): the Secrets the
	// bundle PROVIDES via a forge.KubeconfigSecret mint, a forge.ExternalSecret
	// out-of-band promise, or any other generated/known Secret forge produces.
	// Rendered-stream Secrets (kind: Secret) are collected from Manifests
	// directly, so this need only carry the entity-derived supply. The gate is
	// pure (NO cluster) and ALWAYS runs — it converts a workload mounting a
	// Secret nothing declares (the silent FailedMount / 15-min ContainerCreating
	// rot) into a fail-fast render-time BLOCK. Empty => only rendered-stream
	// Secrets count as supply. See SecretSupply / CheckSecretSupply.
	SecretSupply []SecretSupply

	// StoreExpect and StoreProbe drive the store-source preflight (SF9): the
	// CRDs, the External Secrets Operator's owner, the store's readiness and
	// whether the store holds the names the store-backed ExternalSecrets read.
	// Each expectation names its OWN cluster, so the check runs whatever
	// opts.Context is. Both must be set for it to run. When it does, the
	// generic served-kinds gate leaves the external-secrets.io group to it,
	// because only the store check knows whether THIS deploy installs the
	// operator that serves those kinds. See CheckStore.
	StoreExpect []StoreExpectation
	StoreProbe  StoreProbe
}

PreflightOpts bundles everything Preflight needs: the rendered manifests to scan, the target cluster context + namespace the Secret checks run against, and the injected getter/checker.

type PreflightResult

type PreflightResult struct {
	// MissingSecretKeys maps "<namespace>/<secret>" to the sorted list of
	// referenced keys that are absent (or the whole Secret, rendered as a
	// single marker when the Secret doesn't exist). The namespace is the
	// referencing object's. When the run spans more than one cluster the
	// key is "<context>:<namespace>/<secret>".
	MissingSecretKeys map[string][]string
	// MissingConfigMapKeys maps "<namespace>/<configmap>" (cluster-qualified
	// like MissingSecretKeys) to the sorted list of referenced keys that are
	// absent (or the whole-ConfigMap marker when the ConfigMap doesn't
	// exist).
	MissingConfigMapKeys map[string][]string
	// MissingImages is the sorted list of image refs CONFIRMED absent.
	MissingImages []string
	// MissingImageEvidence maps each MissingImages ref to what the registry
	// answered, verbatim, when the checker carried it (ErrImageNotFound).
	MissingImageEvidence map[string]string
	// UnverifiableImages is the sorted list of "<ref>: <reason>" entries for
	// images whose registry lookup was AUTH-DENIED (denied / unauthorized /
	// 403). Their presence is unknown and a deploy gate must not silently
	// pass an image it cannot confirm — these BLOCK, with a message naming
	// both possible causes (image not pushed, OR the deploy host lacks pull
	// creds for the registry).
	UnverifiableImages []string
	// ImageWarnings is the sorted list of "could not verify" notes for
	// images whose existence check was inconclusive — a TRANSPORT failure
	// (DNS, connection refused, i/o timeout, docker daemon down) that says
	// nothing about the image. These do NOT block the deploy — they are
	// printed so the user knows the gate couldn't vouch for those images.
	ImageWarnings []string
	// ArchMismatchImages is the sorted list of "<ref>: image is <archs>,
	// cluster nodes are <target>" entries for images whose advertised
	// architecture does NOT include the target cluster's declared arch. These
	// BLOCK — running an arm64 image on amd64 nodes is the exec-format-error
	// crash, caught here before a single pod schedules. Only populated when a
	// target arch is DECLARED (an undeclared target arch can't assert a
	// mismatch).
	ArchMismatchImages []string
	// ArchWarnings is the sorted list of advisory notes for images whose arch
	// could not be read (transport failure / inconclusive). These do NOT
	// block — a mismatch can only be asserted on a known arch.
	ArchWarnings []string
	// MissingCRDs is the sorted list of "<Kind> (<apiVersion>) — required by
	// <manifest-name>" entries for NON-CORE kinds the bundle renders that the
	// target cluster's API server does NOT serve (no installed CRD / Gateway
	// API channel). These BLOCK — applying such a manifest fails `no matches
	// for kind` mid-rollout, AFTER other resources already applied. Only
	// populated when a ServedKindChecker is configured (an undeclared discovery
	// surface can't assert a kind is missing).
	MissingCRDs []string
	// VolumeResizeRefusals is the sorted list of "<namespace>/<name>: <reason>"
	// entries for StatefulSets whose declared volumeClaimTemplates differ from
	// the live ones in a way the pre-rollout resize Job (and the direct-apply
	// recovery) REFUSES: a shrink, a changed storage class or access mode, or a
	// claim that must grow whose StorageClass does not allow expansion. These
	// BLOCK: the apply would fail on the immutable field, and a Flux-applied
	// copy would wedge its whole tree.
	VolumeResizeRefusals []string
	// VolumeResizeWarnings are the StatefulSets whose live volumes could not
	// be read (a transport or permission failure). They do NOT block, and are
	// printed so a clean-looking run is not mistaken for a checked one.
	VolumeResizeWarnings []string
	// OptionalKeyWarnings are the keys an `optional: true` reference reads
	// from a Secret or ConfigMap that EXISTS on the live target but lacks
	// them, one "<Kind> <subject>: <key>" entry each, plus optional
	// resources that could not be read. They do NOT block: the reference is
	// optional down to the key (kubelet starts the pod with the variable
	// unset), and a key absent by design — an optional integration's
	// credential in a shared Secret — is exactly what `optional` is for. A
	// key the Secret must carry blocks through a declaration instead: a
	// required reference, or a forge.ExternalSecret's `keys`. An ABSENT
	// optional resource is not listed at all.
	OptionalKeyWarnings []string
	// MissingRequiredSecretKeys maps "<namespace>/<secret>" (as
	// "<context>:<namespace>/<secret>" when the run spans more than one
	// cluster) to the sorted list
	// of declared-required keys absent on the live target (or the whole-Secret
	// marker when the Secret itself is absent), for the DECLARED external
	// Secret prerequisites (forge.ExternalSecret). These BLOCK — the deploy
	// renders a consumer (e.g. cert-manager's DNS-01 ClusterIssuer) that reads
	// the Secret out-of-band, so its absence hangs ACME/DNS silently after a
	// green apply. Only populated when a SecretGetter is configured.
	MissingRequiredSecretKeys map[string][]string
	// ByteMatchMismatches is the sorted list of advisory notes for
	// cross-secret byte-match groups whose live Secret values are NOT
	// identical across the group (a half-rotated / drifted shared credential).
	// These BLOCK — a value_group declares that the same logical secret is
	// projected to N refs, so a divergence means one consumer has the stale
	// value. Only populated when a SecretValueGetter is configured AND every
	// member Secret exists (a missing member is reported by the existence
	// check, not here).
	ByteMatchMismatches []string
	// UndeclaredSecretMounts is the RENDER-TIME back-propagation result: every
	// Secret a workload mounts/references that NOTHING in the rendered bundle
	// provides (no rendered Secret, no KubeconfigSecret, no ExternalSecret, no
	// generated/known Secret). These BLOCK — the pod would stick on
	// MountVolume.SetUp failed / CreateContainerConfigError forever, with zero
	// error at deploy time. Unlike every other field here this is a PURE,
	// no-cluster check that ALWAYS runs (incl. dry-run / local clusters) — it's
	// the complement to the live Secret preflight. See CheckSecretSupply.
	UndeclaredSecretMounts []UndeclaredSecretMount
	// Store is the store-source preflight: gaps in how store-backed Secrets
	// would be delivered (CRDs, operator owner, store readiness, values the
	// store lacks). Its blocking findings BLOCK; Store.Warnings do not.
	Store StoreFindings
}

PreflightResult is the structured outcome of a preflight run — the grouped missing-by-secret / missing-by-configmap / missing-images sets, plus non-blocking image warnings. OK reports whether the deploy may proceed (warnings do NOT block).

func PreflightReport added in v0.1.45

func PreflightReport(ctx context.Context, opts PreflightOpts) (PreflightResult, error)

PreflightReport is Preflight that also returns the STRUCTURED result.

WHY BOTH EXIST. The findings are computed structurally and then, on a failure, formatted into one error string — so from outside this package the only machine-readable thing about a blocked deploy was "it blocked". A consumer that wants to render "these two Secret keys are missing" as a list would have to parse FormatPreflightReport's prose, and the moment that prose is reworded the consumer silently starts reporting nothing.

The error is byte-identical to Preflight's — it is the SAME value, from the same run — so a caller can report the structure and still fail exactly as before. Preflight now delegates here, which is what keeps the two from disagreeing: there is one implementation, and the error-only signature is a projection of it.

The result is populated whether or not the run blocks. A clean preflight returns a zero-finding result and a nil error, and that is deliberately distinguishable from a preflight that never ran (which produces no result at all) — "nothing wrong" and "nobody looked" must not present identically.

func (PreflightResult) OK

func (r PreflightResult) OK() bool

OK reports whether nothing was found missing. Inconclusive image warnings are advisory and do NOT make the result fail.

type ProvisionedSecret added in v0.1.45

type ProvisionedSecret struct {
	// Name is the k8s Secret name this deploy will create.
	Name string
	// Namespace is the declared namespace (empty means the deploy namespace).
	Namespace string
	// By names the provisioning step, for the report.
	By SecretSupplyKind
}

ProvisionedSecret is one Secret THIS deploy creates after the preflight, so its absence up-front is not a missing dependency. See PreflightOpts.ProvisionedByDeploy for why the gate needs to know.

The match is on namespace AND name, because a reference is: the Secret exempts exactly the references that resolve to it, from pods in the namespace it is created in. A same-named Secret provisioned elsewhere does nothing for a pod in another namespace, which would fail at rollout. The cluster is not matched — the provisioning step does not record one, and exempting on every cluster errs toward the deploy proceeding on a Secret forge does create, rather than blocking on its own output.

type PullCredsResolver

type PullCredsResolver interface {
	ResolveDockerConfig(ctx context.Context, kctx, namespace string, secretNames []string) (dockerConfigJSON []byte, err error)
}

PullCredsResolver fetches the CLUSTER's registry pull credentials — the `.dockerconfigjson` of a kubernetes.io/dockerconfigjson Secret — from the target cluster, so the image-verification path can authenticate a registry lookup the LOCAL docker daemon would be denied.

The deploy already collects the bundle's imagePullSecret names for the existence preflight (ManifestRefs.ImagePullSecrets); this resolves those names to the credential blob the cluster would itself use to pull. The returned bytes are a docker config.json `{"auths":{...}}` document (the decoded `.dockerconfigjson`). A resolver MAY merge several pull Secrets into one config (multiple registries). It returns (nil, nil) when no creds are available (no imagePullSecrets, or none readable) — the caller then keeps today's local-daemon behaviour rather than regressing. An error is a genuine lookup failure (kubectl misconfigured) the caller surfaces.

type RefPlacement added in v0.1.45

type RefPlacement struct {
	// Namespace is the namespace of a document that declares none — the
	// deploy namespace `kubectl apply -n` puts it in.
	Namespace string
	// Context is the cluster every document lands on when Contexts is nil.
	Context string
	// Contexts attributes one document to the kubectl contexts it is applied
	// to, for an env that spans several clusters. It must be the deploy's own
	// router (ScopeManifestsToGroup), never a second model of it. Nil means
	// every document lands on Context. A document it attributes to no cluster
	// is applied nowhere, so it demands nothing.
	Contexts func(doc string) []string
}

RefPlacement says where a rendered document's references must resolve.

type RegistryImageChecker added in v0.1.45

type RegistryImageChecker struct {
	// DockerConfigDir, when set, reads credentials from <dir>/config.json
	// instead of the ambient docker config.
	DockerConfigDir string
}

RegistryImageChecker is the live ImageChecker. It asks the image's registry directly — a HEAD on the manifest over the OCI distribution API — and takes its verdict from the HTTP status:

  • 200 → present.
  • 404 → CONFIRMED absent (ErrImageNotFound, carrying the registry's answer).
  • 401 / 403 → auth-denied (ErrImageCheckAuthDenied): the registry was reached and would not say. On a private registry a missing image looks exactly like this, so the preflight does not fail open on it.
  • 429, 5xx, a network failure → inconclusive (ErrImageCheckInconclusive): a statement about the registry, not the image.

WHY NOT `docker manifest inspect`. Its verdict had to be recovered from its prose by substring: any output containing "not found" was a confirmed miss. On 2026-10-07 Docker Hub was answering 500s, and a lookup whose text contained "not found" blocked a prod release for an image that existed and that prod was running. A status code cannot be misread that way, and the lookup needs no docker daemon.

Credentials come from the docker config (credential helpers included) — the ambient one, or DockerConfigDir's when the preflight retries with the CLUSTER's pull credentials.

func (RegistryImageChecker) ImageExists added in v0.1.45

func (c RegistryImageChecker) ImageExists(ctx context.Context, ref string) (bool, error)

ImageExists asks ref's registry whether its manifest exists.

func (RegistryImageChecker) WithDockerConfigDir added in v0.1.45

func (c RegistryImageChecker) WithDockerConfigDir(dir string) ImageChecker

WithDockerConfigDir returns a copy that authenticates with the credentials in dir/config.json — the cluster's pull creds. Implements CredentialedImageChecker.

type RenderedChartStream added in v0.1.45

type RenderedChartStream struct {
	Name    string
	Cluster string
	Stream  string
}

RenderedChartStream is one platform dep's contribution to a deploy's manifest stream: the chart's name, the kubectl context it installs into ("" = the env's primary), and its documents in apply order (CRDs, then the templated chart, then the riding manifests).

func RenderChartStreams added in v0.1.45

func RenderChartStreams(ctx context.Context, specs []HelmChartSpec) ([]RenderedChartStream, error)

RenderChartStreams renders each chart EXACTLY as a deploy does — the same renderSelectedCharts call, the same CRD bundle, the same --skip-crds template, post-hook drop, synthesized Namespace and app-label stamp — and returns each chart's stream in chartStreams order.

It exists so `forge env render` can show chart output without modelling it a second time. A preview that re-implemented the chart render would drift from the apply the first time either changed, and a preview that disagrees with the deploy is worse than none: it is what an audit trusts.

type RequiredSecret

type RequiredSecret struct {
	// Name / Namespace identify the out-of-band Secret. Namespace is the
	// declared namespace (often NOT the deploy namespace).
	Name      string
	Namespace string
	// Keys are the data keys the consumer reads; each must exist.
	Keys []string
	// ValueGroup ties this Secret to others that must carry identical bytes
	// (the cross-secret byte-match group). Empty => standalone.
	ValueGroup string
	// Contexts are the kubectl contexts THIS Secret must exist in: the
	// clusters that receive its consumer. A multi-cluster env does not need
	// every Secret everywhere — control-plane's prod daemon cluster receives
	// only PriorityClasses and a pre-pull DaemonSet, and requiring the app's
	// Secrets there blocked every prod deploy. Empty => every context the
	// prerequisites are checked in (RequiredSecretContexts, else Context), the
	// conservative choice for a Secret nothing attributes to a cluster.
	Contexts []string
}

RequiredSecret is one declared external Secret prerequisite the preflight verifies against the live target. It mirrors the cli ExternalSecretEntity but stays a plain cluster-package struct so this package never depends on the cli entity types.

type ResourceRef added in v0.1.45

type ResourceRef struct {
	// Context is the kubectl context of the cluster the referencing object is
	// applied to. Empty when the collection was not placed on a cluster.
	Context string
	// Namespace is the referencing object's metadata.namespace, or the deploy
	// namespace when it declares none.
	Namespace string
	// Name is the referenced Secret / ConfigMap name.
	Name string
}

ResourceRef locates one Secret or ConfigMap a rendered object depends on.

A pod resolves every Secret and ConfigMap it names in its OWN namespace, on the cluster it runs on, so a reference is only meaningful with both. Keying references by bare name, and checking them all in the deploy namespace on the primary cluster, refused control-plane's prod release for observability-data/obs-credentials and observability/otel-agent-token — Secrets that existed exactly where their workloads use them, reported as missing from control-plane-prod.

type RolloutFailedError added in v0.1.45

type RolloutFailedError struct {
	Failures []error
}

RolloutFailedError is WaitRollouts' verdict when more than one of the rollouts it awaited failed. Each entry is that rollout's own error, prefixed with its label, so the message names every cluster and every resource that did not become ready — not only the first one the wait reached.

func (*RolloutFailedError) Error added in v0.1.45

func (e *RolloutFailedError) Error() string

func (*RolloutFailedError) Unwrap added in v0.1.45

func (e *RolloutFailedError) Unwrap() []error

Unwrap exposes each rollout's error to errors.Is / errors.As.

type RolloutMode added in v0.1.45

type RolloutMode string

RolloutMode is what Apply does after the manifests land.

const (
	// RolloutWait waits for every managed Deployment (and one-shot Job) and
	// FAILS the deploy if any of them does not become ready. The default,
	// and the only correct behavior for a real environment: "kubectl apply
	// exited 0" says the API server accepted the objects, not that anything
	// is running.
	RolloutWait RolloutMode = "wait"

	// RolloutWarn waits and REPORTS failures without failing the deploy.
	// For the local inner loop (`forge cluster reload`), where a
	// half-rolled cluster is something you look at rather than a build
	// result — and where the previous Deployment is still serving anyway.
	// Never appropriate for CI or a cloud env.
	RolloutWarn RolloutMode = "warn"

	// RolloutSkip applies and returns immediately, waiting for nothing.
	// For "push the manifests and let the cluster converge" — a
	// fire-and-forget deploy, or a huge env where the caller does its own
	// verification afterwards.
	RolloutSkip RolloutMode = "skip"
)

type RolloutObservation added in v0.1.45

type RolloutObservation struct {
	// Kind is the workload kind that was awaited: "Deployment" or "Job".
	Kind string
	// Name is the resource's name.
	Name string
	// State is the three-way outcome.
	State RolloutState
	// Err is the underlying wait error for a failed or timed-out resource,
	// nil when ready. Carried so a caller can report the cause without
	// re-deriving it from the state.
	Err error
}

RolloutObservation is one resource's outcome, as handed to an OnRollout callback.

type RolloutPolicy added in v0.1.45

type RolloutPolicy struct {
	// Mode selects wait / warn / skip. Empty means RolloutWait.
	Mode RolloutMode

	// Timeout bounds EACH resource's wait (per Deployment, per Job), not
	// the whole set — a 12-service env is not 12x more likely to be broken
	// than a 1-service one, and a shared budget would make the last
	// service's verdict depend on how slow the first was.
	//
	// Zero means DefaultRolloutTimeout. The old hardcoded 60s was a
	// local-k3d value applied to cloud: a cold cloud rollout that pulls a
	// fresh image routinely exceeds a minute, so it produced spurious
	// failures on exactly the deploys that mattered most.
	Timeout time.Duration

	// FailFast stops at the FIRST resource that fails instead of waiting
	// for the rest.
	//
	// Default (false) is deliberate: when a deploy goes wrong you usually
	// want the whole picture — "these 3 of 12 never came up" — and the
	// remaining waits cost nothing on a healthy resource, because a ready
	// Deployment returns immediately. Turn it on when the failures are
	// expected to cascade and you would rather see the first one now than
	// wait out eleven timeouts.
	FailFast bool

	// Order, when non-empty, is a prefix ordering for the Deployment wait:
	// these names are awaited first, in this order, before everything else
	// (which follows in its natural order).
	//
	// This is a WAIT ordering, not an apply ordering — kubectl applies the
	// stream in one shot and Kubernetes converges concurrently, so forge
	// cannot serialize the rollouts themselves without lying about what it
	// controls. What it CAN do is decide what to report first, which is
	// what makes a phased deploy legible: put the database migration or the
	// API server first and its failure surfaces before a dozen dependents
	// time out waiting on the thing that was already broken.
	//
	// Names not present in the namespace are ignored, so an ordering can
	// name a resource a --target filter excluded without erroring.
	Order []string
}

RolloutPolicy governs the post-apply wait: whether to wait at all, whether a stuck rollout fails the deploy, how long to give it, and whether to stop at the first failure or report every one.

WHY THIS TYPE EXISTS ====================

The wait used to be unconditional and its result was DISCARDED — a failed rollout printed "Warning: rollout for x" and Apply returned nil, so `forge env deploy` exited 0 with nothing running. That is the worst available failure mode: a green deploy over a broken environment. It is not hypothetical — a control-plane prod deploy reported success while three Deployments sat on a stale image for hours, and the CI pipeline that noticed it had to re-implement the whole wait in shell, correctly, beside the forge call that was silently swallowing it.

So: waiting is a POLICY with a safe default, not a hardcoded gesture.

func (RolloutPolicy) Normalize added in v0.1.45

func (p RolloutPolicy) Normalize() RolloutPolicy

Normalize fills in the zero value's defaults: wait-and-fail, with DefaultRolloutTimeout. Keeping this in one place is what lets every caller leave the field unset and still get the safe behavior.

func (RolloutPolicy) Validate added in v0.1.45

func (p RolloutPolicy) Validate() error

Validate rejects an unrecognized mode.

A typo'd `--rollout=wan` must not silently fall through to some default: the whole failure class this policy exists to remove is "the wrong behavior was chosen quietly". A caller that asked for something forge does not understand gets an error naming the valid values, not a guess.

type RolloutState added in v0.1.45

type RolloutState int

RolloutState is one resource's readiness outcome.

Zero value is RolloutStateUnknown, deliberately: an unreported resource must never read as ready. See the CLI's deployJSONRolloutState, which mirrors these and whose decoder refuses to default for the same reason.

const (
	// RolloutStateUnknown means no outcome was observed.
	RolloutStateUnknown RolloutState = iota
	// RolloutStateReady means the Deployment reached its ready condition, or
	// the one-shot Job completed.
	RolloutStateReady
	// RolloutStateFailed is a genuine, positively-observed failure — a Job
	// that satisfied condition=failed, or a wait that errored for a reason
	// other than expiring its budget.
	RolloutStateFailed
	// RolloutStateTimedOut means the readiness budget expired with no verdict.
	// NOT a success and NOT a failure — the absence of an answer.
	RolloutStateTimedOut
	// RolloutStateNotWaited means forge never asked (rollout mode skip).
	RolloutStateNotWaited
)

func WaitJobCompleteObserved added in v0.1.45

func WaitJobCompleteObserved(ctx context.Context, kctx, name, namespace string, timeout time.Duration) (RolloutState, error)

WaitJobCompleteObserved is WaitJobCompleteTimeout with the outcome graded.

A Job that satisfied condition=failed is a positively-observed FAILURE — the migration ran and did not work — which is materially different from a Job whose budget expired while it was still running. WaitJobCompleteTimeout already distinguishes the two internally by racing both conditions; this surfaces that distinction instead of flattening it into one error.

func WaitRolloutObserved added in v0.1.45

func WaitRolloutObserved(ctx context.Context, kctx, name, namespace string, timeout time.Duration) (RolloutState, error)

WaitRolloutObserved is WaitRolloutTimeout that also reports WHICH of the three outcomes occurred.

It exists alongside WaitRolloutTimeout rather than replacing it because the error is what fails the deploy and must stay byte-identical; the state is additional information about that same error. Both are returned from one wait, so they cannot describe different attempts.

kubectl's stderr is TEE'd — forwarded to the real stderr exactly as before AND captured, so the classification reads kubectl's own words without changing anything the user sees.

func (RolloutState) String added in v0.1.45

func (s RolloutState) String() string

String renders the lowercase wire form.

type RunningImageLister added in v0.1.45

type RunningImageLister interface {
	RunningImages(ctx context.Context, kctx string) (map[string]string, error)
}

RunningImageLister reports the images a cluster's containers are RUNNING right now, keyed by normalizeImageRef, each mapped to one "namespace/pod" that runs it (for the message).

Only containers in the running state count. A pod stuck in ImagePullBackOff names the image in its spec too, and counting it would turn "the image is missing" into "the cluster is running it" — the exact lie this exists to avoid.

type SecretGetter

type SecretGetter interface {
	GetSecretKeys(ctx context.Context, kctx, namespace, name string) (keys map[string]struct{}, exists bool, err error)
}

SecretGetter resolves which keys exist on a Secret in a target cluster. GetSecretKeys returns the set of keys present in the named Secret (the keys of its `.data`). exists=false means the Secret itself is absent in the namespace — every referenced key is then reported missing. An error is a genuine lookup failure (kubectl not configured, RBAC denial) and aborts the preflight rather than being treated as "missing".

type SecretSupply

type SecretSupply struct {
	// Name is the k8s Secret name this source provides. The match key.
	Name string
	// Namespace is the source's declared namespace (may differ from the deploy
	// namespace; empty means "the deploy namespace"). Recorded for the report
	// only — the match is name-based to avoid false positives.
	Namespace string
	// Kind labels the source for the report.
	Kind SecretSupplyKind
}

SecretSupply is one Secret the bundle PROVIDES, used to satisfy a demand. The caller (cli layer) projects the env's KubeconfigSecrets / ExternalSecrets / generated Secrets onto this shape so the cluster package stays decoupled from the cli entity types — the same pattern RequiredSecret uses. Rendered-stream Secrets are collected by the gate itself (CollectRenderedSecretNames), so the caller need not enumerate those.

type SecretSupplyKind

type SecretSupplyKind string

SecretSupplyKind labels WHERE a supplied Secret comes from, so the gate can report what already provides a name (and so a future report can explain why a near-miss didn't satisfy a demand). Purely descriptive — the match itself is name-based.

const (
	// SupplyRenderedManifest — a `kind: Secret` document in the rendered stream.
	SupplyRenderedManifest SecretSupplyKind = "rendered Secret"
	// SupplyKubeconfigSecret — a forge.KubeconfigSecret mint.
	SupplyKubeconfigSecret SecretSupplyKind = "KubeconfigSecret"
	// SupplyExternalSecret — a forge.ExternalSecret out-of-band promise.
	SupplyExternalSecret SecretSupplyKind = "ExternalSecret"
	// SupplyGenerated — any other Secret forge is known to produce (operator-
	// provisioned, generated). The catch-all the caller uses for supply that
	// isn't one of the first-class kinds.
	SupplyGenerated SecretSupplyKind = "generated/known Secret"
	// SupplyControlPlaneSecret — a forge.ControlPlaneSecret: the env's control
	// plane writes and renews it AFTER the deploy, so it is promised, never
	// preconditioned.
	SupplyControlPlaneSecret SecretSupplyKind = "ControlPlaneSecret"
	// SupplyCertManagerCertificate — a cert-manager Certificate in the stream,
	// which materialises the Secret its spec.secretName names.
	SupplyCertManagerCertificate SecretSupplyKind = "cert-manager Certificate"
)

type SecretValueGetter

type SecretValueGetter interface {
	GetSecretValues(ctx context.Context, kctx, namespace, name string) (values map[string][]byte, exists bool, err error)
}

SecretValueGetter resolves a Secret's decoded .data values for the cross-secret byte-match check. exists=false means the Secret is absent (already reported by the existence check); an error is a genuine lookup failure that aborts the preflight.

type ServedKindChecker

type ServedKindChecker interface {
	// ServedKinds returns the set of resource types the cluster serves, keyed
	// by servedKindKey(group, kind).
	ServedKinds(ctx context.Context, kctx string) (served map[string]struct{}, err error)
}

ServedKindChecker reports which (group, kind) resource types a target cluster's API server actually SERVES — its discovery surface (`kubectl api-resources`). The CRD preflight uses it to verify, BEFORE a single manifest is applied, that every NON-CORE kind the bundle renders has a CRD installed on the cluster. The motivating footgun: a rendered GRPCRoute (apiVersion gateway.networking.k8s.io/v1) applied to a cluster that never installed the Gateway API channel fails mid-rollout with `no matches for kind "GRPCRoute"` — AFTER other resources already applied. The check turns that deploy-time partial failure into one fail-fast block.

The contract mirrors the image checker's "don't block on a blind spot, don't silently pass a confirmed problem" discipline:

  • (served, nil) — discovery succeeded; served is the set of (group/kind) the cluster serves. The gate compares the bundle's kinds against it.
  • (_, err) — discovery itself FAILED (kubectl not configured, RBAC denial, unreachable apiserver). The cluster's served set is UNKNOWN, so the gate cannot assert a kind is missing — it surfaces the error and aborts the preflight rather than blocking on (or silently passing) every kind.

Served keys are normalized to "<group>/<kind>" with a lowercased group and the kind verbatim (CRD kinds are case-sensitive); a core-group kind keys as "/<kind>". ServesKind does the comparison so callers never reimplement the key shape.

type StoreExpectation added in v1.0.0

type StoreExpectation struct {
	// Context is the kubectl context the objects land on.
	Context string
	// Owner is the component-owner stamp of this Bundle (CRDOwner(project, env)).
	Owner string
	// InstallsESO is true when this deploy installs the External Secrets
	// Operator on Context (a forge.ExternalSecretsOperator for the cluster that
	// the deploy's --target selects). The CRDs are then applied before anything
	// that uses them, so their absence is not a gap, and a different live owner
	// is a conflict.
	InstallsESO bool
	// OpenBao, when non-nil, is the forge.OpenBao this Bundle runs on Context
	// and that a store-backed Secret reads from.
	OpenBao *OpenBaoExpectation
	// Reads are the stores the Secrets on Context read from, by kind.
	Reads []StoreRead
}

StoreExpectation is what the Bundle declares about ONE cluster that holds store-backed Secrets, projected from the render by the CLI (this package never sees the cli entity types).

type StoreFindings added in v1.0.0

type StoreFindings struct {
	// MissingCRDs: kinds the cluster does not serve and nothing in this deploy
	// installs.
	MissingCRDs []string
	// IncompatibleCRDs: the operator is there but does not serve the version
	// the render emits.
	IncompatibleCRDs []string
	// OwnerConflicts: another env owns the cluster's External Secrets Operator.
	OwnerConflicts []string
	// StoreNotReady: the store this Bundle runs has not finished converging.
	StoreNotReady []string
	// MissingStoreNames: names the ExternalSecrets read that the store does not
	// hold.
	MissingStoreNames []string
	// Warnings are advisory: a store that could not be asked. They do not block.
	Warnings []string
	// Adoptions are advisory: this deploy replaces a live OpenBao that forge did
	// not install, and the clients of that store lose access until they log in
	// the way the component's access document allows. They do not block.
	Adoptions []string
}

StoreFindings are the gaps a store preflight found. Each entry is a full sentence naming what to do. Empty (see Empty) means nothing blocks.

func CheckStore added in v1.0.0

func CheckStore(ctx context.Context, probe StoreProbe, expect []StoreExpectation) (StoreFindings, error)

CheckStore runs the store preflight for every expectation, one goroutine per cluster. A read that FAILS (as opposed to finding a gap) is an error: the cluster's state is unknown, and a gate must not read "could not look" as "nothing wrong". A store that cannot be ASKED (credentials, an unreachable control plane) is different: that is a warning, the same discipline as an image check whose registry is down.

func (StoreFindings) Empty added in v1.0.0

func (f StoreFindings) Empty() bool

Empty reports whether nothing BLOCKS.

type StoreProbe added in v1.0.0

type StoreProbe interface {
	// CRD reads one CustomResourceDefinition on kctx.
	CRD(ctx context.Context, kctx, name string) (CRDState, error)
	// ConvergeSucceeded reports whether the NEWEST OpenBao converge Job of the
	// component has completed. found=false when none exists.
	ConvergeSucceeded(ctx context.Context, kctx, namespace, name string) (succeeded, found bool, err error)
	// Presence returns the names of read.Names the store holds a usable value
	// for. It never sees or returns a value.
	Presence(ctx context.Context, kctx string, read StoreRead) (present map[string]bool, err error)
}

StoreProbe is the reads a store preflight makes. A fake in tests; the live one is KubectlStoreProbe plus whatever answers Presence.

type StoreRead added in v1.0.0

type StoreRead struct {
	// Kind is "hosted", "openbao" or "gcpsm"; Project names the GSM project.
	Kind    string
	Project string
	// Names are the store names that must hold a value: every name the
	// ExternalSecrets read, less the ones the source generates itself.
	Names []string
	// Pending is true when the store is being installed by this deploy, so it
	// holds nothing yet and its names cannot be checked.
	Pending bool
}

StoreRead is the set of names the ExternalSecrets on a cluster read from one store.

func (StoreRead) Label added in v1.0.0

func (r StoreRead) Label() string

Label is the store as a human reads it.

type UndeclaredSecretMount

type UndeclaredSecretMount struct {
	// Secret is the demanded Secret name nothing supplies.
	Secret string
	// Workloads are the names of the manifests that mount/reference it,
	// deduped + sorted. Best-effort: a workload whose metadata.name couldn't be
	// read contributes nothing.
	Workloads []string
}

UndeclaredSecretMount is one demanded Secret that NO supply in the bundle provides — a back-propagated render-time failure. Workloads carries the names of the workload manifests that mount/reference it (so the error points at WHO will FailedMount), and Refs records the reference shapes (volume mount vs env secretKeyRef) for the message.

func CheckSecretSupply

func CheckSecretSupply(manifests string, supplied []SecretSupply) []UndeclaredSecretMount

CheckSecretSupply is the render-time back-propagation gate. It collects the DEMAND from the rendered manifests (every mounted/referenced Secret) and the SUPPLY (rendered Secrets + the caller-supplied KubeconfigSecret / ExternalSecret / generated Secrets) and returns one UndeclaredSecretMount per demanded Secret NAME that no supply provides — sorted by Secret name for a deterministic report. A nil/empty return means every mount/ref is satisfied.

No cluster is touched. The match is name-based (namespace-permissive) so a Secret promised by an ExternalSecret in another namespace, or rendered in-stream, or minted by a KubeconfigSecret, always PASSES — only a truly undeclared name fails.

type VolumeResizeChecker added in v1.0.0

type VolumeResizeChecker interface {
	Judge(ctx context.Context, kctx string, desired *appsv1.StatefulSet) error
}

VolumeResizeChecker judges one rendered StatefulSet against the live one on the cluster named by kctx. It returns nil when the live volumes can be converged to the declared ones (or are already), and a stsresize refusal (errors.Is ErrShrink, ErrNotExpandable, ErrTemplateChanged) when they cannot. Any other error means the live state could not be read.

type WorkloadImage added in v0.1.45

type WorkloadImage struct {
	// Kind is the Kubernetes kind, as the API server reports it
	// ("Deployment", "CronJob").
	Kind string
	// Name is metadata.name.
	Name string
	// Container is the container's name within the pod template. Init
	// containers are included and are not distinguished here — the image is
	// what matters, and forge's `job` expansion puts real application images
	// in init containers.
	Container string
	// Init distinguishes a named init slot from a main/sidecar slot.
	Init bool
	// Image is the reference exactly as declared, unparsed:
	// "ghcr.io/acme/api@sha256:..." or "ghcr.io/acme/api:v1.2.3".
	Image string
	// EnvVar names the environment variable this reference came from, empty
	// when the reference is the container's own `image` field.
	//
	// WHY A SECOND SOURCE EXISTS. Not every image a release ships runs as a
	// workload. An operator that launches pods on demand carries its pod
	// image as a config value — control-plane's workspace-controller pins
	// the workspace base image in DAEMON_IMAGE — so the image is deployed,
	// digest-pinned, and part of the release, while running nowhere at the
	// moment anyone looks. Reading only container images reports that as
	// MISSING, which is a false failure on a correct environment; ignoring
	// it entirely would leave a genuinely drifted operator image invisible.
	// Both are avoided by treating it as what it is: a declared reference
	// from a different field of the same pod spec.
	EnvVar string
}

WorkloadImage is one image reference declared by one workload.

Container is carried because a workload may run several (a sidecar, an init container), and a drift report that names only the workload leaves the reader to find which container moved.

func ListWorkloadImages added in v0.1.45

func ListWorkloadImages(ctx context.Context, kctx, namespace string) ([]WorkloadImage, error)

ListWorkloadImages returns every container image declared by every workload in a namespace, across all of workloadKinds.

An error means the read did not COMPLETE — kubectl missing, no such context, credentials refused, namespace absent. It never means "nothing is running": a reachable but empty namespace returns an empty slice and a nil error, and keeping those two outcomes distinct is what lets a caller avoid reporting a network failure as an environment defect.

kctx is required. An empty context would send the read to whatever context happens to be active, which for a command whose entire output is a claim about a specific environment would make that claim silently wrong.

Jump to

Keyboard shortcuts

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