# Phase 1 — GitOps core (backend, no UI, no mutations) Pure logic + fetch. No HTTP endpoints, no DB writes to workloads yet (migration only). ## Tasks - [ ] **Migration**: append 4 additive columns to the workloads-table migration list in `internal/store/store.go` (idempotent `ALTER TABLE workloads ADD COLUMN`): - `gitops_enabled INTEGER NOT NULL DEFAULT 0` - `gitops_path TEXT NOT NULL DEFAULT '.tinyforge.yml'` - `gitops_last_sync_at TEXT NOT NULL DEFAULT ''` - `gitops_commit_sha TEXT NOT NULL DEFAULT ''` - Add the fields to the `Workload` struct in `internal/store/models.go` + the scan/insert/update column lists in `internal/store/workloads.go` (read path now; write path for the setters lands in Phase 2). - [ ] **`internal/gitops` package — `spec.go`**: `Spec` struct mirroring the v1 schema (`Version int`, `Deploy DeploySpec{Port *int, Healthcheck *string, DeployStrategy *string, Resources *ResourceSpec{CpuLimit *float64, MemoryLimit *int}, MaxInstances *int}`). Pointers so "omitted" is distinguishable from "zero". `ParseSpec([]byte) (Spec, error)` using `gopkg.in/yaml.v3` with `KnownFields(true)` to reject unknown keys; reject `version != 1`. - [ ] **`apply.go`**: typed `ApplyPlan{ SourceConfigPatch map[string]any }` (env/faces slots reserved in a comment). `BuildPlan(spec) ApplyPlan` maps only the **present** (non-nil) declared fields to their `source_config` JSON keys (`port`, `healthcheck`, `deploy_strategy`, `cpu_limit`, `memory_limit`, `max_instances`). - [ ] **`merge.go`**: `MergeAndValidate(liveConfig json.RawMessage, plan ApplyPlan, validate func(json.RawMessage) error) (json.RawMessage, error)` — deep-copy live → overlay only the patch keys (omitted-field-preserving) → marshal → run `validate` on the **merged** result → return merged or error. Never returns a partial/empty config. - [ ] **`drift.go`**: `Drift(spec Spec, liveConfig json.RawMessage) ([]DriftEntry, error)` where `DriftEntry{Field string /*dotted path*/, RepoValue string, LiveValue string}`. Compare **only declared leaves**, post-normalization: - `deploy_strategy` via the source's effective-default rule (`"" == "recreate"` for dockerfile/static) — import or replicate `effectiveStrategy` semantics. - numeric coercion (YAML int vs JSON number) compared by value, not raw string. - [ ] **Provider `DownloadFile`**: add `DownloadFile(ctx, owner, repo, ref, path string, maxBytes int64) ([]byte, error)` to the `GitProvider` interface in `internal/staticsite/provider.go` and implement for Gitea, GitHub, GitLab using each provider's existing raw-file endpoint + the **safe HTTP client**. Cap at 64 KiB. Distinguish 404 (file absent) from transport/5xx errors. - [ ] **`fetch.go`**: `Fetch(ctx, deps, w) (Result, error)` where `Result{ Raw []byte, Spec Spec, CommitSHA string, Status string /* ok|no_file|fetch_failed */ }`. Decrypt `access_token`, build provider via `NewGitProvider`, `GetLatestCommitSHA`, then `DownloadFile(gitops_path)`. Missing file → `no_file` (NOT an error). All errors routed through the existing `sanitizeError(msg, token)` so the token never leaks. - [ ] **Unit tests** (`*_test.go`): ParseSpec (valid/unknown-key/bad-version); MergeAndValidate (omitted-field preserved, invalid merged config rejected, no clobber); Drift (declared-only, deploy_strategy normalization, numeric coercion, no false positive on undeclared keys); a redaction test mirroring `static/helpers_test.go`. ## Verify - `go build ./...`, `go vet ./internal/...`, `go test ./internal/...` green. ## Handoff notes _(filled after implementation)_