feat(deployer): configurable per-workload deploy strategy (blue-green for built sources)

Add a deploy_strategy field to each source's config blob — "" (default),
"recreate", or "blue-green" — validated in each source's Validate and read on
the deploy path. No new DB column, no migration: the field rides inside the
existing SourceConfig JSON and every existing workload decodes "" to its
historical behavior (image -> blue-green, others -> recreate).

The real gap this closes: dockerfile and static stopped the old container
before creating the new one on every redeploy — a downtime window image never
had. Their blue-green branch now:
- names the new "green" container with a unique suffix so it coexists with the
  still-serving blue (plumbed into both the container name AND the proxy
  forwardHost);
- skips the collision teardown that destroyed blue early;
- gates green — an HTTP readiness probe (deps.Health.Check) when a healthcheck
  is configured, else the existing liveness window;
- swaps the route via a pure upsert (no pre-DeleteRoute) so NPM repoints in
  place with no gap;
- persists green into the single runtime-state row BEFORE reaping blue, so a
  crash mid-swap can never orphan green or leave the row pointing at a removed
  container (state.go/teardown.go/reconcile.go stay untouched).

image honors explicit "recreate" (reap existing containers after pull, before
cutover); its default blue-green path is unchanged. compose stays
stack-managed and rejects "blue-green" at Validate so the contract is honest.
static forces recreate for storage-backed deno sites — blue-green would mount
the same RW volume into both containers at once.

Shared helper internal/workload/plugin/strategy.go (ValidateStrategy +
BuildGreenName). Backend-only (phase 1); the field is usable today via the
app's advanced-JSON editor — a friendly toggle + i18n follow in phase 2.
Tests: ValidateStrategy matrix, per-source Validate (incl. the empty-key
backward-compat lock), and effectiveStrategy defaults + the deno gate. Design
+ adversarial review: docs/plans/DEPLOY_STRATEGY_PLAN.md.
This commit is contained in:
2026-06-19 16:51:20 +03:00
parent 0c4c338bfe
commit e3d140c57a
13 changed files with 592 additions and 12 deletions
@@ -64,6 +64,23 @@ type Config struct {
// git provider as a commit status (pending/success/failure) on the
// built SHA. Best-effort — a reporting failure never fails a deploy.
ReportCommitStatus bool `json:"report_commit_status"`
// DeployStrategy selects how a redeploy cuts over. "" (default) and
// "recreate" stop the old container before starting the new one (a brief
// downtime window). "blue-green" starts the new build alongside the old,
// gates it, swaps the proxy route in place, then reaps the old —
// zero-downtime under NPM. Validated via plugin.ValidateStrategy.
DeployStrategy string `json:"deploy_strategy,omitempty"`
}
// effectiveStrategy resolves the configured strategy for the dockerfile
// source. Empty maps to recreate — the source's historical behavior — so
// existing workloads are unchanged.
func effectiveStrategy(cfg Config) string {
if cfg.DeployStrategy == "" {
return plugin.StrategyRecreate
}
return cfg.DeployStrategy
}
type source struct{}
@@ -120,6 +137,9 @@ func (*source) Validate(cfg json.RawMessage) error {
return fmt.Errorf("dockerfile source: %q must not contain '..'", p)
}
}
if err := plugin.ValidateStrategy(c.DeployStrategy, true); err != nil {
return fmt.Errorf("dockerfile source: %w", err)
}
return nil
}