Documentation
¶
Overview ¶
Package cluster owns the render-KCL → kubectl-apply → wait-rollouts pipeline that `forge deploy`, `forge cluster reload`, and the deploy phase of `forge 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/) — callers compute the per-call HostSkip / OneShotJobs slices from that and pass them in.
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.
forge:exclude-contract cluster is CLI-internal deploy-pipeline glue (render KCL → kubectl apply → wait rollouts), not a contract-shaped service the bootstrap wires. Its exported methods are the pipeline's own API, so opt out of the require-contract rule.
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 deploy <env> --target <name> # render+apply ONLY this group
forge 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).
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 ¶
- Variables
- func Apply(ctx context.Context, opts ApplyOpts) error
- func CollectRenderedSecretNames(manifests string) map[string]struct{}
- func EnsureNamespace(ctx context.Context, kctx, namespace string) error
- func FormatPreflightReport(r PreflightResult) string
- func FormatUndeclaredSecretMounts(misses []UndeclaredSecretMount) string
- func KubectlApply(ctx context.Context, kctx, manifests string) error
- func KubectlArgs(kctx string, args ...string) []string
- func ListManagedDeployments(ctx context.Context, kctx, namespace string) ([]string, error)
- func LocalImageRef(ref string) bool
- func PartitionConfigManifests(manifests string) (config, rest string)
- func Preflight(ctx context.Context, opts PreflightOpts) error
- func Prune(ctx context.Context, kctx, manifests, namespace string) error
- func RenderHelmChart(ctx context.Context, spec HelmChartSpec) (string, error)
- func RenderManifests(_ context.Context, mainK, imageTag, namespace, env string, ...) (string, error)
- func RenderedDeploymentNames(manifests string) []string
- func RenderedJobNames(manifests string) []string
- func ScopeManifestsToGroup(manifests string, scope GroupScope) string
- func SelectManifestsByGroup(manifests string, targets []string) string
- func ServesKind(served map[string]struct{}, group, kind string) bool
- func WaitJobComplete(ctx context.Context, kctx, name, namespace string) error
- func WaitRollout(ctx context.Context, kctx, name, namespace string) error
- type ApplyOpts
- type ConfigMapGetter
- type CredentialedImageArchChecker
- type CredentialedImageChecker
- type DockerImageArchChecker
- type DockerImageChecker
- type GroupScope
- type HelmChartSpec
- type ImageArchChecker
- type ImageChecker
- type KubectlConfigMapGetter
- type KubectlPullCredsResolver
- type KubectlSecretGetter
- type KubectlSecretValueGetter
- type KubectlServedKinds
- type ManifestGVK
- type ManifestRefs
- type PreflightOpts
- type PreflightResult
- type PullCredsResolver
- type RequiredSecret
- type SecretGetter
- type SecretSupply
- type SecretSupplyKind
- type SecretValueGetter
- type ServedKindChecker
- type UndeclaredSecretMount
Constants ¶
This section is empty.
Variables ¶
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.
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.
Functions ¶
func Apply ¶
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.
func CollectRenderedSecretNames ¶
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 EnsureNamespace ¶
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 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 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 KubectlApply ¶
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 KubectlArgs ¶
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 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 ¶
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 ¶
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 PartitionConfigManifests ¶
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 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 ¶
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 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(_ 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 ¶
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 ¶
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.
The entity-list derivation (oneShotJobNamesFromKCL, reading KCLEntities.CronJobs) is fragile — it only sees Jobs that round-trip through the typed `forge.CronJob` -> `output.cronjobs` contract, and misses a `schedule==""` Job that didn't surface in that list (the real-launch gap where OneShotJobs came back empty and forge rolled the workloads without blocking on the migrate Job) as well as any raw `kind: Job` added via `additional_manifests`. The rendered manifests are what kubectl actually applies, so deriving the wait set from them closes both holes. Apply unions these with any caller-supplied OneShotJobs and de-dupes.
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 ConfigMap left on the global bundle rather than an infra service) → 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 ¶
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 ¶
ServesKind reports whether served (from a ServedKindChecker) contains the (group, kind). Centralizes the key shape so call sites and the live checker agree.
func WaitJobComplete ¶
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 WaitRollout ¶
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.
Types ¶
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 (image NAME → "sha256:..."). When
// set, each rendered service's manifest image resolves to ITS image's
// digest (`<image>@sha256:...`), not the env-wide ImageTag — the
// structural fix for a multi-image env pinning every service to one
// digest. May be nil/empty (the local-registry / no-digest path), in
// which case every image stays on ImageTag, byte-identical to before.
ImageDigests map[string]string
// Namespace is the value bound to KCL's `namespace` -D variable and
// passed to every kubectl invocation. Required.
Namespace string
// 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 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
// HostSkip is the set of Deployment names to skip in the rollout
// wait — services declared `deploy: host` in KCL, which run as host
// processes and don't have a Deployment in the cluster. Empty
// disables the skip (every managed Deployment is awaited).
HostSkip map[string]struct{}
// OneShotJobs is an OPTIONAL caller-supplied list of Job names to
// wait on. Apply UNIONs it with every `kind: Job` it finds in the
// rendered manifest stream (see RenderedJobNames), so the wait set
// is authoritative-by-manifest and a caller no longer has to derive
// it correctly for the schedule=="" migrate-Job wait to fire — this
// field is now belt-and-suspenders for a Job not present in the
// stream. Each Job in the union is waited on with `kubectl wait
// --for=condition=complete` so the caller gets a definitive
// done/fail signal before Apply returns. Scheduled CronJobs render
// as `kind: CronJob` (not `kind: Job`) and are NOT waited on — they
// run on their own cadence and the deploy is done once applied.
OneShotJobs []string
// 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 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 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.
ClusterScope *GroupScope
}
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 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 DockerImageChecker: 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 DockerImageChecker ¶
type DockerImageChecker struct {
// DockerConfigDir overrides DOCKER_CONFIG for the docker invocation. Empty
// means inherit the process environment (ambient docker login).
DockerConfigDir string
}
DockerImageChecker is the live ImageChecker: it resolves an image ref via `docker manifest inspect <ref>` (a cheap registry HEAD, no pull) against the LOCAL docker daemon. Local / HTTP registries get --insecure.
As a deploy GATE it must distinguish two very different non-zero exits:
- A CONFIRMED miss — the registry answered "no such manifest" (MANIFEST_UNKNOWN / "manifest unknown" / "not found" / a 404). The image genuinely isn't there → (false, nil), BLOCK.
- An AUTH-DENIED lookup — the registry refused to answer ("denied", "unauthorized", "403 forbidden", "access to the resource is denied"). This DID reach the registry but left the image's presence UNKNOWN — and on a PRIVATE registry (ghcr.io private packages, GCP Artifact Registry) a genuinely-MISSING image returns exactly this denial. Passing it as inconclusive would fail OPEN and ImagePullBackOff in prod, so → (false, ErrImageCheckAuthDenied), BLOCK (cannot confirm) — overridable via --skip-preflight once the operator has verified the image.
- An INCONCLUSIVE failure — the LOCAL daemon couldn't reach the registry at all: DNS/TLS failure, connection refused, i/o timeout, docker not running. That is a transport problem, not a statement about the image; the CLUSTER may still pull it fine, so blocking here would false-fail a present image. → (false, ErrImageCheckInconclusive), WARN and PROCEED.
The distinction is made by scanning combined stdout+stderr: not-found markers first (most specific), then auth-denied markers; anything left is treated as inconclusive transport noise.
DockerConfigDir, when set, points `docker` at a DOCKER_CONFIG dir holding the CLUSTER's pull credentials (its imagePullSecrets' .dockerconfigjson), so a private-registry lookup the LOCAL daemon's creds would be denied succeeds from the cluster's perspective. The preflight builds a credentialed copy via WithDockerConfigDir to RETRY an auth-denied lookup, turning a false negative into a TRUE existence verdict. Empty = use the ambient docker config.
func (DockerImageChecker) ImageExists ¶
ImageExists reports whether `docker manifest inspect` resolves ref. See the type doc for the confirmed-miss vs inconclusive distinction.
func (DockerImageChecker) WithDockerConfigDir ¶
func (c DockerImageChecker) WithDockerConfigDir(dir string) ImageChecker
WithDockerConfigDir returns a copy of the checker that runs docker with DOCKER_CONFIG=dir — the cluster's pull creds. Implements CredentialedImageChecker so the preflight can retry an auth-denied lookup with the cluster's credentials.
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
// 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
}
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, nil) — the image is CONFIRMED absent (a real registry not-found / MANIFEST_UNKNOWN). BLOCK the deploy.
- (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 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 KubectlSecretGetter ¶
type KubectlSecretGetter struct{}
KubectlSecretGetter is the live SecretGetter: it reads a Secret's `.data` keys from the target cluster via `kubectl --context <ctx> get secret <name> -n <ns> -o json`, 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.
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 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 name 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.
Secrets map[string]map[string]struct{}
// ConfigMaps maps a ConfigMap name 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).
ConfigMaps map[string]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 Secret names 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 the names in Secrets (which
// records every Secret reference shape); kept distinct here because only
// imagePullSecrets carry registry credentials.
ImagePullSecrets map[string]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) 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).
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 namespace the referenced Secrets are expected to
// live in (the deploy's target namespace).
Namespace 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
// Images checks image existence against the registry.
Images ImageChecker
// 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). Resolution
// runs against opts.Context / opts.Namespace, the same target the apply
// uses.
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, all in opts.Namespace),
// a declared prereq 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
// 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
// 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
}
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).
MissingSecretKeys map[string][]string
// MissingConfigMapKeys maps "<namespace>/<configmap>" 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
// 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
// MissingRequiredSecretKeys maps "<namespace>/<secret>" 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
}
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 (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 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 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
}
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 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" )
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 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.