Files
tiny-forge/internal/api/gitops.go
T
alexei.dolgolyov 7733e64b08 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/.
2026-06-21 23:32:02 +03:00

365 lines
12 KiB
Go

package api
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/tinyforge/internal/gitops"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// keyedMutex is a lazily-populated per-key lock. Used to serialize a critical
// section per workload id (the GitOps sync) without a global lock.
type keyedMutex struct {
mu sync.Mutex
m map[string]*sync.Mutex
}
// lock acquires the mutex for key and returns its unlock func.
func (k *keyedMutex) lock(key string) func() {
k.mu.Lock()
if k.m == nil {
k.m = make(map[string]*sync.Mutex)
}
mu, ok := k.m[key]
if !ok {
mu = &sync.Mutex{}
k.m[key] = mu
}
k.mu.Unlock()
mu.Lock()
return mu.Unlock
}
// gitOpsStatusResponse is the single rich payload the GitOps panel reads — it
// folds the file preview, parsed status, and drift into one response so the UI
// makes a single call (no separate /drift round-trip).
type gitOpsStatusResponse struct {
Eligible bool `json:"eligible"` // source kind supports GitOps
Enabled bool `json:"enabled"` // opt-in flag on the workload
Path string `json:"path"` // repo-relative config path
Status string `json:"status"` // disabled|ok|no_file|fetch_failed|invalid
Raw string `json:"raw"` // the .tinyforge.yml text, when present
Message string `json:"message"` // token-redacted detail for non-ok
CommitSHA string `json:"commit_sha"` // ref the file was read at
LastSyncAt string `json:"last_sync_at"` // last successful sync ("" = never)
Drift []gitops.DriftEntry `json:"drift"` // declared fields that differ from live
DriftCount int `json:"drift_count"`
// ManagedFields lists every source_config key the repo overlay declares
// (not just the drifting ones) so the UI can lock exactly those fields on
// the edit form. Populated only when the file parsed (status ok).
ManagedFields []string `json:"managed_fields"`
}
// getWorkloadGitOps handles GET /api/workloads/{id}/gitops. Read-only; open to
// any authenticated user. When GitOps is enabled it fetches the repo's
// .tinyforge.yml live and computes drift against the stored source_config.
func (s *Server) getWorkloadGitOps(w http.ResponseWriter, r *http.Request) {
row, ok := s.loadWorkload(w, chi.URLParam(r, "id"))
if !ok {
return
}
resp := gitOpsStatusResponse{
Eligible: gitops.IsEligibleSource(row.SourceKind),
Enabled: row.GitOpsEnabled,
Path: row.GitOpsPath,
Status: "disabled",
LastSyncAt: row.GitOpsLastSyncAt,
CommitSHA: row.GitOpsCommitSHA,
Drift: []gitops.DriftEntry{},
}
if resp.Path == "" {
resp.Path = ".tinyforge.yml"
}
// Only reach out to the repo when GitOps is actually on.
if row.GitOpsEnabled && resp.Eligible {
ref, err := s.gitOpsRepoRef(row)
if err != nil {
// Decoding/decrypt failure: surface as fetch_failed, never the raw
// error (it can carry the token / config bytes).
slog.Warn("gitops: build repo ref", "workload", row.ID, "error", err)
resp.Status = string(gitops.StatusFetchFailed)
resp.Message = "could not read repo settings for this workload"
respondJSON(w, http.StatusOK, resp)
return
}
res := gitops.Fetch(r.Context(), ref)
resp.Status = string(res.Status)
resp.CommitSHA = firstNonEmpty(res.CommitSHA, row.GitOpsCommitSHA)
resp.Message = res.Message
if len(res.Raw) > 0 {
resp.Raw = string(res.Raw)
}
if res.Status == gitops.StatusOK {
drift, derr := gitops.Drift(res.Spec, json.RawMessage(row.SourceConfig), row.SourceKind)
if derr != nil {
slog.Warn("gitops: drift", "workload", row.ID, "error", derr)
} else if drift != nil {
resp.Drift = drift
}
resp.DriftCount = len(resp.Drift)
resp.ManagedFields = planFields(gitops.BuildPlan(res.Spec, row.SourceKind))
}
}
respondJSON(w, http.StatusOK, resp)
}
// setWorkloadGitOps handles PUT /api/workloads/{id}/gitops. Admin-only.
// Body: {"enabled": bool, "path": string}. Enabling is refused for source
// kinds that aren't git-backed; the path is validated against traversal.
func (s *Server) setWorkloadGitOps(w http.ResponseWriter, r *http.Request) {
row, ok := s.loadWorkload(w, chi.URLParam(r, "id"))
if !ok {
return
}
var body struct {
Enabled bool `json:"enabled"`
Path string `json:"path"`
}
if !decodeJSONStrict(w, r, &body) {
return
}
if body.Enabled && !gitops.IsEligibleSource(row.SourceKind) {
respondError(w, http.StatusBadRequest,
"GitOps is only available for dockerfile and static sources")
return
}
path := strings.TrimSpace(body.Path)
if path != "" && !validGitOpsPath(path) {
respondError(w, http.StatusBadRequest,
"invalid path: must be a repo-relative file (no \"..\", no leading slash)")
return
}
if err := s.store.SetWorkloadGitOps(row.ID, body.Enabled, path); err != nil {
slog.Error("gitops: set", "workload", row.ID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to update GitOps settings")
return
}
if path == "" {
path = ".tinyforge.yml"
}
respondJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled, "path": path})
}
// syncWorkloadGitOps handles POST /api/workloads/{id}/gitops/sync. Admin-only.
// It fetches the repo's .tinyforge.yml, merges the declared overlay onto the
// live source_config (validate-then-commit), persists it, and records the sync.
// Explicit action only — there is no auto-apply on deploy in v1.
func (s *Server) syncWorkloadGitOps(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
respondError(w, http.StatusBadRequest, "workload id is required")
return
}
// Serialize the whole read→merge→write per workload so two concurrent
// syncs can't clobber each other (review S5). Load the row INSIDE the lock
// so each sync merges off the latest persisted config.
unlock := s.gitopsSync.lock(id)
defer unlock()
row, ok := s.loadWorkload(w, id)
if !ok {
return
}
if !gitops.IsEligibleSource(row.SourceKind) {
respondError(w, http.StatusBadRequest,
"GitOps is only available for dockerfile and static sources")
return
}
if !row.GitOpsEnabled {
respondError(w, http.StatusBadRequest, "enable GitOps for this workload first")
return
}
ref, err := s.gitOpsRepoRef(row)
if err != nil {
slog.Warn("gitops: build repo ref", "workload", row.ID, "error", err)
respondError(w, http.StatusBadGateway, "could not read repo settings for this workload")
return
}
res := gitops.Fetch(r.Context(), ref)
switch res.Status {
case gitops.StatusOK:
// proceed
case gitops.StatusNoFile:
respondError(w, http.StatusBadRequest, "no "+ref.Path+" found on branch "+ref.Branch)
return
case gitops.StatusInvalid:
respondError(w, http.StatusBadRequest, "invalid "+ref.Path+": "+res.Message)
return
default: // fetch_failed
slog.Warn("gitops: fetch failed", "workload", row.ID, "detail", res.Message)
respondError(w, http.StatusBadGateway, "could not fetch "+ref.Path+" from the repo")
return
}
src, err := plugin.GetSource(row.SourceKind)
if err != nil {
respondError(w, http.StatusInternalServerError, "unknown source kind")
return
}
plan := gitops.BuildPlan(res.Spec, row.SourceKind)
merged, err := gitops.MergeAndValidate(json.RawMessage(row.SourceConfig), plan, src.Validate)
if err != nil {
// The merged config failed the source's own Validate — the file
// declares something this workload can't accept. Safe to surface (it
// describes config shape, not secrets).
respondError(w, http.StatusBadRequest, "the repo config was rejected: "+err.Error())
return
}
// Persist via a full-row update off the row we loaded (single read →
// merge → write). A per-workload sync lock that closes the remaining
// edit-vs-sync window is a Phase 4 hardening item.
row.SourceConfig = string(merged)
if err := s.store.UpdateWorkload(row); err != nil {
slog.Error("gitops: persist merged config", "workload", row.ID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to apply the repo config")
return
}
if err := s.store.RecordGitOpsSync(row.ID, res.CommitSHA, store.Now()); err != nil {
slog.Warn("gitops: record sync", "workload", row.ID, "error", err)
}
actor := "manual"
if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.Username != "" {
actor = claims.Username
}
appliedFields := planFields(plan)
s.recordGitOpsEvent(row.ID, res.CommitSHA, actor, appliedFields)
respondJSON(w, http.StatusOK, map[string]any{
"status": "applied",
"commit_sha": res.CommitSHA,
"applied_fields": appliedFields,
"triggered_by": actor,
})
}
// loadWorkload fetches a workload by id, writing the appropriate error response
// and returning ok=false on miss. Shared by the GitOps handlers.
func (s *Server) loadWorkload(w http.ResponseWriter, id string) (store.Workload, bool) {
if id == "" {
respondError(w, http.StatusBadRequest, "workload id is required")
return store.Workload{}, false
}
row, err := s.store.GetWorkloadByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return store.Workload{}, false
}
respondError(w, http.StatusInternalServerError, "get workload")
return store.Workload{}, false
}
return row, true
}
// gitOpsRepoRef builds a gitops.RepoRef from a workload's source_config: it
// decodes the common git coords (identical keys across dockerfile + static)
// and decrypts the access token. The gitops package stays decoupled from the
// store/crypto by taking the plain coords.
func (s *Server) gitOpsRepoRef(row store.Workload) (gitops.RepoRef, error) {
var c struct {
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
AccessToken string `json:"access_token"`
}
if err := json.Unmarshal([]byte(row.SourceConfig), &c); err != nil {
return gitops.RepoRef{}, fmt.Errorf("decode source_config: %w", err)
}
token := ""
if c.AccessToken != "" {
dec, err := crypto.Decrypt(s.encKey, c.AccessToken)
if err != nil {
return gitops.RepoRef{}, fmt.Errorf("decrypt access token: %w", err)
}
token = dec
}
branch := c.Branch
if branch == "" {
branch = "main"
}
path := row.GitOpsPath
if path == "" {
path = ".tinyforge.yml"
}
return gitops.RepoRef{
Provider: c.Provider,
BaseURL: c.BaseURL,
Owner: c.RepoOwner,
Repo: c.RepoName,
Branch: branch,
Token: token,
Path: path,
}, nil
}
// recordGitOpsEvent writes a sync to the per-workload event log — the audit
// trail for a config-only sync, kept OUT of deploy_history (which the rollback
// feature treats as redeployable rows).
func (s *Server) recordGitOpsEvent(workloadID, sha, actor string, fields []string) {
meta, _ := json.Marshal(map[string]any{"commit_sha": sha, "by": actor, "fields": fields})
if _, err := s.store.InsertEvent(store.EventLog{
Source: "gitops",
WorkloadID: workloadID,
Severity: "info",
Message: "GitOps config synced from repo",
Metadata: string(meta),
}); err != nil {
slog.Warn("gitops: record event", "workload", workloadID, "error", err)
}
}
// validGitOpsPath rejects absolute paths, traversal, and URL-significant or
// control characters so a stored config path can't escape the repo (review M2)
// or smuggle a query/fragment onto the provider's raw-file URL (review LOW-1).
func validGitOpsPath(p string) bool {
if p == "" || len(p) > 255 {
return false
}
if strings.HasPrefix(p, "/") || strings.HasPrefix(p, "\\") {
return false
}
if strings.Contains(p, "..") {
return false
}
for _, r := range p {
if r < 0x20 || r == 0x7f || r == '?' || r == '#' || r == ' ' || r == '\\' {
return false
}
}
return true
}
// planFields returns the source_config keys an apply plan touches.
func planFields(plan gitops.ApplyPlan) []string {
fields := make([]string, 0, len(plan.SourceConfigPatch))
for k := range plan.SourceConfigPatch {
fields = append(fields, k)
}
return fields
}