Files
tiny-forge/internal/workload/plugin/source/static/static.go
T
alexei.dolgolyov e3d140c57a 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.
2026-06-19 16:51:20 +03:00

122 lines
4.7 KiB
Go

// Package static implements the "static" source: a git-folder-backed
// deployable that can serve plain files or run a Deno backend. Builds
// an image from the cloned folder and runs one container.
//
// The full deploy pipeline is implemented inline in this package
// (deploy.go / teardown.go / reconcile.go). It operates directly on
// plugin.Workload + the containers / workload_env tables — there is no
// longer a synthetic static_sites row backing each workload.
//
// The legacy internal/staticsite package remains alive to serve the
// /api/sites/* HTTP routes and the existing static_sites table; this
// plugin does not depend on it for state, only for git-provider
// helpers and Deno scaffolding generation.
package static
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// Config is the per-workload source config blob. Mirrors the fields
// that used to live on the static_sites table, less anything moved to
// Workload (notification config, webhook secrets, public_face).
type Config struct {
Provider string `json:"provider"` // "gitea" | "github" | "gitlab"; "" = autodetect
BaseURL string `json:"base_url"` // e.g. https://git.example.com
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
FolderPath string `json:"folder_path"` // path within repo
AccessToken string `json:"access_token"` // encrypted; optional for public repos
Mode string `json:"mode"` // "static" | "deno"
RenderMarkdown bool `json:"render_markdown"`
StorageEnabled bool `json:"storage_enabled"`
StorageLimitMB int `json:"storage_limit_mb"`
// ReportCommitStatus, when true, pushes the deploy outcome back to the
// git provider as a commit status (pending/success/failure) on the
// deployed 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 the new one comes up (a brief
// downtime window). "blue-green" starts the new container 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 static source.
// Empty maps to recreate — the source's historical behavior. Storage-backed
// deno sites are forced to recreate even when blue-green is requested: a
// blue-green overlap would mount the same RW named volume into BOTH
// containers at once (a concurrent-writer window recreate never has, since
// recreate stops blue before green starts).
func effectiveStrategy(cfg Config) string {
s := cfg.DeployStrategy
if s == "" {
s = plugin.StrategyRecreate
}
if s == plugin.StrategyBlueGreen && cfg.StorageEnabled && cfg.Mode == "deno" {
return plugin.StrategyRecreate
}
return s
}
type source struct{}
// Eager registration — the deploy pipeline lives entirely inside this
// package now, so the kind is usable as soon as init() fires. No more
// "backend not wired" failure mode at deploy time.
func init() { plugin.RegisterSource(&source{}) }
func (*source) Kind() string { return "static" }
func (*source) SchemaSample() any {
return Config{
Provider: "gitea",
BaseURL: "https://git.example.com",
RepoOwner: "owner",
RepoName: "pages",
Branch: "main",
FolderPath: "",
Mode: "static",
}
}
func (*source) Validate(cfg json.RawMessage) error {
var c Config
if len(cfg) == 0 {
return fmt.Errorf("static source: config is required")
}
if err := json.Unmarshal(cfg, &c); err != nil {
return fmt.Errorf("static source: invalid json: %w", err)
}
if strings.TrimSpace(c.RepoOwner) == "" || strings.TrimSpace(c.RepoName) == "" {
return fmt.Errorf("static source: repo_owner and repo_name are required")
}
if c.Mode != "" && c.Mode != "static" && c.Mode != "deno" {
return fmt.Errorf("static source: mode must be \"static\" or \"deno\"")
}
if err := plugin.ValidateStrategy(c.DeployStrategy, true); err != nil {
return fmt.Errorf("static source: %w", err)
}
return nil
}
func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error {
return deploy(ctx, deps, w, intent)
}
func (*source) Teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
return teardown(ctx, deps, w)
}
func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
return reconcile(ctx, deps, w)
}