7733e64b08
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/.
163 lines
5.4 KiB
Go
163 lines
5.4 KiB
Go
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")
|
|
}
|
|
}
|