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
- Variables
- func BuildStaticSiteTree(ctx context.Context, projectDir string, fe StaticSiteFrontend) (string, error)
- func DomainStateName(wire string) string
- func EnsureHostedEnvironment(ctx context.Context, c HostedCaller, ref HostedEnvRef) (string, bool, error)
- func FormatCustomDomains(indent string, domains []HostedCustomDomain) string
- func FormatGroupSummary(g ServiceGroup) string
- func HostedArtifactKey(svc ResolvedService) string
- func HostedArtifactName(image string) string
- func HostedImageDigest(image string) string
- func HostedImageRepository(image string) string
- func HostedStaticRepository(image string) string
- func LookupHostedEnvironment(ctx context.Context, c HostedCaller, project, envName string) (string, error)
- func PackStaticSiteTree(dir string) ([]byte, error)
- func ProvenanceWireFields(p release.Provenance) map[string]any
- func PushStaticSiteArtifact(ctx context.Context, repository string, layer []byte) (string, error)
- func VerdictName(wire string) string
- func WriteDeployState(projectDir, provider, env, service string, st DeployState) (string, error)
- type BuildOnlyFrontend
- type BundleDirSpec
- type CacheRuleSpec
- type CapacityDemand
- type CapacityRefusedError
- type CapacityVerdict
- type ClusterBinding
- type ComposeProvider
- type ComposeSpec
- type DeployState
- type FirebaseFrontend
- type FirebaseHostingSpec
- type FirebaseProvider
- func (p FirebaseProvider) BuildOnly(ctx context.Context, fes []BuildOnlyFrontend, dryRun bool) error
- func (p FirebaseProvider) Deploy(ctx context.Context, group ServiceGroup) error
- func (FirebaseProvider) Name() string
- func (p FirebaseProvider) Observe(_ context.Context, group ServiceGroup) (Observed, error)
- type HTTPDoer
- type Health
- type HeldError
- type HostInfraProvider
- type HostInfraSpec
- type HostedCaller
- type HostedCustomDomain
- type HostedDNSRecord
- type HostedEnvKind
- type HostedEnvRef
- type HostedEnvStatus
- type HostedHold
- type HostedPreflightItem
- type HostedProvider
- type HostedRecord
- type HostedTarget
- type HostedTier
- type HostedWorkload
- type HostedWorkloadStatus
- type K8sClusterProvider
- func (p K8sClusterProvider) ApplyNoWait(ctx context.Context, group ServiceGroup) (*cluster.PendingRollout, error)
- func (p K8sClusterProvider) Deploy(ctx context.Context, group ServiceGroup) error
- func (K8sClusterProvider) Name() string
- func (p K8sClusterProvider) Observe(ctx context.Context, group ServiceGroup) (Observed, error)
- type K8sClusterSpec
- type ObservationUnsupportedError
- type Observed
- type ObservedItem
- type Provider
- type RawK8sCluster
- type RawService
- type Registry
- type ReplicaCounts
- type ResolvedService
- type ServiceGroup
- type StageInput
- type StagePlan
- type StaticSiteCDNSpec
- type StaticSiteFrontend
- type StaticSiteProvider
- type StaticSiteSpec
Constants ¶
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.
const ( DomainStatePendingDNS = "pending_dns" DomainStateVerifying = "verifying" DomainStateIssuing = "issuing" DomainStateLive = "live" DomainStateFailed = "failed" DomainStateConflict = "conflict" DomainStateUnknown = "unknown" )
The domain-state vocabulary forge reports.
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.
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" )
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.
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.
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.
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.
const HostedProviderID = "hosted"
HostedProviderID is the registry id of the hosted provider.
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.
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 ¶
var ErrHostedEnvironmentNotFound = errors.New("the control plane has no environment")
ErrHostedEnvironmentNotFound is LookupHostedEnvironment's "no environment of that name" answer.
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.
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 BuildStaticSiteTree ¶ added in v0.1.45
func BuildStaticSiteTree(ctx context.Context, projectDir string, fe StaticSiteFrontend) (string, 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.
func DomainStateName ¶ added in v0.1.45
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 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 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
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 HostedImageDigest ¶ added in v0.1.45
HostedImageDigest is the digest an image reference is pinned by, or "".
func HostedImageRepository ¶ added in v0.1.45
HostedImageRepository is an image reference with its digest or tag removed.
func HostedStaticRepository ¶ added in v0.1.45
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 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 PackStaticSiteTree ¶ added in v0.1.45
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 PushStaticSiteArtifact ¶ added in v0.1.45
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 VerdictName ¶ added in v0.1.45
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.
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
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
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.
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
// 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 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
// 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
// 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:
- 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.
- 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.
- 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 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
MarshalJSON emits the string form so `forge env observe --json` is readable by a human and by jq, rather than emitting the iota.
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.
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
}
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
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"`
}
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 when the control plane's hub converges
// the recorded bundle onto them and forge applies nothing, false when
// forge applies them from the author's machine (a ledger-only or mixed
// env — control-plane prod). It is the declaration of the decision
// envLedger.HubConverged makes on this side, sent so the control plane
// obeys it instead of inferring it from the bindings: 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
}
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
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
}
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"`
// 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
func (p K8sClusterProvider) ApplyNoWait(ctx context.Context, group ServiceGroup) (*cluster.PendingRollout, error)
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 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 (e ObservationUnsupportedError) Error() string
func (ObservationUnsupportedError) Unwrap ¶ added in v0.1.45
func (e ObservationUnsupportedError) Unwrap() error
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
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
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 ¶
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.
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
// 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.
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
}
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
}
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 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
// 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
// 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
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.
Source Files
¶
- capacity.go
- compose.go
- compose_ownership.go
- deploytarget.go
- envfile.go
- exec.go
- firebase.go
- firebase_assets.go
- firebase_serving.go
- host_infra.go
- hosted.go
- hosted_declaration.go
- hosted_domains.go
- hosted_records.go
- hosted_rollout.go
- k8s_cluster.go
- observe.go
- observe_compose.go
- observe_hosted.go
- observe_hostinfra.go
- observe_k8s.go
- observe_staticsite.go
- observe_unsupported.go
- state.go
- staticsite.go
- staticsite_oci.go
- staticstage.go
- wire_int64.go