e3d140c57a
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.
122 lines
4.7 KiB
Go
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)
|
|
}
|