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:
@@ -0,0 +1,364 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/gitops"
|
||||
)
|
||||
|
||||
func TestValidGitOpsPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
ok bool
|
||||
}{
|
||||
{".tinyforge.yml", true},
|
||||
{"deploy/.tinyforge.yml", true},
|
||||
{"config/app.yaml", true},
|
||||
{"/etc/passwd", false}, // absolute
|
||||
{"\\windows\\path", false}, // absolute (backslash)
|
||||
{"../../etc/passwd", false}, // traversal
|
||||
{"deploy/../../x", false}, // traversal mid-path
|
||||
{"foo?ref=evil", false}, // query-param injection (LOW-1)
|
||||
{"foo#frag", false}, // fragment injection
|
||||
{"with space.yml", false}, // whitespace
|
||||
{"", false}, // empty
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := validGitOpsPath(c.path); got != c.ok {
|
||||
t.Errorf("validGitOpsPath(%q) = %v, want %v", c.path, got, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanFields(t *testing.T) {
|
||||
spec := gitops.Spec{Version: 1, Deploy: gitops.DeploySpec{
|
||||
Port: ptrInt(8080),
|
||||
DeployStrategy: ptrStr("blue-green"),
|
||||
}}
|
||||
got := planFields(gitops.BuildPlan(spec, gitops.SourceDockerfile))
|
||||
sort.Strings(got)
|
||||
want := []string{"deploy_strategy", "port"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("planFields = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrInt(i int) *int { return &i }
|
||||
func ptrStr(s string) *string { return &s }
|
||||
@@ -52,6 +52,10 @@ type Server struct {
|
||||
oidcProvider *auth.OIDCProvider
|
||||
staleScanner *stale.Scanner
|
||||
|
||||
// gitopsSync serializes the GitOps sync (read→merge→write) per workload so
|
||||
// two concurrent syncs can't race on source_config (review S5).
|
||||
gitopsSync keyedMutex
|
||||
|
||||
dnsProviderMu sync.RWMutex
|
||||
dnsProvider dns.Provider
|
||||
onDNSProviderChanged DNSProviderChangedFunc
|
||||
@@ -342,6 +346,13 @@ func (s *Server) Router() chi.Router {
|
||||
r.Get("/deploys", s.listWorkloadDeploys)
|
||||
r.With(auth.AdminOnly).Post("/rollback", s.rollbackWorkload)
|
||||
|
||||
// GitOps config-as-code (dockerfile/static). The status read
|
||||
// (incl. live drift) is open to any authenticated user; enable/
|
||||
// disable and sync mutate config, so they are admin-gated.
|
||||
r.Get("/gitops", s.getWorkloadGitOps)
|
||||
r.With(auth.AdminOnly).Put("/gitops", s.setWorkloadGitOps)
|
||||
r.With(auth.AdminOnly).Post("/gitops/sync", s.syncWorkloadGitOps)
|
||||
|
||||
// Volume snapshots (admin-only). Capture/list a workload's
|
||||
// host-bind data volumes; {sid}-scoped download/delete live
|
||||
// in the global admin group alongside backups.
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -44,6 +44,9 @@ func (*fakeReporterProvider) ListTree(context.Context, string, string, string) (
|
||||
func (*fakeReporterProvider) DownloadFolder(context.Context, string, string, string, string, string) error {
|
||||
panic("unused")
|
||||
}
|
||||
func (*fakeReporterProvider) DownloadFile(context.Context, string, string, string, string, int64) ([]byte, error) {
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
// Enabled: forwards to the provider with the captured identifiers + target.
|
||||
func TestCommitStatusReporter_Enabled_Calls(t *testing.T) {
|
||||
|
||||
@@ -295,6 +295,15 @@ func (f *GiteaContentFetcher) DownloadFolder(ctx context.Context, owner, repo, b
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadFile fetches a single file's raw bytes via Gitea's raw endpoint
|
||||
// (also serves Forgejo/Gogs), capped at maxBytes. Returns ErrFileNotFound on
|
||||
// a 404 so an absent config file reads as a non-error state.
|
||||
func (f *GiteaContentFetcher) DownloadFile(ctx context.Context, owner, repo, ref, path string, maxBytes int64) ([]byte, error) {
|
||||
p := strings.TrimPrefix(path, "/")
|
||||
fileURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s", f.baseURL, owner, repo, p, ref)
|
||||
return getFileBytes(ctx, f.httpClient, fileURL, maxBytes, f.setAuth)
|
||||
}
|
||||
|
||||
// TestConnection verifies that the repository is accessible.
|
||||
func (f *GiteaContentFetcher) TestConnection(ctx context.Context, owner, repo string) error {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s", f.baseURL, owner, repo)
|
||||
|
||||
@@ -288,6 +288,19 @@ func (g *GitHubProvider) DownloadFolder(ctx context.Context, owner, repo, branch
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadFile fetches a single file's raw bytes via the GitHub contents API
|
||||
// using the raw media type (works for both github.com and GHE), capped at
|
||||
// maxBytes. Returns ErrFileNotFound on a 404.
|
||||
func (g *GitHubProvider) DownloadFile(ctx context.Context, owner, repo, ref, path string, maxBytes int64) ([]byte, error) {
|
||||
p := strings.TrimPrefix(path, "/")
|
||||
fileURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", g.apiBase, owner, repo, p, ref)
|
||||
auth := func(r *http.Request) {
|
||||
g.setAuth(r)
|
||||
r.Header.Set("Accept", "application/vnd.github.raw+json")
|
||||
}
|
||||
return getFileBytes(ctx, g.httpClient, fileURL, maxBytes, auth)
|
||||
}
|
||||
|
||||
func (g *GitHubProvider) doGet(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -273,6 +273,22 @@ func (g *GitLabProvider) DownloadFolder(ctx context.Context, owner, repo, branch
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadFile fetches a single file's raw bytes via GitLab's raw endpoint,
|
||||
// capped at maxBytes. Returns ErrFileNotFound on a 404. owner/repo/ref are
|
||||
// path-escaped; the file path is passed through verbatim to preserve its `/`
|
||||
// separators (a `..` segment is harmless — the bytes are only parsed in
|
||||
// memory, never written to disk, so there is no local-traversal sink).
|
||||
func (g *GitLabProvider) DownloadFile(ctx context.Context, owner, repo, ref, path string, maxBytes int64) ([]byte, error) {
|
||||
p := strings.TrimPrefix(path, "/")
|
||||
fileURL := fmt.Sprintf("%s/%s/%s/-/raw/%s/%s",
|
||||
g.rawBase,
|
||||
url.PathEscape(owner),
|
||||
url.PathEscape(repo),
|
||||
url.PathEscape(ref),
|
||||
p)
|
||||
return getFileBytes(ctx, g.httpClient, fileURL, maxBytes, g.setAuth)
|
||||
}
|
||||
|
||||
func (g *GitLabProvider) doGet(ctx context.Context, apiURL string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package staticsite
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -12,6 +13,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrFileNotFound is returned by GitProvider.DownloadFile when the file is
|
||||
// absent (HTTP 404). Callers use it to distinguish "no file" (a normal,
|
||||
// non-error state for GitOps) from a genuine fetch failure.
|
||||
var ErrFileNotFound = errors.New("file not found")
|
||||
|
||||
// RepoInfo represents a repository returned by the provider's list/search API.
|
||||
type RepoInfo struct {
|
||||
Owner string `json:"owner"`
|
||||
@@ -81,6 +87,12 @@ type GitProvider interface {
|
||||
// DownloadFolder downloads all files from a folder path to a local directory.
|
||||
DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error
|
||||
|
||||
// DownloadFile fetches a single file's bytes from a ref (branch/sha),
|
||||
// capped at maxBytes. Returns ErrFileNotFound on a 404 so callers can
|
||||
// treat an absent file as a non-error state. Used to read a small in-repo
|
||||
// config file (e.g. .tinyforge.yml) without materializing a whole tree.
|
||||
DownloadFile(ctx context.Context, owner, repo, ref, path string, maxBytes int64) ([]byte, error)
|
||||
|
||||
// SetCommitStatus reports a deploy status on a commit. Best-effort;
|
||||
// callers ignore errors beyond logging. targetURL and description are
|
||||
// optional (pass "" to omit); description is truncated to a provider-
|
||||
@@ -206,6 +218,44 @@ func postJSON(ctx context.Context, client *http.Client, url string, body []byte,
|
||||
return nil
|
||||
}
|
||||
|
||||
// getFileBytes GETs fileURL with the caller's auth applied and returns the
|
||||
// body, enforcing a maxBytes cap. Returns ErrFileNotFound on 404; a
|
||||
// status-code-only error otherwise (it must NOT echo the response body — a
|
||||
// hostile/misconfigured provider could reflect the request's auth token back).
|
||||
func getFileBytes(ctx context.Context, client *http.Client, fileURL string, maxBytes int64, authHeader func(r *http.Request)) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
if authHeader != nil {
|
||||
authHeader(req)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return nil, ErrFileNotFound
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read one byte past the cap so an over-size file is detected rather than
|
||||
// silently truncated.
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return nil, fmt.Errorf("file exceeds %d byte cap", maxBytes)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// downloadFileHTTP is a shared helper for downloading a file from a URL.
|
||||
func downloadFileHTTP(ctx context.Context, client *http.Client, url, localPath string, authHeader func(r *http.Request)) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package store
|
||||
|
||||
import "fmt"
|
||||
|
||||
// SetWorkloadGitOps toggles GitOps and sets the config path for a workload.
|
||||
// Targeted column update (not UpdateWorkload) so it never clobbers the
|
||||
// source_config / faces / webhook fields — and conversely, the edit-form save
|
||||
// (UpdateWorkload) never touches these columns.
|
||||
func (s *Store) SetWorkloadGitOps(id string, enabled bool, path string) error {
|
||||
if path == "" {
|
||||
path = ".tinyforge.yml"
|
||||
}
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE workloads SET gitops_enabled=?, gitops_path=?, updated_at=? WHERE id=?`,
|
||||
BoolToInt(enabled), path, Now(), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set workload gitops: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("workload %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordGitOpsSync stamps the commit SHA + timestamp of the last successful
|
||||
// sync, so the UI can show "last synced <when> at <sha>".
|
||||
func (s *Store) RecordGitOpsSync(id, commitSHA, syncedAt string) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE workloads SET gitops_last_sync_at=?, gitops_commit_sha=?, updated_at=? WHERE id=?`,
|
||||
syncedAt, commitSHA, Now(), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record gitops sync: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("workload %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSetWorkloadGitOps_RoundTrip(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
w, err := s.CreateWorkload(Workload{Kind: "plugin", Name: "app", SourceKind: "dockerfile"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkload: %v", err)
|
||||
}
|
||||
|
||||
// Fresh row defaults: GitOps off, default path applied by CreateWorkload.
|
||||
if w.GitOpsEnabled {
|
||||
t.Fatalf("new workload should default to gitops disabled")
|
||||
}
|
||||
if w.GitOpsPath != ".tinyforge.yml" {
|
||||
t.Fatalf("default path = %q, want .tinyforge.yml", w.GitOpsPath)
|
||||
}
|
||||
|
||||
// Enable with a custom path.
|
||||
if err := s.SetWorkloadGitOps(w.ID, true, "deploy/.tinyforge.yml"); err != nil {
|
||||
t.Fatalf("SetWorkloadGitOps: %v", err)
|
||||
}
|
||||
got, err := s.GetWorkloadByID(w.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkloadByID: %v", err)
|
||||
}
|
||||
if !got.GitOpsEnabled || got.GitOpsPath != "deploy/.tinyforge.yml" {
|
||||
t.Fatalf("after enable: enabled=%v path=%q", got.GitOpsEnabled, got.GitOpsPath)
|
||||
}
|
||||
|
||||
// Empty path falls back to the default.
|
||||
if err := s.SetWorkloadGitOps(w.ID, false, ""); err != nil {
|
||||
t.Fatalf("SetWorkloadGitOps disable: %v", err)
|
||||
}
|
||||
got, _ = s.GetWorkloadByID(w.ID)
|
||||
if got.GitOpsEnabled || got.GitOpsPath != ".tinyforge.yml" {
|
||||
t.Fatalf("after disable: enabled=%v path=%q", got.GitOpsEnabled, got.GitOpsPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordGitOpsSync(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
w, _ := s.CreateWorkload(Workload{Kind: "plugin", Name: "app", SourceKind: "static"})
|
||||
|
||||
if err := s.RecordGitOpsSync(w.ID, "abc123", "2026-06-21 10:00:00"); err != nil {
|
||||
t.Fatalf("RecordGitOpsSync: %v", err)
|
||||
}
|
||||
got, _ := s.GetWorkloadByID(w.ID)
|
||||
if got.GitOpsCommitSHA != "abc123" || got.GitOpsLastSyncAt != "2026-06-21 10:00:00" {
|
||||
t.Fatalf("sync not recorded: sha=%q at=%q", got.GitOpsCommitSHA, got.GitOpsLastSyncAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitOpsSetters_NotFound(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if err := s.SetWorkloadGitOps("nope", true, ""); err == nil {
|
||||
t.Fatalf("expected ErrNotFound for missing workload")
|
||||
}
|
||||
if err := s.RecordGitOpsSync("nope", "x", "y"); err == nil {
|
||||
t.Fatalf("expected ErrNotFound for missing workload")
|
||||
}
|
||||
}
|
||||
@@ -394,8 +394,14 @@ type Workload struct {
|
||||
WebhookSecret string `json:"-"` // URL-identifier secret; never serialized
|
||||
WebhookSigningSecret string `json:"-"` // HMAC key; never serialized
|
||||
WebhookRequireSignature bool `json:"webhook_require_signature"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
// GitOps config-as-code (dockerfile/static only). Opt-in: when enabled,
|
||||
// the workload reads its deploy config from GitOpsPath in its own repo.
|
||||
GitOpsEnabled bool `json:"gitops_enabled"`
|
||||
GitOpsPath string `json:"gitops_path"` // repo-relative; default ".tinyforge.yml"
|
||||
GitOpsLastSyncAt string `json:"gitops_last_sync_at"` // "" = never synced
|
||||
GitOpsCommitSHA string `json:"gitops_commit_sha"` // sha applied at last sync
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// WorkloadNotification is one configured outbound notification route for
|
||||
|
||||
@@ -173,6 +173,14 @@ func (s *Store) runMigrations() error {
|
||||
`ALTER TABLE workloads ADD COLUMN trigger_config TEXT NOT NULL DEFAULT '{}'`,
|
||||
`ALTER TABLE workloads ADD COLUMN public_faces TEXT NOT NULL DEFAULT '[]'`,
|
||||
`ALTER TABLE workloads ADD COLUMN parent_workload_id TEXT NOT NULL DEFAULT ''`,
|
||||
// GitOps config-as-code: a dockerfile/static workload may read its
|
||||
// deploy config from a .tinyforge.yml in its own repo. Opt-in per
|
||||
// workload; all four land additively so existing rows default to
|
||||
// "GitOps off" and stay byte-identical.
|
||||
`ALTER TABLE workloads ADD COLUMN gitops_enabled INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE workloads ADD COLUMN gitops_path TEXT NOT NULL DEFAULT '.tinyforge.yml'`,
|
||||
`ALTER TABLE workloads ADD COLUMN gitops_last_sync_at TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE workloads ADD COLUMN gitops_commit_sha TEXT NOT NULL DEFAULT ''`,
|
||||
// Schedule trigger needs a column to remember when it last fired so
|
||||
// the scheduler can compute next-fire windows across restarts.
|
||||
// Empty string = never fired. Pre-trigger-split DBs land the column
|
||||
|
||||
@@ -13,6 +13,7 @@ const workloadColumns = `id, kind, ref_id, name, app_id,
|
||||
public_faces, parent_workload_id,
|
||||
notification_url, notification_secret,
|
||||
webhook_secret, webhook_signing_secret, webhook_require_signature,
|
||||
gitops_enabled, gitops_path, gitops_last_sync_at, gitops_commit_sha,
|
||||
created_at, updated_at`
|
||||
|
||||
func scanWorkload(scanner interface{ Scan(...any) error }) (Workload, error) {
|
||||
@@ -23,6 +24,7 @@ func scanWorkload(scanner interface{ Scan(...any) error }) (Workload, error) {
|
||||
&w.PublicFaces, &w.ParentWorkloadID,
|
||||
&w.NotificationURL, &w.NotificationSecret,
|
||||
&w.WebhookSecret, &w.WebhookSigningSecret, &w.WebhookRequireSignature,
|
||||
&w.GitOpsEnabled, &w.GitOpsPath, &w.GitOpsLastSyncAt, &w.GitOpsCommitSHA,
|
||||
&w.CreatedAt, &w.UpdatedAt,
|
||||
)
|
||||
return w, err
|
||||
@@ -53,14 +55,18 @@ func (s *Store) CreateWorkload(w Workload) (Workload, error) {
|
||||
if w.PublicFaces == "" {
|
||||
w.PublicFaces = "[]"
|
||||
}
|
||||
if w.GitOpsPath == "" {
|
||||
w.GitOpsPath = ".tinyforge.yml"
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO workloads (`+workloadColumns+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
w.ID, w.Kind, w.RefID, w.Name, w.AppID,
|
||||
w.SourceKind, w.SourceConfig, w.TriggerKind, w.TriggerConfig,
|
||||
w.PublicFaces, w.ParentWorkloadID,
|
||||
w.NotificationURL, w.NotificationSecret,
|
||||
w.WebhookSecret, w.WebhookSigningSecret, BoolToInt(w.WebhookRequireSignature),
|
||||
BoolToInt(w.GitOpsEnabled), w.GitOpsPath, w.GitOpsLastSyncAt, w.GitOpsCommitSHA,
|
||||
w.CreatedAt, w.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user