diff --git a/.dockerignore b/.dockerignore index 057d540..dfa8fb4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,47 @@ +# VCS / tooling .git -node_modules -web/node_modules -web/build -data -*.md -plans/ -.claude/ +.gitignore .dockerignore +.gitea/ +.github/ +.claude/ +.code-review-graph/ +.vex.toml +.facts-sync.json +.facts-suggestions.md + +# Node / frontend build artifacts (frontend stage rebuilds web/build) +node_modules/ +web/node_modules/ +web/build/ +web/.svelte-kit/ + +# Runtime / local data +data/ +.env +.env.* +*.log + +# Compiled binaries (rebuilt inside the image) +tinyforge +tinyforge.exe +tinyforge-server.exe +server.exe +docker-watcher +docker-watcher.exe +docker-watcher.exe~ +/cli +/cli.exe + +# Build/orchestration files not needed inside the image +Dockerfile +docker-compose.yml +Makefile +*.example.yaml + +# Docs / planning / design (not needed at runtime) +*.md +docs/ +plans/ +design-mockups/ +test-data/ diff --git a/.facts-suggestions.md b/.facts-suggestions.md new file mode 100644 index 0000000..f6e198e --- /dev/null +++ b/.facts-suggestions.md @@ -0,0 +1,27 @@ +# Facts Repo Suggestions + +Pending suggestions to push back to claude-code-facts. + +--- + +## 2026-06-21: Buildx + registry buildcache DOES work on the TrueNAS Gitea runner + +**Target file:** gitea-python-ci-cd.md +**Section:** "## 7. Docker Build" and "## 9. Gitea vs GitHub Actions Differences" +**Reason:** The doc's compatibility table says "Docker Buildx — May not work (runner networking)" and the Docker section uses plain `docker build` + `docker push --all-tags`. In practice, `docker/setup-buildx-action@v3` + `docker/build-push-action@v5` with `cache-from/to: type=registry,ref=$REGISTRY:buildcache,mode=max` (and `type=gha` for no-push CI builds) works on the current `git.dolgolyov-family.by` runner — verified in the notify-bridge and tiny-forge pipelines. Recommend adding a "buildx path (preferred when it works)" variant alongside the conservative plain-`docker build` path, and softening the row to "Usually works; falls back to plain `docker build`." + +--- + +## 2026-06-21: Quote `if:` expressions that contain a colon + +**Target file:** gitea-python-ci-cd.md +**Section:** "## 9. Gitea vs GitHub Actions Differences" (or a new "Workflow gotchas") +**Reason:** A common skip-guard `if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}` contains `: ` inside the literal, which makes strict YAML parsers (PyYAML, and validators) treat it as a nested mapping and error with "mapping values are not allowed here". Gitea's parser is lenient and accepts the unquoted form, but it fails any standard YAML lint. Fix: wrap the whole expression in double quotes — `if: "${{ ... 'chore: release v' ... }}"`. + +--- + +## 2026-06-21: Add a "Go on Gitea" CI/CD note + +**Target file:** gitea-python-ci-cd.md (or a new gitea-go-ci-cd.md) +**Section:** new +**Reason:** The doc is Python-only. The same release/Docker patterns apply to Go services with these deltas: pin `setup-go` to match the `go` directive in `go.mod` (a mismatch silently triggers a slow `GOTOOLCHAIN=auto` toolchain download); gate on `go vet ./...` + `go test ./internal/...`; multi-stage Dockerfile with `--mount=type=cache,target=/go/pkg/mod` and `target=/root/.cache/go-build` (requires `# syntax=docker/dockerfile:1.7`); `CGO_ENABLED=0 -ldflags="-s -w"` static binary on an `alpine` runtime with a non-root user and a `wget --spider` HEALTHCHECK. diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index ab07943..a8dc6a3 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -5,34 +5,70 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: jobs: - build: + frontend: + # Skip the build on release-bump commits — the tag push runs release.yml. + if: "${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install frontend dependencies + working-directory: web + run: npm ci --no-audit + + - name: Svelte check + working-directory: web + run: npm run check + + - name: Unit tests (vitest) + working-directory: web + run: npm run test + + - name: Build frontend + working-directory: web + run: npm run build + + backend: + if: "${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.24' - - - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install frontend dependencies - working-directory: web - run: npm ci --no-audit - - - name: Build frontend - working-directory: web - run: npm run build + go-version: '1.25' + cache-dependency-path: go.sum - name: Vet Go code run: go vet ./... - - name: Build Go binary - run: CGO_ENABLED=0 go build -ldflags="-s -w" -o tinyforge ./cmd/server + - name: Run Go tests + run: go test ./internal/... -count=1 - - name: Build Docker image - run: docker build -t tinyforge:dev . + build-image: + if: "${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}" + needs: [frontend, backend] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image (no push) + uses: docker/build-push-action@v5 + with: + context: . + push: false + tags: tinyforge:ci-${{ gitea.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 18e4b3b..7232a01 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -10,19 +10,109 @@ env: REGISTRY: git.dolgolyov-family.by/alexei.dolgolyov/tiny-forge jobs: - create-release: + # ─────────────────────────────────────────────────────────────────────── + # Gate the release on a passing test suite. A tagged release must never + # ship code that fails `go vet` / `go test`. + # ─────────────────────────────────────────────────────────────────────── + test: runs-on: ubuntu-latest - outputs: - release_id: ${{ steps.create.outputs.release_id }} steps: - - name: Fetch RELEASE_NOTES.md only + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache-dependency-path: go.sum + + - name: Vet Go code + run: go vet ./... + + - name: Run Go tests + run: go test ./internal/... -count=1 + + # ─────────────────────────────────────────────────────────────────────── + # Build + push the image FIRST. If this fails, no release is created + # (create-release depends on it) — so we never leave an orphan release + # pointing at a tag with no published image. + # ─────────────────────────────────────────────────────────────────────── + build-docker: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Compute tags + id: meta + run: | + TAG="${{ gitea.ref_name }}" + VERSION="${TAG#v}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + # Detect pre-release (alpha/beta/rc) — these do NOT get :latest. + if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then + echo "is_pre=true" >> "$GITHUB_OUTPUT" + else + echo "is_pre=false" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.SERVER_HOST }} + username: ${{ gitea.actor }} + password: ${{ secrets.DEPLOY_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.REGISTRY }}:${{ steps.meta.outputs.tag }} + ${{ env.REGISTRY }}:${{ steps.meta.outputs.version }} + ${{ env.REGISTRY }}:sha-${{ gitea.sha }} + ${{ steps.meta.outputs.is_pre == 'false' && format('{0}:latest', env.REGISTRY) || '' }} + cache-from: type=registry,ref=${{ env.REGISTRY }}:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}:buildcache,mode=max + + - name: Trigger redeploy webhook + if: steps.meta.outputs.is_pre == 'false' + continue-on-error: true + run: | + if [ -n "${{ secrets.DOCKER_REDEPLOY_WEBHOOK_URL }}" ]; then + echo "Triggering redeploy webhook..." + curl -sf -X POST "${{ secrets.DOCKER_REDEPLOY_WEBHOOK_URL }}" \ + --max-time 30 || echo "::warning::Redeploy webhook failed" + else + echo "DOCKER_REDEPLOY_WEBHOOK_URL not set — skipping auto-deploy" + fi + + # ─────────────────────────────────────────────────────────────────────── + # Create the Gitea release LAST — body = RELEASE_NOTES.md + auto-changelog. + # ─────────────────────────────────────────────────────────────────────── + create-release: + needs: build-docker + runs-on: ubuntu-latest + steps: + - name: Checkout (full history for changelog) uses: actions/checkout@v4 with: - sparse-checkout: RELEASE_NOTES.md - sparse-checkout-cone-mode: false + fetch-depth: 0 + + - name: Generate changelog + id: changelog + run: | + PREV_TAG=$(git tag --sort=-v:refname | head -2 | tail -1) + if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "${{ gitea.ref_name }}" ]; then + git log --oneline --no-decorate -n 20 > /tmp/changelog.txt + else + git log --oneline --no-decorate "${PREV_TAG}..HEAD" > /tmp/changelog.txt + fi - name: Create Gitea release - id: create env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} run: | @@ -42,74 +132,49 @@ jobs: echo "Found RELEASE_NOTES.md" else export RELEASE_NOTES="" - echo "No RELEASE_NOTES.md found — release will have no body" + echo "No RELEASE_NOTES.md found — release body = changelog only" fi - BODY_JSON=$(python3 -c " + # Build release body (notes + changelog) via Python to avoid shell + # escaping and CLI length limits. + export TAG VERSION IS_PRE + python3 <<'PY' import json, os - notes = os.environ.get('RELEASE_NOTES', '') - print(json.dumps(notes.strip())) - ") - # Create release via Gitea API - RELEASE=$(curl -s -X POST "$BASE_URL/releases" \ + notes = os.environ.get('RELEASE_NOTES', '') + changelog = open('/tmp/changelog.txt').read().strip() + + sections = [] + if notes.strip(): + sections.append(notes.strip()) + if changelog: + sections.append('## Changelog\n\n' + changelog) + + payload = { + 'tag_name': os.environ['TAG'], + 'name': os.environ['VERSION'], + 'body': '\n\n'.join(sections), + 'draft': False, + 'prerelease': os.environ['IS_PRE'] == 'true', + } + with open('/tmp/release-payload.json', 'w') as f: + json.dump(payload, f) + PY + + HTTP=$(curl -s -o /tmp/release-resp.json -w "%{http_code}" \ + -X POST "$BASE_URL/releases" \ -H "Authorization: token $DEPLOY_TOKEN" \ -H "Content-Type: application/json" \ - -d "{ - \"tag_name\": \"$TAG\", - \"name\": \"$VERSION\", - \"body\": $BODY_JSON, - \"draft\": false, - \"prerelease\": $IS_PRE - }") + --data-binary @/tmp/release-payload.json) - # Fallback: if release already exists for this tag, reuse it - RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null) - if [ -z "$RELEASE_ID" ]; then - echo "::warning::Release already exists for tag $TAG — reusing existing release" - RELEASE=$(curl -s "$BASE_URL/releases/tags/$TAG" \ - -H "Authorization: token $DEPLOY_TOKEN") - RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") - fi - echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT" - echo "Created release $RELEASE_ID for $TAG" - - build-docker: - needs: create-release - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Login to Gitea Container Registry - id: docker-login - continue-on-error: true - run: | - echo "${{ secrets.DEPLOY_TOKEN }}" | docker login \ - "$SERVER_HOST" -u "${{ gitea.actor }}" --password-stdin - - - name: Build and tag - if: steps.docker-login.outcome == 'success' - run: | - TAG="${{ gitea.ref_name }}" - VERSION="${TAG#v}" - docker build -t "$REGISTRY:$TAG" -t "$REGISTRY:$VERSION" . - # Tag as 'latest' only for stable releases - if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then - docker tag "$REGISTRY:$TAG" "$REGISTRY:latest" - fi - - - name: Push - if: steps.docker-login.outcome == 'success' - run: docker push "$REGISTRY" --all-tags - - - name: Trigger Portainer redeploy - if: steps.docker-login.outcome == 'success' - continue-on-error: true - run: | - if [ -n "${{ secrets.DOCKER_REDEPLOY_WEBHOOK_URL }}" ]; then - echo "Triggering Portainer redeploy..." - curl -sf -X POST "${{ secrets.DOCKER_REDEPLOY_WEBHOOK_URL }}" \ - --max-time 30 || echo "::warning::Portainer webhook failed" + echo "POST /releases → HTTP $HTTP" + if [ "$HTTP" = "201" ]; then + RELEASE_ID=$(python3 -c "import json; print(json.load(open('/tmp/release-resp.json'))['id'])") + echo "Created release $RELEASE_ID for $TAG" + elif [ "$HTTP" = "409" ] || grep -q "already exists" /tmp/release-resp.json; then + echo "::warning::Release already exists for tag $TAG — reusing" else - echo "DOCKER_REDEPLOY_WEBHOOK_URL not set — skipping auto-deploy" + echo "::error::Failed to create release for $TAG (HTTP $HTTP)" + head -c 2000 /tmp/release-resp.json; echo + exit 1 fi diff --git a/Dockerfile b/Dockerfile index 54dbcc0..9831eb2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1.7 # Stage 1: Build frontend FROM node:20-alpine AS frontend-builder @@ -9,25 +10,33 @@ COPY web/ ./ RUN npm run build # Stage 2: Build Go binary -FROM golang:1.24-alpine AS backend-builder +FROM golang:1.25-alpine AS backend-builder RUN apk add --no-cache git ca-certificates WORKDIR /build COPY go.mod go.sum ./ ENV GOTOOLCHAIN=auto -RUN go mod download +# Cache mounts persist the module + build caches across rebuilds (BuildKit). +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download COPY . . # Copy built frontend into the expected embed location. COPY --from=frontend-builder /build/web/build ./web/build -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /tinyforge ./cmd/server +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /tinyforge ./cmd/server # Stage 3: Minimal runtime image FROM alpine:3.19 -RUN apk add --no-cache ca-certificates tzdata +LABEL org.opencontainers.image.source="https://git.dolgolyov-family.by/alexei.dolgolyov/tiny-forge" +LABEL org.opencontainers.image.title="Tinyforge" +LABEL org.opencontainers.image.description="Self-hosted Docker deployment + mini-CI platform" + +RUN apk add --no-cache ca-certificates tzdata wget # Create non-root user. RUN addgroup -g 1000 -S app && adduser -u 1000 -S app -G app @@ -46,4 +55,10 @@ EXPOSE 8080 ENV DATA_DIR=/app/data ENV LISTEN_ADDR=:8080 +VOLUME /app/data + +# /readyz is the public readiness probe (pings the DB); /livez is liveness. +HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/readyz || exit 1 + ENTRYPOINT ["/app/tinyforge"] diff --git a/docker-compose.yml b/docker-compose.yml index 32f5608..9d7d3ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,13 @@ services: tinyforge: + # Default: build from source so a fresh clone works out of the box. build: . - image: tinyforge:latest + # Image name doubles as the Gitea registry tag. To DEPLOY the pre-built + # image instead of building (e.g. Portainer pulling on a webhook), comment + # out `build:` above — compose will then pull this tag. `:latest` is pushed + # only for stable (non pre-release) releases, and the registry may require + # `docker login git.dolgolyov-family.by` first if the package is private. + image: git.dolgolyov-family.by/alexei.dolgolyov/tiny-forge:latest container_name: tinyforge restart: unless-stopped ports: @@ -31,7 +37,10 @@ services: networks: - staging-net healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/auth/login"] + # /readyz is the public readiness probe (pings the DB, rate-limited). + # The previous target (/api/auth/login) is POST-only, so a GET/spider + # request returned 405 and the container was always reported unhealthy. + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/readyz"] interval: 30s timeout: 5s retries: 3 diff --git a/docs/gitops.md b/docs/gitops.md new file mode 100644 index 0000000..f59c952 --- /dev/null +++ b/docs/gitops.md @@ -0,0 +1,77 @@ +# GitOps: config-as-code with `.tinyforge.yml` + +A **dockerfile** or **static** workload can read part of its deploy config from a +`.tinyforge.yml` file in its own repo. Tinyforge fetches the file, shows you how it +differs from the live config (**drift**), and applies it when you click **Sync** — so the +repo becomes the source of truth for the declared fields. + +This is opt-in per workload and **manual-sync only** in v1: nothing is applied automatically +on deploy, and a sync never runs without an explicit admin action. + +## Enabling it + +1. Open the workload (Apps → your app). +2. In the **GitOps** panel, toggle it on. The default file path is `.tinyforge.yml` at the + repo root; change it if your file lives elsewhere (e.g. `deploy/.tinyforge.yml`). +3. Add a `.tinyforge.yml` to the repo (schema below) and push. +4. The panel shows the parsed file and any drift vs. the live config. Click **Sync now** to + apply the repo's values to the workload. + +Only **dockerfile** and **static** sources are eligible — they're the git-backed sources. +`image` and `compose` workloads don't show the panel. + +## `.tinyforge.yml` schema (v1) + +```yaml +version: 1 # required, must be 1 +deploy: + # dockerfile only: + port: 8080 # container port the app listens on + healthcheck: /healthz # HTTP path probed before a blue-green cutover ("" to disable) + # dockerfile + static: + deploy_strategy: blue-green # "" | recreate | blue-green +``` + +Notes: + +- **Only the fields above are honored.** Unknown keys are rejected with an error (so a typo + surfaces instead of being silently ignored). +- Fields you omit are **left untouched** — the file overlays only what it declares; it never + clears the rest of your config. +- The file is **source-aware**: a `static` workload only honors `deploy_strategy` (a static + site has no port/healthcheck); `port`/`healthcheck` in a static site's file are ignored. +- `deploy_strategy: ""` and `recreate` are equivalent (both are the default for dockerfile + and static), so they never show as drift against each other. + +## What `.tinyforge.yml` does **not** contain + +- **No repo location** (provider / owner / repo / branch) and **no access token** — those + stay in Tinyforge's encrypted database. This is deliberate: it keeps credentials out of + your repo. (You need the repo coords to find the file in the first place, so they can't + live in it.) + +## Drift and sync + +- **Drift** is computed only over the fields the file declares, after normalization (so a + defaulted strategy or a YAML-int vs stored-number difference isn't a false positive). +- **Sync** fetches the file, merges the declared fields onto a copy of the live config, + **validates the merged result** with the source's own rules, and only persists it if it + passes — a bad file is rejected as a whole and never leaves a partial config. The sync is + recorded to the workload's activity log (not the deploy ledger — it changes config, it + isn't a deploy). +- While GitOps is enabled, the edit form shows a banner noting which fields the repo manages; + editing them in the UI works, but the next Sync overwrites them with the repo's values. + +## Not in v1 (planned) + +These are intentionally out of scope for the first version; the design leaves clean seams +for them: + +- **`env` and `faces` (public subdomains)** — they live in separate stores and (for `env`) + would re-introduce a secrets-in-repo risk; deferred to a typed multi-target apply. +- **Auto-apply on deploy** — applying the repo config automatically on every push. v1 keeps + a human in the loop with the drift view + manual Sync. When added, it will read the file + at the exact deployed commit (a source-plugin concern), not at dispatch time. +- **Multi-workload reconcile** — one repo declaring/creating/deleting many workloads + (the full Flux/Argo model). v1 is per-workload, config-only, with no create/delete. +- **`image` / `compose` sources** — not git-backed / overlapping config surface. diff --git a/internal/api/gitops.go b/internal/api/gitops.go new file mode 100644 index 0000000..1a277bd --- /dev/null +++ b/internal/api/gitops.go @@ -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 +} diff --git a/internal/api/gitops_test.go b/internal/api/gitops_test.go new file mode 100644 index 0000000..f3c01b1 --- /dev/null +++ b/internal/api/gitops_test.go @@ -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 } diff --git a/internal/api/router.go b/internal/api/router.go index ff6b686..b56035c 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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. diff --git a/internal/gitops/apply.go b/internal/gitops/apply.go new file mode 100644 index 0000000..c73ea0b --- /dev/null +++ b/internal/gitops/apply.go @@ -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} +} diff --git a/internal/gitops/drift.go b/internal/gitops/drift.go new file mode 100644 index 0000000..f4b6867 --- /dev/null +++ b/internal/gitops/drift.go @@ -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) +} diff --git a/internal/gitops/fetch.go b/internal/gitops/fetch.go new file mode 100644 index 0000000..bdd6bd6 --- /dev/null +++ b/internal/gitops/fetch.go @@ -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 +} diff --git a/internal/gitops/gitops_test.go b/internal/gitops/gitops_test.go new file mode 100644 index 0000000..29d5b56 --- /dev/null +++ b/internal/gitops/gitops_test.go @@ -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") + } +} diff --git a/internal/gitops/merge.go b/internal/gitops/merge.go new file mode 100644 index 0000000..931c3d5 --- /dev/null +++ b/internal/gitops/merge.go @@ -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 +} diff --git a/internal/gitops/spec.go b/internal/gitops/spec.go new file mode 100644 index 0000000..8b2f037 --- /dev/null +++ b/internal/gitops/spec.go @@ -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 +} diff --git a/internal/staticsite/commit_status_reporter_test.go b/internal/staticsite/commit_status_reporter_test.go index dc478ae..2a19394 100644 --- a/internal/staticsite/commit_status_reporter_test.go +++ b/internal/staticsite/commit_status_reporter_test.go @@ -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) { diff --git a/internal/staticsite/gitea_content.go b/internal/staticsite/gitea_content.go index 1e950ac..77c0034 100644 --- a/internal/staticsite/gitea_content.go +++ b/internal/staticsite/gitea_content.go @@ -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) diff --git a/internal/staticsite/github_provider.go b/internal/staticsite/github_provider.go index a1f5c15..46e5e3c 100644 --- a/internal/staticsite/github_provider.go +++ b/internal/staticsite/github_provider.go @@ -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 { diff --git a/internal/staticsite/gitlab_provider.go b/internal/staticsite/gitlab_provider.go index dde46d3..e052f88 100644 --- a/internal/staticsite/gitlab_provider.go +++ b/internal/staticsite/gitlab_provider.go @@ -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 { diff --git a/internal/staticsite/provider.go b/internal/staticsite/provider.go index 4b19ed9..d7ead3a 100644 --- a/internal/staticsite/provider.go +++ b/internal/staticsite/provider.go @@ -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) diff --git a/internal/store/gitops.go b/internal/store/gitops.go new file mode 100644 index 0000000..fb7f474 --- /dev/null +++ b/internal/store/gitops.go @@ -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 at ". +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 +} diff --git a/internal/store/gitops_test.go b/internal/store/gitops_test.go new file mode 100644 index 0000000..da58a9f --- /dev/null +++ b/internal/store/gitops_test.go @@ -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") + } +} diff --git a/internal/store/models.go b/internal/store/models.go index 7bcf4d6..3ed34c0 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -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 diff --git a/internal/store/store.go b/internal/store/store.go index e419ea3..f7780fb 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 diff --git a/internal/store/workloads.go b/internal/store/workloads.go index 7507911..0dde431 100644 --- a/internal/store/workloads.go +++ b/internal/store/workloads.go @@ -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 { diff --git a/plans/gitops/PLAN.md b/plans/gitops/PLAN.md new file mode 100644 index 0000000..8171b4f --- /dev/null +++ b/plans/gitops/PLAN.md @@ -0,0 +1,115 @@ +# Tinyforge GitOps v1 — config-as-code for repo-backed workloads + +**Status:** ✅ Complete (squash-merged to main 2026-06-21) +**Branch:** `feat/gitops-config-as-code` +**Mode:** Automated · Orchestrator (hybrid: backend built direct, Phase 3 via frontend implementer) · Incremental +**Started:** 2026-06-21 + +## Summary + +A `dockerfile` or `static` workload can opt in to reading its **deploy config** from a +`.tinyforge.yml` file in its own repo. Tinyforge fetches the file, shows it, computes +**drift** vs the live config, and lets an admin **sync** (repo → live) with one explicit +action. The repo becomes the source of truth for the *declared* fields; the UI locks +those fields and renders a drift view. + +### v1 scope (deliberate) + +- **In:** opt-in per workload (dockerfile/static only); `.tinyforge.yml` declares only + **source_config-resident** fields (`port`, `healthcheck`, `deploy_strategy`, + `resources.{cpu_limit,memory_limit}`, `max_instances`); manual explicit sync; + declared-field drift view; GitOps-managed badge + read-only gate. +- **Out (documented future seams):** `env`/`faces` declaration (separate stores — + needs typed multi-target apply); auto-apply-on-deploy (must be a Source-plugin concern, + not a dispatch concern); multi-workload reconcile with create/delete (Framing B); + image/compose sources (not git-backed / overlapping config surface). + +### `.tinyforge.yml` v1 schema + +```yaml +version: 1 +deploy: + port: 8080 + healthcheck: /healthz + deploy_strategy: blue-green # "" | recreate | blue-green (validated per source) + resources: { cpu_limit: 0.5, memory_limit: 256 } + max_instances: 1 +``` + +No repo location, no tokens, no secrets — those stay in the encrypted DB. + +## Design constraints (from the adversarial review — non-negotiable) + +- **C1** Overlay is a typed `ApplyPlan{SourceConfigPatch}` routed to `source_config` only. + env/faces are NOT in source_config (they live in `workload_env` / `public_faces`), so + they are cut from v1; the typed plan reserves their slots for later. +- **C2** No `env` in v1 → no secrets-in-repo hole. +- **C3** No auto-apply-on-deploy in v1 (SHA is resolved *inside* `src.Deploy`; image has + no repo). Future auto-apply lands as a Source-plugin concern. +- **C4** Sync is explicit-action only, with a hard gate: + parse → build overlay → **omitted-field-preserving** deep-merge onto a fresh copy of the + live source_config → run `Source.Validate` on the *merged* result → persist in one + transaction only if valid. +- **C5** Drift is computed **only over declared leaves**, post-normalization + (`deploy_strategy:"" == "recreate"`; YAML int vs JSON coercion). Omitted = unmanaged. +- Reuse `staticsite.NewGitProvider` (inherits SSRF defense); add a size-capped + `DownloadFile`. Route all fetch errors through the existing `sanitizeError(msg, token)`. + Distinct `no_file` status. Sync audit is NOT `deploy_history` (rollback assumes + deployable rows). Gate enable to `dockerfile|static`. Derive read-only fields from the + declared overlay leaves (no provenance column). 4 additive `gitops_*` columns only. + +## Phases + +| # | Title | Subplan | Status | +|---|-------|---------|--------| +| 1 | GitOps core (backend, no UI/mutation) | [phase-1-core.md](phase-1-core.md) | ✅ Done | +| 2 | Store + API (manual sync) | [phase-2-api.md](phase-2-api.md) | ✅ Done | +| 3 | Frontend experience (UI/UX showcase) | [phase-3-frontend.md](phase-3-frontend.md) | ✅ Done | +| 4 | Hardening + docs + final review | [phase-4-hardening.md](phase-4-hardening.md) | ✅ Done | + +## Phase progress log + +- **Phase 1 — Done (2026-06-21).** Migration (4 additive `gitops_*` columns) + `Workload` + read path. New `internal/gitops` package: `Spec`/`ParseSpec` (KnownFields rejects + unknown keys incl. env/faces attempts), source-aware `ApplyPlan`/`BuildPlan` + (dockerfile: port/healthcheck/deploy_strategy; static: deploy_strategy only — `resources`/ + `max_instances` dropped after confirming they aren't on dockerfile/static configs), + `MergeAndValidate` (omitted-field-preserving + validate-then-commit), `Drift` + (declared-only, normalized), `Fetch` (no_file/fetch_failed/invalid statuses, token-redacted). + `DownloadFile` added to the `GitProvider` interface + 3 impls (64 KiB cap, ErrFileNotFound, + SSRF-safe client reused, GitHub raw media type). Independent go-review: **APPROVE**, no + CRITICAL/HIGH; M1 (GitLab doc comment) fixed; M2 (validate GitOpsPath at write) carried + into Phase 2. 28/28 packages green. +- **Phase 2 — Done (2026-06-21).** Store setters `SetWorkloadGitOps` / `RecordGitOpsSync` + (targeted column updates — disjoint from `UpdateWorkload`, so neither writer clobbers the + other). API: `GET /gitops` (single rich payload: status + raw + live drift + meta — folded + the separate `/drift` endpoint in to avoid a double fetch), `PUT /gitops` (admin, + enable/disable + path, rejects non-eligible source + traversal/URL-injection paths), + `POST /gitops/sync` (admin: fetch → MergeAndValidate → UpdateWorkload → RecordGitOpsSync → + event-log audit). Sync recorded to `event_log` (not `deploy_history` — review S6). Tests: + store round-trip + `validGitOpsPath` + `planFields`. Independent **security review: + clean, no CRITICAL/HIGH** (token never leaks, SSRF locked by safe dialer, authZ correct, + no field loss); LOW-1 (path query/fragment injection) hardened in `validGitOpsPath`. Full + backend suite green. +- **Phase 3 — Done (2026-06-21).** Built by a frontend implementation agent. + `GitOpsPanel.svelte` (self-fetching panel: status pill, purpose-built field-level drift + view — repo→live per declared field on the forge/ember palette, `.tinyforge.yml` preview, + enable `ToggleSwitch`, "Sync now" via `ConfirmDialog`, all five status states). api.ts + fetchers + `GitOpsStatus`/`GitOpsDriftEntry`; `gitops_*` on the `Workload` TS type; + GitOps-managed badge on the detail hero + apps list (payload already carries + `gitops_enabled`); read-only edit-form banner (banner-only — hard-disabling inputs would + need prop-threading through all 4 source forms; deferred). Backend `managed_fields` added + to `GET /gitops` for the gate. i18n `apps.detail.gitops.*` en+ru (parity 1804/1804). + Independent ts-review: one HIGH (`isAdmin` hardcoded true) + 2 MEDIUM — **all fixed**: + real role wired via `getCurrentUser()` (panel default now `false`), stale-guard on the + edit-open fetch, misleading `eligible` comment trimmed. check 0 errors · build · 26/26. +- **Phase 4 — Done (2026-06-21).** Concurrent-sync guard (review S5): a per-workload + `keyedMutex` on `Server`; `syncWorkloadGitOps` locks by id and loads the row inside the + lock, serializing the read→merge→write so two syncs can't race. Docs: `docs/gitops.md` + (enable flow, v1 schema, drift/sync semantics, explicit "not in v1": env/faces, auto-apply, + multi-workload, image/compose). Backend green. Final comprehensive review + merge gate + next. + +## Amendment log + +_(plan changes require approval + an entry here)_ diff --git a/plans/gitops/phase-1-core.md b/plans/gitops/phase-1-core.md new file mode 100644 index 0000000..b42f224 --- /dev/null +++ b/plans/gitops/phase-1-core.md @@ -0,0 +1,57 @@ +# 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)_ diff --git a/plans/gitops/phase-2-api.md b/plans/gitops/phase-2-api.md new file mode 100644 index 0000000..7a09944 --- /dev/null +++ b/plans/gitops/phase-2-api.md @@ -0,0 +1,35 @@ +# Phase 2 — Store + API (manual sync, explicit-action) + +## Tasks + +- [ ] **Store setters** (`internal/store/workloads.go` or a new `gitops.go`): + - `SetWorkloadGitOps(id string, enabled bool, path string) error` — gated to + dockerfile/static at the API layer. + - `RecordGitOpsSync(id, commitSHA, syncedAt string) error`. + - All writes re-read the row first / use targeted column updates (avoid full-row + clobber races — review S5). +- [ ] **Sync audit** (NOT deploy_history): a small `gitops_sync_audit` table + (`id, workload_id, outcome, commit_sha, drift_count, error, created_at`) with an insert + helper. Errors stored as generic markers only (secret-safe). _(Or reuse the event log if + cleaner — pick one and note it.)_ +- [ ] **API handlers** (`internal/api/gitops.go`, wired in `internal/api/router.go`): + - `GET /api/workloads/{id}/gitops` → `{ enabled, path, status, raw, parsed, commit_sha, + last_sync_at, drift_count }` (calls `gitops.Fetch` + `gitops.Drift`). + - `GET /api/workloads/{id}/gitops/drift` → `[]DriftEntry`. + - `POST /api/workloads/{id}/gitops/sync` (`auth.AdminOnly`) → `Fetch` → + `MergeAndValidate` → `UpdateWorkload` (single txn) → `RecordGitOpsSync` + audit. + Returns the applied summary. Secret-safe errors. + - `PUT /api/workloads/{id}/gitops` (`auth.AdminOnly`) → enable/disable + path; **reject + if source_kind ∉ {dockerfile, static}** with a clear 400. +- [ ] **Validation**: path must be a repo-relative file (no `..`, no leading `/`, sane + length); `enabled` only when the source is git-backed and has repo coords. + +## Verify + +- `go build ./...`, `go vet ./internal/...`, `go test ./internal/...` green. +- Handler tests: admin-gate on sync/put, no_file path, secret-safe error on a failed + fetch, drift_count surfaced, non-git source rejected by PUT. + +## Handoff notes + +_(filled after implementation)_ diff --git a/plans/gitops/phase-3-frontend.md b/plans/gitops/phase-3-frontend.md new file mode 100644 index 0000000..aa9bbb0 --- /dev/null +++ b/plans/gitops/phase-3-frontend.md @@ -0,0 +1,38 @@ +# Phase 3 — Frontend experience (frontend-design + UI/UX agent showcase) + +Built by the **frontend implementer agent** under the frontend-design skill. Must follow +project conventions: Svelte 5 runes, `ToggleSwitch` for booleans, `ConfirmDialog` for the +sync action, `$t` with **en+ru parity**, the `.panel` vocabulary from `DeployHistoryPanel`. + +## Tasks + +- [ ] **`web/src/lib/api.ts`**: `GitOpsStatus` + `DriftEntry` interfaces; `fetchWorkloadGitOps(id)`, + `fetchWorkloadDrift(id)`, `syncWorkloadGitOps(id)`, `setWorkloadGitOps(id, {enabled, path})` + following the existing `get`/`post` typed-fetcher pattern (mirror `fetchWorkloadDeploys`). +- [ ] **`GitOpsPanel.svelte`** (mounted on `apps/[id]` near the other panels): the + centerpiece. Sections: + - Header: title + status pill (`synced` / `N changes` / `no file` / `fetch failed`) + + last-sync/commit meta + enable/disable `ToggleSwitch`. + - **Drift view** — the design focus. For each declared field show repo-value vs + live-value with a clean/changed state. Distinctive, legible, on-brand (forge tokens, + `--forge-mono`, the `--color-warning`/`--color-success` hues already used). No diff + library exists — design a purpose-built field-level diff (NOT a generic `
` dump).
+  - Rendered `.tinyforge.yml` preview (the `.code-area`/editor frame vocabulary).
+  - "Sync now" button → `ConfirmDialog` ("apply repo config to live") → `syncWorkloadGitOps`
+    → toast + refresh. Admin-only affordance.
+  - `no_file` / `fetch_failed` empty states (clear, not alarming).
+- [ ] **GitOps-managed badge** on apps list rows (`apps/+page.svelte`, only dockerfile/static)
+  and the detail hero — reuse the `.badge` chip vocabulary.
+- [ ] **Read-only gate** on the source-config edit form: when managed, lock exactly the
+  fields the synced overlay declares (derive from the drift/parsed payload) + a banner
+  ("managed by `.tinyforge.yml` — edit the file and sync").
+- [ ] **i18n**: `apps.detail.gitops.*` in BOTH `en.json` and `ru.json` (verify parity).
+
+## Verify
+
+- `npm run check` (0 errors), `npm run build`, `npm run test` green; i18n key parity equal.
+- Restart dev server (`./scripts/dev-server.sh`).
+
+## Handoff notes
+
+_(filled after implementation)_
diff --git a/plans/gitops/phase-4-hardening.md b/plans/gitops/phase-4-hardening.md
new file mode 100644
index 0000000..c5f462f
--- /dev/null
+++ b/plans/gitops/phase-4-hardening.md
@@ -0,0 +1,25 @@
+# Phase 4 — Hardening + docs + final review
+
+## Tasks
+
+- [ ] **Concurrent-sync guard** (review S5): per-workload sync mutex (or re-read-then-apply
+  with a compare) so a sync racing the edit-form save / another sync can't silently lose
+  writes.
+- [ ] **File-size + path hardening**: confirm the 64 KiB `DownloadFile` cap is enforced
+  across all three providers; confirm `gitops_path` validation rejects traversal.
+- [ ] **Security-reviewer pass**: SSRF (verify the fetch goes through `NewGitProvider`/the
+  safe client, never raw `http.Get`), secret handling (token never logged/persisted/leaked
+  in errors — `sanitizeError`), admin-gating on sync + put.
+- [ ] **Docs**: `docs/gitops.md` (or extend `docs/plans/`): the `.tinyforge.yml` v1 schema
+  reference, how to enable, the sync flow, and an explicit **"not in v1"** section
+  (env/faces, auto-apply-on-deploy, multi-workload Framing B) with the future seams noted.
+- [ ] **Final comprehensive review** + (if triggered) security review, then present for the
+  merge gate.
+
+## Verify
+
+- Full backend + frontend build/test/vet green; dev server healthy on :8090.
+
+## Handoff notes
+
+_(filled after implementation)_
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index ddd1bb4..bbaa086 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -976,6 +976,52 @@ export function rollbackWorkload(
 	return post(`/api/workloads/${id}/rollback`, { deploy_id: deployId });
 }
 
+// ── GitOps (config-as-code) ─────────────────────────────────────────
+// One rich payload per workload folds the file preview, parsed status, and
+// field-level drift into a single GET so the panel makes one call. The shape
+// mirrors the Go `gitOpsStatusResponse` (snake_case is preserved end-to-end,
+// matching the rest of this file). Drift entries list only the declared
+// fields that DIFFER from live; `managed_fields` lists every key the file
+// declares (the read-only gate keys on these).
+export interface GitOpsDriftEntry {
+	field: string;
+	repo_value: string;
+	live_value: string;
+}
+
+export type GitOpsStatusKind = 'disabled' | 'ok' | 'no_file' | 'fetch_failed' | 'invalid';
+
+export interface GitOpsStatus {
+	eligible: boolean;
+	enabled: boolean;
+	path: string;
+	status: GitOpsStatusKind;
+	raw: string;
+	message: string;
+	commit_sha: string;
+	last_sync_at: string;
+	drift: GitOpsDriftEntry[];
+	drift_count: number;
+	managed_fields: string[];
+}
+
+export function fetchWorkloadGitOps(id: string, signal?: AbortSignal): Promise {
+	return get(`/api/workloads/${id}/gitops`, signal);
+}
+
+export function setWorkloadGitOps(
+	id: string,
+	body: { enabled: boolean; path: string }
+): Promise<{ enabled: boolean; path: string }> {
+	return put<{ enabled: boolean; path: string }>(`/api/workloads/${id}/gitops`, body);
+}
+
+export function syncWorkloadGitOps(
+	id: string
+): Promise<{ status: string; commit_sha: string; applied_fields: string[]; triggered_by: string }> {
+	return post(`/api/workloads/${id}/gitops/sync`);
+}
+
 // ── Per-workload metrics history ────────────────────────────────────
 // CPU% and memory (bytes) summed across the workload's containers, one
 // point per sampled timestamp. Empty when stats collection is off / Docker
diff --git a/web/src/lib/components/GitOpsPanel.svelte b/web/src/lib/components/GitOpsPanel.svelte
new file mode 100644
index 0000000..23b7706
--- /dev/null
+++ b/web/src/lib/components/GitOpsPanel.svelte
@@ -0,0 +1,818 @@
+
+
+{#if eligibleByKind}
+	
+ + + + + +
+
+

+ {$t('apps.detail.gitops.title')}. +

+ {$t('apps.detail.gitops.sub')} +
+ + + {#if pill.tone === 'sync'} + + {:else} + + {/if} + {pill.label} + + + {#if isAdmin} +
+ {$t('apps.detail.gitops.toggleLabel')} + +
+ {/if} +
+ + {#if error} + + {/if} + + {#if loading && !gitops} +

{$t('apps.detail.gitops.loading')}

+ {:else if gitops} + {#if !gitops.enabled} + +
+

{$t('apps.detail.gitops.disabledLead')}

+

+ {$t('apps.detail.gitops.disabledSub')} + {gitops.path || '.tinyforge.yml'} +

+
+ {:else} + +
+ + {$t('apps.detail.gitops.metaPath')} + {gitops.path || '.tinyforge.yml'} + + {#if gitops.commit_sha} + + + {$t('apps.detail.gitops.metaCommit')} + {shortSha(gitops.commit_sha)} + + {/if} + + + {$t('apps.detail.gitops.metaLastSync')} + {#if lastSync} + {$fmt.relative(lastSync)} + {:else} + {$t('apps.detail.gitops.metaNeverSynced')} + {/if} + +
+ + + {#if isOK} + {#if inSync} +
+ +
+ {$t('apps.detail.gitops.inSyncTitle')} + {$t('apps.detail.gitops.inSyncSub')} +
+
+ {:else} +
+ + {#each gitops.drift as d (d.field)} +
+ {fieldLabel(d.field)} + {d.repo_value || '—'} + + {d.live_value || '—'} +
+ {/each} +

{$t('apps.detail.gitops.driftFoot')}

+
+ {/if} + {:else if gitops.status === 'no_file'} +
+

{$t('apps.detail.gitops.noFileLead')}

+

+ {$t('apps.detail.gitops.noFileSub')} + {gitops.path || '.tinyforge.yml'} +

+
+ {:else if gitops.status === 'fetch_failed'} +
+

{$t('apps.detail.gitops.fetchFailedLead')}

+ {#if gitops.message}

{gitops.message}

{/if} +
+ {:else if gitops.status === 'invalid'} +
+

{$t('apps.detail.gitops.invalidLead')}

+ {#if gitops.message}

{gitops.message}

{/if} +
+ {/if} + + + {#if gitops.raw} +
+
+ + {gitops.path || '.tinyforge.yml'} + + +
+
{gitops.raw}
+
+ {/if} + + + {#if isAdmin && (isOK || gitops.status === 'no_file')} +
+

+ {hasDrift + ? $t('apps.detail.gitops.syncHintDrift') + : $t('apps.detail.gitops.syncHintClean')} +

+ +
+ {/if} + {/if} + {/if} +
+{/if} + +{#if confirmSync} + (confirmSync = false)} + /> +{/if} + + diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 2d65bdd..afde4ec 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1407,7 +1407,9 @@ "colTrigger": "Trigger", "colCreated": "Created", "colActions": "Actions", - "rowOpen": "Open" + "rowOpen": "Open", + "gitopsBadge": "GitOps", + "gitopsBadgeTitle": "Deploy config for this app is managed from its repo." }, "new": { "pageTitle": "New App · Tinyforge", @@ -1731,6 +1733,62 @@ "cpuSeries": "CPU", "memorySeries": "Memory" }, + "gitops": { + "title": "GitOps", + "sub": "Declare this app's deploy config in your repo and sync it on demand.", + "loading": "Loading GitOps status…", + "disabledLead": "GitOps is off for this app.", + "disabledSub": "Enable it to manage the deploy config from", + "badge": "GitOps", + "badgeTitle": "Deploy config for this app is managed from its repo.", + "toggleLabel": "Enabled", + "toggleAria": "Enable GitOps for this app", + "toggleHint": "Read the deploy config from a file in this app's repo.", + "enabledToast": "GitOps enabled.", + "disabledToast": "GitOps disabled.", + "toggleFailed": "Could not update GitOps settings.", + "pillSynced": "Synced", + "pillChangesOne": "1 change", + "pillChangesMany": "{count} changes", + "pillNoFile": "No file", + "pillFetchFailed": "Fetch failed", + "pillInvalid": "Invalid", + "pillDisabled": "Disabled", + "metaPath": "Path", + "metaCommit": "Commit", + "metaLastSync": "Last sync", + "metaNeverSynced": "Never synced", + "inSyncTitle": "Live config matches the repo", + "inSyncSub": "Every declared field is already applied. Nothing to sync.", + "driftAria": "Fields that differ between the repo file and the live config", + "driftColField": "Field", + "driftColRepo": "Repo", + "driftColLive": "Live", + "driftFoot": "Syncing applies the repo values, replacing the live ones above.", + "noFileLead": "No config file on the branch", + "noFileSub": "GitOps is on, but nothing was found at", + "fetchFailedLead": "Couldn't read the config from the repo", + "invalidLead": "The repo config couldn't be parsed", + "copy": "Copy", + "copied": "Copied", + "copyAria": "Copy the config file to the clipboard", + "syncHintDrift": "Sync to apply the repo config to this app's live config.", + "syncHintClean": "Live config already matches the repo.", + "syncNow": "Sync now", + "syncing": "Syncing…", + "syncedToast": "Synced {count} field(s) from {sha}.", + "syncFailed": "Sync failed.", + "confirmTitle": "Apply the repo config?", + "confirmMessage": "This applies the repo config to this app's live config. The current values for the declared fields are replaced.", + "gateTitle": "Managed by .tinyforge.yml", + "gateBody": "Edit the config in the repo and Sync — changes made here are overwritten on the next sync.", + "gateFieldsLabel": "Managed fields", + "field": { + "port": "Port", + "healthcheck": "Healthcheck", + "deploy_strategy": "Deploy strategy" + } + }, "toolbar": { "stop": "Stop", "start": "Start", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 52e1f1a..ed7906f 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -1407,7 +1407,9 @@ "colTrigger": "Триггер", "colCreated": "Создано", "colActions": "Действия", - "rowOpen": "Открыть" + "rowOpen": "Открыть", + "gitopsBadge": "GitOps", + "gitopsBadgeTitle": "Конфигурация деплоя этого приложения управляется из репозитория." }, "new": { "pageTitle": "Новое приложение · Tinyforge", @@ -1731,6 +1733,62 @@ "cpuSeries": "CPU", "memorySeries": "Память" }, + "gitops": { + "title": "GitOps", + "sub": "Опишите конфигурацию деплоя в репозитории и применяйте её по запросу.", + "loading": "Загрузка статуса GitOps…", + "disabledLead": "GitOps для этого приложения отключён.", + "disabledSub": "Включите его, чтобы управлять конфигурацией деплоя из", + "badge": "GitOps", + "badgeTitle": "Конфигурация деплоя этого приложения управляется из репозитория.", + "toggleLabel": "Включено", + "toggleAria": "Включить GitOps для этого приложения", + "toggleHint": "Читать конфигурацию деплоя из файла в репозитории приложения.", + "enabledToast": "GitOps включён.", + "disabledToast": "GitOps отключён.", + "toggleFailed": "Не удалось обновить настройки GitOps.", + "pillSynced": "Синхронизировано", + "pillChangesOne": "1 изменение", + "pillChangesMany": "изменений: {count}", + "pillNoFile": "Нет файла", + "pillFetchFailed": "Ошибка загрузки", + "pillInvalid": "Некорректно", + "pillDisabled": "Отключено", + "metaPath": "Путь", + "metaCommit": "Коммит", + "metaLastSync": "Последняя синхр.", + "metaNeverSynced": "Не синхронизировано", + "inSyncTitle": "Текущая конфигурация совпадает с репозиторием", + "inSyncSub": "Все объявленные поля уже применены. Синхронизировать нечего.", + "driftAria": "Поля, отличающиеся между файлом в репозитории и текущей конфигурацией", + "driftColField": "Поле", + "driftColRepo": "Репозиторий", + "driftColLive": "Текущее", + "driftFoot": "Синхронизация применит значения из репозитория, заменив текущие выше.", + "noFileLead": "В ветке нет файла конфигурации", + "noFileSub": "GitOps включён, но ничего не найдено по пути", + "fetchFailedLead": "Не удалось прочитать конфигурацию из репозитория", + "invalidLead": "Конфигурацию из репозитория не удалось разобрать", + "copy": "Копировать", + "copied": "Скопировано", + "copyAria": "Скопировать файл конфигурации в буфер обмена", + "syncHintDrift": "Синхронизируйте, чтобы применить конфигурацию из репозитория к текущей.", + "syncHintClean": "Текущая конфигурация уже совпадает с репозиторием.", + "syncNow": "Синхронизировать", + "syncing": "Синхронизация…", + "syncedToast": "Применено полей: {count} из {sha}.", + "syncFailed": "Ошибка синхронизации.", + "confirmTitle": "Применить конфигурацию из репозитория?", + "confirmMessage": "Конфигурация из репозитория будет применена к текущей конфигурации приложения. Текущие значения объявленных полей будут заменены.", + "gateTitle": "Управляется через .tinyforge.yml", + "gateBody": "Редактируйте конфигурацию в репозитории и синхронизируйте — изменения здесь будут перезаписаны при следующей синхронизации.", + "gateFieldsLabel": "Управляемые поля", + "field": { + "port": "Порт", + "healthcheck": "Healthcheck", + "deploy_strategy": "Стратегия деплоя" + } + }, "toolbar": { "stop": "Стоп", "start": "Старт", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 1c1d840..c5a2618 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -372,6 +372,13 @@ export interface Workload { parent_workload_id: string; notification_url: string; webhook_require_signature: boolean; + // GitOps config-as-code (dockerfile/static sources only). Opt-in: when + // enabled, the workload's deploy config is declared in `gitops_path` + // (default ".tinyforge.yml") in its own repo and applied via Sync. + gitops_enabled: boolean; + gitops_path: string; + gitops_last_sync_at: string; + gitops_commit_sha: string; created_at: string; updated_at: string; } diff --git a/web/src/routes/apps/+page.svelte b/web/src/routes/apps/+page.svelte index fd214dc..cc0d77c 100644 --- a/web/src/routes/apps/+page.svelte +++ b/web/src/routes/apps/+page.svelte @@ -169,6 +169,11 @@ {w.source_kind} + {#if w.gitops_enabled && (w.source_kind === 'dockerfile' || w.source_kind === 'static')} + + {$t('apps.list.gitopsBadge')} + + {/if} {w.trigger_kind} @@ -506,6 +511,16 @@ background: var(--surface-card-hover); color: var(--text-secondary); } + /* GitOps-managed chip — ember-tinted, sits beside the source badge. */ + .badge-gitops { + margin-left: 0.35rem; + background: var(--forge-accent-soft); + color: var(--color-brand-700); + border-color: color-mix(in srgb, var(--color-brand-500) 35%, transparent); + } + :global([data-theme='dark']) .badge-gitops { + color: var(--color-brand-300); + } .muted { color: var(--text-tertiary); diff --git a/web/src/routes/apps/[id]/+page.svelte b/web/src/routes/apps/[id]/+page.svelte index 0fc1481..cf829e0 100644 --- a/web/src/routes/apps/[id]/+page.svelte +++ b/web/src/routes/apps/[id]/+page.svelte @@ -23,7 +23,8 @@ IconGlobe, IconHardDrive, IconClock, - IconLoader + IconLoader, + IconLock } from '$lib/components/icons'; import ForgeHero from '$lib/components/ForgeHero.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; @@ -35,6 +36,7 @@ import WorkloadSnapshotsPanel from '$lib/components/WorkloadSnapshotsPanel.svelte'; import DeployHistoryPanel from '$lib/components/DeployHistoryPanel.svelte'; import WorkloadMetricsPanel from '$lib/components/WorkloadMetricsPanel.svelte'; + import GitOpsPanel from '$lib/components/GitOpsPanel.svelte'; import TriggerKindForm, { createTriggerKindFormState, isTriggerFormValid, @@ -139,6 +141,19 @@ // plain text input if the request fails. Bound into ImageSourceForm. let editRegistries = $state<{ name: string; url: string }[]>([]); + // Source-config keys the repo's .tinyforge.yml declares for a + // GitOps-managed workload. Fetched (fire-and-forget) when the edit form + // opens on a gitops_enabled workload; drives the read-only gate banner that + // warns these fields are overwritten on the next Sync. Empty = no gate. + let gitopsManagedFields = $state([]); + // Viewer role drives whether the GitOps panel offers its admin affordances + // (enable toggle, Sync). Fetched once; the server also enforces AdminOnly. + let isAdmin = $state(false); + let roleFetched = false; + const editGitOpsGated = $derived( + (workload?.gitops_enabled ?? false) && gitopsManagedFields.length > 0 + ); + // A cleared binds to null (not 0) in Svelte 5, and // `null <= 0` is false — so a bare `port <= 0` check would pass validation // on an empty field and POST `port: null`. The module's isDockerfileValid @@ -1209,6 +1224,23 @@ .catch(() => { editRegistries = []; }); + // Fire-and-forget: surface which fields the repo config manages so the + // edit form can warn they'll be overwritten on Sync. Only relevant when + // GitOps is on; failure leaves the gate off (no banner) — never blocks. + gitopsManagedFields = []; + if (workload.gitops_enabled) { + // Guard against a slow response landing on a different workload after + // an A→B nav (the component instance is reused). + const forId = workload.id; + void api + .fetchWorkloadGitOps(forId) + .then((g) => { + if (forId === workload?.id) gitopsManagedFields = g.managed_fields ?? []; + }) + .catch(() => { + if (forId === workload?.id) gitopsManagedFields = []; + }); + } editing = true; } @@ -1492,6 +1524,18 @@ // fetch for the previous id cannot land on the new id's state. $effect(() => { const _ = id; // explicit dependency + // Resolve the viewer's role once (role is session-wide, not per-id). + if (!roleFetched) { + roleFetched = true; + void api + .getCurrentUser() + .then((u) => { + isAdmin = u.role === 'admin'; + }) + .catch(() => { + isAdmin = false; + }); + } runtimeAbort?.abort(); storageAbort?.abort(); triggersAbort?.abort(); @@ -1706,6 +1750,12 @@ {workload!.source_kind} + {#if workload!.gitops_enabled} + + + {$t('apps.detail.gitops.badge')} + + {/if} {bindings.length === 0 ? $t('apps.detail.chainTriggersZero') @@ -1750,6 +1800,26 @@ + {#if editGitOpsGated} + +
+ +
+

{$t('apps.detail.gitops.gateTitle')}

+

{$t('apps.detail.gitops.gateBody')}

+
+ {$t('apps.detail.gitops.gateFieldsLabel')} + {#each gitopsManagedFields as f (f)} + {f} + {/each} +
+
+
+ {/if} +