feat(gitops): config-as-code via .tinyforge.yml for repo-backed workloads

A dockerfile or static workload can opt in to reading its deploy config from a
.tinyforge.yml in its own repo. Tinyforge fetches the file, shows field-level
drift vs the live config, and an admin applies it with an explicit Sync. The
repo becomes the source of truth for the declared fields. Manual-sync only;
no auto-apply on deploy, no multi-workload reconcile, no create/delete in v1.

Scope is deliberately source-aware and source_config-resident: dockerfile
declares port/healthcheck/deploy_strategy, static declares deploy_strategy.
The file never carries repo coords or secrets (those stay in the encrypted
DB), which keeps credentials out of the repo.

Backend:
- internal/gitops: Spec/ParseSpec (KnownFields rejects unknown keys), a
  source-aware ApplyPlan/BuildPlan, MergeAndValidate (omitted-field-preserving
  deep merge + validate-the-merged-result-then-commit — never a partial
  config), declared-only Drift with normalization, and Fetch with
  ok/no_file/fetch_failed/invalid statuses and token-redacted messages.
- staticsite: DownloadFile added to GitProvider + Gitea/GitHub/GitLab impls,
  reusing each provider's SSRF-safe client; 64 KiB cap; ErrFileNotFound.
- store: 4 additive gitops_* columns + setters (disjoint from UpdateWorkload
  so the edit-form save and a sync never clobber each other).
- api: GET /workloads/{id}/gitops (status + raw + live drift + managed_fields),
  PUT /gitops (admin, enable/path, traversal-safe), POST /gitops/sync (admin,
  per-workload locked read->merge->validate->write, audited to event_log).

Frontend:
- GitOpsPanel.svelte: status pill, a purpose-built field-level drift view,
  .tinyforge.yml preview, enable ToggleSwitch, Sync via ConfirmDialog; all five
  statuses handled, admin affordances gated on the real viewer role.
- GitOps-managed badge (list + detail hero) and a read-only edit-form banner.
- api.ts fetchers + types; i18n apps.detail.gitops.* (en + ru parity).

Built phase-by-phase with an adversarial plan review (caught 5 design flaws
pre-implementation) and an independent review per phase (go / security / ts /
final) — all APPROVE, 0 CRITICAL/HIGH. docs/gitops.md documents the schema and
what's intentionally out of v1. Plan: plans/gitops/.
This commit is contained in:
2026-06-21 23:32:02 +03:00
parent 5b51bbbd7f
commit 7733e64b08
38 changed files with 3013 additions and 106 deletions
+45 -7
View File
@@ -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/
+27
View File
@@ -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.
+54 -18
View File
@@ -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
+134 -69
View File
@@ -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
+19 -4
View File
@@ -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"]
+11 -2
View File
@@ -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
+77
View File
@@ -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.
+364
View File
@@ -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
}
+48
View File
@@ -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 }
+11
View File
@@ -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.
+83
View File
@@ -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}
}
+122
View File
@@ -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)
}
+96
View File
@@ -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
}
+162
View File
@@ -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")
}
}
+48
View File
@@ -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
}
+57
View File
@@ -0,0 +1,57 @@
// Package gitops implements config-as-code for repo-backed workloads: a
// dockerfile/static workload can read a small .tinyforge.yml from its own repo
// that declares a subset of its deploy config. The package is deliberately
// decoupled from the store and source plugins — it takes a RepoRef (repo
// coords + a decrypted token) and a live source_config blob, and returns a
// validated merged config + a field-level drift report. It never writes to the
// database and never decides to deploy.
//
// v1 scope (see plans/gitops/PLAN.md): only source_config-resident fields are
// overlayable, and the set is source-aware (dockerfile: port/healthcheck/
// deploy_strategy; static: deploy_strategy). env/faces live in separate stores
// and are intentionally out of v1; the typed ApplyPlan reserves their slots.
package gitops
import (
"bytes"
"errors"
"fmt"
"io"
"gopkg.in/yaml.v3"
)
// Spec is the parsed shape of a .tinyforge.yml file (v1).
type Spec struct {
Version int `yaml:"version"`
Deploy DeploySpec `yaml:"deploy"`
}
// DeploySpec carries the overlayable deploy fields. Pointers so an omitted key
// is distinguishable from a zero value — only present (non-nil) fields are
// applied or drift-compared, so an absent key never clears live config.
type DeploySpec struct {
Port *int `yaml:"port"`
Healthcheck *string `yaml:"healthcheck"`
DeployStrategy *string `yaml:"deploy_strategy"`
}
// ParseSpec decodes a .tinyforge.yml body. Unknown keys are rejected
// (KnownFields) so a typo or an unsupported field — e.g. someone trying to
// declare env/faces in v1 — surfaces as an error instead of being silently
// dropped. Only version 1 is accepted.
func ParseSpec(data []byte) (Spec, error) {
var s Spec
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(&s); err != nil {
if errors.Is(err, io.EOF) {
return Spec{}, fmt.Errorf("gitops: empty .tinyforge.yml")
}
return Spec{}, fmt.Errorf("gitops: parse .tinyforge.yml: %w", err)
}
if s.Version != 1 {
return Spec{}, fmt.Errorf("gitops: unsupported version %d (want 1)", s.Version)
}
return s, nil
}
@@ -44,6 +44,9 @@ func (*fakeReporterProvider) ListTree(context.Context, string, string, string) (
func (*fakeReporterProvider) DownloadFolder(context.Context, string, string, string, string, string) error {
panic("unused")
}
func (*fakeReporterProvider) DownloadFile(context.Context, string, string, string, string, int64) ([]byte, error) {
panic("unused")
}
// Enabled: forwards to the provider with the captured identifiers + target.
func TestCommitStatusReporter_Enabled_Calls(t *testing.T) {
+9
View File
@@ -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)
+13
View File
@@ -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 {
+16
View File
@@ -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 {
+50
View File
@@ -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)
+48
View File
@@ -0,0 +1,48 @@
package store
import "fmt"
// SetWorkloadGitOps toggles GitOps and sets the config path for a workload.
// Targeted column update (not UpdateWorkload) so it never clobbers the
// source_config / faces / webhook fields — and conversely, the edit-form save
// (UpdateWorkload) never touches these columns.
func (s *Store) SetWorkloadGitOps(id string, enabled bool, path string) error {
if path == "" {
path = ".tinyforge.yml"
}
result, err := s.db.Exec(
`UPDATE workloads SET gitops_enabled=?, gitops_path=?, updated_at=? WHERE id=?`,
BoolToInt(enabled), path, Now(), id,
)
if err != nil {
return fmt.Errorf("set workload gitops: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("workload %s: %w", id, ErrNotFound)
}
return nil
}
// RecordGitOpsSync stamps the commit SHA + timestamp of the last successful
// sync, so the UI can show "last synced <when> at <sha>".
func (s *Store) RecordGitOpsSync(id, commitSHA, syncedAt string) error {
result, err := s.db.Exec(
`UPDATE workloads SET gitops_last_sync_at=?, gitops_commit_sha=?, updated_at=? WHERE id=?`,
syncedAt, commitSHA, Now(), id,
)
if err != nil {
return fmt.Errorf("record gitops sync: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("workload %s: %w", id, ErrNotFound)
}
return nil
}
+63
View File
@@ -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")
}
}
+8 -2
View File
@@ -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
+8
View File
@@ -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
+7 -1
View File
@@ -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 {
+115
View File
@@ -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)_
+57
View File
@@ -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)_
+35
View File
@@ -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)_
+38
View File
@@ -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<T>`/`post<T>` 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 `<pre>` 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)_
+25
View File
@@ -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)_
+46
View File
@@ -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<GitOpsStatus> {
return get<GitOpsStatus>(`/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
+818
View File
@@ -0,0 +1,818 @@
<script lang="ts">
/**
* GitOpsPanel
*
* Config-as-code for dockerfile/static workloads. The repo's
* `.tinyforge.yml` declares a small overlay (port / healthcheck /
* deploy_strategy); this panel shows whether the live source_config
* matches that file (drift), previews the file, and applies it on demand
* via "Sync now" (validate-then-commit on the server).
*
* Self-contained: it fetches its own GET /gitops on mount and on
* workloadId change, and owns the enable/disable toggle. On a successful
* sync it both re-fetches its own status AND calls `onSynced` so the parent
* page reloads the (now-changed) workload row.
*
* The `.panel` / `.reg` card chrome is declared locally — Svelte scopes the
* detail page's panel styles to that route, so a child component must carry
* its own copy to render the forge card frame.
*/
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import { toasts } from '$lib/stores/toast';
import { fmt } from '$lib/format/datetime';
import ToggleSwitch from './ToggleSwitch.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import { IconRefresh, IconCheck, IconCopy } from './icons';
interface Props {
workloadId: string;
sourceKind: string;
isAdmin?: boolean;
/** Called after a successful sync so the parent reloads the workload. */
onSynced?: () => void;
}
// Default false: the admin affordances (enable toggle, Sync) stay hidden
// unless the parent explicitly proves the viewer is an admin. The server
// also gates PUT/POST with AdminOnly, so this is defense-in-depth.
let { workloadId, sourceKind, isAdmin = false, onSynced }: Props = $props();
let gitops = $state<api.GitOpsStatus | null>(null);
let loading = $state(true);
let error = $state('');
let togglePending = $state(false);
let syncing = $state(false);
let confirmSync = $state(false);
let copied = $state(false);
// Eligibility is decided client-side from the source kind so the panel can
// render-nothing instantly. (The server also reports `eligible`; the two
// always agree.) dockerfile + static are the git-backed sources.
const ELIGIBLE_KINDS = ['dockerfile', 'static'];
const eligibleByKind = $derived(ELIGIBLE_KINDS.includes(sourceKind));
async function load(signal?: AbortSignal): Promise<void> {
try {
gitops = await api.fetchWorkloadGitOps(workloadId, signal);
error = '';
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
// Reload whenever workloadId changes — the parent reuses this instance
// across /apps/A → /apps/B navigation.
$effect(() => {
void workloadId;
loading = true;
const controller = new AbortController();
load(controller.signal);
return () => controller.abort();
});
async function onToggle(next: boolean): Promise<void> {
if (togglePending || !gitops) return;
togglePending = true;
const path = gitops.path || '.tinyforge.yml';
try {
await api.setWorkloadGitOps(workloadId, { enabled: next, path });
toasts.success(
next ? $t('apps.detail.gitops.enabledToast') : $t('apps.detail.gitops.disabledToast')
);
await load();
onSynced?.();
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('apps.detail.gitops.toggleFailed'));
// Reload so the switch reflects the persisted (unchanged) value.
await load();
} finally {
togglePending = false;
}
}
async function doSync(): Promise<void> {
if (syncing) return;
syncing = true;
try {
const res = await api.syncWorkloadGitOps(workloadId);
toasts.success(
$t('apps.detail.gitops.syncedToast', {
count: String(res.applied_fields.length),
sha: shortSha(res.commit_sha)
})
);
await load();
onSynced?.();
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('apps.detail.gitops.syncFailed'));
} finally {
syncing = false;
confirmSync = false;
}
}
async function copyRaw(): Promise<void> {
if (!gitops?.raw) return;
try {
await navigator.clipboard.writeText(gitops.raw);
copied = true;
setTimeout(() => (copied = false), 1500);
} catch {
// Clipboard unavailable (insecure context) — silently no-op; the
// preview is visible and selectable regardless.
}
}
function shortSha(sha: string): string {
if (!sha) return '—';
return /^[0-9a-f]{8,}$/i.test(sha) ? sha.slice(0, 10) : sha;
}
// Pill descriptor: status tone + label drive the header pill. "ok" splits
// into in-sync (0 drift) vs N-changes so the single most important signal —
// "does live match the repo?" — reads at a glance.
type PillTone = 'sync' | 'drift' | 'muted' | 'warn' | 'danger';
const pill = $derived.by((): { tone: PillTone; label: string } => {
if (!gitops || !gitops.enabled) {
return { tone: 'muted', label: $t('apps.detail.gitops.pillDisabled') };
}
switch (gitops.status) {
case 'ok':
return gitops.drift_count === 0
? { tone: 'sync', label: $t('apps.detail.gitops.pillSynced') }
: {
tone: 'drift',
label:
gitops.drift_count === 1
? $t('apps.detail.gitops.pillChangesOne')
: $t('apps.detail.gitops.pillChangesMany', {
count: String(gitops.drift_count)
})
};
case 'no_file':
return { tone: 'warn', label: $t('apps.detail.gitops.pillNoFile') };
case 'fetch_failed':
return { tone: 'danger', label: $t('apps.detail.gitops.pillFetchFailed') };
case 'invalid':
return { tone: 'danger', label: $t('apps.detail.gitops.pillInvalid') };
default:
return { tone: 'muted', label: $t('apps.detail.gitops.pillDisabled') };
}
});
const isOK = $derived(gitops?.status === 'ok');
const inSync = $derived(isOK && (gitops?.drift_count ?? 0) === 0);
const hasDrift = $derived(isOK && (gitops?.drift_count ?? 0) > 0);
const lastSync = $derived(gitops?.last_sync_at ?? '');
// Human label for a managed source_config key.
function fieldLabel(key: string): string {
const k = `apps.detail.gitops.field.${key}`;
const label = $t(k);
// $t returns the key verbatim when missing — fall back to the raw key.
return label === k ? key : label;
}
</script>
{#if eligibleByKind}
<section class="panel gp-panel" aria-labelledby="gp-heading">
<span class="reg reg-tl" aria-hidden="true"></span>
<span class="reg reg-tr" aria-hidden="true"></span>
<span class="reg reg-bl" aria-hidden="true"></span>
<span class="reg reg-br" aria-hidden="true"></span>
<header class="panel-head">
<div class="gp-titlewrap">
<h2 class="panel-title" id="gp-heading">
{$t('apps.detail.gitops.title')}<span class="title-accent">.</span>
</h2>
<span class="panel-sub">{$t('apps.detail.gitops.sub')}</span>
</div>
<span class="gp-pill gp-pill-{pill.tone}">
{#if pill.tone === 'sync'}
<IconCheck size={11} />
{:else}
<span class="gp-pill-dot" aria-hidden="true"></span>
{/if}
{pill.label}
</span>
{#if isAdmin}
<div class="gp-toggle" title={$t('apps.detail.gitops.toggleHint')}>
<span class="gp-toggle-lbl">{$t('apps.detail.gitops.toggleLabel')}</span>
<ToggleSwitch
checked={gitops?.enabled ?? false}
disabled={togglePending || loading}
ariaLabel={$t('apps.detail.gitops.toggleAria')}
onchange={onToggle}
/>
</div>
{/if}
</header>
{#if error}
<div class="alert inline-alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
{#if loading && !gitops}
<p class="gp-hint">{$t('apps.detail.gitops.loading')}</p>
{:else if gitops}
{#if !gitops.enabled}
<!-- Disabled: calm one-liner + path the file is expected at. -->
<div class="gp-empty">
<p class="gp-empty-lead">{$t('apps.detail.gitops.disabledLead')}</p>
<p class="gp-empty-sub">
{$t('apps.detail.gitops.disabledSub')}
<code class="gp-path">{gitops.path || '.tinyforge.yml'}</code>
</p>
</div>
{:else}
<!-- Meta row: path · short sha · last sync. -->
<div class="gp-meta">
<span class="gp-meta-item">
<span class="gp-meta-k">{$t('apps.detail.gitops.metaPath')}</span>
<code class="gp-path">{gitops.path || '.tinyforge.yml'}</code>
</span>
{#if gitops.commit_sha}
<span class="gp-meta-sep" aria-hidden="true">·</span>
<span class="gp-meta-item">
<span class="gp-meta-k">{$t('apps.detail.gitops.metaCommit')}</span>
<code class="gp-sha" title={gitops.commit_sha}>{shortSha(gitops.commit_sha)}</code>
</span>
{/if}
<span class="gp-meta-sep" aria-hidden="true">·</span>
<span class="gp-meta-item">
<span class="gp-meta-k">{$t('apps.detail.gitops.metaLastSync')}</span>
{#if lastSync}
<span class="gp-meta-v" title={$fmt.dateTime(lastSync)}>{$fmt.relative(lastSync)}</span>
{:else}
<span class="gp-meta-v gp-muted">{$t('apps.detail.gitops.metaNeverSynced')}</span>
{/if}
</span>
</div>
<!-- ── THE DRIFT VIEW ─────────────────────────────────
Field-level repo-vs-live diff. Purpose-built: one row per
declared field, repo value on the left, live value on the
right, with a connective glyph that turns amber when the
two differ. In-sync collapses to a single confident gitops. -->
{#if isOK}
{#if inSync}
<div class="gp-insync" role="status">
<span class="gp-insync-icon" aria-hidden="true"><IconCheck size={16} /></span>
<div class="gp-insync-text">
<strong>{$t('apps.detail.gitops.inSyncTitle')}</strong>
<span>{$t('apps.detail.gitops.inSyncSub')}</span>
</div>
</div>
{:else}
<div class="gp-drift" aria-label={$t('apps.detail.gitops.driftAria')}>
<div class="gp-drift-head" aria-hidden="true">
<span class="gp-col-field">{$t('apps.detail.gitops.driftColField')}</span>
<span class="gp-col gp-col-repo">{$t('apps.detail.gitops.driftColRepo')}</span>
<span class="gp-col-arrow"></span>
<span class="gp-col gp-col-live">{$t('apps.detail.gitops.driftColLive')}</span>
</div>
{#each gitops.drift as d (d.field)}
<div class="gp-drift-row">
<span class="gp-field">{fieldLabel(d.field)}</span>
<span class="gp-val gp-val-repo" title={d.repo_value}>{d.repo_value || '—'}</span>
<span class="gp-arrow" aria-hidden="true"></span>
<span class="gp-val gp-val-live" title={d.live_value}>{d.live_value || '—'}</span>
</div>
{/each}
<p class="gp-drift-foot">{$t('apps.detail.gitops.driftFoot')}</p>
</div>
{/if}
{:else if gitops.status === 'no_file'}
<div class="gp-status-note gp-note-warn">
<p class="gp-note-lead">{$t('apps.detail.gitops.noFileLead')}</p>
<p class="gp-note-sub">
{$t('apps.detail.gitops.noFileSub')}
<code class="gp-path">{gitops.path || '.tinyforge.yml'}</code>
</p>
</div>
{:else if gitops.status === 'fetch_failed'}
<div class="gp-status-note gp-note-danger">
<p class="gp-note-lead">{$t('apps.detail.gitops.fetchFailedLead')}</p>
{#if gitops.message}<p class="gp-note-sub">{gitops.message}</p>{/if}
</div>
{:else if gitops.status === 'invalid'}
<div class="gp-status-note gp-note-danger">
<p class="gp-note-lead">{$t('apps.detail.gitops.invalidLead')}</p>
{#if gitops.message}<p class="gp-note-sub gp-mono">{gitops.message}</p>{/if}
</div>
{/if}
<!-- ── Rendered .tinyforge.yml preview (when present) ──── -->
{#if gitops.raw}
<div class="gp-editor">
<div class="gp-editor-head">
<span class="gp-dot"></span><span class="gp-dot"></span><span class="gp-dot"></span>
<span class="gp-editor-title">{gitops.path || '.tinyforge.yml'}</span>
<span class="gp-spacer"></span>
<button
type="button"
class="gp-editor-chip"
onclick={copyRaw}
title={$t('apps.detail.gitops.copyAria')}
>
{#if copied}
<IconCheck size={12} />{$t('apps.detail.gitops.copied')}
{:else}
<IconCopy size={12} />{$t('apps.detail.gitops.copy')}
{/if}
</button>
</div>
<pre class="gp-code" aria-label={gitops.path || '.tinyforge.yml'}>{gitops.raw}</pre>
</div>
{/if}
<!-- ── Sync action (admin) ─────────────────────────────── -->
{#if isAdmin && (isOK || gitops.status === 'no_file')}
<div class="gp-actions">
<p class="gp-actions-hint">
{hasDrift
? $t('apps.detail.gitops.syncHintDrift')
: $t('apps.detail.gitops.syncHintClean')}
</p>
<button
class="forge-btn"
onclick={() => (confirmSync = true)}
disabled={syncing || !isOK}
>
<IconRefresh size={13} />
<span>{syncing ? $t('apps.detail.gitops.syncing') : $t('apps.detail.gitops.syncNow')}</span>
</button>
</div>
{/if}
{/if}
{/if}
</section>
{/if}
{#if confirmSync}
<ConfirmDialog
open={true}
title={$t('apps.detail.gitops.confirmTitle')}
message={$t('apps.detail.gitops.confirmMessage')}
confirmLabel={$t('apps.detail.gitops.syncNow')}
confirmVariant="primary"
onconfirm={doSync}
oncancel={() => (confirmSync = false)}
/>
{/if}
<style>
/* ── Card chrome (page-scoped on the detail route; carried locally so
this child renders the forge frame on its own). ───────────────── */
.gp-panel {
--accent: var(--forge-accent);
--accent-soft: var(--forge-accent-soft);
position: relative;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
padding: 1.25rem 1.5rem 1.4rem;
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.reg {
position: absolute;
width: 10px;
height: 10px;
border-color: var(--color-brand-500);
border-style: solid;
border-width: 0;
pointer-events: none;
}
.reg-tl {
top: -1px;
left: -1px;
border-top-width: 2px;
border-left-width: 2px;
border-top-left-radius: var(--radius-2xl);
}
.reg-tr {
top: -1px;
right: -1px;
border-top-width: 2px;
border-right-width: 2px;
border-top-right-radius: var(--radius-2xl);
}
.reg-bl {
bottom: -1px;
left: -1px;
border-bottom-width: 2px;
border-left-width: 2px;
border-bottom-left-radius: var(--radius-2xl);
}
.reg-br {
bottom: -1px;
right: -1px;
border-bottom-width: 2px;
border-right-width: 2px;
border-bottom-right-radius: var(--radius-2xl);
}
.panel-head {
display: flex;
align-items: center;
gap: 0.7rem;
flex-wrap: wrap;
}
.gp-titlewrap {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.panel-title {
font-family: var(--font-family-sans);
font-size: 0.98rem;
font-weight: 700;
letter-spacing: -0.005em;
color: var(--text-primary);
margin: 0;
}
.title-accent {
color: var(--accent);
font-weight: 700;
}
.panel-sub {
font-size: 0.78rem;
color: var(--text-tertiary);
}
/* ── Status pill ─────────────────────────────────────────────── */
.gp-pill {
display: inline-flex;
align-items: center;
gap: 0.32rem;
margin-left: auto;
padding: 0.22rem 0.6rem;
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.66rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
border: 1px solid transparent;
white-space: nowrap;
}
.gp-pill-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.gp-pill-sync {
color: var(--color-success-dark);
background: color-mix(in srgb, var(--color-success) 12%, transparent);
border-color: color-mix(in srgb, var(--color-success) 35%, transparent);
}
.gp-pill-drift {
color: var(--color-brand-700);
background: var(--forge-accent-soft);
border-color: color-mix(in srgb, var(--color-brand-500) 38%, transparent);
}
.gp-pill-warn {
color: var(--color-warning-dark);
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
border-color: color-mix(in srgb, var(--color-warning) 32%, transparent);
}
.gp-pill-danger {
color: var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-color: color-mix(in srgb, var(--color-danger) 32%, transparent);
}
.gp-pill-muted {
color: var(--text-tertiary);
background: var(--surface-card-hover);
border-color: var(--border-primary);
}
/* ── Enable toggle ───────────────────────────────────────────── */
.gp-toggle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.gp-toggle-lbl {
font-family: var(--forge-mono);
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.gp-hint {
font-size: 0.74rem;
color: var(--text-tertiary);
margin: 0.2rem 0;
}
/* ── Disabled / empty ────────────────────────────────────────── */
.gp-empty {
padding: 0.4rem 0 0.1rem;
}
.gp-empty-lead {
font-size: 0.82rem;
color: var(--text-secondary);
margin: 0 0 0.25rem;
}
.gp-empty-sub {
font-size: 0.74rem;
color: var(--text-tertiary);
margin: 0;
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
/* ── Meta row ────────────────────────────────────────────────── */
.gp-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.73rem;
color: var(--text-secondary);
}
.gp-meta-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.gp-meta-k {
color: var(--text-tertiary);
font-size: 0.66rem;
letter-spacing: 0.04em;
text-transform: uppercase;
font-family: var(--forge-mono);
}
.gp-meta-sep {
color: var(--text-tertiary-soft);
}
.gp-muted,
.gp-meta-v.gp-muted {
color: var(--text-tertiary);
}
.gp-path,
.gp-sha {
font-family: var(--forge-mono);
font-size: 0.7rem;
padding: 0.08rem 0.38rem;
border-radius: var(--radius-sm);
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
color: var(--text-secondary);
}
/* ── In-sync confident gitops ─────────────────────────────────── */
.gp-insync {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.85rem 1rem;
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-success) 7%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--color-success) 28%, transparent);
}
.gp-insync-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 50%;
color: #fff;
background: var(--color-success);
}
.gp-insync-text {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.gp-insync-text strong {
font-size: 0.82rem;
color: var(--text-primary);
}
.gp-insync-text span {
font-size: 0.72rem;
color: var(--text-secondary);
}
/* ── THE DRIFT VIEW ──────────────────────────────────────────── */
.gp-drift {
border: 1px solid color-mix(in srgb, var(--color-brand-500) 28%, var(--border-primary));
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--surface-card);
}
.gp-drift-head {
display: grid;
grid-template-columns: minmax(7rem, 0.9fr) 1fr 1.6rem 1fr;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.85rem;
background: var(--forge-accent-soft);
border-bottom: 1px solid color-mix(in srgb, var(--color-brand-500) 22%, transparent);
font-family: var(--forge-mono);
font-size: 0.6rem;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.gp-col-repo {
color: var(--color-brand-700);
}
.gp-col-live {
color: var(--text-secondary);
}
.gp-drift-row {
display: grid;
grid-template-columns: minmax(7rem, 0.9fr) 1fr 1.6rem 1fr;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.85rem;
border-bottom: 1px solid var(--border-secondary);
}
.gp-drift-row:last-of-type {
border-bottom: 0;
}
.gp-field {
font-family: var(--forge-mono);
font-size: 0.74rem;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gp-val {
font-family: var(--forge-mono);
font-size: 0.74rem;
padding: 0.22rem 0.5rem;
border-radius: var(--radius-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Repo = the desired/incoming value: ember-tinted, the "source of truth". */
.gp-val-repo {
color: var(--color-brand-700);
background: var(--forge-accent-soft);
border: 1px solid color-mix(in srgb, var(--color-brand-500) 30%, transparent);
font-weight: 600;
}
/* Live = the current value being replaced: muted, struck feel via dashed. */
.gp-val-live {
color: var(--text-tertiary);
background: var(--surface-card-hover);
border: 1px dashed var(--border-input);
}
.gp-arrow {
justify-self: center;
color: var(--accent);
font-weight: 700;
font-size: 0.85rem;
}
.gp-drift-foot {
margin: 0;
padding: 0.5rem 0.85rem;
font-size: 0.68rem;
color: var(--text-tertiary);
background: var(--surface-card-hover);
border-top: 1px solid var(--border-secondary);
}
/* ── Status notes (no_file / fetch_failed / invalid) ─────────── */
.gp-status-note {
padding: 0.7rem 0.9rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border-primary);
}
.gp-note-warn {
background: color-mix(in srgb, var(--color-warning) 7%, var(--surface-card));
border-color: color-mix(in srgb, var(--color-warning) 26%, transparent);
}
.gp-note-danger {
background: color-mix(in srgb, var(--color-danger) 6%, var(--surface-card));
border-color: color-mix(in srgb, var(--color-danger) 26%, transparent);
}
.gp-note-lead {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary);
}
.gp-note-sub {
margin: 0.3rem 0 0;
font-size: 0.73rem;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.gp-mono {
font-family: var(--forge-mono);
font-size: 0.7rem;
word-break: break-word;
}
/* ── Rendered .tinyforge.yml preview ─────────────────────────── */
.gp-editor {
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--surface-card);
}
.gp-editor-head {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.7rem;
background: var(--surface-card-hover);
border-bottom: 1px solid var(--border-primary);
}
.gp-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border-input);
}
.gp-editor-title {
margin-left: 0.3rem;
font-family: var(--forge-mono);
font-size: 0.68rem;
color: var(--text-secondary);
}
.gp-spacer {
flex: 1;
}
.gp-editor-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.18rem 0.5rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
background: var(--surface-card);
color: var(--text-secondary);
font-family: var(--forge-mono);
font-size: 0.62rem;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
transition: color 120ms ease, border-color 120ms ease;
}
.gp-editor-chip:hover {
color: var(--accent);
border-color: var(--accent);
}
.gp-code {
margin: 0;
padding: 0.85rem 1rem;
font-family: var(--forge-mono);
font-size: 0.76rem;
line-height: 1.55;
color: var(--text-primary);
white-space: pre;
overflow-x: auto;
tab-size: 2;
max-height: 22rem;
}
/* ── Sync action ─────────────────────────────────────────────── */
.gp-actions {
display: flex;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
}
.gp-actions-hint {
margin: 0;
font-size: 0.72rem;
color: var(--text-tertiary);
flex: 1;
min-width: 12rem;
}
@media (prefers-reduced-motion: reduce) {
.gp-editor-chip {
transition: none;
}
}
</style>
+59 -1
View File
@@ -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",
+59 -1
View File
@@ -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": "Старт",
+7
View File
@@ -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;
}
+15
View File
@@ -169,6 +169,11 @@
<span class="badge {sourceBadge(w.source_kind)}">
<span class="badge-dot" aria-hidden="true"></span>{w.source_kind}
</span>
{#if w.gitops_enabled && (w.source_kind === 'dockerfile' || w.source_kind === 'static')}
<span class="badge badge-gitops" title={$t('apps.list.gitopsBadgeTitle')}>
<span class="badge-dot" aria-hidden="true"></span>{$t('apps.list.gitopsBadge')}
</span>
{/if}
</td>
<td>
<span class="badge badge-trigger">{w.trigger_kind}</span>
@@ -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);
+156 -1
View File
@@ -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<string[]>([]);
// 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 <input type="number"> 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 @@
<span class="badge-dot" aria-hidden="true"></span>
{workload!.source_kind}
</span>
{#if workload!.gitops_enabled}
<span class="badge badge-gitops" title={$t('apps.detail.gitops.badgeTitle')}>
<span class="badge-dot" aria-hidden="true"></span>
{$t('apps.detail.gitops.badge')}
</span>
{/if}
<span class="badge trigger">
{bindings.length === 0
? $t('apps.detail.chainTriggersZero')
@@ -1750,6 +1800,26 @@
</span>
</header>
{#if editGitOpsGated}
<!-- Read-only gate: this workload's config is declared in the repo.
Edits here are valid but get overwritten on the next Sync, so
the banner steers the user to the file + lists the managed
fields. Calm (accent, not danger) — it's guidance, not an error. -->
<div class="gitops-gate" role="note">
<span class="gitops-gate-icon" aria-hidden="true"><IconLock size={15} /></span>
<div class="gitops-gate-body">
<p class="gitops-gate-title">{$t('apps.detail.gitops.gateTitle')}</p>
<p class="gitops-gate-text">{$t('apps.detail.gitops.gateBody')}</p>
<div class="gitops-gate-fields">
<span class="gitops-gate-flabel">{$t('apps.detail.gitops.gateFieldsLabel')}</span>
{#each gitopsManagedFields as f (f)}
<code class="gitops-gate-field">{f}</code>
{/each}
</div>
</div>
</div>
{/if}
<div class="field">
<label for="edit-name" class="field-label">
<span class="num">01</span>
@@ -2820,6 +2890,16 @@
<DeployHistoryPanel workloadId={id} />
{/if}
<!-- ── GitOps config-as-code (dockerfile/static) ──── -->
{#if !editing && (workload.source_kind === 'dockerfile' || workload.source_kind === 'static')}
<GitOpsPanel
workloadId={id}
sourceKind={workload.source_kind}
{isAdmin}
onSynced={load}
/>
{/if}
<!-- ── Per-workload notification routes ───────────── -->
{#if !editing}
<WorkloadNotificationsPanel workloadId={id} />
@@ -3477,6 +3557,81 @@
background: var(--surface-card-hover);
color: var(--text-secondary);
}
/* GitOps-managed chip: ember-tinted so it reads as "this app's config
is driven from the repo", visually allied with the accent. */
.badge-gitops {
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);
}
/* ── GitOps read-only gate banner (edit form) ──── */
.gitops-gate {
display: flex;
gap: 0.7rem;
padding: 0.85rem 1rem;
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-brand-500) 7%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--color-brand-500) 30%, transparent);
}
.gitops-gate-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: var(--radius-md);
color: #fff;
background: var(--color-brand-600);
}
.gitops-gate-body {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
.gitops-gate-title {
margin: 0;
font-size: 0.82rem;
font-weight: 700;
color: var(--text-primary);
}
.gitops-gate-text {
margin: 0;
font-size: 0.74rem;
line-height: 1.5;
color: var(--text-secondary);
}
.gitops-gate-fields {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.15rem;
}
.gitops-gate-flabel {
font-family: var(--forge-mono);
font-size: 0.62rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.gitops-gate-field {
font-family: var(--forge-mono);
font-size: 0.7rem;
padding: 0.1rem 0.42rem;
border-radius: var(--radius-sm);
background: var(--forge-accent-soft);
border: 1px solid color-mix(in srgb, var(--color-brand-500) 30%, transparent);
color: var(--color-brand-700);
}
:global([data-theme='dark']) .gitops-gate-field {
color: var(--color-brand-300);
}
/* ── Runtime / storage panels ────────────────────
Two narrow operational-status cards displayed in a 2-up grid