deploytarget

package
v1.0.1 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 deploytarget owns the deploy dispatch — the surface that maps a rendered workload's RUNTIME (and each frontend's and database's deploy target) to a concrete pipeline that ships it somewhere.

Placement is fully owned by KCL: every fw.Workload is bound to one runtime (forge.OnHost | OnCompose | OnCluster | OnHosted | BuildOnly), per workload, so one env can run some workloads on a cluster, some on the control plane and some as host processes. The CLI walks the rendered workloads, groups them by target (workloads that share a cluster/namespace or a compose file flow through one pipeline invocation), and dispatches to the right Provider.

Providers:

  • K8sClusterProvider — wraps internal/cluster.Apply (the render-KCL → expand Workload records through pkg/deploy.RenderWorkloads → kubectl-apply → wait-rollouts pipeline).
  • HostedProvider — publishes a Workload CR per hosted workload (plus StaticSite / ManagedDatabase) to the control plane.
  • ComposeProvider — docker compose pull/up -d.
  • HostInfraProvider — a third-party server (postgres) run as a HOST PROCESS, no container runtime. This is the DEFAULT shape for dev infrastructure; Compose is the opt-in for projects that want the container. See internal/hostinfra.
  • FirebaseProvider / StaticSiteProvider — frontends.

Host-bound and build-only workloads aren't providers — `forge env up` / `forge env up` own the host story, and BuildOnly is consumed by `forge build`. The dispatcher skips both rather than routing them through a Provider.

HostInfra IS a provider even though it also runs on the host, because it answers a different question: a host workload launches code this project BUILDS, HostInfra supervises a dependency forge FETCHES. They fail differently (a compile error vs. a missing binary vs. a held port), and a single "runs on the host" target would have to guess which it was looking at.

Index

Constants

View Source
const (
	CapacityCodeOK              = "DEPLOY_CAPACITY_CODE_OK"
	CapacityCodeNoComputePlan   = "DEPLOY_CAPACITY_CODE_NO_COMPUTE_PLAN"
	CapacityCodeExceedsCeiling  = "DEPLOY_CAPACITY_CODE_EXCEEDS_CEILING"
	CapacityCodeSpendCapReached = "DEPLOY_CAPACITY_CODE_SPEND_CAP_REACHED"
	CapacityCodePlanUnknown     = "DEPLOY_CAPACITY_CODE_PLAN_UNKNOWN"
)

The DeployCapacityCode wire names this client acts on.

View Source
const (
	// ServingPathEdge is the platform's CDN edge (hosted static sites).
	ServingPathEdge = "edge"
	// ServingPathIngress is the regional ingress: served, with no CDN.
	ServingPathIngress = "ingress"
	// ServingPathElsewhere is neither: the hostname does not point at the
	// platform, or is split across several destinations.
	ServingPathElsewhere = "elsewhere"
)

The serving-path vocabulary forge reports: which platform path a hostname's DNS reaches.

View Source
const (
	DomainStatePendingDNS = "pending_dns"
	DomainStateVerifying  = "verifying"
	DomainStateIssuing    = "issuing"
	DomainStateLive       = "live"
	DomainStateFailed     = "failed"
	DomainStateConflict   = "conflict"
	DomainStateUnknown    = "unknown"
)

The domain-state vocabulary forge reports.

View Source
const (
	// SourcemapsPublish extracts maps to the sidecar and serves none. Default.
	SourcemapsPublish = "publish"
	// SourcemapsPublic leaves maps in the served tree, for an open-source app
	// that wants them public. The explicit opt-out of the guard.
	SourcemapsPublic = "public"
	// SourcemapsOff drops maps: stripped from the tree, kept nowhere.
	SourcemapsOff = "off"
)

Sourcemap publishing modes (the frontend's `sourcemaps` setting).

View Source
const (
	// SourcemapManifestSchema versions manifest.json.
	SourcemapManifestSchema = "forge.dev/sourcemaps/v1"
	// SourcemapManifestName is the manifest's file name inside the sidecar.
	SourcemapManifestName = "manifest.json"
	// SourcemapsEnvVar is handed to the frontend build: "1" asks the bundler to
	// emit maps (the scaffolded vite/next configs read it), "0" says do not.
	// Gating on it keeps maps out of builds that do not go through this stage
	// — a Docker image of the same frontend would otherwise serve them.
	SourcemapsEnvVar = "FORGE_SOURCEMAPS"
)
View Source
const (
	// SourcemapsArtifactType is the OCI artifactType of a sourcemaps artifact.
	SourcemapsArtifactType = "application/vnd.forge.sourcemaps.v1"
	// SourcemapsLayerMediaType is its single layer: the sidecar directory.
	SourcemapsLayerMediaType = "application/vnd.forge.sourcemaps.v1.tar+gzip"

	// SourcemapsAnnotationBuildID is the build id the maps belong to.
	SourcemapsAnnotationBuildID = "dev.forge.sourcemaps.build_id"
	// SourcemapsAnnotationFrontend is the frontend the maps belong to.
	SourcemapsAnnotationFrontend = "dev.forge.sourcemaps.frontend"
	// SourcemapsAnnotationProject is the forge project the frontend belongs to.
	SourcemapsAnnotationProject = "dev.forge.sourcemaps.project"
)
View Source
const (
	InvalidateEntrypoints = "entrypoints"
	InvalidateNone        = "none"
	InvalidateAll         = "all"
)

Invalidation policy values. Mirrors the KCL enum, which is closed — a value outside this set is rejected at render, so the provider's switch has no silent default arm.

View Source
const (
	// StaticSiteArtifactType is the OCI artifactType of a site release.
	StaticSiteArtifactType = "application/vnd.forge.staticsite.v1"
	// StaticSiteLayerMediaType is its single layer: the assembled tree.
	StaticSiteLayerMediaType = "application/vnd.forge.staticsite.layer.v1.tar+gzip"
	// StaticSiteRepositorySegment is the path segment site releases live
	// under inside an org's registry subtree. THE DOT IS LOAD-BEARING: an
	// RFC-1123 build name cannot contain one, so no backend image an org
	// builds can collide with a site release (the control plane's
	// ociregistry.ConfigNamespace uses the same property).
	StaticSiteRepositorySegment = "static.v1"
)
View Source
const AssetManifestName = "forge-assets.json"

AssetManifestName is the file each Firebase deploy writes at its mount (`<base_path>/forge-assets.json`) recording the assets the site serves.

View Source
const FirebaseCLI = "firebase"

FirebaseCLI is the executable a Firebase Hosting deploy runs. Exported so a deploy's preflight can check it is installed before anything ships, rather than discovering its absence after the cluster half has already applied.

View Source
const FrontendConfigJSName = "config.js"

FrontendConfigJSName is the filename of the runtime config document inside a deployed site. It MUST agree with codegen.FrontendConfigJSFile — the generator emits the <script src> that loads it, and this package writes the file that src resolves to. It is duplicated rather than imported because this package deliberately has no dependency on the codegen layer; the agreement is pinned by a test rather than by the type system.

View Source
const HostedPreflightNamespace = "hosted-platform"

HostedPreflightNamespace stands in for the namespace the control plane allocates. It only has to be well-formed: what renders into it is read, never applied.

View Source
const HostedProviderID = "hosted"

HostedProviderID is the registry id of the hosted provider.

View Source
const K8sClusterProviderID = "k8s-cluster"

K8sClusterProviderID is the registry id of the cluster provider — the groups whose deploy is a `kubectl apply` against a cluster.

Named rather than spelled as a bare literal at each site, the way HostedProviderID already is. There is no behavioural weight to it: the dispatcher looks a provider up by this id, and the string is unchanged.

View Source
const ProcDeployCapabilities = "controlplane.v1.DeployService/GetCapabilities"

ProcDeployCapabilities is the RPC a control plane names what it decodes on.

View Source
const (

	// WireRolloutPhaseHeld: the promotion was ACCEPTED and RECORDED and is
	// queued on a human action (billing) before the control plane applies
	// it. Exported because the CLI's wait and status read the same value.
	WireRolloutPhaseHeld = "DEPLOY_ROLLOUT_PHASE_HELD"
)

The DeployRolloutPhase enum's value names, as protojson writes them.

Only the ones this file branches on are named. An unrecognised phase is deliberately NOT mapped to anything: it is treated as "not ready", which is the conservative reading of a value forge does not understand.

Variables

View Source
var ErrHostedEnvironmentNotFound = errors.New("the control plane has no environment")

ErrHostedEnvironmentNotFound is LookupHostedEnvironment's "no environment of that name" answer.

View Source
var ErrObservationUnsupported = errors.New("forge: deploy provider cannot observe this target")

ErrObservationUnsupported is the sentinel a provider returns when it cannot read its target back AT ALL. Match it with errors.Is.

This is NOT ErrProviderNotImplemented. That sentinel means "forge has not built this yet"; this one covers both that case AND the permanent, structural case — External's `sh -c` is opaque by design, so no future version of forge will be able to observe it either. The two are distinguished by ObservationUnsupportedError.Reason rather than by two sentinels, because every caller treats them identically (report unknown) and only a human reading the message cares which it is.

View Source
var ErrProviderNotImplemented = errors.New("forge: deploy provider not yet implemented in this release")

ErrProviderNotImplemented is the sentinel future providers (Lambda, EdgeWorker, etc.) wrap when their dispatch lands as a stub. Keep using errors.Is to distinguish "feature deferred" from "real failure" — the active K8sCluster / Compose / Hosted providers do NOT return this; they implement the full pipeline.

Functions

func ClearSourcemapsState added in v0.1.46

func ClearSourcemapsState(projectDir, env, frontend string) error

ClearSourcemapsState forgets the last record, so a record that exists after a build is that build's.

func ComposeProcessEnv added in v0.1.46

func ComposeProcessEnv(spec *ComposeSpec, secrets map[string]string) (map[string]string, error)

ComposeProcessEnv resolves the HOST-side Compose launcher overlay, not a container's environment: secrets < env_file < declared runtime env. Callers merge this over their inherited environment. No container DNS or port rewrite is possible for arbitrary application values; declare host-reachable values.

func DescribeServingPath added in v1.0.0

func DescribeServingPath(path string) string

DescribeServingPath is a serving path in words, for a status line: what an author needs to know is whether the CDN is in front of the hostname.

func DomainStateName added in v0.1.45

func DomainStateName(wire string) string

DomainStateName renders a DeployCustomDomainState value name in forge's lower-case vocabulary. UNSPECIFIED and anything forge does not recognise are "unknown" — a control plane newer than this forge reports a state this binary has never heard of, and guessing at it is how a not-yet-serving domain reads as serving.

Exported so `forge domain` decodes the state with THIS function rather than its own copy: the same enum reaches the CLI from two services (DomainService.Domain.state and DeployService's observed domains), and two decoders would eventually disagree about what "live" means.

func EnsureHostedEnvironment added in v0.1.45

func EnsureHostedEnvironment(ctx context.Context, c HostedCaller, ref HostedEnvRef) (string, bool, error)

EnsureHostedEnvironment makes the environment ref names exist in the caller's org and returns its id and whether this call created it. It is the one write every hosted path that needs an environment goes through, so a hosted env is DECLARED in KCL and never provisioned by a manual step — whichever of promote / deploy / secret set runs first creates it.

func FileStageSourcemaps added in v0.1.46

func FileStageSourcemaps(ctx context.Context, pub SourcemapPublisher, kind, project, frontend string, res StageResult) error

FileStageSourcemaps files the maps a stage run extracted. It is the one call every static path makes, after the stage and BEFORE anything goes live. A nil publisher, or a stage that extracted nothing, is a no-op.

func FormatCustomDomains added in v0.1.45

func FormatCustomDomains(indent string, domains []HostedCustomDomain) string

FormatCustomDomains renders the per-domain block that `forge env status` and the post-deploy summary print, one line per domain plus the DNS records still required, indented under indent. Empty for a workload with no custom domains, so a caller can print it unconditionally.

The DNS records are printed in the shape a registrar's form asks for — type, name, value — because the author's next action is to type them into that form, and a status line that says "pending DNS" without saying WHICH record is missing sends them to a doc instead.

func FormatGroupSummary

func FormatGroupSummary(g ServiceGroup) string

FormatGroupSummary returns a one-line description of a group for CLI output. Shape:

[<provider>] <target>: <svc-1>, <svc-2>, ...

func FormatHostedURLs added in v1.0.0

func FormatHostedURLs(indent string, workloads []HostedWorkloadStatus) string

FormatHostedURLs renders one "url <name>: <url>" line per workload that has a platform URL, in the order given; "" when none has one. It is the one spelling `forge env deploy` and `forge env status` share, so the line an author copies a URL from reads the same in both.

func HostedArtifactKey added in v0.1.45

func HostedArtifactKey(svc ResolvedService) string

HostedArtifactKey is the release artifact a hosted service's digest is bound under — the key the plan looks a pin up by. Exported so a caller that BINDS a release (or asserts on one) uses the plan's own rule, never a copy of it.

func HostedArtifactName added in v0.1.45

func HostedArtifactName(image string) string

HostedArtifactName is the release-ledger artifact name a hosted workload's image is recorded and bound under: the repository's LAST path segment ("ghcr.io/acme/api:v1" → "api"). That is the same key forge's own builds use (a registry-less image name, with the registry held separately as the artifact's URI), so a workload whose image forge builds and one whose image CI pushes resolve through one rule.

func HostedBundleReader added in v0.1.46

func HostedBundleReader(ctx context.Context, c HostedCaller) (*release.BundleReader, error)

HostedBundleReader is what a control plane's strict decoders accept: the optional document features its DeployService/GetCapabilities names.

A control plane that does not serve the RPC (it predates it, so it predates every feature) and one that serves it without the field both name NO feature, and get the document their decoder knew. Any other failure is an error rather than a guess: a server that answered nothing about what it decodes is not evidence that it decodes nothing, and the write that follows would meet the same failure.

func HostedImageDigest added in v0.1.45

func HostedImageDigest(image string) string

HostedImageDigest is the digest an image reference is pinned by, or "".

func HostedImageRepository added in v0.1.45

func HostedImageRepository(image string) string

HostedImageRepository is an image reference with its digest or tag removed.

func HostedStaticRepository added in v0.1.45

func HostedStaticRepository(image string) string

HostedStaticRepository is the repository a site's releases are pushed to: the frontend's OWN declared image reference plus the platform's layout segment (`ghcr.io/acme/web` → `ghcr.io/acme/web/static.v1`).

The segment is APPENDED rather than asked for. It exists so the control plane can tell a site release from a backend image, which is forge's business and not something an author should have to encode — so the reference they wrote stays theirs and static.v1 never appears in their KCL.

func LocalSourcemapsDir added in v0.1.46

func LocalSourcemapsDir(projectDir, env, frontend, buildID string) string

LocalSourcemapsDir is where a build's maps are kept: <project>/.forge/sourcemaps/<env>/<frontend>/<build id>.

func LookupHostedEnvironment added in v0.1.45

func LookupHostedEnvironment(ctx context.Context, c HostedCaller, project, envName string) (string, error)

LookupHostedEnvironment resolves an environment (project, NAME) to the control plane's id, by listing the caller's environments and matching the name EXACTLY. `search` only narrows server-side: it is a free-text match, and "prod" must not resolve to "prod-eu". Two exact matches is a contract violation and is refused rather than guessed at — a guess would write into the wrong env.

func PackSourcemaps added in v0.1.46

func PackSourcemaps(dir string) ([]byte, error)

PackSourcemaps packs a sidecar directory into the artifact's layer.

func PackStaticSiteTree added in v0.1.45

func PackStaticSiteTree(dir string) ([]byte, error)

PackStaticSiteTree packs an assembled tree into a DETERMINISTIC tar.gz: entries sorted, zero timestamps and owners, fixed modes, no gzip header time. Identical trees therefore produce identical bytes, so re-pushing unchanged content yields the same digest and an idempotent release cut.

Only regular files and directories are packed. A symlink is REFUSED rather than followed or preserved: followed, it would ship whatever it points at on the build machine; preserved, it would ask the platform to materialize a link it must never write into a shared bucket.

func ProvenanceWireFields added in v0.1.45

func ProvenanceWireFields(p release.Provenance) map[string]any

ProvenanceWireFields is controlplane.v1.DeploySourceProvenance: where a render or a release came from.

THE PATH NEVER LEAVES THE MACHINE. A worktree path names a person's home directory, and this record is read by everyone in the org, so the hosted copy is taken through release.Provenance.ForHosted rather than trusting every caller to strip it. The field does not exist in DeployWorktree at all, which is the structural half of the same rule.

Empty fields are omitted rather than sent as "": proto3 JSON treats an absent scalar and its zero value identically, so sending them would add bytes and say nothing — except for `dirty`, which is sent whenever it is true and omitted otherwise for the same reason.

func PushSourcemapsArtifact added in v0.1.46

func PushSourcemapsArtifact(ctx context.Context, repository, tag string, b SourcemapBuild, layer []byte) (string, error)

PushSourcemapsArtifact pushes a packed sidecar to repository under tag and returns the manifest digest. Credentials come from the ambient docker credential store, as for the static site.

func PushStaticSiteArtifact added in v0.1.45

func PushStaticSiteArtifact(ctx context.Context, repository string, layer []byte) (string, error)

PushStaticSiteArtifact pushes a packed site to repository and returns the manifest digest. Credentials come from the ambient docker credential store — the same one `docker push` of a backend image to the same push base uses.

func ReportSourcemapsRecord added in v0.1.46

func ReportSourcemapsRecord(kind, name string, rec SourcemapsRecord)

ReportSourcemapsRecord prints the one line that says where a build's maps went.

func ServingPathName added in v1.0.0

func ServingPathName(wire string) string

ServingPathName renders a DeployServingPath value name in forge's lower-case vocabulary, or "" for UNSPECIFIED, an absent field, and any value this forge does not know — a path forge cannot name is one it must not claim.

func SourcemapsDir added in v0.1.46

func SourcemapsDir(stagingDir string) string

SourcemapsDir is where a staging tree's extracted maps and manifest live: a sibling of the tree, so nothing that publishes the tree can reach it.

func VerdictName added in v0.1.45

func VerdictName(wire string) string

VerdictName renders a DeployVerdict value name in the lower-case vocabulary forge's deploystate uses. Unset and unrecognised values are "unknown" — never defaulted toward health.

func WriteDeployState

func WriteDeployState(projectDir, provider, env, service string, st DeployState) (string, error)

WriteDeployState persists a successful provider deploy. Returns the path written so callers can include it in log lines / errors. The state directory is created lazily so projects that never use non-cluster targets never grow the tree.

func WriteSourcemapsState added in v0.1.46

func WriteSourcemapsState(projectDir, env string, rec SourcemapsRecord) error

WriteSourcemapsState records what the last build of a frontend filed.

Types

type BuildOnlyFrontend

type BuildOnlyFrontend struct {
	// Name is the forge frontend name (logging).
	Name string

	// Path is the frontend source dir relative to the project root —
	// where install / `npm run build` run.
	Path string

	// DevRunner is "npm" (default) | "pnpm" | "yarn"; selects the
	// install command.
	DevRunner string

	// BuildEnv is the build-time env injected into the build process
	// (NEXT_PUBLIC_* / VITE_*). Layered over NODE_ENV=production.
	BuildEnv map[string]string

	// PublicDir is the build-output dir the build emits (relative to
	// Path), e.g. "out" for a Next.js static export. Used for dry-run
	// reporting of the emitted directory.
	PublicDir string
}

BuildOnlyFrontend is a frontend that forge must BUILD (env-injected) but NOT deploy — a `deploy = None` frontend. Its build output (e.g. a Next.js static export under PublicDir) becomes available on disk so a sibling FirebaseHosting / StaticSite frontend can assemble it into its bundle. Mirrors the build inputs of FirebaseFrontend minus any deploy spec.

type BundleDirSpec added in v0.1.45

type BundleDirSpec struct {
	Src  string
	Dest string
}

BundleDirSpec is one extra pre-built static directory assembled into a site alongside the frontend's own build output. Dest empty means the site root. Target-neutral: Firebase Hosting and a GCS static site assemble bundles identically.

type CacheRuleSpec added in v0.1.45

type CacheRuleSpec struct {
	Pattern      string
	CacheControl string
}

CacheRuleSpec is one Cache-Control header applied to the objects a glob matches. Order is significant — first match wins.

type CapacityDemand added in v0.1.45

type CapacityDemand struct {
	CPUMillicores int64
	MemoryBytes   int64
	StorageGiB    int64
	Workloads     int // services and jobs: anything that runs a pod
	Databases     int
	Builds        int
	StaticSites   int
}

CapacityDemand is what a hosted deploy would run, summed across its items. It mirrors controlplane.v1.DeployCapacityDemand; the server re-derives the demand from the recorded bundle when it enforces, so this is never trusted for admission.

func DemandOf added in v0.1.45

func DemandOf(items []HostedPreflightItem, builds int) CapacityDemand

DemandOf sums the admitted hosted items. REQUESTS, not limits, are what a workload reserves, and a workload with a volume runs exactly one replica. builds is how many images forge would build for this deploy (0 for a deploy of an already-cut release).

func (CapacityDemand) RunsCompute added in v0.1.45

func (d CapacityDemand) RunsCompute() bool

RunsCompute reports whether the demand needs a compute plan at all.

func (CapacityDemand) String added in v0.1.45

func (d CapacityDemand) String() string

String is the demand in one line, for the refusal and the plan output.

type CapacityRefusedError added in v0.1.45

type CapacityRefusedError struct {
	Env     string
	Verdict CapacityVerdict
	Demand  CapacityDemand
}

CapacityRefusedError is a refused pre-flight. It renders as a runbook: what was asked for, what the plan allows, and the literal next step.

func (*CapacityRefusedError) Error added in v0.1.45

func (e *CapacityRefusedError) Error() string

type CapacityVerdict added in v0.1.45

type CapacityVerdict struct {
	Allowed        bool
	Code           string
	Reason         string
	Fix            string
	HasComputePlan bool
	CeilingCPU     int64
	CeilingMemory  int64
	CeilingStorage int64
	// Unchecked is true when the control plane does not serve the procedure
	// (an older server): nothing was decided, and the server still enforces at
	// RecordBundle / Promote.
	Unchecked bool
	// Holds is set when Allowed is false but the control plane would ACCEPT
	// the deploy and QUEUE it on a human action (billing) rather than refuse
	// it. A deploy then proceeds — build, record, promote — and is reported
	// queued. An older control plane never sends it, so it keeps refusing.
	Holds []HostedHold
}

CapacityVerdict is the control plane's answer.

func CheckHostedCapacity added in v0.1.45

func CheckHostedCapacity(ctx context.Context, c HostedCaller, envID string, demand CapacityDemand) (CapacityVerdict, error)

CheckHostedCapacity asks the control plane whether demand fits, for the environment envID ("" when the environment does not exist yet). A server that does not serve the procedure yields Unchecked=true with no error; every other failure is returned.

func (CapacityVerdict) Queued added in v0.1.45

func (v CapacityVerdict) Queued() bool

Queued reports a verdict that refuses to RUN the deploy now but would accept and queue it: proceed, and report the queue.

func (CapacityVerdict) Summary added in v0.1.45

func (v CapacityVerdict) Summary() string

Summary is the one-line form for plan and deploy output.

type ClusterBinding added in v0.1.45

type ClusterBinding struct {
	// Cluster is the rendered cluster / kube-context name, as it appears in
	// the bundle's ClusterPaths. forge's key.
	Cluster string
	// ClusterID is the connected cluster's id, from ConnectCluster.
	ClusterID string
}

ClusterBinding is one rendered cluster and the connected cluster it maps to. Mirrors controlplane.v1.ClusterBinding.

type ComposeProvider

type ComposeProvider struct {
	// ProjectDir is the project root used for state-file paths. Empty
	// means "current working directory".
	ProjectDir string

	// Runner is the os/exec indirection used to invoke docker compose.
	// Nil falls back to the package default.
	Runner commandRunner
}

ComposeProvider deploys each service in a group via docker-compose on the local host (or a remote docker context — that's a CLI-side concern, not the provider's). Unlike ExternalProvider there's no shell-override option: the docker-compose CLI is the contract, and users who want to escape it should reach for External.

Deploy is a vanilla `docker compose pull` + `docker compose up -d` against the compose file declared in KCL. Compose handles the container swap itself.

There is no rollback: recovery is roll forward — deploy a newer tag.

func (ComposeProvider) Deploy

func (p ComposeProvider) Deploy(ctx context.Context, group ServiceGroup) error

Deploy ships every service in the group via docker compose.

Services deploy in order and each readiness-gated one BLOCKS until it is healthy, so a group can express "postgres, then the thing that dials postgres" as an ordered list of ordinary services.

func (ComposeProvider) Name

func (ComposeProvider) Name() string

Name returns the provider identifier.

func (ComposeProvider) Observe added in v0.1.45

func (p ComposeProvider) Observe(ctx context.Context, group ServiceGroup) (Observed, error)

Observe reads each compose service's container state back out of the local docker daemon.

Why `compose ps --format json` and not the state file

The deploy path writes a DeployState after a successful `up`, and reading it would be cheap, offline and wrong: it records what forge last DID, which stops being true the moment a container exits, is stopped by hand, or is recreated by another tool. That is precisely the gap this verb exists to close, so the observation asks the daemon.

`--format json` rather than the table `compose ps` the deploy-time health check parses. The table's columns shift between compose versions and `composeHasRunningLine` only needs a yes/no, but an observation needs the running image, the state and the health separately — and recovering three fields by splitting a table whose column order is not a contract is how a parser silently starts reporting the wrong column. The JSON keys are stable.

Why the digest is usually empty, and why that is correct

Compose reports the image REFERENCE the container was created from, which for the overwhelmingly common dev case is a mutable tag. A tag cannot answer "which bytes are running", so Digest stays empty unless the reference is digest-pinned — the same rule, and the same reasoning, as the cluster tier's digestFromImageRef.

Why no replica counts

A compose service CAN be scaled, and compose reports one row per container, so the count is readable. It is deliberately not reported as ReplicaCounts: that struct's four fields are a rollout's vocabulary (updated-vs-ready is what distinguishes "rolled out" from "rolling out"), and compose has no rollout — there is no pod template generation to be current or stale with respect to. Filling Desired and Ready while leaving Updated and Available at zero would render as a permanently half-finished rollout. Container count goes in Detail, where it is information rather than a claim about a concept compose does not have.

func (ComposeProvider) OwnedRunning added in v0.1.46

func (p ComposeProvider) OwnedRunning(ctx context.Context, env string) (bool, error)

OwnedRunning discovers a compose-only stack without relying on a heartbeat or on a listener belonging to some unrelated process.

func (ComposeProvider) StopOwned added in v0.1.46

func (p ComposeProvider) StopOwned(ctx context.Context, env string) error

StopOwned removes only this checkout/env's recorded, revalidated container IDs. Volumes are deliberately retained (docker rm without -v); no compose down, prune, name inference, or recreation from changed YAML is involved.

One container that cannot be verified or removed does not stop the teardown: every other record and ID is still processed, and the failures are returned together. A failed ID is never removed (its ownership is unproven) and stays in its record, so the next env down retries exactly that ID; a record whose every ID was settled is deleted.

type ComposeSpec

type ComposeSpec struct {
	ComposeFile string
	Service     string
	EnvFile     string

	// Env is the KCL-declared map forge puts in the `docker compose`
	// PROCESS environment — which is what the compose file's own `${VAR}`
	// references interpolate from. EnvFile is a different channel: it only
	// forwards values into CONTAINERS, and passing --env-file also
	// REPLACES compose's default `.env`.
	//
	// It is what lets a value be declared once in an env's KCL and reach
	// the container (the dev IdP's port is the motivating case — see
	// Compose.env in kcl/schema.k). These entries WIN over the inherited
	// shell environment, deliberately: a shell override would move the
	// container while every KCL reference to the same value stayed put.
	Env map[string]string

	// Wait selects readiness-gated deploys: `docker compose up -d
	// --wait` blocks until the service's declared healthcheck passes
	// instead of returning the moment the container is created.
	//
	// It is a pointer because the useful default is TRUE and a bool's
	// zero value is false: a nil Wait means "not specified", which
	// composeWait resolves to true. That keeps a ComposeSpec built by
	// any path — KCL, a test literal, a future caller — readiness-gated
	// unless someone opts OUT on purpose.
	Wait *bool

	// WaitTimeoutSeconds is a CEILING on the wait, not the mechanism:
	// readiness is decided by the healthcheck, and this only bounds how
	// long forge tolerates a container that never gets there, so a
	// wedged service fails loudly instead of hanging a dev loop or a CI
	// job forever. Zero means "no explicit ceiling" and lets the
	// healthcheck's own retries x interval bound it.
	WaitTimeoutSeconds int

	// ProjectDirectory is the directory compose runs the stack FROM: it
	// resolves ComposeFile and EnvFile, it is what every relative bind
	// mount in the file resolves against, and it is the
	// `com.docker.compose.project.working_dir` compose stamps on the
	// containers. Empty means the process working directory — a stack the
	// current checkout owns.
	//
	// The dispatcher sets it to the repo's PRIMARY checkout for a
	// `shared = True` stack (OnCompose.shared), so every worktree drives
	// the SAME containers with the SAME resolved config and an `up` from
	// any of them is a no-op once the stack is running.
	ProjectDirectory string

	// Shared records that the stack is machine infrastructure every
	// worktree uses (OnCompose.shared). It changes how a FOREIGN owner is
	// treated — see checkComposeOwnership.
	Shared bool
}

ComposeSpec is the per-service docker-compose deploy spec. Mirrors the kcl/schema.k Compose schema.

type DeployState

type DeployState struct {
	Image      string `json:"image"`
	Tag        string `json:"tag"`
	DeployedAt string `json:"deployed_at"` // RFC3339, wall-clock
}

DeployState records the last image+tag a non-cluster provider (External, Compose) successfully shipped for one service-env pair. It exists because external and compose have no cluster to ask what is deployed: the next deploy reads it back as ${LAST_TAG}, and the reconciler reads it as the observed tag.

The shape mirrors internal/cli.BuildState (image / tag / deployed_at) but it's a deliberate copy rather than a shared type: internal/cli depends on internal/deploytarget, so reusing the cli type would require either an import cycle or hoisting the type into a third package for one struct. The cost of a 4-field duplicate is lower than the cost of either alternative.

File layout: one file per (provider, env, service) under .forge/state/. The directory is already in .gitignore via the existing `.forge/` rule; the file is 0o644 (same as build-state) so users can peek at it without sudo.

func ReadDeployState

func ReadDeployState(projectDir, provider, env, service string) (*DeployState, error)

ReadDeployState loads the per-(provider, env, service) state file. Returns (nil, nil) when the file is missing — that's "no previous deploy", which the caller handles separately from "file exists but is malformed" (returns (nil, err)).

type DistSourcemaps added in v0.1.46

type DistSourcemaps struct {
	// Dir is the built output, with its bundles and their maps.
	Dir string
	// Project, Frontend and BuildID identify the build, as for a staged one.
	Project, Frontend, BuildID string
	// BasePath is where the output is mounted in the served site ("" for the
	// root); it becomes the leading part of every path in the manifest.
	BasePath string
}

DistSourcemaps describes a built output directory forge did not build (the Electron renderer, built by CI) whose maps are to be extracted.

type FirebaseFrontend

type FirebaseFrontend struct {
	// Name is the forge frontend name (logging + target fallbacks).
	Name string

	// Path is the frontend source dir relative to the project root —
	// where `npm install` / `npm run build` run.
	Path string

	// DevRunner is "npm" (default) | "pnpm" | "yarn"; selects the
	// install command. The build command is always `<runner> run build`.
	DevRunner string

	// BuildEnv is the build-time env injected into the build process
	// (NEXT_PUBLIC_* / VITE_*). Layered on top of os.Environ().
	BuildEnv map[string]string

	// Project is the forge project name (recorded in the source map manifest).
	Project string

	// VersionEnvVar is the variable the bundle reads service.version from
	// (NEXT_PUBLIC_APP_VERSION / VITE_APP_VERSION); forge injects the build id
	// into it unless BuildEnv sets it. Empty injects nothing.
	VersionEnvVar string

	// Sourcemaps is "publish" (default), "public" or "off". See the
	// Sourcemaps* constants.
	Sourcemaps string

	// RuntimeConfigJS is this ENVIRONMENT's rendered runtime config
	// document — the `window.__FORGE_CONFIG__ = {...}` script the browser
	// loads before any bundle code runs, produced from the environment's
	// KCL (frontend_config_gen.k's runtime projection).
	//
	// It is written into the assembled tree AFTER the built bundle is
	// copied, which is what makes promotion real: the bundle is
	// environment-agnostic and this one file is the only part that
	// differs, so the same bytes ship to dev and prod carrying different
	// configuration. Empty means the frontend declares no typed config —
	// nothing is written and the assembled layout is unchanged.
	RuntimeConfigJS string

	// SourcemapPublisher files the source maps the build extracts (F1) so
	// errors can be symbolicated: pushed to the platform registry when the
	// env's observability is hosted, kept on disk otherwise. nil files nothing
	// (the maps are still extracted, and never served).
	SourcemapPublisher SourcemapPublisher

	// Spec is the FirebaseHosting deploy config.
	Spec FirebaseHostingSpec
}

FirebaseFrontend is one frontend the Firebase provider should deploy. It carries the resolved build inputs plus the FirebaseHosting spec. The CLI builds this from the rendered KCL FrontendEntity; tests construct it directly.

type FirebaseHostingSpec

type FirebaseHostingSpec struct {
	Project   string
	Site      string
	Target    string
	PublicDir string
	BasePath  string
	Bundle    []BundleDirSpec
	Rewrites  []map[string]any

	// SPAFallback is the entry document ("/index.html") served for a
	// client-side route — a missing path inside its directory whose last
	// segment is not a static file. Empty means no SPA fallback. Rendered
	// AFTER Rewrites, so an explicit rewrite always wins.
	SPAFallback string

	// AssetDir is the build's content-hashed asset directory relative to
	// the frontend's mount ("assets" for Vite, "_next/static" for a
	// Next.js export). Empty means the build declares none: no immutable
	// cache rule and no asset retention.
	AssetDir string

	// KeepAssetReleases is how many releases' hashed assets the live site
	// serves, the new one included. Zero means the default (10); the floor
	// is 2. See firebase_assets.go.
	KeepAssetReleases int

	// CacheControl is the frontend's declared Cache-Control rules
	// (first match wins). Empty means forge's default: the asset dir
	// immutable, everything else no-cache. See firebaseHeaders.
	CacheControl []CacheRuleSpec
}

FirebaseHostingSpec mirrors the kcl/schema.k FirebaseHosting schema (and the CLI-side FirebaseHostingDeploy entity). Kept in this package so the provider has no import on internal/cli.

type FirebaseProvider

type FirebaseProvider struct {
	// ProjectDir is the project root. Frontend paths and Bundle.Src
	// resolve against it. Empty means the current working directory.
	ProjectDir string

	// Runner is the os/exec indirection (npm / firebase). Nil falls
	// back to the package default. Tests inject a fake runner.
	Runner commandRunner

	// StagingRoot overrides where the assembled hosting tree is written.
	// Empty means a temp dir under os.TempDir(). Tests set it so they
	// can inspect the assembled layout.
	StagingRoot string

	// HTTPClient reads the live release (its asset manifest and the
	// assets retention carries forward). Nil means an *http.Client with
	// a two-minute timeout.
	HTTPClient HTTPDoer

	// SiteURL maps a Firebase Hosting site id to the origin its live
	// release is read from. Nil means https://<site>.web.app, the origin
	// every site answers on. Tests point it at an httptest server.
	SiteURL func(site string) string

	// Sleep waits between attempts to read the live release, returning
	// early with ctx's error. Nil means a real, context-aware sleep.
	Sleep func(ctx context.Context, d time.Duration) error
}

FirebaseProvider deploys a frontend's static build output to Firebase Hosting. It is the frontend analogue of ExternalProvider — but unlike External it is NOT a generic shell escape hatch: the contract is "build a static export, assemble it (plus any sibling static dirs) into one tree, and ship that tree to a Firebase Hosting site." Firebase Hosting is a common-enough target (per-env preview/staging/prod sites, SPA + sub-app co-hosting under a base_path) that owning the assembly + firebase.json generation in forge — rather than asking every project to hand-roll it in CI — is the coherent product move.

The pipeline, per frontend:

  1. Build + assemble — the SHARED static staging step (staticstage.go): install, `npm run build` with the frontend's env injected, copy public_dir under base_path plus every bundle dir, then write the environment's runtime config document last. StaticSiteProvider runs the identical step, which is why it lives there and not here.
  2. Configure — write a firebase.json (hosting.public = staging, hosting.site = Site, plus any Rewrites) and a .firebaserc mapping the hosting Target to Site for the Project.
  3. Deploy — run `firebase deploy --project <project> --only hosting:<target> --non-interactive` from the staging parent.

--dry-run prints the resolved plan (build command, assembled layout, and the exact firebase deploy command) and performs NO build, NO file assembly side effects beyond an in-memory plan, and NO firebase call.

func (FirebaseProvider) BuildOnly

func (p FirebaseProvider) BuildOnly(ctx context.Context, fes []BuildOnlyFrontend, dryRun bool) error

BuildOnly builds each build-only frontend (install + `npm run build` with its env_vars injected) so its output exists on disk before any deploying frontend assembles a bundle that references it. dryRun prints the build plan and performs no side effects, mirroring the Firebase deploy dry-run.

func (FirebaseProvider) Deploy

func (p FirebaseProvider) Deploy(ctx context.Context, group ServiceGroup) error

Deploy ships every frontend in the group to its Firebase Hosting site. It reads the frontends off group.Frontends and the dry-run knob off group.DryRun so the Firebase provider satisfies the same Provider interface as k8s-cluster / external / compose and dispatches through the registry — no bespoke hand-dispatch in forge env deploy.

func (FirebaseProvider) Name

func (FirebaseProvider) Name() string

Name returns the provider identifier.

func (FirebaseProvider) Observe added in v0.1.45

func (p FirebaseProvider) Observe(_ context.Context, group ServiceGroup) (Observed, error)

Observe is not supported for Firebase Hosting targets.

Structural: forge ships the assembled tree to Firebase and Firebase owns the release history afterwards. forge keeps no artifact of its own (contrast StaticSite, whose per-digest release prefix is exactly what makes both promotion and observation real), so there is no forge-side identity to compare a live site against.

type HTTPDoer added in v0.1.45

type HTTPDoer interface {
	Do(*http.Request) (*http.Response, error)
}

HTTPDoer is the one method retention needs from an HTTP client.

type Health added in v0.1.45

type Health int

Health is a provider's verdict on one observed item. It is an int enum precisely so its ZERO VALUE is HealthUnknown: an Observed built by any path — a provider, a test literal, a future caller — reads as unknown until something deliberately says otherwise.

Four values, and each is a genuinely different decision for a caller: "nothing is there" and "something is there and it is broken" call for opposite actions (create vs. investigate), so collapsing them into one "not healthy" would force every consumer to re-derive the distinction.

const (
	// HealthUnknown means the provider did not measure. The zero value.
	HealthUnknown Health = iota
	// HealthAbsent means the provider looked and found nothing deployed.
	// Distinct from unknown: this IS a measurement.
	HealthAbsent
	// HealthDegraded means something is deployed but is not fully serving
	// (replicas short of desired, a live pointer at a missing release).
	HealthDegraded
	// HealthHealthy means the target reports everything it was asked for
	// as running and ready.
	HealthHealthy
)

func (Health) MarshalJSON added in v0.1.45

func (h Health) MarshalJSON() ([]byte, error)

MarshalJSON emits the string form so `forge env observe --json` is readable by a human and by jq, rather than emitting the iota.

func (Health) String added in v0.1.45

func (h Health) String() string

String renders the health for logs and JSON. Unknown renders as "unknown" rather than "" so an unset value is visible in output rather than reading as an empty column.

type HeldError added in v0.1.45

type HeldError struct {
	Env         string
	PromotionID string
	Release     string
	Holds       []HostedHold
}

HeldError is a hosted deploy that was ACCEPTED and RECORDED and is QUEUED on a human action: nothing failed, nothing needs retrying, and the platform applies it on its own once the hold clears. The provider returns it instead of waiting out a rollout nobody is moving; the CLI renders it as the queued block and its own exit code.

func (*HeldError) Error added in v0.1.45

func (e *HeldError) Error() string

type HostInfraProvider added in v0.1.45

type HostInfraProvider struct {
	// ProjectDir is the project root. Data directories declared relative
	// (the default) resolve against it. Empty means the working directory.
	ProjectDir string
}

HostInfraProvider brings up the dev infrastructure an environment declares as `forge.HostInfra` — a real server binary forge fetches and supervises as a HOST PROCESS, with no container runtime involved.

It is a Provider like any other so that the thing which decides HOW dev infra runs is the environment's KCL, not a branch in forge's Go. An env that declares `forge.HostInfra {engine = "postgres", …}` gets a host postgres; one that declares `forge.Compose {service = "postgres"}` gets the container; and the code path that dispatches them is the same group-then-look-up-the-provider loop in both cases. Adding a mode meant adding a provider, which is the seam that already existed.

See internal/hostinfra for the postgres implementation and for why the data directory persists while the server outlives the process that started it.

func (HostInfraProvider) Deploy added in v0.1.45

func (p HostInfraProvider) Deploy(ctx context.Context, group ServiceGroup) error

Deploy starts every declared instance in the group, or confirms it is already serving.

EVERY instance is attempted even after one fails, and the failures are reported together. A group is a set of independent servers, and stopping at the first one that could not start would hide the state of the rest behind a single message — which is exactly how a port collision on postgres used to take an entire dev stack down with it while naming only postgres.

func (HostInfraProvider) Name added in v0.1.45

func (HostInfraProvider) Name() string

Name returns the provider identifier — the ProviderID a host-infra ServiceGroup carries.

func (HostInfraProvider) Observe added in v0.1.45

func (p HostInfraProvider) Observe(ctx context.Context, group ServiceGroup) (Observed, error)

Observe reports whether each declared host-infra instance is currently serving, on which port, and which engine build.

Why the data directory and not the port

A port probe is the obvious implementation and it is the wrong one on the machine this tier targets. A developer box runs several stacks; the declared port may be held by ANOTHER project's postgres or a system one, and reporting that as "our instance is up" would show green for a database this project has never written to. That is the same adoption mistake hostinfra.Start refuses to make, and an observation that made it would be worse than the deploy — it would be believed.

So ownership is decided by the DATA DIRECTORY (hostinfra.StatusOf), and a port held by something else is reported as its own state rather than folded into either "up" or "down".

Why running is not healthy

A live process is not a server that answers. Postgres writes its pid file and then spends its recovery phase refusing connections; the dev IdP is alive for the whole of its declarative bootstrap. Reporting either as healthy would tell a caller the thing it is about to dial will respond, which is exactly the claim that is false. Health is HealthHealthy only when the instance both runs AND answers.

Why Replicas is nil and Digest is empty

Nil rather than &ReplicaCounts{Desired: 1}: a supervised host process has no replica concept at all, and this is the distinction the pointer exists to carry — "not applicable" versus "nothing is running". Digest stays empty because there is no content-addressed identity to report: forge fetches a released engine binary by version, and a version string is a mutable pointer in the same way an image tag is. The version goes in Detail, which is information rather than a claim about bytes.

func (HostInfraProvider) Stop added in v0.1.45

func (p HostInfraProvider) Stop(group ServiceGroup) error

Stop shuts every instance in the group down cleanly, leaving its data in place. This is `forge env down`'s half of the lifecycle; nothing on the Provider interface covers it, because compose and k8s targets are stopped by their own runtimes and forge never had to.

Best-effort and aggregated for the same reason Deploy is: one instance that will not stop must not leave the others running with no report.

type HostInfraSpec added in v0.1.45

type HostInfraSpec struct {
	Engine   string
	Port     int
	Database string
	User     string
	Password string
	DataDir  string
	Version  string

	// engine = "zitadel" only — the dev IdP's backing database and its
	// declarative bootstrap. See the HostInfra schema in kcl/schema.k.
	IDPDatabase     string
	IDPDatabasePort int
	IDPMasterKey    string
	IDPStepsFile    string
	IDPPATPath      string

	// engine = "nats" only.
	NATSMonitorPort int
	// engine = "temporal" only.
	TemporalNamespaces []string
	TemporalUIPort     int
}

HostInfraSpec is the per-service host-infra deploy spec. Mirrors the kcl/schema.k HostInfra schema: a third-party server forge runs as a host process rather than a container. See internal/hostinfra.

type HostedCaller added in v0.1.45

type HostedCaller interface {
	Call(ctx context.Context, procedure string, req, out any) error
}

HostedCaller is the one method the provider needs from a control-plane client. Declared here, at its consumer; cloud.Client satisfies it.

type HostedCustomDomain added in v0.1.45

type HostedCustomDomain struct {
	Domain string `json:"domain"`
	// State is the lower-case vocabulary: pending_dns | verifying |
	// issuing | live | failed | conflict | unknown.
	State string `json:"state"`
	// RequiredRecords are the DNS records still to be set. The platform
	// reports them for as long as they matter, so a domain stuck in
	// pending_dns always carries the reason it is stuck.
	RequiredRecords []HostedDNSRecord `json:"required_records,omitempty"`
	LastError       string            `json:"last_error,omitempty"`
	// LiveSince is RFC3339 for when the domain started serving, empty
	// until it does.
	LiveSince string `json:"live_since,omitempty"`
	// ServingPath is where the domain's DNS sends its traffic, as the
	// control plane resolves it: edge | ingress | elsewhere. Empty when the
	// control plane did not say (the name does not resolve yet, or an older
	// control plane). See ServingPathName.
	ServingPath string `json:"serving_path,omitempty"`
}

HostedCustomDomain is one declared custom hostname's observed state.

type HostedDNSRecord added in v0.1.45

type HostedDNSRecord struct {
	// Type is A | CNAME | TXT.
	Type string `json:"type,omitempty"`
	// Name is the record name, as the registrar wants it.
	Name string `json:"name,omitempty"`
	// Value is the record's value — an address, a target, or a token.
	Value string `json:"value,omitempty"`
}

HostedDNSRecord is one record the author must publish at their registrar before a domain can go live.

type HostedEnvKind added in v0.1.45

type HostedEnvKind string

HostedEnvKind is the controlplane.v1.DeployEnvironmentKind value name an environment is ensured with. It is IMMUTABLE server-side: ensuring an existing environment with a different kind is refused (FailedPrecondition), which is what keeps a persistent env's secrets from ever becoming pullable.

const (
	// HostedEnvPersistent is an env whose workloads the platform runs (it
	// declares at least one hosted tier).
	HostedEnvPersistent HostedEnvKind = "DEPLOY_ENVIRONMENT_KIND_PERSISTENT"
	// HostedEnvLocal is an env whose workloads run on a developer machine
	// (`forge env up`); the control plane is only its secret store, and its
	// secrets are pullable.
	HostedEnvLocal HostedEnvKind = "DEPLOY_ENVIRONMENT_KIND_LOCAL"
	// HostedEnvSelfManaged is an env whose ledger the control plane keeps
	// while forge applies it to a cluster the author operates: nothing is
	// hosted, something runs on a cluster. The platform places nothing, and
	// its secrets are write-only — a cluster env's secrets must never be
	// pullable the way a LOCAL env's are.
	HostedEnvSelfManaged HostedEnvKind = "DEPLOY_ENVIRONMENT_KIND_SELF_MANAGED"
)

type HostedEnvRef added in v0.1.45

type HostedEnvRef struct {
	// Project is the forge project name (forge.yaml `name`).
	Project string
	Name    string
	Kind    HostedEnvKind

	// Shape is the env's rendered shape, recorded as the DECLARATION:
	// what runs, where, which secrets it needs, one hash per object. It is
	// what lets a reader answer "what kind, which secrets, which provider"
	// with no checkout and no daemon, which is the whole point of
	// recording it.
	//
	// NIL MEANS "I DID NOT RENDER", NOT "THE ENV DECLARES NOTHING". An
	// absent shape leaves the stored one untouched server-side, so the
	// ensures that run without a render — `forge secret set`, a promote —
	// cannot erase a declaration that a build recorded. Only a path that
	// actually rendered the env may set it.
	Shape *release.Shape
	// DeclaredBy is the provenance of the render Shape came from: which
	// commit, which checkout, which forge. It is a CLAIM, recorded as
	// one — identity is the caller's token, never this (O-11).
	DeclaredBy *release.Provenance

	// ClusterBindings maps each cluster the bundle renders for to the
	// CONNECTED cluster it deploys to — the control plane's record of where
	// this environment lands.
	//
	// A REPEATED FIELD BECAUSE ONE ENV CAN HAVE SEVERAL TARGETS. A forge
	// bundle renders a subtree per cluster (release.BundleClusterPath), so
	// an env that spans two clusters has two bindings, which a single
	// cluster id could not express.
	//
	// Nil means "no bindings to record", which leaves the stored ones
	// untouched server-side — the same rule Shape follows, and for the same
	// reason: an ensure that did not render (a secret set, a promote) must
	// not erase what a build recorded.
	ClusterBindings []ClusterBinding

	// HubConverged is WHO APPLIES the env's bound clusters, as decided from
	// the same render as Shape: true ONLY when the control plane's hub
	// converges EVERY delivery unit and forge applies none of them, false when
	// forge applies any part of the env from the author's machine. It is the
	// env-level summary of HubConvergedUnits, kept because a control plane that
	// predates per-unit convergence reads only this: sending true for an env
	// that has moved some units would make an old control plane converge the
	// units forge still applies, so the summary is deliberately the
	// conservative one. A bound cluster says WHERE the env lands, not who puts
	// it there.
	//
	// A SHAPE FIELD, sent only beside a Shape, because it is a projection of
	// that render. Nil means "this ensure did not render" and leaves the
	// stored value untouched, like Shape and ClusterBindings. An env never
	// declared is treated as forge-applied server-side, so an older forge
	// can never make the hub a second applier over a cluster it applies.
	HubConverged *bool
	// HubConvergedUnits are the delivery units the control plane's hub
	// converges, by name: the per-unit declaration an env uses to move onto
	// Flux one unit at a time (Bundle.hub_converges). Every other unit of the
	// env is applied by forge. Nil means this ensure did not render, like
	// HubConverged; a non-nil empty slice says "the hub converges none".
	//
	// A control plane that predates the field ignores it (connect's JSON codec
	// discards unknown fields) and obeys HubConverged, which is false until
	// every unit has moved: so an old control plane never converges a
	// partially moved env.
	HubConvergedUnits *[]string
}

HostedEnvRef addresses one control-plane environment: (project, name) in the caller's org, plus the kind an ensure creates it with, plus — for the paths that have rendered the env — what its KCL declares.

func (HostedEnvRef) DeclaresShape added in v0.1.45

func (r HostedEnvRef) DeclaresShape() bool

DeclaresShape reports whether this ref carries a declaration to record.

type HostedEnvStatus added in v0.1.45

type HostedEnvStatus struct {
	EnvironmentID string
	// Verdict is the environment-level verdict, lower-case.
	Verdict         string
	ReconcilePolicy string
	Workloads       []HostedWorkloadStatus
}

HostedEnvStatus is one environment's hosted status.

func ReadHostedStatus added in v0.1.45

func ReadHostedStatus(ctx context.Context, c HostedCaller, project, envName string) (HostedEnvStatus, error)

ReadHostedStatus reads an environment's status by (project, NAME). An environment the control plane has never heard of returns ErrHostedEnvironmentNotFound.

type HostedHold added in v0.1.45

type HostedHold struct {
	// Kind is the DeployHoldKind value name (DEPLOY_HOLD_KIND_BILLING).
	Kind        string `json:"kind,omitempty"`
	PromotionID string `json:"promotionId,omitempty"`
	// Reason states the real quantity: "this runs compute (1 workload, 1
	// database) and the organization has no active compute plan".
	Reason string `json:"reason,omitempty"`
	// Fix is the literal next step, written by the control plane.
	Fix string `json:"fix,omitempty"`
	// ActionURL is where a human resolves it: the environment's page in
	// the Reliant web app. Empty when the control plane has none to give.
	ActionURL        string     `json:"actionUrl,omitempty"`
	CallerCanResolve bool       `json:"callerCanResolve,omitempty"`
	HeldSince        *time.Time `json:"heldSince,omitempty"`
}

HostedHold is controlplane.v1.DeployHold: one reason an accepted deploy has not started. The control plane applies the promotion — with no further command — the moment the hold clears, so a hold is a state to REPORT, never a failure to retry.

EVERY FIELD IS RENDERED GENERICALLY. A hold carries its own sentence, remedy and link, so a kind this forge has never heard of is still a complete answer; Kind is read only for a short label.

func (HostedHold) Label added in v0.1.45

func (h HostedHold) Label() string

Label is the hold's short name: "billing" for DEPLOY_HOLD_KIND_BILLING, and a neutral phrase for a kind this build does not know.

type HostedPreflightItem added in v0.1.45

type HostedPreflightItem struct {
	Name     string
	Tier     HostedTier
	Workload *v1alpha1.WorkloadSpec
	Database *v1alpha1.ManagedDatabaseSpec
	Static   *v1alpha1.StaticSiteSpec
}

HostedPreflightItem is one workload PreflightHosted admitted: its name, tier and the spec the control plane would store — pinned to a PLACEHOLDER digest, because no release is involved. Exactly one spec is set.

func PreflightHosted added in v0.1.45

func PreflightHosted(group ServiceGroup) ([]HostedPreflightItem, error)

PreflightHosted answers "would the control plane admit this env's workloads?" WITHOUT a release, a credential or a control plane — the question CI asks of a checkout (`forge ci validate-kcl`, `forge doctor`) before anything is built, pushed or promoted.

It is planHostedWith, not a re-statement of it: every artifact the plan would pin is bound to a placeholder digest (and a registry-less image to a placeholder registry), and the plan then runs every rule it runs at deploy time — spec validation, the shape band, the artifact collision rule and the cross-workload references. What it cannot check is what only a live deploy knows: that the promoted release pins these artifacts, and that the org's image push base admits the image.

type HostedProvider added in v0.1.45

type HostedProvider struct {
	Client HostedCaller
	// Rollout bounds and shapes the readiness wait: mode skip waits for
	// nothing, warn reports without failing, wait (the default) fails on a
	// workload that never becomes ready. Timeout is the WHOLE wait.
	Rollout cluster.RolloutPolicy
	// PollInterval is the GetStatus cadence. Zero means 3s.
	PollInterval time.Duration
	// OnRollout, when set, receives one outcome per published workload.
	OnRollout func(cluster.RolloutObservation)
	// OnEnvironment, when set, receives the control plane's environment id
	// as soon as it is known, so a report can carry it even if a later step
	// fails.
	OnEnvironment func(id string)
	// RecordBundle builds, pushes and records the env's bundle once the
	// environment exists. The provider cannot do it itself: building needs
	// the project render, which only the CLI can drive.
	// It returns the recorded bundle's digest, which the readiness wait
	// uses to confirm the platform applied THIS bundle before judging
	// workloads ("" skips that confirmation).
	RecordBundle func(ctx context.Context, environmentID string) (bundleDigest string, err error)
}

HostedProvider ships a hosted group through a control plane.

The zero value is registered in NewRegistry so the observation audit covers it; the deploy dispatcher re-registers a configured one (Client set), the same pattern K8sClusterProvider uses for its ApplyOptsBuilder.

func (HostedProvider) Deploy added in v0.1.45

func (p HostedProvider) Deploy(ctx context.Context, group ServiceGroup) error

Deploy runs the fixed order in the file header.

func (HostedProvider) Name added in v0.1.45

func (HostedProvider) Name() string

Name is the provider's registry id.

func (HostedProvider) Observe added in v0.1.45

func (p HostedProvider) Observe(ctx context.Context, group ServiceGroup) (Observed, error)

Observe reads the control plane's status for the env and maps each declared workload onto an ObservedItem.

The control plane is the ONLY witness: forge has no credentials for the cluster a hosted workload runs on, so what the platform's observer last confirmed is what is reported — with its verdict in Detail, never upgraded. A workload the platform does not list is ABSENT; health is only HEALTHY for an observed READY state (the verdict may still be converging, which Detail says).

type HostedRecord added in v0.1.45

type HostedRecord struct {
	Kind string
	Name string
	// YAML is the serialized CR.
	YAML []byte
}

HostedRecord is one hosted tier declaration as it rides a bundle: the forge.dev CR the control plane applies, WITHOUT identity.

No namespace and no org/environment/deployment labels: forge cannot know them, and identity is metadata the platform stamps, never something a spec author (or a bundle) can claim. The control plane's Kustomization sets the namespace and stamps the labels.

func HostedRecords added in v0.1.45

func HostedRecords(group ServiceGroup) ([]HostedRecord, error)

HostedRecords plans the hosted group exactly as a publish would — pin, validate under ProfileRestricted, band-check, render the set — and returns the CRs, so what a bundle carries is what the platform admits.

func PreflightHostedRecords added in v0.1.45

func PreflightHostedRecords(group ServiceGroup) ([]HostedRecord, error)

PreflightHostedRecords is HostedRecords over placeholder digests, for a render that has no release yet. The result is for DISPLAY and shape projection only: its digests are obviously fake and a bundle must never seal it (callers refuse to).

type HostedTarget added in v0.1.45

type HostedTarget struct {
	// Endpoint is the control plane's normalized base URL. Display only —
	// the Caller already addresses it.
	Endpoint string
	// Project is the forge project name the environment is addressed under
	// on the control plane (identity is (org, project, env name)).
	Project string
	// Release is the version the env is promoted to. Empty means unbound,
	// which a deploy refuses: a hosted deploy ships only promoted digests.
	Release string
	// PromotionID is the promotion whose frozen pins this deploy is
	// publishing — the one forge read Digests from. Recorded server-side
	// as Deployment.applied_promotion_id, which is what lets the
	// converger tell "a promotion is waiting to be applied" from "this
	// row has drifted": the two need opposite answers under the OBSERVE
	// policy, and comparing digests cannot distinguish them because both
	// read as "the row does not match the pin".
	//
	// Empty when the env is unbound, or when a control plane that
	// predates the ledger's promotion ids returned none. An omitted
	// promotion leaves the stored one untouched server-side, which is the
	// correct reading of a hand-run deploy: that is drift, not a
	// promotion.
	PromotionID string
	// Digests is the bound release's artifact → digest map (the ledger's
	// Resolved set).
	Digests map[string]string
	// Registries is the bound release's artifact → registry map: where each
	// image was pushed (the release artifact's URI). It locates the bytes of
	// a workload whose image THIS project builds, which is declared
	// registry-less (`image = "api"`): forge composed the address from the
	// env's declared push base, and recorded it here by
	// `forge env build <env> --push`.
	Registries map[string]string
	// PushBase is the registry subtree this env's hosted artifacts live
	// under, composed by forge from the env's declaration
	// (`<registry_host>/<org>/<project>`) and carried here so the
	// pre-publish check judges a pinned image against the same base the
	// build pushed to.
	//
	// It is the CLIENT's composition, deliberately: the server does not
	// advertise a base, it ENFORCES one — at publish (ociregistry.Admit) and
	// at the registry realm, which refuses a token outside its own org's
	// subtree. So a mis-declared org is caught by the thing that knows,
	// rather than by forge believing an answer it was handed.
	PushBase string
	// Shape and DeclaredBy are the env's rendered DECLARATION, recorded by
	// the EnsureEnvironment this deploy already performs rather than by a
	// write of their own.
	//
	// That placement is the contract, not a convenience. The order in this
	// file's header exists so nothing is written until everything is known
	// to be admissible — every workload validated, the hosted set rendered
	// as the platform will — and a deploy that forge is about to refuse
	// (unbound, off shape band, image outside the push base) must leave the
	// control plane untouched. A declaration sent ahead of the plan would
	// be the one write that escaped that rule.
	//
	// Nil means "no render to record", which leaves the stored shape
	// untouched server-side.
	Shape      *release.Shape
	DeclaredBy *release.Provenance
	// Kind is the env's control-plane kind as the CLI derived it from the
	// whole render (hostedEnvKindOf). "" means "not derived", which the
	// provider reads as PERSISTENT. It is the legacy wire field the control
	// plane is retiring; placement itself is per workload.
	Kind HostedEnvKind
}

HostedTarget is the env-level half of a hosted group.

type HostedTier added in v0.1.45

type HostedTier string

HostedTier is the tier a hosted workload is published as.

const (
	HostedTierWorkload HostedTier = "workload"
	HostedTierDatabase HostedTier = "database"
	HostedTierStatic   HostedTier = "static"
)

The three hosted tiers, one per forge.dev kind the control plane admits.

type HostedWorkload added in v0.1.45

type HostedWorkload struct {
	Tier HostedTier
	// Artifact is the release-ledger artifact name this workload's digest is
	// bound under. Empty means HostedArtifactName(Workload.Image) for a
	// workload and the workload's own name for a static site (the frontend
	// name `forge build` records its site release under).
	Artifact string
	// Workload is the spec published as a forge.dev Workload CR (named by
	// the ResolvedService name). Its Image is the declared image — a
	// registry-less artifact name this project builds, or a pinned
	// third-party reference — and the plan pins it to the release digest.
	Workload *v1alpha1.WorkloadSpec
	Database *v1alpha1.ManagedDatabaseSpec
	// Static is the deployed half of a StaticSite: everything but the
	// digest, which planHosted pins from the bound release as liveDigest.
	Static *v1alpha1.StaticSiteSpec
}

HostedWorkload is one declaration bound for the control plane. Exactly one of Workload / Database / Static is set, matching Tier.

type HostedWorkloadStatus added in v0.1.45

type HostedWorkloadStatus struct {
	Name string `json:"name"`
	// Tier is workload | database | static.
	Tier string `json:"tier,omitempty"`
	// URL is the platform-allocated public URL, once one exists.
	URL string `json:"url,omitempty"`
	// Hostname is URL's host, for a badge that has no room for a scheme.
	Hostname string `json:"hostname,omitempty"`
	// ServingPath is where Hostname's DNS sends its traffic, as the control
	// plane resolves it: edge | ingress | elsewhere, or empty when it did
	// not say (ServingPathName).
	ServingPath string `json:"serving_path,omitempty"`
	// Verdict is the control plane's lower-case deploystate verdict:
	// unknown | converging | converged | diverged | degraded.
	Verdict       string `json:"verdict"`
	VerdictReason string `json:"verdict_reason,omitempty"`
	// ObservedState is the observation vocabulary: pending | progressing |
	// ready | degraded | suspended | deleted | unknown.
	ObservedState  string `json:"observed_state"`
	ObservedDigest string `json:"observed_digest,omitempty"`
	DesiredDigest  string `json:"desired_digest,omitempty"`
	Drifted        bool   `json:"drifted,omitempty"`
	LastError      string `json:"last_error,omitempty"`
	// Domains is the per-domain state of every custom hostname this
	// deployment declares — what it is doing, and what DNS the author
	// still owes. Empty for a deployment that declares none.
	Domains []HostedCustomDomain `json:"domains,omitempty"`
}

HostedWorkloadStatus is one deployment as the console contract reports it.

type K8sClusterProvider

type K8sClusterProvider struct {
	// ApplyOptsBuilder lets callers customize cluster.ApplyOpts before
	// the provider invokes cluster.Apply. The forge CLI uses this to
	// plumb through MainK, EnvConfigKV, Prune
	// from the rendered KCL — fields the provider itself doesn't know
	// about. A nil builder means "use the group's namespace+image tag
	// and let cluster.Apply default everything else", which is enough
	// for tests but not for the real forge env deploy path.
	ApplyOptsBuilder func(group ServiceGroup) cluster.ApplyOpts

	// Runner is the os/exec indirection used by OBSERVE ONLY, to shell
	// `kubectl get deployment -o json`. Nil falls back to the package
	// default.
	//
	// Deploy deliberately does NOT route through it: it delegates to
	// cluster.Apply, which owns its own execution, the KCL render and the
	// rollout wait. Threading this field into it would change deploy
	// behaviour, which is not what adding a read verb is for.
	Runner commandRunner
}

K8sClusterProvider is the full Go implementation for the K8sCluster deploy target. It wraps internal/cluster.Apply — the existing render-KCL → kubectl-apply → wait-rollouts pipeline that `forge env deploy` / `forge cluster reload` / `forge env up` share.

The provider takes the env-wide knobs off the ServiceGroup (which got them from the first K8sCluster ref in the group). The per- service knobs are reflected on the rendered manifests by KCL — this provider doesn't re-apply them, it just hands the right env / image tag / namespace to the cluster pipeline and lets the renderer do the rest.

func (K8sClusterProvider) ApplyNoWait added in v0.1.45

ApplyNoWait applies the group to its cluster — render, select, scope, apply, and the pre-rollout Job gate — and returns the rollout still to be awaited, for the caller to hand to cluster.WaitRollouts together with every other cluster's. See cluster.ApplyNoWait for why the two halves are separable.

Every error, and the pending rollout's own verdict, names the group's namespace and cluster: in a multi-cluster deploy that is the only thing that says which cluster a failure belongs to.

func (K8sClusterProvider) Deploy

func (p K8sClusterProvider) Deploy(ctx context.Context, group ServiceGroup) error

Deploy invokes cluster.Apply for the group. The provider doesn't re-render KCL or re-walk services — that work is already done at the dispatcher layer; this just hands cluster.Apply the env-wide knobs (namespace, image tag) and lets it shell `kcl run` against the env's main.k.

Deploy is ApplyNoWait followed by the rollout wait. A dispatcher deploying several cluster groups uses ApplyNoWait for each and waits once, so one cluster's rollout is never awaited before another cluster it depends on has been applied.

func (K8sClusterProvider) Name

func (K8sClusterProvider) Name() string

Name returns the provider identifier.

func (K8sClusterProvider) Observe added in v0.1.45

func (p K8sClusterProvider) Observe(ctx context.Context, group ServiceGroup) (Observed, error)

Observe reads each service's Deployment status out of the cluster.

Why the Deployment's status and not the pods

A Deployment's status subresource is the cluster's OWN summary of a rollout: the controller maintains updatedReplicas / readyReplicas / availableReplicas against the current pod template. Listing pods and counting them would re-derive that summary, and re-derive it WRONGLY during a rollout — a pod from the previous ReplicaSet is ready, and counting it as ready would report a rollout complete while half of it is still the old version. updatedReplicas is the field that carries that distinction, and it exists only on the Deployment.

Why the digest comes from the pod template

The image on `.spec.template.spec.containers[0].image` is what the Deployment is CURRENTLY ASKING FOR. An image pinned by digest (`repo@sha256:…`) yields a content-addressed identity a reconciler can compare against a release ledger; an image pinned by mutable tag yields nothing comparable, so Digest stays empty rather than carrying a tag a caller might mistake for an identity. That empty is not a failure — forge's own --no-digest path produces it deliberately — so it does not downgrade health on its own.

Context is declarative, exactly as it is for a write

The kubectl context is group.Cluster, with NO fallback to the active context. A write (cluster.Apply) refuses outright on an empty context; a read cannot corrupt anything, but it CAN report another cluster's state as this environment's, which is a worse outcome than no answer. So an empty context reports unknown rather than reading whatever context happens to be selected.

type K8sClusterSpec

type K8sClusterSpec struct {
	Replicas int
	Platform string
	Ports    []int

	// OwnedClaims names the PersistentVolumeClaims forge ITSELF emits
	// for this workload, so an observation can be accountable for them.
	//
	// The distinction is ownership, not usage. A forge.Volume of type
	// "pvc" REFERENCES a claim by name and leaves its existence to
	// whoever provisioned it — forge has no standing to report on a
	// claim it did not create. A workload's `storageGiB` is the one
	// case where forge emits the PVC (pkg/deploy.RenderWorkloads, named by PVCName), and a thing forge creates is a thing forge must be
	// able to read back.
	//
	// It matters because an unbound claim is invisible in the half of
	// the state this observation otherwise reads. A Deployment whose PVC
	// never binds reports "0/1 ready" — true, and it names the symptom
	// while the cause (no default StorageClass on this cluster, or a
	// storage_gib change the class refused) sits one object away.
	//
	// Empty for an ordinary cluster service, which is why this is a
	// nil-able slice rather than a bool plus a naming convention: the
	// observer asks the group what forge owns instead of re-deriving
	// "<name>-data" and hoping the render still agrees.
	OwnedClaims []string
}

K8sClusterSpec is the per-service portion of a K8sCluster deploy target. Env-wide fields (cluster/namespace/registry/domain) live on the ServiceGroup, not here.

Ingress used to be a per-service field; it now lives at the Gateway/HTTPRoute level (see kcl/schema.k, internal/cli/kcl_render.go KCLEntities.Gateways), with routes referencing services by name.

type LocalSourcemaps added in v0.1.46

type LocalSourcemaps struct {
	// ProjectDir is the project root the .forge directory lives under, and Env
	// the deploy env the maps are kept for.
	ProjectDir, Env string
}

LocalSourcemaps keeps maps on disk for an environment whose observability is not hosted: nothing is uploaded.

func (LocalSourcemaps) PublishSourcemaps added in v0.1.46

func (l LocalSourcemaps) PublishSourcemaps(_ context.Context, b SourcemapBuild) (SourcemapsRecord, error)

PublishSourcemaps implements SourcemapPublisher.

type ObservationUnsupportedError added in v0.1.45

type ObservationUnsupportedError struct {
	// Provider is the declining provider's Name().
	Provider string
	// Reason states what makes the target unreadable, in the user's
	// terms. Required — an unsupported with no reason tells a user
	// nothing they can act on.
	Reason string
}

ObservationUnsupportedError carries which provider declined and why.

func (ObservationUnsupportedError) Error added in v0.1.45

func (ObservationUnsupportedError) Unwrap added in v0.1.45

Unwrap makes errors.Is(err, ErrObservationUnsupported) true.

type Observed added in v0.1.45

type Observed struct {
	// ProviderID is the Name() of the provider that produced this, so a
	// merged multi-group report stays attributable.
	ProviderID string `json:"provider"`

	// ObservedAt is when the read happened, wall-clock UTC. A reconciler
	// comparing declared against observed needs to know how stale the
	// observation is; without it, a cached or retried observation is
	// indistinguishable from a fresh one.
	ObservedAt time.Time `json:"observed_at"`

	// Items is the per-service / per-frontend detail.
	Items []ObservedItem `json:"items"`
}

Observed is one provider's answer for one ServiceGroup.

func ObserveGroups added in v0.1.45

func ObserveGroups(ctx context.Context, reg *Registry, groups []ServiceGroup) ([]Observed, error)

ObserveGroups reads every group through its registered provider and returns one Observed per group, in input order.

It NEVER fails the whole read because one provider declined: ErrObservationUnsupported is folded into the returned Observed (whose items already read as unknown) and dropped from the error set, because "this tier is unobservable" is an ANSWER, not a failure. Any other error is joined and returned alongside whatever was successfully observed — a partial observation is still worth more than none, as long as the gaps are visible, which they are: an unreadable group's items stay HealthUnknown.

A group whose ProviderID is not registered is reported as unknown rather than skipped. Skipping it would shrink the report to the tiers forge happens to understand, which is the silent-green shape this verb exists to prevent.

type ObservedItem added in v0.1.45

type ObservedItem struct {
	// Name is the service or frontend name, matching
	// ResolvedService.Name / StaticSiteFrontend.Name so a caller can join
	// an observation back to what was declared.
	Name string `json:"name"`

	// Digest is the content-addressed identity of what is RUNNING: an
	// image digest for a container tier, the live release digest for a
	// static site. Empty means the provider could not determine it —
	// which includes the common, legitimate case of an image pinned by
	// mutable tag rather than by digest.
	Digest string `json:"digest,omitempty"`

	// Replicas is the scale observation, or nil when the tier has no
	// replica concept. Nil and &ReplicaCounts{} mean different things:
	// "not applicable" versus "nothing is running".
	Replicas *ReplicaCounts `json:"replicas,omitempty"`

	// Health is the provider's verdict. Zero value is HealthUnknown.
	Health Health `json:"health"`

	// Detail is one line of human-readable context, and it is REQUIRED
	// whenever Health is not HealthHealthy. An "unknown" with no reason
	// is indistinguishable from a provider that silently failed, which is
	// the exact confusion this whole verb exists to remove.
	Detail string `json:"detail,omitempty"`
}

ObservedItem is what one provider currently sees for one service or frontend. Kept to four facts — identity, running digest, scale, health — on purpose.

THE TEMPTATION TO GROW THIS IS THE THING TO RESIST. Every tier has something it could add (pod conditions, a CDN cache age, a Fly machine id), and a union struct carrying all of them would force every provider to fill fields that mean nothing to it, and every consumer to guess which are populated. Anything tier-specific belongs behind the kind dispatch — i.e. in the provider — not here.

type Provider

type Provider interface {
	// Name returns the provider's stable identifier — used in log
	// output and error messages so users can tell which provider
	// produced a given line.
	Name() string

	// Deploy ships every service in the group. The provider owns the
	// in-pipeline ordering (e.g. K8sClusterProvider does one
	// kubectl-apply for all services at once because they share a
	// cluster/namespace).
	Deploy(ctx context.Context, group ServiceGroup) error

	// Observe reports what this provider currently sees deployed, so a
	// caller can compare it against what was declared.
	//
	// A READ with no side effects: it may run against a target forge has
	// never deployed to, and it must never mutate one. Deploy describes
	// a transition; this is the only verb that answers
	// "what is actually running right now", which is what a reconciler
	// is built around.
	//
	// A provider that cannot read its target back returns
	// ErrObservationUnsupported (via the unsupported helper) — never a
	// green Observed. See observe.go for why unknown is the zero value
	// on every field that could otherwise default to "fine".
	Observe(ctx context.Context, group ServiceGroup) (Observed, error)
}

Provider is the dispatch surface for one deploy target type. Each concrete provider owns the pipeline for its target (k8s, hosted, compose, etc.); the dispatcher in forge env deploy hands it a ServiceGroup and the provider does the rest.

There is deliberately no Rollback verb. Recovery is ROLL FORWARD: ship a new release (or promote a newer build) through Deploy. A "go back to the previous revision" verb claims to undo a release it cannot undo — the release has already written data and run migrations the older code never saw — so forge does not offer one.

type RawK8sCluster

type RawK8sCluster struct {
	Cluster   string
	Namespace string
	Registry  string
	Domain    string
	Spec      *K8sClusterSpec
}

RawK8sCluster combines the env-wide K8sCluster fields (which key the group) with the per-service spec (which the provider consumes). Kept separate from the group-level fields so GroupServices can read them without unpacking K8sClusterSpec twice.

type RawService

type RawService struct {
	Name string

	// K8sCluster carries both the env-wide fields (used for grouping)
	// and the per-service spec (carried through to the provider).
	K8sCluster *RawK8sCluster

	Compose   *ComposeSpec
	HostInfra *HostInfraSpec

	// Secrets carries resolved secret values to inline into the runtime
	// env for Compose services. Carried verbatim onto the
	// ResolvedService. nil for K8sCluster services (those get rendered
	// Secret objects, not inlined values) and for external/none
	// providers.
	Secrets map[string]string
}

RawService is the input shape for GroupServices — one entry per rendered Service, with the deploy union already dispatched to the matching variant. Exactly one of K8sCluster / Compose / HostInfra is non-nil for services the dispatcher should ship; all nil means "skip" (host / build-only / no deploy declared).

type Registry

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

Registry holds the set of Providers registered with the dispatcher. In forge today there's one canonical registry built by NewRegistry; tests can construct their own to swap in fakes.

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns a Registry pre-populated with the canonical forge providers (k8s-cluster + compose + host-infra + hosted + frontends). Callers that need to inject test doubles should construct an empty Registry and Register the doubles directly.

The Firebase provider is registered with its zero value here; the deploy dispatcher re-registers a ProjectDir-configured one (the same pattern K8sClusterProvider uses for its ApplyOptsBuilder) before dispatching the frontend group.

func (*Registry) IDs added in v0.1.45

func (r *Registry) IDs() []string

IDs returns every registered provider id, sorted.

It exists so a test can ENUMERATE the registry rather than restate it. A hand-written list of providers in a test is a second declaration of the same fact, and the failure mode is silence: a provider added to NewRegistry and not to the list is simply never checked, which is the exact shape — a clean board for something nobody measured — that the observation contract exists to prevent. See TestEveryRegisteredProvider ObservesOrDeclares in observe_audit_test.go.

func (*Registry) Lookup

func (r *Registry) Lookup(id string) Provider

Lookup returns the provider for an id, or nil if none registered. Callers should treat a nil return as "no provider for this target type" and emit a friendly error pointing at the migration skill.

func (*Registry) Register

func (r *Registry) Register(p Provider)

Register adds (or replaces) a provider under its declared Name(). Safe on a zero-value Registry (lazy-inits the map) so tests can do `r := &Registry{}; r.Register(fake)` without going through NewRegistry — matches the package doc-comment promise.

type RegistrySourcemaps added in v0.1.46

type RegistrySourcemaps struct {
	// ProjectDir and Env say where the record is written
	// (.forge/state/sourcemaps-<env>-<frontend>.json). ProjectDir empty means ".".
	ProjectDir, Env string
	// Base resolves the project's push base (`<host>/<org>/<project>`). It is
	// called at the first publish, not at construction, so a deploy that
	// extracted no map never needs the credential that resolving it takes.
	Base func(ctx context.Context) (string, error)
	// Push stores the packed artifact. nil is the real registry
	// (PushSourcemapsArtifact); tests state the registry's answer.
	Push func(ctx context.Context, repository, tag string, b SourcemapBuild, layer []byte) (digest string, err error)
}

RegistrySourcemaps files maps in the project's registry subtree.

func (RegistrySourcemaps) PublishSourcemaps added in v0.1.46

func (r RegistrySourcemaps) PublishSourcemaps(ctx context.Context, b SourcemapBuild) (SourcemapsRecord, error)

PublishSourcemaps implements SourcemapPublisher.

type ReplicaCounts added in v0.1.45

type ReplicaCounts struct {
	// Desired is what the target was asked for (Deployment.spec.replicas).
	Desired int `json:"desired"`
	// Ready is how many are passing their readiness probe.
	Ready int `json:"ready"`
	// Updated is how many are running the CURRENT pod template — the
	// count that distinguishes "rolled out" from "rolling out", which a
	// ready count alone cannot.
	Updated int `json:"updated"`
	// Available is how many have stayed ready past minReadySeconds.
	Available int `json:"available"`
}

ReplicaCounts is the scale half of an observation, for tiers that have a replica concept at all. The four fields are Kubernetes' own vocabulary because that is the tier that has them; a provider without replicas leaves ObservedItem.Replicas nil rather than filling zeros.

type ResolvedService

type ResolvedService struct {
	Name string

	// Exactly one of the following is non-nil. Discriminated by the
	// owning ServiceGroup.ProviderID, but kept as separate pointers
	// so each provider's Deploy method can type-assert against its
	// own concrete shape without a runtime switch.
	K8sCluster *K8sClusterSpec
	Compose    *ComposeSpec
	HostInfra  *HostInfraSpec
	Hosted     *HostedWorkload

	// Secrets carries resolved secret values to inject into the runtime
	// env (compose). Populated by the deploy dispatch from a
	// dotenv secret_provider's All() map; nil for external/none providers
	// (those resolve secrets out-of-band) and always nil for K8sCluster
	// services (those get rendered Secret objects + secretKeyRef, not
	// inlined env values). Merged UNDER the env_file overlay so an
	// explicit env_file entry wins on key conflict.
	Secrets map[string]string
}

ResolvedService is one service in a group, with its deploy block already dispatched by type. Exactly one of K8sCluster/Hosted/ Compose/HostInfra is non-nil; the dispatcher discards services with HostDeploy/BuildOnly (those aren't in any deploy-target group).

type ServiceGroup

type ServiceGroup struct {
	// Env is the environment name (matches the deploy/kcl/<env>/
	// directory name and the `forge env deploy <env>` CLI arg).
	Env string

	// ProviderID identifies the provider type — "k8s-cluster",
	// "hosted", "compose". Used by the dispatcher to look the
	// provider up and by log output to tag lines per-group.
	ProviderID string

	// Services is the per-service list. Each entry's Deploy field is
	// the dispatched view of the rendered KCL — see ResolvedService.
	Services []ResolvedService

	// Frontends carries the frontends the "firebase" provider ships.
	// It's the frontend analogue of Services — empty for the
	// service-shaped providers (k8s-cluster / compose / host-infra), which
	// read Services instead. The Firebase provider reads Frontends +
	// DryRun off the group so it dispatches through the registry like
	// every other provider.
	Frontends []FirebaseFrontend

	// StaticSites carries the frontends the "static-site" provider
	// ships. A separate field rather than a shared one because the two
	// frontend providers consume genuinely different specs (a hosting
	// site id vs. a bucket + CDN + retention policy), and a group only
	// ever routes to ONE provider — so a union type here would buy a
	// type assertion in both providers and nothing else.
	StaticSites []StaticSiteFrontend

	// Hosted is the env-level half of a "hosted" group: the control plane's
	// endpoint and the bound release's digests. Nil for every other
	// provider. Services carry the per-workload tier specs.
	Hosted *HostedTarget

	// HostedPlan is the plan a hosted group is admitted under: the
	// standard (billed) plan, judged by the shape band, or the anonymous
	// claimable plan, judged by its caps (deploy.CheckHostedResources). A
	// field rather than a property of the credential, so a preview with no
	// credential (`forge env shape --claimable`, `--claimable --plan-only`)
	// is judged exactly as the deploy will be.
	HostedPlan deploy.HostedPlan
	// ClaimableLimits are the claimable plan's caps HostedPlanClaimable is
	// judged by: the control plane's published answer, or forge's
	// compiled-in copy when it could not be asked. Zero (an unset field)
	// means the compiled-in copy.
	ClaimableLimits deploy.ClaimableLimits

	// ImageTag is the tag forge built (or is about to build) for
	// these services. Passed through to the provider so it can stamp
	// the image references correctly.
	ImageTag string

	// Common K8sCluster fields. Pulled from the first service in the
	// group (KCL refs guarantee they're identical across the group).
	// Empty for non-cluster providers.
	Cluster   string
	Namespace string
	Registry  string
	Domain    string

	// DryRun, when true, instructs the provider to print the exact
	// commands it would exec instead of running them, and skip any
	// state-file writes. Providers honor this independently of the
	// cluster.ApplyOpts.DryRun knob (K8sCluster's provider plumbs it
	// through ApplyOpts; Compose checks this field
	// directly because they don't go through cluster.Apply).
	DryRun bool
}

ServiceGroup is a set of services that share a deploy target — same provider type, same cluster/host/compose-file. The dispatcher groups by the (Provider, target-identifier) tuple so each group can flow through one provider invocation.

The common env-wide fields (Cluster/Namespace/Registry/Domain) live on the group because K8sCluster refs make them identical across every service in the group. The provider reads them off the group rather than re-deriving them from each ResolvedService.

func GroupServices

func GroupServices(env string, services []RawService) ([]ServiceGroup, error)

GroupServices walks a rendered service list and returns the deploy groups it should be split into. Services with deploy types `host` and `build-only` are NOT included — those are owned by `forge env up` and `forge build`.

Cluster grouping rule: services sharing a (Cluster, Namespace, Registry) tuple end up in one group. This handles the typical pattern (single K8sCluster ref attached to many services) AND per-service overrides via `_prod_k8s | { replicas = 5 }` (which preserves cluster/namespace/registry so the override service joins the same group).

Compose grouping rule: services sharing a ComposeFile end up in one group.

The returned groups are sorted by ProviderID then by the target-identifier so test output is deterministic.

func (ServiceGroup) ClaimableCaps added in v1.0.1

func (g ServiceGroup) ClaimableCaps() deploy.ClaimableLimits

ClaimableCaps is the caps a claimable group is judged by: ClaimableLimits, or the compiled-in copy when none were resolved.

type SourcemapBuild added in v0.1.46

type SourcemapBuild struct {
	// Project and Frontend name the build in the artifact's annotations.
	Project, Frontend string
	// BuildID is the bundle's service.version and the key the maps are filed
	// under.
	BuildID string
	// Dir is the sidecar directory (SourcemapsDir of the staging tree).
	Dir string
	// Maps is how many maps the sidecar holds.
	Maps int
}

SourcemapBuild is one build's extracted maps, ready to be filed.

func ExtractDistSourcemaps added in v0.1.46

func ExtractDistSourcemaps(in DistSourcemaps) (b SourcemapBuild, cleanup func(), err error)

ExtractDistSourcemaps does for a built directory what the stage does for a staged one: moves every map out of a COPY of it into a sidecar, with a manifest. The original is not touched. cleanup removes the copy and the sidecar.

type SourcemapFile added in v0.1.46

type SourcemapFile struct {
	Path   string `json:"path"`
	Map    string `json:"map"`
	SHA256 string `json:"sha256"`
}

SourcemapFile is one extracted map. Path is the generated file it maps (assets/index-abc.js), Map is the map's own path in the sidecar (assets/index-abc.js.map).

type SourcemapManifest added in v0.1.46

type SourcemapManifest struct {
	Schema   string          `json:"schema"`
	Project  string          `json:"project"`
	Frontend string          `json:"frontend"`
	BuildID  string          `json:"build_id"`
	BasePath string          `json:"base_path"`
	Files    []SourcemapFile `json:"files"`
}

SourcemapManifest is manifest.json: which maps the sidecar holds and the build they belong to.

Every path is relative to the site root, which is also the staging root, so a generated file's Path is its URL path with the origin and the leading slash removed (BasePath is already part of it; it is recorded for readers that want to know the mount).

type SourcemapPublisher added in v0.1.46

type SourcemapPublisher interface {
	PublishSourcemaps(ctx context.Context, b SourcemapBuild) (SourcemapsRecord, error)
}

SourcemapPublisher files one build's maps. The static providers call it after the stage and BEFORE anything goes live, so a build whose maps could not be filed never ships: it would be an unsymbolicatable build in production. nil on a provider means maps are extracted (F1) and not filed.

type SourcemapsRecord added in v0.1.46

type SourcemapsRecord struct {
	BuildID    string `json:"build_id"`
	Frontend   string `json:"frontend"`
	Maps       int    `json:"maps"`
	Repository string `json:"repository,omitempty"`
	Tag        string `json:"tag,omitempty"`
	Digest     string `json:"digest,omitempty"`
	Dir        string `json:"dir,omitempty"`
	FiledAt    string `json:"filed_at"`
}

SourcemapsRecord is the outcome of filing a build's maps. Repository and Digest are set for a registry push; Dir for a local copy.

func ReadSourcemapsState added in v0.1.46

func ReadSourcemapsState(projectDir, env, frontend string) (*SourcemapsRecord, error)

ReadSourcemapsState returns what the last build of frontend filed, or (nil, nil) when nothing was.

func (SourcemapsRecord) Pushed added in v0.1.46

func (r SourcemapsRecord) Pushed() bool

Pushed reports whether the maps went to a registry (and so can be recorded in a release), rather than to a local directory.

type StageInput added in v0.1.45

type StageInput struct {
	// Name is the forge frontend name (logging, error prefixes, and the
	// default staging directory name).
	Name string

	// Path is the frontend source dir — where install and build run.
	// Relative paths resolve against ProjectDir.
	Path string

	// DevRunner is "npm" (default) | "pnpm" | "yarn"; selects the
	// install command. The build command is always `npm run build`.
	DevRunner string

	// BuildEnv is the build-time env injected into the build process
	// (NEXT_PUBLIC_* / VITE_*), layered over NODE_ENV=production.
	BuildEnv map[string]string

	// PublicDir is the build output directory, relative to Path.
	PublicDir string

	// BasePath is the sub-path under the site root to mount PublicDir
	// under ("/admin"). Empty means the site root.
	BasePath string

	// Bundle is the extra pre-built static dirs to assemble into the
	// same tree. Src resolves against ProjectDir.
	Bundle []BundleDirSpec

	// RuntimeConfigJS is this ENVIRONMENT's rendered runtime config
	// document. Written into the assembled tree AFTER every copy, which
	// is what makes promotion real: the built bundle is
	// environment-agnostic and this one file is the only part of the
	// artifact that differs between environments. Empty means the
	// frontend declares no typed config and nothing is written.
	RuntimeConfigJS string

	// StripRuntimeConfig deletes <basePath>/config.js from the assembled
	// tree instead of writing one: the environment-agnostic artifact a
	// hosted release ships. See StaticSiteFrontend.StripRuntimeConfig.
	StripRuntimeConfig bool

	// ProjectDir is the project root that Path and Bundle[].Src resolve
	// against. Empty means the current working directory.
	ProjectDir string

	// StagingRoot overrides where the assembled tree is written. Empty
	// means a temp dir under os.TempDir().
	StagingRoot string

	// Project is the forge project name, recorded in the source map manifest.
	Project string

	// VersionEnvVar is the framework-scoped variable the bundle reads its
	// service.version from (NEXT_PUBLIC_APP_VERSION / VITE_APP_VERSION).
	// forge sets it to the build id unless BuildEnv already sets it. Empty
	// means the frontend has no such variable and no id is injected.
	VersionEnvVar string

	// Sourcemaps is "publish" (default: maps leave the served tree for a
	// sidecar), "public" (maps are served) or "off" (maps are dropped).
	Sourcemaps string
}

StageInput is everything the shared build-and-assemble step needs, with no target-specific field on it. Both FirebaseFrontend and StaticSiteFrontend project onto this.

type StagePlan added in v0.1.45

type StagePlan struct {
	Name        string
	FrontendDir string // absolute frontend source dir
	InstallCmd  []string
	BuildCmd    []string
	BuildEnv    map[string]string
	StagingDir  string // absolute assembled public root

	// Copies is the ordered list of (absoluteSrc → relativeDestUnderStaging)
	// the assembler performs. The first entry is always the frontend's own
	// public_dir (mounted under base_path); the rest are bundle dirs.
	Copies []stageCopy

	// RuntimeConfigJS is the environment's runtime config document, and
	// RuntimeConfigRel is where it lands relative to the staging root —
	// inside the base-path subtree, because that is where the document
	// head's <script src="<basePath>/config.js"> resolves it. Empty
	// RuntimeConfigJS means no document is written.
	RuntimeConfigJS  string
	RuntimeConfigRel string
	// StripRuntimeConfig removes RuntimeConfigRel from the tree after the
	// copies rather than writing it.
	StripRuntimeConfig bool

	// Project, VersionEnvVar, Sourcemaps and BasePath are the StageInput
	// fields the build-identity and source map steps need. Sourcemaps is
	// already resolved (never empty).
	Project       string
	VersionEnvVar string
	Sourcemaps    string
	BasePath      string

	// BuildID is the build's identity, set by runStagePlan before the build
	// runs. Empty in a plan that has not run (a dry-run) or has no
	// VersionEnvVar.
	BuildID string
}

StagePlan is the resolved, side-effect-free description of one frontend's build-and-assemble. It is computed first so --dry-run can print it without touching the filesystem or shelling out, and the real path executes against the same plan.

type StageResult added in v0.1.46

type StageResult struct {
	// BuildID is injected into the bundle as service.version and keys the
	// source maps. Empty when the frontend has no version variable.
	BuildID string
	// SourcemapsDir is the sidecar holding the extracted maps and
	// manifest.json; empty when none were extracted.
	SourcemapsDir string
	// Sourcemaps is how many maps were extracted.
	Sourcemaps int
}

StageResult is what a stage run learned that the plan could not know.

func BuildStaticSiteTree added in v0.1.45

func BuildStaticSiteTree(ctx context.Context, projectDir string, fe StaticSiteFrontend) (string, StageResult, error)

BuildStaticSiteTree builds and assembles one frontend with the shared StagePlan and returns the assembled directory — the exact tree the self-hosted executor would upload.

The tree holds no source map. The StageResult says what the build id was and where the extracted maps went, so a caller can file them under it.

type StaticSiteCDNSpec added in v0.1.45

type StaticSiteCDNSpec struct {
	URLMap               string
	Invalidate           string // "entrypoints" (default) | "none" | "all"
	ExtraInvalidatePaths []string
}

StaticSiteCDNSpec is the CDN in front of the bucket and the per-deploy invalidation policy. A nil *StaticSiteCDNSpec means the bucket is served directly and a deploy invalidates nothing — deliberately distinct from a declared CDN whose Invalidate is "none" (a CDN exists; this deploy leaves its cache alone).

type StaticSiteFrontend added in v0.1.45

type StaticSiteFrontend struct {
	// Name is the forge frontend name (logging + the staging dir name).
	Name string

	// Path is the frontend source dir relative to the project root —
	// where install / `npm run build` run.
	Path string

	// DevRunner is "npm" (default) | "pnpm" | "yarn"; selects the
	// install command.
	DevRunner string

	// BuildEnv is the build-time env injected into the build process
	// (NEXT_PUBLIC_* / VITE_*).
	BuildEnv map[string]string

	// Project is the forge project name (recorded in the source map manifest).
	Project string

	// VersionEnvVar is the variable the bundle reads service.version from
	// (NEXT_PUBLIC_APP_VERSION / VITE_APP_VERSION); forge injects the build id
	// into it unless BuildEnv sets it. Empty injects nothing.
	VersionEnvVar string

	// Sourcemaps is "publish" (default), "public" or "off". See the
	// Sourcemaps* constants.
	Sourcemaps string

	// RuntimeConfigJS is this ENVIRONMENT's rendered runtime config
	// document, written into the assembled tree after every copy. See
	// FirebaseFrontend.RuntimeConfigJS — identical semantics, and the
	// reason a promoted bundle can carry different configuration
	// without rebuilding.
	RuntimeConfigJS string

	// StripRuntimeConfig removes any runtime config document that
	// travelled inside the built bundle (the dev copy under the frontend's
	// public/) instead of writing one. Set for a HOSTED release, whose
	// artifact must be environment-agnostic: its document is spec, written
	// by the control plane after each sync. Mutually exclusive with a
	// non-empty RuntimeConfigJS.
	StripRuntimeConfig bool

	// SourcemapPublisher files the source maps the build extracts (F1) so
	// errors can be symbolicated: pushed to the platform registry when the
	// env's observability is hosted, kept on disk otherwise. nil files nothing
	// (the maps are still extracted, and never served).
	SourcemapPublisher SourcemapPublisher

	// Spec is the StaticSite deploy config.
	Spec StaticSiteSpec
}

StaticSiteFrontend is one frontend the StaticSite provider should deploy: the resolved build inputs plus the StaticSite spec. The CLI builds this from the rendered KCL FrontendEntity; tests construct it directly. Mirrors FirebaseFrontend field-for-field on the build half.

type StaticSiteProvider added in v0.1.45

type StaticSiteProvider struct {
	// ProjectDir is the project root. Frontend paths and Bundle.Src
	// resolve against it. Empty means the current working directory.
	ProjectDir string

	// Runner is the os/exec indirection (npm / gcloud). Nil falls back
	// to the package default. Tests inject a fake runner.
	Runner commandRunner
}

StaticSiteProvider publishes a frontend's assembled static tree to an object-storage bucket, optionally invalidating a CDN in front of it.

It shares the ENTIRE build-and-assemble half with FirebaseProvider — both call buildStagePlan/runStagePlan in staticstage.go — and differs only in what happens to the finished tree. That split is deliberate: the details that are easy to get subtly wrong (which NODE_ENV each phase runs under, that the runtime config document is written after the copies so a dev config.js cannot ship to prod) are the ones a second copy would drift on first.

The two prefixes

A deploy uploads the assembled tree TWICE:

<bucket>/releases/<digest>/…   immutable archive, never mutated
<bucket>/live/…               what the CDN serves, synced from it

<digest> is a content hash over the assembled tree (stagingDigest), so identical content lands at an identical path and re-deploying unchanged content overwrites itself byte-for-byte.

This is what makes promotion real rather than aspirational: PROMOTION re-points an environment's live/ at an EXISTING digest. Never a rebuild, so it cannot produce different bytes than the release that was tested.

Retention

After a successful deploy, releases beyond KeepReleases are deleted newest-first — but the digest now live and the digest previously live are ALWAYS exempt, whatever the count says. A browser that loaded the previous release's HTML moments ago is still fetching its hashed assets; deleting its prefix turns that page into 404s mid-session. The KCL schema refuses keep_releases = 1 for the same reason, so the exemption is a belt-and-braces floor rather than the only guard.

Invalidation

See invalidationPaths. The default purges only the paths that cannot be content-addressed, because Cloud CDN meters invalidation requests per project and a naive /* on every push exhausts that quota for a team iterating on a preview environment.

--dry-run prints the resolved plan — build command, assembled layout, the exact upload/sync commands, and what would be invalidated — and performs NO build, NO upload, and NO invalidation.

func (StaticSiteProvider) Deploy added in v0.1.45

func (p StaticSiteProvider) Deploy(ctx context.Context, group ServiceGroup) error

Deploy ships every frontend in the group to its bucket. Reads the frontends off group.StaticSites and the dry-run knob off group.DryRun so this provider dispatches through the registry like every other one.

func (StaticSiteProvider) Name added in v0.1.45

func (StaticSiteProvider) Name() string

Name returns the provider identifier.

func (StaticSiteProvider) Observe added in v0.1.45

func (p StaticSiteProvider) Observe(ctx context.Context, group ServiceGroup) (Observed, error)

Observe reports which release digest each frontend's live prefix is currently serving.

Why the recorded state, checked against the bucket

A static site has no process to ask, so "what is running" is "what does the live prefix hold". The bucket cannot answer that directly: live/ is a SYNCED COPY of a release prefix, not a pointer at one, so listing it tells you the files and not which release they came from. The deploy path already records the answer (WriteDeployState after every deploy), so the digest comes from there.

A recorded value alone would be a CLAIM, not an observation — it says what forge did last, which stops being true the moment retention, a bucket lifecycle rule or a human `gcloud storage rm` removes the archive underneath it. So the recorded digest is CONFIRMED against the bucket's actual release listing, and a digest whose archive is gone reports degraded: live/ is still serving those bytes today, but the archive behind it has evaporated, so no environment can be re-pointed at that release again.

That check is also the only thing here that costs a network call, and it is the reason this is an observation rather than a state-file read.

type StaticSiteSpec added in v0.1.45

type StaticSiteSpec struct {
	Bucket    string
	PublicDir string
	BasePath  string
	Bundle    []BundleDirSpec

	// AssetDir is the build's content-hashed asset directory relative to
	// the frontend's mount ("assets", "_next/static"); empty when the
	// build declares none. It is what the default cache policy marks
	// immutable (see cacheRules).
	AssetDir string

	// CacheControl is the frontend's declared Cache-Control rules (first
	// match wins). Empty means forge's default, the same one OnFirebase
	// and hosted static hosting serve: AssetDir immutable, everything
	// else no-cache.
	CacheControl []CacheRuleSpec

	CDN          *StaticSiteCDNSpec
	KeepReleases int
}

StaticSiteSpec mirrors the kcl/schema.k StaticSite schema (and the CLI-side StaticSiteDeploy entity). Kept in this package so the provider has no import on internal/cli.

Jump to

Keyboard shortcuts

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