feat(gitops): config-as-code via .tinyforge.yml for repo-backed workloads

A dockerfile or static workload can opt in to reading its deploy config from a
.tinyforge.yml in its own repo. Tinyforge fetches the file, shows field-level
drift vs the live config, and an admin applies it with an explicit Sync. The
repo becomes the source of truth for the declared fields. Manual-sync only;
no auto-apply on deploy, no multi-workload reconcile, no create/delete in v1.

Scope is deliberately source-aware and source_config-resident: dockerfile
declares port/healthcheck/deploy_strategy, static declares deploy_strategy.
The file never carries repo coords or secrets (those stay in the encrypted
DB), which keeps credentials out of the repo.

Backend:
- internal/gitops: Spec/ParseSpec (KnownFields rejects unknown keys), a
  source-aware ApplyPlan/BuildPlan, MergeAndValidate (omitted-field-preserving
  deep merge + validate-the-merged-result-then-commit — never a partial
  config), declared-only Drift with normalization, and Fetch with
  ok/no_file/fetch_failed/invalid statuses and token-redacted messages.
- staticsite: DownloadFile added to GitProvider + Gitea/GitHub/GitLab impls,
  reusing each provider's SSRF-safe client; 64 KiB cap; ErrFileNotFound.
- store: 4 additive gitops_* columns + setters (disjoint from UpdateWorkload
  so the edit-form save and a sync never clobber each other).
- api: GET /workloads/{id}/gitops (status + raw + live drift + managed_fields),
  PUT /gitops (admin, enable/path, traversal-safe), POST /gitops/sync (admin,
  per-workload locked read->merge->validate->write, audited to event_log).

Frontend:
- GitOpsPanel.svelte: status pill, a purpose-built field-level drift view,
  .tinyforge.yml preview, enable ToggleSwitch, Sync via ConfirmDialog; all five
  statuses handled, admin affordances gated on the real viewer role.
- GitOps-managed badge (list + detail hero) and a read-only edit-form banner.
- api.ts fetchers + types; i18n apps.detail.gitops.* (en + ru parity).

Built phase-by-phase with an adversarial plan review (caught 5 design flaws
pre-implementation) and an independent review per phase (go / security / ts /
final) — all APPROVE, 0 CRITICAL/HIGH. docs/gitops.md documents the schema and
what's intentionally out of v1. Plan: plans/gitops/.
This commit is contained in:
2026-06-21 23:32:02 +03:00
parent 5b51bbbd7f
commit 7733e64b08
38 changed files with 3013 additions and 106 deletions
+83
View File
@@ -0,0 +1,83 @@
package gitops
// source_config JSON keys this package can overlay. Kept as constants so the
// apply, merge, and drift paths agree on the exact key strings.
const (
keyPort = "port"
keyHealthcheck = "healthcheck"
keyDeployStrategy = "deploy_strategy"
)
// Source kinds eligible for GitOps in v1 (git-backed sources only).
const (
SourceDockerfile = "dockerfile"
SourceStatic = "static"
)
// supportedKeys returns the source_config keys a given source kind accepts
// from a .tinyforge.yml overlay. A field declared in the file but not in this
// set is ignored (not applied, not drift-compared) so a shared file can target
// either source without producing dead keys or false drift.
//
// dockerfile: port + healthcheck + deploy_strategy (its real run knobs).
// static: deploy_strategy only (a static site has no port/healthcheck).
func supportedKeys(sourceKind string) map[string]bool {
switch sourceKind {
case SourceDockerfile:
return map[string]bool{keyPort: true, keyHealthcheck: true, keyDeployStrategy: true}
case SourceStatic:
return map[string]bool{keyDeployStrategy: true}
default:
return nil
}
}
// IsEligibleSource reports whether GitOps may be enabled for a source kind.
func IsEligibleSource(sourceKind string) bool {
return supportedKeys(sourceKind) != nil
}
// ApplyPlan is the typed, multi-target plan for applying an overlay. In v1 only
// SourceConfigPatch is populated; EnvUpserts/Faces are reserved so env (the
// workload_env table) and faces (the public_faces column) can be added later
// without reshaping the apply path — they are NOT in v1 (env would re-open the
// secrets-in-repo hole; faces live in a sibling store).
type ApplyPlan struct {
// SourceConfigPatch holds the source_config keys to overlay onto the live
// config. Only keys supported by the target source are present.
SourceConfigPatch map[string]any
// reserved for future phases — see package doc.
// EnvUpserts []store.WorkloadEnv
// Faces []plugin.PublicFace
}
// declaredValues returns the present (non-nil) overlay fields keyed by their
// source_config JSON key, before the per-source filter. Shared by BuildPlan and
// Drift so they agree on what the file declared.
func declaredValues(spec Spec) map[string]any {
out := map[string]any{}
if spec.Deploy.Port != nil {
out[keyPort] = *spec.Deploy.Port
}
if spec.Deploy.Healthcheck != nil {
out[keyHealthcheck] = *spec.Deploy.Healthcheck
}
if spec.Deploy.DeployStrategy != nil {
out[keyDeployStrategy] = *spec.Deploy.DeployStrategy
}
return out
}
// BuildPlan maps the present, source-supported overlay fields to a patch for
// the given source kind. Unsupported/absent fields are dropped.
func BuildPlan(spec Spec, sourceKind string) ApplyPlan {
allowed := supportedKeys(sourceKind)
patch := map[string]any{}
for k, v := range declaredValues(spec) {
if allowed[k] {
patch[k] = v
}
}
return ApplyPlan{SourceConfigPatch: patch}
}
+122
View File
@@ -0,0 +1,122 @@
package gitops
import (
"encoding/json"
"fmt"
"strconv"
)
// DriftEntry is one field where the repo-declared value differs from the live
// stored value. Values are display strings; comparison is done on normalized
// forms so cosmetic differences (default coercion, YAML int vs JSON number)
// don't register as drift.
type DriftEntry struct {
Field string `json:"field"`
RepoValue string `json:"repo_value"`
LiveValue string `json:"live_value"`
}
// driftFieldOrder is the stable order drift entries are reported in.
var driftFieldOrder = []string{keyPort, keyHealthcheck, keyDeployStrategy}
// Drift compares the declared overlay (the present, source-supported fields)
// against the live source_config and returns the fields that differ. Only
// declared fields are considered — a key the file omits is "unmanaged",
// neither drift nor clean (review C5). Comparison is post-normalization.
func Drift(spec Spec, live json.RawMessage, sourceKind string) ([]DriftEntry, error) {
liveMap := map[string]any{}
if len(live) > 0 {
if err := json.Unmarshal(live, &liveMap); err != nil {
return nil, fmt.Errorf("gitops: decode live source_config: %w", err)
}
}
allowed := supportedKeys(sourceKind)
declared := declaredValues(spec)
var entries []DriftEntry
for _, k := range driftFieldOrder {
repoVal, ok := declared[k]
if !ok || !allowed[k] {
continue
}
liveVal, livePresent := liveMap[k]
if normalizeField(k, repoVal) == normalizeField(k, liveVal) {
continue
}
entries = append(entries, DriftEntry{
Field: k,
RepoValue: displayField(k, repoVal, true),
LiveValue: displayField(k, liveVal, livePresent),
})
}
return entries, nil
}
// normalizeField returns the canonical comparison form of a field value.
func normalizeField(key string, v any) string {
switch key {
case keyDeployStrategy:
// "" and "recreate" are the same effective strategy for dockerfile and
// static (see each source's effectiveStrategy).
s := toStr(v)
if s == "" || s == "recreate" {
return "recreate"
}
return s
case keyPort:
return canonInt(v)
default:
return toStr(v)
}
}
// displayField renders a value for the UI. present=false means the key is
// absent from the live config.
func displayField(key string, v any, present bool) string {
if !present {
return "(unset)"
}
if key == keyDeployStrategy {
if s := toStr(v); s == "" {
return "recreate (default)"
}
}
switch n := v.(type) {
case float64:
// JSON numbers decode as float64; show whole numbers without ".0".
return strconv.FormatInt(int64(n), 10)
case nil:
return "(unset)"
default:
return fmt.Sprint(v)
}
}
// canonInt coerces any numeric representation (YAML int, JSON float64, etc.)
// to a base-10 integer string for value-equality comparison.
func canonInt(v any) string {
switch n := v.(type) {
case int:
return strconv.Itoa(n)
case int64:
return strconv.FormatInt(n, 10)
case float64:
return strconv.FormatInt(int64(n), 10)
case json.Number:
return n.String()
case nil:
return "0"
default:
return fmt.Sprint(v)
}
}
func toStr(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprint(v)
}
+96
View File
@@ -0,0 +1,96 @@
package gitops
import (
"context"
"errors"
"strings"
"github.com/alexei/tinyforge/internal/staticsite"
)
// maxConfigBytes caps the .tinyforge.yml fetch. The file is tiny; the cap
// stops a hostile/misconfigured repo from streaming an unbounded body.
const maxConfigBytes = 64 * 1024
// Status is the outcome of a Fetch. All outcomes are values (not errors) so a
// caller always has something to show: an absent file or a provider blip is a
// normal state, not a 500.
type Status string
const (
StatusOK Status = "ok" // file present and parsed
StatusNoFile Status = "no_file" // GitOps enabled, no file at path
StatusFetchFailed Status = "fetch_failed" // transport/auth/5xx error
StatusInvalid Status = "invalid" // file present but failed to parse
)
// RepoRef is the minimal repo locator Fetch needs. The caller (API layer)
// extracts these from the workload's source_config and decrypts the token —
// this package stays decoupled from the store and source plugins.
type RepoRef struct {
Provider string // "gitea" | "github" | "gitlab" | "" (autodetect from BaseURL)
BaseURL string
Owner string
Repo string
Branch string
Token string // decrypted; "" for public repos
Path string // repo-relative file path; defaults to .tinyforge.yml
}
// Result carries everything the API/UI needs about a fetch. Message is a
// human-safe, token-redacted detail for non-ok statuses.
type Result struct {
Status Status
Raw []byte
Spec Spec
CommitSHA string
Message string
}
// Fetch reads the .tinyforge.yml from a workload's repo and parses it. Every
// failure mode is encoded in Result.Status (never a returned error), with any
// detail token-redacted in Result.Message. A missing file is StatusNoFile, not
// a failure — never a reason to block or clear config.
func Fetch(ctx context.Context, ref RepoRef) Result {
provider, err := staticsite.NewGitProvider(staticsite.ProviderType(ref.Provider), ref.BaseURL, ref.Token)
if err != nil {
return Result{Status: StatusFetchFailed, Message: redact(err, ref.Token)}
}
// Best-effort: the SHA lets the UI show which ref the file came from. A
// failure here doesn't sink the fetch — the file read below is what matters.
sha, _ := provider.GetLatestCommitSHA(ctx, ref.Owner, ref.Repo, ref.Branch)
path := ref.Path
if path == "" {
path = ".tinyforge.yml"
}
data, err := provider.DownloadFile(ctx, ref.Owner, ref.Repo, ref.Branch, path, maxConfigBytes)
if err != nil {
if errors.Is(err, staticsite.ErrFileNotFound) {
return Result{Status: StatusNoFile, CommitSHA: sha}
}
return Result{Status: StatusFetchFailed, CommitSHA: sha, Message: redact(err, ref.Token)}
}
spec, err := ParseSpec(data)
if err != nil {
// Parse errors describe YAML structure (line/col), not the token.
return Result{Status: StatusInvalid, Raw: data, CommitSHA: sha, Message: err.Error()}
}
return Result{Status: StatusOK, Raw: data, Spec: spec, CommitSHA: sha}
}
// redact strips the access token from an error message so a fetch failure can
// be surfaced or persisted without leaking the credential (mirrors the
// sanitizeError convention in the static/dockerfile sources).
func redact(err error, token string) string {
if err == nil {
return ""
}
msg := err.Error()
if token != "" {
msg = strings.ReplaceAll(msg, token, "[redacted]")
}
return msg
}
+162
View File
@@ -0,0 +1,162 @@
package gitops
import (
"encoding/json"
"errors"
"strings"
"testing"
)
func strp(s string) *string { return &s }
func intp(i int) *int { return &i }
func TestParseSpec(t *testing.T) {
s, err := ParseSpec([]byte("version: 1\ndeploy:\n port: 8080\n deploy_strategy: blue-green\n"))
if err != nil {
t.Fatalf("valid parse: %v", err)
}
if s.Version != 1 || s.Deploy.Port == nil || *s.Deploy.Port != 8080 {
t.Fatalf("unexpected spec: %+v", s)
}
if s.Deploy.Healthcheck != nil {
t.Fatalf("omitted healthcheck must stay nil")
}
// Unknown keys are rejected — incl. an attempt to declare env (out of v1).
if _, err := ParseSpec([]byte("version: 1\ndeploy:\n env:\n FOO: bar\n")); err == nil {
t.Fatalf("expected unknown-field error for deploy.env")
}
if _, err := ParseSpec([]byte("version: 1\nworkloads: []\n")); err == nil {
t.Fatalf("expected unknown-field error for top-level workloads")
}
if _, err := ParseSpec([]byte("version: 2\n")); err == nil {
t.Fatalf("expected unsupported-version error")
}
if _, err := ParseSpec(nil); err == nil {
t.Fatalf("expected empty-file error")
}
}
func TestBuildPlan_SourceAware(t *testing.T) {
spec := Spec{Version: 1, Deploy: DeploySpec{
Port: intp(8080), Healthcheck: strp("/h"), DeployStrategy: strp("blue-green"),
}}
df := BuildPlan(spec, SourceDockerfile).SourceConfigPatch
if df[keyPort] != 8080 || df[keyHealthcheck] != "/h" || df[keyDeployStrategy] != "blue-green" {
t.Fatalf("dockerfile patch wrong: %+v", df)
}
// static has no port/healthcheck — they must NOT leak into its patch.
st := BuildPlan(spec, SourceStatic).SourceConfigPatch
if _, ok := st[keyPort]; ok {
t.Fatalf("static patch must not contain port")
}
if _, ok := st[keyHealthcheck]; ok {
t.Fatalf("static patch must not contain healthcheck")
}
if st[keyDeployStrategy] != "blue-green" {
t.Fatalf("static should keep deploy_strategy: %+v", st)
}
if IsEligibleSource("image") || IsEligibleSource("compose") {
t.Fatalf("only dockerfile/static are GitOps-eligible in v1")
}
if !IsEligibleSource(SourceDockerfile) || !IsEligibleSource(SourceStatic) {
t.Fatalf("dockerfile + static must be eligible")
}
}
func TestMergeAndValidate_PreservesOmittedFields(t *testing.T) {
live := json.RawMessage(`{"repo_owner":"o","repo_name":"r","port":3000,"healthcheck":"/old","deploy_strategy":""}`)
spec := Spec{Version: 1, Deploy: DeploySpec{Port: intp(8080)}} // only port declared
merged, err := MergeAndValidate(live, BuildPlan(spec, SourceDockerfile), func(json.RawMessage) error { return nil })
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(merged, &m); err != nil {
t.Fatal(err)
}
if m["port"].(float64) != 8080 {
t.Fatalf("declared port not applied: %v", m["port"])
}
if m["healthcheck"] != "/old" {
t.Fatalf("undeclared healthcheck must be preserved, got %v", m["healthcheck"])
}
if m["repo_owner"] != "o" {
t.Fatalf("untouched repo_owner lost")
}
}
func TestMergeAndValidate_RejectsInvalidMergedConfig(t *testing.T) {
live := json.RawMessage(`{"port":3000}`)
spec := Spec{Version: 1, Deploy: DeploySpec{DeployStrategy: strp("rolling")}}
_, err := MergeAndValidate(live, BuildPlan(spec, SourceDockerfile), func(c json.RawMessage) error {
var x struct {
DeployStrategy string `json:"deploy_strategy"`
}
_ = json.Unmarshal(c, &x)
if x.DeployStrategy == "rolling" {
return errors.New("invalid deploy_strategy")
}
return nil
})
if err == nil {
t.Fatalf("expected the merged config to be rejected as a whole")
}
}
func TestDrift_DeclaredOnly_WithNormalization(t *testing.T) {
// live: port 3000, healthcheck "/h", strategy "" (== recreate effective).
live := json.RawMessage(`{"port":3000,"healthcheck":"/h","deploy_strategy":"","registry_name":"x"}`)
// declare: port (changed) + deploy_strategy "recreate" (equal to "" -> no drift).
spec := Spec{Version: 1, Deploy: DeploySpec{Port: intp(8080), DeployStrategy: strp("recreate")}}
d, err := Drift(spec, live, SourceDockerfile)
if err != nil {
t.Fatal(err)
}
if len(d) != 1 {
t.Fatalf("want exactly 1 drift (port), got %d: %+v", len(d), d)
}
if d[0].Field != keyPort || d[0].RepoValue != "8080" || d[0].LiveValue != "3000" {
t.Fatalf("port drift wrong: %+v", d[0])
}
}
func TestDrift_StaticIgnoresUnsupportedFields(t *testing.T) {
live := json.RawMessage(`{"deploy_strategy":"recreate","mode":"static"}`)
// port declared but unsupported for static -> ignored; strategy differs -> drift.
spec := Spec{Version: 1, Deploy: DeploySpec{Port: intp(8080), DeployStrategy: strp("blue-green")}}
d, err := Drift(spec, live, SourceStatic)
if err != nil {
t.Fatal(err)
}
if len(d) != 1 || d[0].Field != keyDeployStrategy {
t.Fatalf("static should only drift on deploy_strategy: %+v", d)
}
}
func TestDrift_UnsetLiveValue(t *testing.T) {
spec := Spec{Version: 1, Deploy: DeploySpec{Healthcheck: strp("/up")}}
d, err := Drift(spec, json.RawMessage(`{}`), SourceDockerfile)
if err != nil {
t.Fatal(err)
}
if len(d) != 1 || d[0].RepoValue != "/up" || d[0].LiveValue != "(unset)" {
t.Fatalf("unset live should render as (unset): %+v", d)
}
}
func TestRedact_StripsToken(t *testing.T) {
msg := redact(errors.New("execute request: token ghp_SECRET rejected"), "ghp_SECRET")
if strings.Contains(msg, "ghp_SECRET") {
t.Fatalf("token leaked: %s", msg)
}
if !strings.Contains(msg, "[redacted]") {
t.Fatalf("expected redaction marker: %s", msg)
}
if redact(nil, "x") != "" {
t.Fatalf("nil error should redact to empty string")
}
}
+48
View File
@@ -0,0 +1,48 @@
package gitops
import (
"encoding/json"
"fmt"
)
// MergeAndValidate overlays the plan's SourceConfigPatch onto a copy of the
// live source_config and returns the merged JSON — but only after the target
// source's own Validate accepts the *merged* result. This is the hard apply
// gate (review C4):
//
// - omitted-field-preserving: keys the file doesn't declare are untouched, so
// a partial .tinyforge.yml never clears live config;
// - validate-then-commit: a patch that would produce an invalid config (e.g.
// deploy_strategy "blue-green" on a source that rejects it, or a bad port)
// is refused as a whole — the function never returns a partial/empty config;
// - pure: it does not write anything; the caller persists the returned bytes.
//
// validate is the matching Source.Validate (passed in to keep this package
// decoupled from the source plugins).
func MergeAndValidate(live json.RawMessage, plan ApplyPlan, validate func(json.RawMessage) error) (json.RawMessage, error) {
// Decode the live config into a generic map we can overlay. An empty/null
// live config starts from an empty object rather than failing.
merged := map[string]any{}
if len(live) > 0 {
if err := json.Unmarshal(live, &merged); err != nil {
return nil, fmt.Errorf("gitops: decode live source_config: %w", err)
}
}
// Overlay only the declared patch keys — everything else is preserved.
for k, v := range plan.SourceConfigPatch {
merged[k] = v
}
out, err := json.Marshal(merged)
if err != nil {
return nil, fmt.Errorf("gitops: encode merged source_config: %w", err)
}
if validate != nil {
if err := validate(out); err != nil {
return nil, fmt.Errorf("gitops: merged config rejected: %w", err)
}
}
return out, nil
}
+57
View File
@@ -0,0 +1,57 @@
// Package gitops implements config-as-code for repo-backed workloads: a
// dockerfile/static workload can read a small .tinyforge.yml from its own repo
// that declares a subset of its deploy config. The package is deliberately
// decoupled from the store and source plugins — it takes a RepoRef (repo
// coords + a decrypted token) and a live source_config blob, and returns a
// validated merged config + a field-level drift report. It never writes to the
// database and never decides to deploy.
//
// v1 scope (see plans/gitops/PLAN.md): only source_config-resident fields are
// overlayable, and the set is source-aware (dockerfile: port/healthcheck/
// deploy_strategy; static: deploy_strategy). env/faces live in separate stores
// and are intentionally out of v1; the typed ApplyPlan reserves their slots.
package gitops
import (
"bytes"
"errors"
"fmt"
"io"
"gopkg.in/yaml.v3"
)
// Spec is the parsed shape of a .tinyforge.yml file (v1).
type Spec struct {
Version int `yaml:"version"`
Deploy DeploySpec `yaml:"deploy"`
}
// DeploySpec carries the overlayable deploy fields. Pointers so an omitted key
// is distinguishable from a zero value — only present (non-nil) fields are
// applied or drift-compared, so an absent key never clears live config.
type DeploySpec struct {
Port *int `yaml:"port"`
Healthcheck *string `yaml:"healthcheck"`
DeployStrategy *string `yaml:"deploy_strategy"`
}
// ParseSpec decodes a .tinyforge.yml body. Unknown keys are rejected
// (KnownFields) so a typo or an unsupported field — e.g. someone trying to
// declare env/faces in v1 — surfaces as an error instead of being silently
// dropped. Only version 1 is accepted.
func ParseSpec(data []byte) (Spec, error) {
var s Spec
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(&s); err != nil {
if errors.Is(err, io.EOF) {
return Spec{}, fmt.Errorf("gitops: empty .tinyforge.yml")
}
return Spec{}, fmt.Errorf("gitops: parse .tinyforge.yml: %w", err)
}
if s.Version != 1 {
return Spec{}, fmt.Errorf("gitops: unsupported version %d (want 1)", s.Version)
}
return s, nil
}