Documentation
¶
Overview ¶
Package deploytarget owns the per-service deploy dispatch — the surface that maps a rendered KCL Service.deploy block to a concrete pipeline that ships the service somewhere.
Deploy config is fully owned by KCL: per-service deploy-target schemas (`K8sCluster`, `External`, `Compose`) carry both the env-wide info (cluster, namespace, registry, domain) and the per-service knobs (replicas, ingress, ports). KCL refs DRY the common case across many services:
_prod_k8s = forge.K8sCluster {
cluster = "prod-cluster"; namespace = "kalshi-prod"
registry = "ghcr.io/reliant/kalshi"
}
forge.Service { name = "trader"; deploy = _prod_k8s }
forge.Service { name = "admin"; deploy = _prod_k8s | { replicas = 5 } }
This package walks the rendered services, groups by deploy target (so services that share a cluster/host/compose-file flow through one pipeline invocation), and dispatches to the right Provider.
Providers in this release:
- K8sClusterProvider — wraps internal/cluster.Apply (the existing render-KCL → kubectl-apply → wait-rollouts pipeline). Group- level cluster/namespace come from the first service's K8sCluster.{Cluster,Namespace}.
- ExternalProvider — generic shell-command escape hatch. Run `sh -c <deploy_cmd>` with ${IMAGE}/${TAG}/${SERVICE}/etc. substituted; record last-good tag in .forge/state for rollback. Covers Fly.io / Cloud Run / Cloudflare Workers / ECS / Vercel / systemd-on-VM and any other CLI-driven deploy target.
- ComposeProvider — docker compose pull/up -d. Rollback writes a generated override file pinning the previous tag.
HostDeploy and BuildOnly aren't providers — `forge run` / `forge up` own the host story, and BuildOnly is consumed by `forge build`. The dispatcher skips both rather than routing them through a Provider.
forge:exclude-contract deploytarget is an outbound deploy-dispatch adapter (per-service deploy providers: k8s cluster / external / compose), not a contract-shaped service. Opt out of the require-contract rule.
Index ¶
- Variables
- func ExpandVars(template string, vars map[string]string) string
- func FormatGroupSummary(g ServiceGroup) string
- func WriteDeployState(projectDir, provider, env, service string, st DeployState) (string, error)
- type BuildOnlyFrontend
- type ComposeProvider
- type ComposeSpec
- type DeployState
- type ExternalProvider
- type ExternalSpec
- type FirebaseBundleSpec
- 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 (FirebaseProvider) Rollback(_ context.Context, _ ServiceGroup, _ string) error
- type K8sClusterProvider
- type K8sClusterSpec
- type Provider
- type RawK8sCluster
- type RawService
- type Registry
- type ResolvedService
- type ServiceGroup
Constants ¶
This section is empty.
Variables ¶
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 / External / Compose providers do NOT return this; they implement the full pipeline.
Functions ¶
func ExpandVars ¶
ExpandVars substitutes ${KEY} (and $KEY) tokens in a command-string template against the provided map. Unknown keys are left empty — matches os.Expand's default behaviour and keeps the surprise floor low (a typo in the template surfaces as a missing flag rather than a leaked `${IMAGE}` literal landing on the remote shell).
Intended for any user-supplied shell-command template forge runs via `sh -c` after substituting a documented set of tokens:
- External deploy: DeployCmd / RollbackCmd / HealthCmd, where the kcl/schema.k contract advertises ${IMAGE} / ${TAG} / ${CODE_VERSION} / ${PIPELINE} / ${LAST_TAG} / ${SERVICE} / ${ENV} / ${ENV_FILE} / ${PROJECT_DIR}.
- Service.build_cmd: the build-side escape hatch, where the contract advertises ${IMAGE} / ${TAG} / ${SERVICE} / ${TARGETARCH} / ${REGISTRY} / ${PROJECT_DIR} / ${BUILD_CWD} + keys from `build_env`. See internal/buildtarget for the build-side consumer.
Exported so the build-side runner can use the same substitution semantics the deploy-side External provider uses — one mental model across both the build and deploy escape hatches.
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 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 frontend can assemble it into its hosting bundle. Mirrors the build inputs of FirebaseFrontend minus any deploy spec.
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.
Rollback for docker-compose is a sharper edge: compose itself has no native "go back to the previous revision" affordance. Our strategy:
- Track the last-good image+tag in a per-(env, service) state file (same shape External uses).
- On rollback, generate a temporary override file alongside the state file that pins `image: <name>:<old-tag>`, then run `docker compose -f <main> -f <override> up -d --force-recreate <svc>`. This avoids mutating the user's compose file or requiring them to keep multiple tagged copies around.
- When no state file exists, error loudly — there's nothing to roll back to and guessing would risk shipping a regression.
The override-file approach assumes the docker daemon already has the old image locally (it was pulled by the previous deploy). If it doesn't — e.g. the registry GC'd the tag, or the user wiped /var/lib/docker — the up command surfaces the pull failure and rollback reports it.
func (ComposeProvider) Deploy ¶
func (p ComposeProvider) Deploy(ctx context.Context, group ServiceGroup) error
Deploy ships every service in the group via docker compose.
func (ComposeProvider) Name ¶
func (ComposeProvider) Name() string
Name returns the provider identifier.
func (ComposeProvider) Rollback ¶
func (p ComposeProvider) Rollback(ctx context.Context, group ServiceGroup, lastGoodTag string) error
Rollback restarts every service against its previously recorded good tag via a generated override file. Best-effort: per-service failures are joined into the returned error rather than aborting the loop.
type ComposeSpec ¶
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, unlike `kubectl rollout undo`, have no native "go back to the previous revision" affordance — the provider has to remember the previous good tag itself if the rollback path is going to mean anything.
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 ExternalProvider ¶
type ExternalProvider struct {
// ProjectDir is the project root used for state-file paths and the
// ${PROJECT_DIR} substitution. Empty means "current working
// directory" — the forge CLI sets this explicitly; tests pass a
// t.TempDir().
ProjectDir string
// Runner is the os/exec indirection used to invoke `sh -c`. Nil
// falls back to the package default. Tests inject a fake runner.
Runner commandRunner
}
ExternalProvider deploys each service in a group by exec'ing a user-supplied shell command via `sh -c`. It's the generic escape-hatch deploy target — Fly.io (`flyctl deploy`), Cloudflare Workers (`wrangler deploy`), GCP Cloud Run (`gcloud run deploy`), AWS ECS (`aws ecs update-service`), Vercel, Railway, systemd-on-VM, NixOS, etc. all flow through this one provider.
The provider's responsibilities are deliberately narrow:
- Substitute the documented ${X} tokens into deploy_cmd / rollback_cmd / health_cmd against the merged env map (built-ins + user-declared `env`).
- Run deploy_cmd via `sh -c`. On success, optionally run health_cmd. On both success, persist the (image, tag) tuple to .forge/state/external-<env>-<service>.json so a future rollback has a previous good tag to target.
- Rollback reads the state file, substitutes ${LAST_TAG}, and runs rollback_cmd. When no state file exists or rollback_cmd is unset, return a clear error rather than guess.
The provider does NOT understand the user's CLI. It doesn't know whether `flyctl deploy` succeeded beyond the process exit code, doesn't parse JSON, doesn't retry. That's all left to the user-supplied health_cmd. Keeping the provider narrow is the point: every deploy target ever invented can be modelled as "run THIS CLI command," so the provider has to be CLI-agnostic.
func (ExternalProvider) Deploy ¶
func (p ExternalProvider) Deploy(ctx context.Context, group ServiceGroup) error
Deploy ships every service in the group by running the user- supplied deploy_cmd. Per-service failures abort the loop — external groups are typically one-service-per-group (each group's natural batching is "services sharing the same deploy_cmd," which is rare across services) and "keep going after a failure" would surprise the user.
func (ExternalProvider) Name ¶
func (ExternalProvider) Name() string
Name returns the provider identifier.
func (ExternalProvider) Rollback ¶
func (p ExternalProvider) Rollback(ctx context.Context, group ServiceGroup, lastGoodTag string) error
Rollback reverts every service in the group to its previously recorded good tag by running the user-supplied rollback_cmd. Per-service failures are accumulated rather than aborting the loop — rollback is a recovery affordance, not a way to mask the underlying failure.
type ExternalSpec ¶
type ExternalSpec struct {
Image string
DeployCmd string
RollbackCmd string
HealthCmd string
EnvFile string
// Env is the user-declared substitution map merged underneath the
// built-in tokens (IMAGE / TAG / LAST_TAG / SERVICE / ENV /
// ENV_FILE / PROJECT_DIR).
Env map[string]string
}
ExternalSpec is the per-service shell-command deploy spec. Mirrors the kcl/schema.k External schema.
Image isn't on the KCL schema itself — it's hoisted from the surrounding Service.image so the ${IMAGE} substitution token has a well-defined value without forcing the user to duplicate the service's image string on the deploy block.
type FirebaseBundleSpec ¶
FirebaseBundleSpec is one extra pre-built static dir assembled into the hosting site. Dest empty means the site root.
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
// 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 []FirebaseBundleSpec
Rewrites []map[string]any
}
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
}
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 — run `<dev_runner> install` then `npm run build` in the frontend dir, with the frontend's env_vars injected as build-time env (NEXT_PUBLIC_* / VITE_*). The build emits PublicDir (e.g. "out" for a Next.js static export, "dist" for Vite).
- Assemble — copy PublicDir into a staging tree under BasePath (e.g. <staging>/admin for base_path "/admin"), then copy each Bundle.Src into <staging>/<Bundle.Dest>. The result is one public root: a root SPA with the forge frontend mounted under its prefix.
- 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 FirebaseHosting 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 deploy.
func (FirebaseProvider) Name ¶
func (FirebaseProvider) Name() string
Name returns the provider identifier.
func (FirebaseProvider) Rollback ¶
func (FirebaseProvider) Rollback(_ context.Context, _ ServiceGroup, _ string) error
Rollback is unsupported for Firebase Hosting: a hosting deploy ships a fully-assembled static tree with no forge-tracked previous-tag state, and Firebase's own `hosting:rollback` (release history) is the right recovery surface. We return ErrProviderNotImplemented so the dispatcher records "rollback not supported" rather than silently claiming success.
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, HostSkip, OneShotJobs, 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 deploy path.
ApplyOptsBuilder func(group ServiceGroup) cluster.ApplyOpts
}
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 deploy` / `forge cluster reload` / `forge 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) 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.
func (K8sClusterProvider) Name ¶
func (K8sClusterProvider) Name() string
Name returns the provider identifier.
func (K8sClusterProvider) Rollback ¶
func (p K8sClusterProvider) Rollback(ctx context.Context, group ServiceGroup, lastGoodTag string) error
Rollback runs `kubectl rollout undo deployment/<svc> -n <ns>` for every service in the group. Best-effort: per-service failures are logged and joined into the returned error, but the loop doesn't abort on the first failure (one stuck service shouldn't block rolling back the others).
The function falls back to a no-op when kubectl isn't on PATH or the namespace is empty (an invalid group shape) — those cases already failed louder upstream.
type K8sClusterSpec ¶
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 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
// Rollback reverts every service in the group to lastGoodTag.
// Best-effort: per-service failures are logged and accumulated
// into the returned error rather than aborting the loop.
Rollback(ctx context.Context, group ServiceGroup, lastGoodTag string) error
}
Provider is the dispatch surface for one deploy target type. Each concrete provider owns the pipeline for its target (k8s, external, compose, etc.); the dispatcher in forge deploy hands it a ServiceGroup and the provider does the rest.
Rollback is invoked on Deploy failure with the last-known-good tag the dispatcher has tracked. Rollback errors are logged but the group's overall outcome remains "failed" — rollback is a recovery affordance, not a way to mask the underlying problem.
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
External *ExternalSpec
Compose *ComposeSpec
// Secrets carries resolved secret values to inline into the runtime
// env for External/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 / External / Compose is non-nil for services the dispatcher should ship; all three 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 + external + compose + firebase). 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) 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 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
External *ExternalSpec
Compose *ComposeSpec
// Secrets carries resolved secret values to inject into the runtime
// env (compose / external). 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/External/ Compose 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 deploy <env>` CLI arg).
Env string
// ProviderID identifies the provider type — "k8s-cluster",
// "external", "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 a frontend-target provider ships
// (today: the "firebase" provider). It's the frontend analogue of
// Services — empty for the service-shaped providers (k8s-cluster /
// external / compose), 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
// 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; External and Compose check 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 run` 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).
External grouping rule: services sharing an identical deploy_cmd end up in one group. KCL refs that point at the same External var render to identical deploy_cmd strings, which is the natural batching signal. Without a shared ref, every service ends up in its own group — which is fine because external providers typically deploy one service per invocation anyway.
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.