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 }