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:
+45
-7
@@ -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/
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,364 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/auth"
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/gitops"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// keyedMutex is a lazily-populated per-key lock. Used to serialize a critical
|
||||
// section per workload id (the GitOps sync) without a global lock.
|
||||
type keyedMutex struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
// lock acquires the mutex for key and returns its unlock func.
|
||||
func (k *keyedMutex) lock(key string) func() {
|
||||
k.mu.Lock()
|
||||
if k.m == nil {
|
||||
k.m = make(map[string]*sync.Mutex)
|
||||
}
|
||||
mu, ok := k.m[key]
|
||||
if !ok {
|
||||
mu = &sync.Mutex{}
|
||||
k.m[key] = mu
|
||||
}
|
||||
k.mu.Unlock()
|
||||
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
// gitOpsStatusResponse is the single rich payload the GitOps panel reads — it
|
||||
// folds the file preview, parsed status, and drift into one response so the UI
|
||||
// makes a single call (no separate /drift round-trip).
|
||||
type gitOpsStatusResponse struct {
|
||||
Eligible bool `json:"eligible"` // source kind supports GitOps
|
||||
Enabled bool `json:"enabled"` // opt-in flag on the workload
|
||||
Path string `json:"path"` // repo-relative config path
|
||||
Status string `json:"status"` // disabled|ok|no_file|fetch_failed|invalid
|
||||
Raw string `json:"raw"` // the .tinyforge.yml text, when present
|
||||
Message string `json:"message"` // token-redacted detail for non-ok
|
||||
CommitSHA string `json:"commit_sha"` // ref the file was read at
|
||||
LastSyncAt string `json:"last_sync_at"` // last successful sync ("" = never)
|
||||
Drift []gitops.DriftEntry `json:"drift"` // declared fields that differ from live
|
||||
DriftCount int `json:"drift_count"`
|
||||
// ManagedFields lists every source_config key the repo overlay declares
|
||||
// (not just the drifting ones) so the UI can lock exactly those fields on
|
||||
// the edit form. Populated only when the file parsed (status ok).
|
||||
ManagedFields []string `json:"managed_fields"`
|
||||
}
|
||||
|
||||
// getWorkloadGitOps handles GET /api/workloads/{id}/gitops. Read-only; open to
|
||||
// any authenticated user. When GitOps is enabled it fetches the repo's
|
||||
// .tinyforge.yml live and computes drift against the stored source_config.
|
||||
func (s *Server) getWorkloadGitOps(w http.ResponseWriter, r *http.Request) {
|
||||
row, ok := s.loadWorkload(w, chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
resp := gitOpsStatusResponse{
|
||||
Eligible: gitops.IsEligibleSource(row.SourceKind),
|
||||
Enabled: row.GitOpsEnabled,
|
||||
Path: row.GitOpsPath,
|
||||
Status: "disabled",
|
||||
LastSyncAt: row.GitOpsLastSyncAt,
|
||||
CommitSHA: row.GitOpsCommitSHA,
|
||||
Drift: []gitops.DriftEntry{},
|
||||
}
|
||||
if resp.Path == "" {
|
||||
resp.Path = ".tinyforge.yml"
|
||||
}
|
||||
|
||||
// Only reach out to the repo when GitOps is actually on.
|
||||
if row.GitOpsEnabled && resp.Eligible {
|
||||
ref, err := s.gitOpsRepoRef(row)
|
||||
if err != nil {
|
||||
// Decoding/decrypt failure: surface as fetch_failed, never the raw
|
||||
// error (it can carry the token / config bytes).
|
||||
slog.Warn("gitops: build repo ref", "workload", row.ID, "error", err)
|
||||
resp.Status = string(gitops.StatusFetchFailed)
|
||||
resp.Message = "could not read repo settings for this workload"
|
||||
respondJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
res := gitops.Fetch(r.Context(), ref)
|
||||
resp.Status = string(res.Status)
|
||||
resp.CommitSHA = firstNonEmpty(res.CommitSHA, row.GitOpsCommitSHA)
|
||||
resp.Message = res.Message
|
||||
if len(res.Raw) > 0 {
|
||||
resp.Raw = string(res.Raw)
|
||||
}
|
||||
if res.Status == gitops.StatusOK {
|
||||
drift, derr := gitops.Drift(res.Spec, json.RawMessage(row.SourceConfig), row.SourceKind)
|
||||
if derr != nil {
|
||||
slog.Warn("gitops: drift", "workload", row.ID, "error", derr)
|
||||
} else if drift != nil {
|
||||
resp.Drift = drift
|
||||
}
|
||||
resp.DriftCount = len(resp.Drift)
|
||||
resp.ManagedFields = planFields(gitops.BuildPlan(res.Spec, row.SourceKind))
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// setWorkloadGitOps handles PUT /api/workloads/{id}/gitops. Admin-only.
|
||||
// Body: {"enabled": bool, "path": string}. Enabling is refused for source
|
||||
// kinds that aren't git-backed; the path is validated against traversal.
|
||||
func (s *Server) setWorkloadGitOps(w http.ResponseWriter, r *http.Request) {
|
||||
row, ok := s.loadWorkload(w, chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if !decodeJSONStrict(w, r, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
if body.Enabled && !gitops.IsEligibleSource(row.SourceKind) {
|
||||
respondError(w, http.StatusBadRequest,
|
||||
"GitOps is only available for dockerfile and static sources")
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(body.Path)
|
||||
if path != "" && !validGitOpsPath(path) {
|
||||
respondError(w, http.StatusBadRequest,
|
||||
"invalid path: must be a repo-relative file (no \"..\", no leading slash)")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.SetWorkloadGitOps(row.ID, body.Enabled, path); err != nil {
|
||||
slog.Error("gitops: set", "workload", row.ID, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to update GitOps settings")
|
||||
return
|
||||
}
|
||||
if path == "" {
|
||||
path = ".tinyforge.yml"
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled, "path": path})
|
||||
}
|
||||
|
||||
// syncWorkloadGitOps handles POST /api/workloads/{id}/gitops/sync. Admin-only.
|
||||
// It fetches the repo's .tinyforge.yml, merges the declared overlay onto the
|
||||
// live source_config (validate-then-commit), persists it, and records the sync.
|
||||
// Explicit action only — there is no auto-apply on deploy in v1.
|
||||
func (s *Server) syncWorkloadGitOps(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
respondError(w, http.StatusBadRequest, "workload id is required")
|
||||
return
|
||||
}
|
||||
// Serialize the whole read→merge→write per workload so two concurrent
|
||||
// syncs can't clobber each other (review S5). Load the row INSIDE the lock
|
||||
// so each sync merges off the latest persisted config.
|
||||
unlock := s.gitopsSync.lock(id)
|
||||
defer unlock()
|
||||
|
||||
row, ok := s.loadWorkload(w, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !gitops.IsEligibleSource(row.SourceKind) {
|
||||
respondError(w, http.StatusBadRequest,
|
||||
"GitOps is only available for dockerfile and static sources")
|
||||
return
|
||||
}
|
||||
if !row.GitOpsEnabled {
|
||||
respondError(w, http.StatusBadRequest, "enable GitOps for this workload first")
|
||||
return
|
||||
}
|
||||
|
||||
ref, err := s.gitOpsRepoRef(row)
|
||||
if err != nil {
|
||||
slog.Warn("gitops: build repo ref", "workload", row.ID, "error", err)
|
||||
respondError(w, http.StatusBadGateway, "could not read repo settings for this workload")
|
||||
return
|
||||
}
|
||||
|
||||
res := gitops.Fetch(r.Context(), ref)
|
||||
switch res.Status {
|
||||
case gitops.StatusOK:
|
||||
// proceed
|
||||
case gitops.StatusNoFile:
|
||||
respondError(w, http.StatusBadRequest, "no "+ref.Path+" found on branch "+ref.Branch)
|
||||
return
|
||||
case gitops.StatusInvalid:
|
||||
respondError(w, http.StatusBadRequest, "invalid "+ref.Path+": "+res.Message)
|
||||
return
|
||||
default: // fetch_failed
|
||||
slog.Warn("gitops: fetch failed", "workload", row.ID, "detail", res.Message)
|
||||
respondError(w, http.StatusBadGateway, "could not fetch "+ref.Path+" from the repo")
|
||||
return
|
||||
}
|
||||
|
||||
src, err := plugin.GetSource(row.SourceKind)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "unknown source kind")
|
||||
return
|
||||
}
|
||||
plan := gitops.BuildPlan(res.Spec, row.SourceKind)
|
||||
merged, err := gitops.MergeAndValidate(json.RawMessage(row.SourceConfig), plan, src.Validate)
|
||||
if err != nil {
|
||||
// The merged config failed the source's own Validate — the file
|
||||
// declares something this workload can't accept. Safe to surface (it
|
||||
// describes config shape, not secrets).
|
||||
respondError(w, http.StatusBadRequest, "the repo config was rejected: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Persist via a full-row update off the row we loaded (single read →
|
||||
// merge → write). A per-workload sync lock that closes the remaining
|
||||
// edit-vs-sync window is a Phase 4 hardening item.
|
||||
row.SourceConfig = string(merged)
|
||||
if err := s.store.UpdateWorkload(row); err != nil {
|
||||
slog.Error("gitops: persist merged config", "workload", row.ID, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to apply the repo config")
|
||||
return
|
||||
}
|
||||
if err := s.store.RecordGitOpsSync(row.ID, res.CommitSHA, store.Now()); err != nil {
|
||||
slog.Warn("gitops: record sync", "workload", row.ID, "error", err)
|
||||
}
|
||||
|
||||
actor := "manual"
|
||||
if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.Username != "" {
|
||||
actor = claims.Username
|
||||
}
|
||||
appliedFields := planFields(plan)
|
||||
s.recordGitOpsEvent(row.ID, res.CommitSHA, actor, appliedFields)
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "applied",
|
||||
"commit_sha": res.CommitSHA,
|
||||
"applied_fields": appliedFields,
|
||||
"triggered_by": actor,
|
||||
})
|
||||
}
|
||||
|
||||
// loadWorkload fetches a workload by id, writing the appropriate error response
|
||||
// and returning ok=false on miss. Shared by the GitOps handlers.
|
||||
func (s *Server) loadWorkload(w http.ResponseWriter, id string) (store.Workload, bool) {
|
||||
if id == "" {
|
||||
respondError(w, http.StatusBadRequest, "workload id is required")
|
||||
return store.Workload{}, false
|
||||
}
|
||||
row, err := s.store.GetWorkloadByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "workload")
|
||||
return store.Workload{}, false
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "get workload")
|
||||
return store.Workload{}, false
|
||||
}
|
||||
return row, true
|
||||
}
|
||||
|
||||
// gitOpsRepoRef builds a gitops.RepoRef from a workload's source_config: it
|
||||
// decodes the common git coords (identical keys across dockerfile + static)
|
||||
// and decrypts the access token. The gitops package stays decoupled from the
|
||||
// store/crypto by taking the plain coords.
|
||||
func (s *Server) gitOpsRepoRef(row store.Workload) (gitops.RepoRef, error) {
|
||||
var c struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
RepoName string `json:"repo_name"`
|
||||
Branch string `json:"branch"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(row.SourceConfig), &c); err != nil {
|
||||
return gitops.RepoRef{}, fmt.Errorf("decode source_config: %w", err)
|
||||
}
|
||||
token := ""
|
||||
if c.AccessToken != "" {
|
||||
dec, err := crypto.Decrypt(s.encKey, c.AccessToken)
|
||||
if err != nil {
|
||||
return gitops.RepoRef{}, fmt.Errorf("decrypt access token: %w", err)
|
||||
}
|
||||
token = dec
|
||||
}
|
||||
branch := c.Branch
|
||||
if branch == "" {
|
||||
branch = "main"
|
||||
}
|
||||
path := row.GitOpsPath
|
||||
if path == "" {
|
||||
path = ".tinyforge.yml"
|
||||
}
|
||||
return gitops.RepoRef{
|
||||
Provider: c.Provider,
|
||||
BaseURL: c.BaseURL,
|
||||
Owner: c.RepoOwner,
|
||||
Repo: c.RepoName,
|
||||
Branch: branch,
|
||||
Token: token,
|
||||
Path: path,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// recordGitOpsEvent writes a sync to the per-workload event log — the audit
|
||||
// trail for a config-only sync, kept OUT of deploy_history (which the rollback
|
||||
// feature treats as redeployable rows).
|
||||
func (s *Server) recordGitOpsEvent(workloadID, sha, actor string, fields []string) {
|
||||
meta, _ := json.Marshal(map[string]any{"commit_sha": sha, "by": actor, "fields": fields})
|
||||
if _, err := s.store.InsertEvent(store.EventLog{
|
||||
Source: "gitops",
|
||||
WorkloadID: workloadID,
|
||||
Severity: "info",
|
||||
Message: "GitOps config synced from repo",
|
||||
Metadata: string(meta),
|
||||
}); err != nil {
|
||||
slog.Warn("gitops: record event", "workload", workloadID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// validGitOpsPath rejects absolute paths, traversal, and URL-significant or
|
||||
// control characters so a stored config path can't escape the repo (review M2)
|
||||
// or smuggle a query/fragment onto the provider's raw-file URL (review LOW-1).
|
||||
func validGitOpsPath(p string) bool {
|
||||
if p == "" || len(p) > 255 {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(p, "/") || strings.HasPrefix(p, "\\") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(p, "..") {
|
||||
return false
|
||||
}
|
||||
for _, r := range p {
|
||||
if r < 0x20 || r == 0x7f || r == '?' || r == '#' || r == ' ' || r == '\\' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// planFields returns the source_config keys an apply plan touches.
|
||||
func planFields(plan gitops.ApplyPlan) []string {
|
||||
fields := make([]string, 0, len(plan.SourceConfigPatch))
|
||||
for k := range plan.SourceConfigPatch {
|
||||
fields = append(fields, k)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/gitops"
|
||||
)
|
||||
|
||||
func TestValidGitOpsPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
ok bool
|
||||
}{
|
||||
{".tinyforge.yml", true},
|
||||
{"deploy/.tinyforge.yml", true},
|
||||
{"config/app.yaml", true},
|
||||
{"/etc/passwd", false}, // absolute
|
||||
{"\\windows\\path", false}, // absolute (backslash)
|
||||
{"../../etc/passwd", false}, // traversal
|
||||
{"deploy/../../x", false}, // traversal mid-path
|
||||
{"foo?ref=evil", false}, // query-param injection (LOW-1)
|
||||
{"foo#frag", false}, // fragment injection
|
||||
{"with space.yml", false}, // whitespace
|
||||
{"", false}, // empty
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := validGitOpsPath(c.path); got != c.ok {
|
||||
t.Errorf("validGitOpsPath(%q) = %v, want %v", c.path, got, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanFields(t *testing.T) {
|
||||
spec := gitops.Spec{Version: 1, Deploy: gitops.DeploySpec{
|
||||
Port: ptrInt(8080),
|
||||
DeployStrategy: ptrStr("blue-green"),
|
||||
}}
|
||||
got := planFields(gitops.BuildPlan(spec, gitops.SourceDockerfile))
|
||||
sort.Strings(got)
|
||||
want := []string{"deploy_strategy", "port"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("planFields = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrInt(i int) *int { return &i }
|
||||
func ptrStr(s string) *string { return &s }
|
||||
@@ -52,6 +52,10 @@ type Server struct {
|
||||
oidcProvider *auth.OIDCProvider
|
||||
staleScanner *stale.Scanner
|
||||
|
||||
// gitopsSync serializes the GitOps sync (read→merge→write) per workload so
|
||||
// two concurrent syncs can't race on source_config (review S5).
|
||||
gitopsSync keyedMutex
|
||||
|
||||
dnsProviderMu sync.RWMutex
|
||||
dnsProvider dns.Provider
|
||||
onDNSProviderChanged DNSProviderChangedFunc
|
||||
@@ -342,6 +346,13 @@ func (s *Server) Router() chi.Router {
|
||||
r.Get("/deploys", s.listWorkloadDeploys)
|
||||
r.With(auth.AdminOnly).Post("/rollback", s.rollbackWorkload)
|
||||
|
||||
// GitOps config-as-code (dockerfile/static). The status read
|
||||
// (incl. live drift) is open to any authenticated user; enable/
|
||||
// disable and sync mutate config, so they are admin-gated.
|
||||
r.Get("/gitops", s.getWorkloadGitOps)
|
||||
r.With(auth.AdminOnly).Put("/gitops", s.setWorkloadGitOps)
|
||||
r.With(auth.AdminOnly).Post("/gitops/sync", s.syncWorkloadGitOps)
|
||||
|
||||
// Volume snapshots (admin-only). Capture/list a workload's
|
||||
// host-bind data volumes; {sid}-scoped download/delete live
|
||||
// in the global admin group alongside backups.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package gitops
|
||||
|
||||
// source_config JSON keys this package can overlay. Kept as constants so the
|
||||
// apply, merge, and drift paths agree on the exact key strings.
|
||||
const (
|
||||
keyPort = "port"
|
||||
keyHealthcheck = "healthcheck"
|
||||
keyDeployStrategy = "deploy_strategy"
|
||||
)
|
||||
|
||||
// Source kinds eligible for GitOps in v1 (git-backed sources only).
|
||||
const (
|
||||
SourceDockerfile = "dockerfile"
|
||||
SourceStatic = "static"
|
||||
)
|
||||
|
||||
// supportedKeys returns the source_config keys a given source kind accepts
|
||||
// from a .tinyforge.yml overlay. A field declared in the file but not in this
|
||||
// set is ignored (not applied, not drift-compared) so a shared file can target
|
||||
// either source without producing dead keys or false drift.
|
||||
//
|
||||
// dockerfile: port + healthcheck + deploy_strategy (its real run knobs).
|
||||
// static: deploy_strategy only (a static site has no port/healthcheck).
|
||||
func supportedKeys(sourceKind string) map[string]bool {
|
||||
switch sourceKind {
|
||||
case SourceDockerfile:
|
||||
return map[string]bool{keyPort: true, keyHealthcheck: true, keyDeployStrategy: true}
|
||||
case SourceStatic:
|
||||
return map[string]bool{keyDeployStrategy: true}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// IsEligibleSource reports whether GitOps may be enabled for a source kind.
|
||||
func IsEligibleSource(sourceKind string) bool {
|
||||
return supportedKeys(sourceKind) != nil
|
||||
}
|
||||
|
||||
// ApplyPlan is the typed, multi-target plan for applying an overlay. In v1 only
|
||||
// SourceConfigPatch is populated; EnvUpserts/Faces are reserved so env (the
|
||||
// workload_env table) and faces (the public_faces column) can be added later
|
||||
// without reshaping the apply path — they are NOT in v1 (env would re-open the
|
||||
// secrets-in-repo hole; faces live in a sibling store).
|
||||
type ApplyPlan struct {
|
||||
// SourceConfigPatch holds the source_config keys to overlay onto the live
|
||||
// config. Only keys supported by the target source are present.
|
||||
SourceConfigPatch map[string]any
|
||||
|
||||
// reserved for future phases — see package doc.
|
||||
// EnvUpserts []store.WorkloadEnv
|
||||
// Faces []plugin.PublicFace
|
||||
}
|
||||
|
||||
// declaredValues returns the present (non-nil) overlay fields keyed by their
|
||||
// source_config JSON key, before the per-source filter. Shared by BuildPlan and
|
||||
// Drift so they agree on what the file declared.
|
||||
func declaredValues(spec Spec) map[string]any {
|
||||
out := map[string]any{}
|
||||
if spec.Deploy.Port != nil {
|
||||
out[keyPort] = *spec.Deploy.Port
|
||||
}
|
||||
if spec.Deploy.Healthcheck != nil {
|
||||
out[keyHealthcheck] = *spec.Deploy.Healthcheck
|
||||
}
|
||||
if spec.Deploy.DeployStrategy != nil {
|
||||
out[keyDeployStrategy] = *spec.Deploy.DeployStrategy
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildPlan maps the present, source-supported overlay fields to a patch for
|
||||
// the given source kind. Unsupported/absent fields are dropped.
|
||||
func BuildPlan(spec Spec, sourceKind string) ApplyPlan {
|
||||
allowed := supportedKeys(sourceKind)
|
||||
patch := map[string]any{}
|
||||
for k, v := range declaredValues(spec) {
|
||||
if allowed[k] {
|
||||
patch[k] = v
|
||||
}
|
||||
}
|
||||
return ApplyPlan{SourceConfigPatch: patch}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package gitops
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// DriftEntry is one field where the repo-declared value differs from the live
|
||||
// stored value. Values are display strings; comparison is done on normalized
|
||||
// forms so cosmetic differences (default coercion, YAML int vs JSON number)
|
||||
// don't register as drift.
|
||||
type DriftEntry struct {
|
||||
Field string `json:"field"`
|
||||
RepoValue string `json:"repo_value"`
|
||||
LiveValue string `json:"live_value"`
|
||||
}
|
||||
|
||||
// driftFieldOrder is the stable order drift entries are reported in.
|
||||
var driftFieldOrder = []string{keyPort, keyHealthcheck, keyDeployStrategy}
|
||||
|
||||
// Drift compares the declared overlay (the present, source-supported fields)
|
||||
// against the live source_config and returns the fields that differ. Only
|
||||
// declared fields are considered — a key the file omits is "unmanaged",
|
||||
// neither drift nor clean (review C5). Comparison is post-normalization.
|
||||
func Drift(spec Spec, live json.RawMessage, sourceKind string) ([]DriftEntry, error) {
|
||||
liveMap := map[string]any{}
|
||||
if len(live) > 0 {
|
||||
if err := json.Unmarshal(live, &liveMap); err != nil {
|
||||
return nil, fmt.Errorf("gitops: decode live source_config: %w", err)
|
||||
}
|
||||
}
|
||||
allowed := supportedKeys(sourceKind)
|
||||
declared := declaredValues(spec)
|
||||
|
||||
var entries []DriftEntry
|
||||
for _, k := range driftFieldOrder {
|
||||
repoVal, ok := declared[k]
|
||||
if !ok || !allowed[k] {
|
||||
continue
|
||||
}
|
||||
liveVal, livePresent := liveMap[k]
|
||||
if normalizeField(k, repoVal) == normalizeField(k, liveVal) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, DriftEntry{
|
||||
Field: k,
|
||||
RepoValue: displayField(k, repoVal, true),
|
||||
LiveValue: displayField(k, liveVal, livePresent),
|
||||
})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// normalizeField returns the canonical comparison form of a field value.
|
||||
func normalizeField(key string, v any) string {
|
||||
switch key {
|
||||
case keyDeployStrategy:
|
||||
// "" and "recreate" are the same effective strategy for dockerfile and
|
||||
// static (see each source's effectiveStrategy).
|
||||
s := toStr(v)
|
||||
if s == "" || s == "recreate" {
|
||||
return "recreate"
|
||||
}
|
||||
return s
|
||||
case keyPort:
|
||||
return canonInt(v)
|
||||
default:
|
||||
return toStr(v)
|
||||
}
|
||||
}
|
||||
|
||||
// displayField renders a value for the UI. present=false means the key is
|
||||
// absent from the live config.
|
||||
func displayField(key string, v any, present bool) string {
|
||||
if !present {
|
||||
return "(unset)"
|
||||
}
|
||||
if key == keyDeployStrategy {
|
||||
if s := toStr(v); s == "" {
|
||||
return "recreate (default)"
|
||||
}
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
// JSON numbers decode as float64; show whole numbers without ".0".
|
||||
return strconv.FormatInt(int64(n), 10)
|
||||
case nil:
|
||||
return "(unset)"
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
// canonInt coerces any numeric representation (YAML int, JSON float64, etc.)
|
||||
// to a base-10 integer string for value-equality comparison.
|
||||
func canonInt(v any) string {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return strconv.Itoa(n)
|
||||
case int64:
|
||||
return strconv.FormatInt(n, 10)
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(n), 10)
|
||||
case json.Number:
|
||||
return n.String()
|
||||
case nil:
|
||||
return "0"
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
func toStr(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package gitops
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/staticsite"
|
||||
)
|
||||
|
||||
// maxConfigBytes caps the .tinyforge.yml fetch. The file is tiny; the cap
|
||||
// stops a hostile/misconfigured repo from streaming an unbounded body.
|
||||
const maxConfigBytes = 64 * 1024
|
||||
|
||||
// Status is the outcome of a Fetch. All outcomes are values (not errors) so a
|
||||
// caller always has something to show: an absent file or a provider blip is a
|
||||
// normal state, not a 500.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusOK Status = "ok" // file present and parsed
|
||||
StatusNoFile Status = "no_file" // GitOps enabled, no file at path
|
||||
StatusFetchFailed Status = "fetch_failed" // transport/auth/5xx error
|
||||
StatusInvalid Status = "invalid" // file present but failed to parse
|
||||
)
|
||||
|
||||
// RepoRef is the minimal repo locator Fetch needs. The caller (API layer)
|
||||
// extracts these from the workload's source_config and decrypts the token —
|
||||
// this package stays decoupled from the store and source plugins.
|
||||
type RepoRef struct {
|
||||
Provider string // "gitea" | "github" | "gitlab" | "" (autodetect from BaseURL)
|
||||
BaseURL string
|
||||
Owner string
|
||||
Repo string
|
||||
Branch string
|
||||
Token string // decrypted; "" for public repos
|
||||
Path string // repo-relative file path; defaults to .tinyforge.yml
|
||||
}
|
||||
|
||||
// Result carries everything the API/UI needs about a fetch. Message is a
|
||||
// human-safe, token-redacted detail for non-ok statuses.
|
||||
type Result struct {
|
||||
Status Status
|
||||
Raw []byte
|
||||
Spec Spec
|
||||
CommitSHA string
|
||||
Message string
|
||||
}
|
||||
|
||||
// Fetch reads the .tinyforge.yml from a workload's repo and parses it. Every
|
||||
// failure mode is encoded in Result.Status (never a returned error), with any
|
||||
// detail token-redacted in Result.Message. A missing file is StatusNoFile, not
|
||||
// a failure — never a reason to block or clear config.
|
||||
func Fetch(ctx context.Context, ref RepoRef) Result {
|
||||
provider, err := staticsite.NewGitProvider(staticsite.ProviderType(ref.Provider), ref.BaseURL, ref.Token)
|
||||
if err != nil {
|
||||
return Result{Status: StatusFetchFailed, Message: redact(err, ref.Token)}
|
||||
}
|
||||
|
||||
// Best-effort: the SHA lets the UI show which ref the file came from. A
|
||||
// failure here doesn't sink the fetch — the file read below is what matters.
|
||||
sha, _ := provider.GetLatestCommitSHA(ctx, ref.Owner, ref.Repo, ref.Branch)
|
||||
|
||||
path := ref.Path
|
||||
if path == "" {
|
||||
path = ".tinyforge.yml"
|
||||
}
|
||||
data, err := provider.DownloadFile(ctx, ref.Owner, ref.Repo, ref.Branch, path, maxConfigBytes)
|
||||
if err != nil {
|
||||
if errors.Is(err, staticsite.ErrFileNotFound) {
|
||||
return Result{Status: StatusNoFile, CommitSHA: sha}
|
||||
}
|
||||
return Result{Status: StatusFetchFailed, CommitSHA: sha, Message: redact(err, ref.Token)}
|
||||
}
|
||||
|
||||
spec, err := ParseSpec(data)
|
||||
if err != nil {
|
||||
// Parse errors describe YAML structure (line/col), not the token.
|
||||
return Result{Status: StatusInvalid, Raw: data, CommitSHA: sha, Message: err.Error()}
|
||||
}
|
||||
return Result{Status: StatusOK, Raw: data, Spec: spec, CommitSHA: sha}
|
||||
}
|
||||
|
||||
// redact strips the access token from an error message so a fetch failure can
|
||||
// be surfaced or persisted without leaking the credential (mirrors the
|
||||
// sanitizeError convention in the static/dockerfile sources).
|
||||
func redact(err error, token string) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := err.Error()
|
||||
if token != "" {
|
||||
msg = strings.ReplaceAll(msg, token, "[redacted]")
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package gitops
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func strp(s string) *string { return &s }
|
||||
func intp(i int) *int { return &i }
|
||||
|
||||
func TestParseSpec(t *testing.T) {
|
||||
s, err := ParseSpec([]byte("version: 1\ndeploy:\n port: 8080\n deploy_strategy: blue-green\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("valid parse: %v", err)
|
||||
}
|
||||
if s.Version != 1 || s.Deploy.Port == nil || *s.Deploy.Port != 8080 {
|
||||
t.Fatalf("unexpected spec: %+v", s)
|
||||
}
|
||||
if s.Deploy.Healthcheck != nil {
|
||||
t.Fatalf("omitted healthcheck must stay nil")
|
||||
}
|
||||
|
||||
// Unknown keys are rejected — incl. an attempt to declare env (out of v1).
|
||||
if _, err := ParseSpec([]byte("version: 1\ndeploy:\n env:\n FOO: bar\n")); err == nil {
|
||||
t.Fatalf("expected unknown-field error for deploy.env")
|
||||
}
|
||||
if _, err := ParseSpec([]byte("version: 1\nworkloads: []\n")); err == nil {
|
||||
t.Fatalf("expected unknown-field error for top-level workloads")
|
||||
}
|
||||
if _, err := ParseSpec([]byte("version: 2\n")); err == nil {
|
||||
t.Fatalf("expected unsupported-version error")
|
||||
}
|
||||
if _, err := ParseSpec(nil); err == nil {
|
||||
t.Fatalf("expected empty-file error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_SourceAware(t *testing.T) {
|
||||
spec := Spec{Version: 1, Deploy: DeploySpec{
|
||||
Port: intp(8080), Healthcheck: strp("/h"), DeployStrategy: strp("blue-green"),
|
||||
}}
|
||||
|
||||
df := BuildPlan(spec, SourceDockerfile).SourceConfigPatch
|
||||
if df[keyPort] != 8080 || df[keyHealthcheck] != "/h" || df[keyDeployStrategy] != "blue-green" {
|
||||
t.Fatalf("dockerfile patch wrong: %+v", df)
|
||||
}
|
||||
|
||||
// static has no port/healthcheck — they must NOT leak into its patch.
|
||||
st := BuildPlan(spec, SourceStatic).SourceConfigPatch
|
||||
if _, ok := st[keyPort]; ok {
|
||||
t.Fatalf("static patch must not contain port")
|
||||
}
|
||||
if _, ok := st[keyHealthcheck]; ok {
|
||||
t.Fatalf("static patch must not contain healthcheck")
|
||||
}
|
||||
if st[keyDeployStrategy] != "blue-green" {
|
||||
t.Fatalf("static should keep deploy_strategy: %+v", st)
|
||||
}
|
||||
|
||||
if IsEligibleSource("image") || IsEligibleSource("compose") {
|
||||
t.Fatalf("only dockerfile/static are GitOps-eligible in v1")
|
||||
}
|
||||
if !IsEligibleSource(SourceDockerfile) || !IsEligibleSource(SourceStatic) {
|
||||
t.Fatalf("dockerfile + static must be eligible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAndValidate_PreservesOmittedFields(t *testing.T) {
|
||||
live := json.RawMessage(`{"repo_owner":"o","repo_name":"r","port":3000,"healthcheck":"/old","deploy_strategy":""}`)
|
||||
spec := Spec{Version: 1, Deploy: DeploySpec{Port: intp(8080)}} // only port declared
|
||||
merged, err := MergeAndValidate(live, BuildPlan(spec, SourceDockerfile), func(json.RawMessage) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(merged, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m["port"].(float64) != 8080 {
|
||||
t.Fatalf("declared port not applied: %v", m["port"])
|
||||
}
|
||||
if m["healthcheck"] != "/old" {
|
||||
t.Fatalf("undeclared healthcheck must be preserved, got %v", m["healthcheck"])
|
||||
}
|
||||
if m["repo_owner"] != "o" {
|
||||
t.Fatalf("untouched repo_owner lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAndValidate_RejectsInvalidMergedConfig(t *testing.T) {
|
||||
live := json.RawMessage(`{"port":3000}`)
|
||||
spec := Spec{Version: 1, Deploy: DeploySpec{DeployStrategy: strp("rolling")}}
|
||||
_, err := MergeAndValidate(live, BuildPlan(spec, SourceDockerfile), func(c json.RawMessage) error {
|
||||
var x struct {
|
||||
DeployStrategy string `json:"deploy_strategy"`
|
||||
}
|
||||
_ = json.Unmarshal(c, &x)
|
||||
if x.DeployStrategy == "rolling" {
|
||||
return errors.New("invalid deploy_strategy")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected the merged config to be rejected as a whole")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrift_DeclaredOnly_WithNormalization(t *testing.T) {
|
||||
// live: port 3000, healthcheck "/h", strategy "" (== recreate effective).
|
||||
live := json.RawMessage(`{"port":3000,"healthcheck":"/h","deploy_strategy":"","registry_name":"x"}`)
|
||||
// declare: port (changed) + deploy_strategy "recreate" (equal to "" -> no drift).
|
||||
spec := Spec{Version: 1, Deploy: DeploySpec{Port: intp(8080), DeployStrategy: strp("recreate")}}
|
||||
d, err := Drift(spec, live, SourceDockerfile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(d) != 1 {
|
||||
t.Fatalf("want exactly 1 drift (port), got %d: %+v", len(d), d)
|
||||
}
|
||||
if d[0].Field != keyPort || d[0].RepoValue != "8080" || d[0].LiveValue != "3000" {
|
||||
t.Fatalf("port drift wrong: %+v", d[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrift_StaticIgnoresUnsupportedFields(t *testing.T) {
|
||||
live := json.RawMessage(`{"deploy_strategy":"recreate","mode":"static"}`)
|
||||
// port declared but unsupported for static -> ignored; strategy differs -> drift.
|
||||
spec := Spec{Version: 1, Deploy: DeploySpec{Port: intp(8080), DeployStrategy: strp("blue-green")}}
|
||||
d, err := Drift(spec, live, SourceStatic)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(d) != 1 || d[0].Field != keyDeployStrategy {
|
||||
t.Fatalf("static should only drift on deploy_strategy: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrift_UnsetLiveValue(t *testing.T) {
|
||||
spec := Spec{Version: 1, Deploy: DeploySpec{Healthcheck: strp("/up")}}
|
||||
d, err := Drift(spec, json.RawMessage(`{}`), SourceDockerfile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(d) != 1 || d[0].RepoValue != "/up" || d[0].LiveValue != "(unset)" {
|
||||
t.Fatalf("unset live should render as (unset): %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedact_StripsToken(t *testing.T) {
|
||||
msg := redact(errors.New("execute request: token ghp_SECRET rejected"), "ghp_SECRET")
|
||||
if strings.Contains(msg, "ghp_SECRET") {
|
||||
t.Fatalf("token leaked: %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "[redacted]") {
|
||||
t.Fatalf("expected redaction marker: %s", msg)
|
||||
}
|
||||
if redact(nil, "x") != "" {
|
||||
t.Fatalf("nil error should redact to empty string")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package gitops
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// MergeAndValidate overlays the plan's SourceConfigPatch onto a copy of the
|
||||
// live source_config and returns the merged JSON — but only after the target
|
||||
// source's own Validate accepts the *merged* result. This is the hard apply
|
||||
// gate (review C4):
|
||||
//
|
||||
// - omitted-field-preserving: keys the file doesn't declare are untouched, so
|
||||
// a partial .tinyforge.yml never clears live config;
|
||||
// - validate-then-commit: a patch that would produce an invalid config (e.g.
|
||||
// deploy_strategy "blue-green" on a source that rejects it, or a bad port)
|
||||
// is refused as a whole — the function never returns a partial/empty config;
|
||||
// - pure: it does not write anything; the caller persists the returned bytes.
|
||||
//
|
||||
// validate is the matching Source.Validate (passed in to keep this package
|
||||
// decoupled from the source plugins).
|
||||
func MergeAndValidate(live json.RawMessage, plan ApplyPlan, validate func(json.RawMessage) error) (json.RawMessage, error) {
|
||||
// Decode the live config into a generic map we can overlay. An empty/null
|
||||
// live config starts from an empty object rather than failing.
|
||||
merged := map[string]any{}
|
||||
if len(live) > 0 {
|
||||
if err := json.Unmarshal(live, &merged); err != nil {
|
||||
return nil, fmt.Errorf("gitops: decode live source_config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay only the declared patch keys — everything else is preserved.
|
||||
for k, v := range plan.SourceConfigPatch {
|
||||
merged[k] = v
|
||||
}
|
||||
|
||||
out, err := json.Marshal(merged)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitops: encode merged source_config: %w", err)
|
||||
}
|
||||
|
||||
if validate != nil {
|
||||
if err := validate(out); err != nil {
|
||||
return nil, fmt.Errorf("gitops: merged config rejected: %w", err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Package gitops implements config-as-code for repo-backed workloads: a
|
||||
// dockerfile/static workload can read a small .tinyforge.yml from its own repo
|
||||
// that declares a subset of its deploy config. The package is deliberately
|
||||
// decoupled from the store and source plugins — it takes a RepoRef (repo
|
||||
// coords + a decrypted token) and a live source_config blob, and returns a
|
||||
// validated merged config + a field-level drift report. It never writes to the
|
||||
// database and never decides to deploy.
|
||||
//
|
||||
// v1 scope (see plans/gitops/PLAN.md): only source_config-resident fields are
|
||||
// overlayable, and the set is source-aware (dockerfile: port/healthcheck/
|
||||
// deploy_strategy; static: deploy_strategy). env/faces live in separate stores
|
||||
// and are intentionally out of v1; the typed ApplyPlan reserves their slots.
|
||||
package gitops
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Spec is the parsed shape of a .tinyforge.yml file (v1).
|
||||
type Spec struct {
|
||||
Version int `yaml:"version"`
|
||||
Deploy DeploySpec `yaml:"deploy"`
|
||||
}
|
||||
|
||||
// DeploySpec carries the overlayable deploy fields. Pointers so an omitted key
|
||||
// is distinguishable from a zero value — only present (non-nil) fields are
|
||||
// applied or drift-compared, so an absent key never clears live config.
|
||||
type DeploySpec struct {
|
||||
Port *int `yaml:"port"`
|
||||
Healthcheck *string `yaml:"healthcheck"`
|
||||
DeployStrategy *string `yaml:"deploy_strategy"`
|
||||
}
|
||||
|
||||
// ParseSpec decodes a .tinyforge.yml body. Unknown keys are rejected
|
||||
// (KnownFields) so a typo or an unsupported field — e.g. someone trying to
|
||||
// declare env/faces in v1 — surfaces as an error instead of being silently
|
||||
// dropped. Only version 1 is accepted.
|
||||
func ParseSpec(data []byte) (Spec, error) {
|
||||
var s Spec
|
||||
dec := yaml.NewDecoder(bytes.NewReader(data))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(&s); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return Spec{}, fmt.Errorf("gitops: empty .tinyforge.yml")
|
||||
}
|
||||
return Spec{}, fmt.Errorf("gitops: parse .tinyforge.yml: %w", err)
|
||||
}
|
||||
if s.Version != 1 {
|
||||
return Spec{}, fmt.Errorf("gitops: unsupported version %d (want 1)", s.Version)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -44,6 +44,9 @@ func (*fakeReporterProvider) ListTree(context.Context, string, string, string) (
|
||||
func (*fakeReporterProvider) DownloadFolder(context.Context, string, string, string, string, string) error {
|
||||
panic("unused")
|
||||
}
|
||||
func (*fakeReporterProvider) DownloadFile(context.Context, string, string, string, string, int64) ([]byte, error) {
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
// Enabled: forwards to the provider with the captured identifiers + target.
|
||||
func TestCommitStatusReporter_Enabled_Calls(t *testing.T) {
|
||||
|
||||
@@ -295,6 +295,15 @@ func (f *GiteaContentFetcher) DownloadFolder(ctx context.Context, owner, repo, b
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadFile fetches a single file's raw bytes via Gitea's raw endpoint
|
||||
// (also serves Forgejo/Gogs), capped at maxBytes. Returns ErrFileNotFound on
|
||||
// a 404 so an absent config file reads as a non-error state.
|
||||
func (f *GiteaContentFetcher) DownloadFile(ctx context.Context, owner, repo, ref, path string, maxBytes int64) ([]byte, error) {
|
||||
p := strings.TrimPrefix(path, "/")
|
||||
fileURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s", f.baseURL, owner, repo, p, ref)
|
||||
return getFileBytes(ctx, f.httpClient, fileURL, maxBytes, f.setAuth)
|
||||
}
|
||||
|
||||
// TestConnection verifies that the repository is accessible.
|
||||
func (f *GiteaContentFetcher) TestConnection(ctx context.Context, owner, repo string) error {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s", f.baseURL, owner, repo)
|
||||
|
||||
@@ -288,6 +288,19 @@ func (g *GitHubProvider) DownloadFolder(ctx context.Context, owner, repo, branch
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadFile fetches a single file's raw bytes via the GitHub contents API
|
||||
// using the raw media type (works for both github.com and GHE), capped at
|
||||
// maxBytes. Returns ErrFileNotFound on a 404.
|
||||
func (g *GitHubProvider) DownloadFile(ctx context.Context, owner, repo, ref, path string, maxBytes int64) ([]byte, error) {
|
||||
p := strings.TrimPrefix(path, "/")
|
||||
fileURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", g.apiBase, owner, repo, p, ref)
|
||||
auth := func(r *http.Request) {
|
||||
g.setAuth(r)
|
||||
r.Header.Set("Accept", "application/vnd.github.raw+json")
|
||||
}
|
||||
return getFileBytes(ctx, g.httpClient, fileURL, maxBytes, auth)
|
||||
}
|
||||
|
||||
func (g *GitHubProvider) doGet(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -273,6 +273,22 @@ func (g *GitLabProvider) DownloadFolder(ctx context.Context, owner, repo, branch
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadFile fetches a single file's raw bytes via GitLab's raw endpoint,
|
||||
// capped at maxBytes. Returns ErrFileNotFound on a 404. owner/repo/ref are
|
||||
// path-escaped; the file path is passed through verbatim to preserve its `/`
|
||||
// separators (a `..` segment is harmless — the bytes are only parsed in
|
||||
// memory, never written to disk, so there is no local-traversal sink).
|
||||
func (g *GitLabProvider) DownloadFile(ctx context.Context, owner, repo, ref, path string, maxBytes int64) ([]byte, error) {
|
||||
p := strings.TrimPrefix(path, "/")
|
||||
fileURL := fmt.Sprintf("%s/%s/%s/-/raw/%s/%s",
|
||||
g.rawBase,
|
||||
url.PathEscape(owner),
|
||||
url.PathEscape(repo),
|
||||
url.PathEscape(ref),
|
||||
p)
|
||||
return getFileBytes(ctx, g.httpClient, fileURL, maxBytes, g.setAuth)
|
||||
}
|
||||
|
||||
func (g *GitLabProvider) doGet(ctx context.Context, apiURL string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package staticsite
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -12,6 +13,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrFileNotFound is returned by GitProvider.DownloadFile when the file is
|
||||
// absent (HTTP 404). Callers use it to distinguish "no file" (a normal,
|
||||
// non-error state for GitOps) from a genuine fetch failure.
|
||||
var ErrFileNotFound = errors.New("file not found")
|
||||
|
||||
// RepoInfo represents a repository returned by the provider's list/search API.
|
||||
type RepoInfo struct {
|
||||
Owner string `json:"owner"`
|
||||
@@ -81,6 +87,12 @@ type GitProvider interface {
|
||||
// DownloadFolder downloads all files from a folder path to a local directory.
|
||||
DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error
|
||||
|
||||
// DownloadFile fetches a single file's bytes from a ref (branch/sha),
|
||||
// capped at maxBytes. Returns ErrFileNotFound on a 404 so callers can
|
||||
// treat an absent file as a non-error state. Used to read a small in-repo
|
||||
// config file (e.g. .tinyforge.yml) without materializing a whole tree.
|
||||
DownloadFile(ctx context.Context, owner, repo, ref, path string, maxBytes int64) ([]byte, error)
|
||||
|
||||
// SetCommitStatus reports a deploy status on a commit. Best-effort;
|
||||
// callers ignore errors beyond logging. targetURL and description are
|
||||
// optional (pass "" to omit); description is truncated to a provider-
|
||||
@@ -206,6 +218,44 @@ func postJSON(ctx context.Context, client *http.Client, url string, body []byte,
|
||||
return nil
|
||||
}
|
||||
|
||||
// getFileBytes GETs fileURL with the caller's auth applied and returns the
|
||||
// body, enforcing a maxBytes cap. Returns ErrFileNotFound on 404; a
|
||||
// status-code-only error otherwise (it must NOT echo the response body — a
|
||||
// hostile/misconfigured provider could reflect the request's auth token back).
|
||||
func getFileBytes(ctx context.Context, client *http.Client, fileURL string, maxBytes int64, authHeader func(r *http.Request)) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
if authHeader != nil {
|
||||
authHeader(req)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return nil, ErrFileNotFound
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read one byte past the cap so an over-size file is detected rather than
|
||||
// silently truncated.
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return nil, fmt.Errorf("file exceeds %d byte cap", maxBytes)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// downloadFileHTTP is a shared helper for downloading a file from a URL.
|
||||
func downloadFileHTTP(ctx context.Context, client *http.Client, url, localPath string, authHeader func(r *http.Request)) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package store
|
||||
|
||||
import "fmt"
|
||||
|
||||
// SetWorkloadGitOps toggles GitOps and sets the config path for a workload.
|
||||
// Targeted column update (not UpdateWorkload) so it never clobbers the
|
||||
// source_config / faces / webhook fields — and conversely, the edit-form save
|
||||
// (UpdateWorkload) never touches these columns.
|
||||
func (s *Store) SetWorkloadGitOps(id string, enabled bool, path string) error {
|
||||
if path == "" {
|
||||
path = ".tinyforge.yml"
|
||||
}
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE workloads SET gitops_enabled=?, gitops_path=?, updated_at=? WHERE id=?`,
|
||||
BoolToInt(enabled), path, Now(), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set workload gitops: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("workload %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordGitOpsSync stamps the commit SHA + timestamp of the last successful
|
||||
// sync, so the UI can show "last synced <when> at <sha>".
|
||||
func (s *Store) RecordGitOpsSync(id, commitSHA, syncedAt string) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE workloads SET gitops_last_sync_at=?, gitops_commit_sha=?, updated_at=? WHERE id=?`,
|
||||
syncedAt, commitSHA, Now(), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record gitops sync: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("workload %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSetWorkloadGitOps_RoundTrip(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
w, err := s.CreateWorkload(Workload{Kind: "plugin", Name: "app", SourceKind: "dockerfile"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkload: %v", err)
|
||||
}
|
||||
|
||||
// Fresh row defaults: GitOps off, default path applied by CreateWorkload.
|
||||
if w.GitOpsEnabled {
|
||||
t.Fatalf("new workload should default to gitops disabled")
|
||||
}
|
||||
if w.GitOpsPath != ".tinyforge.yml" {
|
||||
t.Fatalf("default path = %q, want .tinyforge.yml", w.GitOpsPath)
|
||||
}
|
||||
|
||||
// Enable with a custom path.
|
||||
if err := s.SetWorkloadGitOps(w.ID, true, "deploy/.tinyforge.yml"); err != nil {
|
||||
t.Fatalf("SetWorkloadGitOps: %v", err)
|
||||
}
|
||||
got, err := s.GetWorkloadByID(w.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkloadByID: %v", err)
|
||||
}
|
||||
if !got.GitOpsEnabled || got.GitOpsPath != "deploy/.tinyforge.yml" {
|
||||
t.Fatalf("after enable: enabled=%v path=%q", got.GitOpsEnabled, got.GitOpsPath)
|
||||
}
|
||||
|
||||
// Empty path falls back to the default.
|
||||
if err := s.SetWorkloadGitOps(w.ID, false, ""); err != nil {
|
||||
t.Fatalf("SetWorkloadGitOps disable: %v", err)
|
||||
}
|
||||
got, _ = s.GetWorkloadByID(w.ID)
|
||||
if got.GitOpsEnabled || got.GitOpsPath != ".tinyforge.yml" {
|
||||
t.Fatalf("after disable: enabled=%v path=%q", got.GitOpsEnabled, got.GitOpsPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordGitOpsSync(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
w, _ := s.CreateWorkload(Workload{Kind: "plugin", Name: "app", SourceKind: "static"})
|
||||
|
||||
if err := s.RecordGitOpsSync(w.ID, "abc123", "2026-06-21 10:00:00"); err != nil {
|
||||
t.Fatalf("RecordGitOpsSync: %v", err)
|
||||
}
|
||||
got, _ := s.GetWorkloadByID(w.ID)
|
||||
if got.GitOpsCommitSHA != "abc123" || got.GitOpsLastSyncAt != "2026-06-21 10:00:00" {
|
||||
t.Fatalf("sync not recorded: sha=%q at=%q", got.GitOpsCommitSHA, got.GitOpsLastSyncAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitOpsSetters_NotFound(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if err := s.SetWorkloadGitOps("nope", true, ""); err == nil {
|
||||
t.Fatalf("expected ErrNotFound for missing workload")
|
||||
}
|
||||
if err := s.RecordGitOpsSync("nope", "x", "y"); err == nil {
|
||||
t.Fatalf("expected ErrNotFound for missing workload")
|
||||
}
|
||||
}
|
||||
@@ -394,8 +394,14 @@ type Workload struct {
|
||||
WebhookSecret string `json:"-"` // URL-identifier secret; never serialized
|
||||
WebhookSigningSecret string `json:"-"` // HMAC key; never serialized
|
||||
WebhookRequireSignature bool `json:"webhook_require_signature"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
// GitOps config-as-code (dockerfile/static only). Opt-in: when enabled,
|
||||
// the workload reads its deploy config from GitOpsPath in its own repo.
|
||||
GitOpsEnabled bool `json:"gitops_enabled"`
|
||||
GitOpsPath string `json:"gitops_path"` // repo-relative; default ".tinyforge.yml"
|
||||
GitOpsLastSyncAt string `json:"gitops_last_sync_at"` // "" = never synced
|
||||
GitOpsCommitSHA string `json:"gitops_commit_sha"` // sha applied at last sync
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// WorkloadNotification is one configured outbound notification route for
|
||||
|
||||
@@ -173,6 +173,14 @@ func (s *Store) runMigrations() error {
|
||||
`ALTER TABLE workloads ADD COLUMN trigger_config TEXT NOT NULL DEFAULT '{}'`,
|
||||
`ALTER TABLE workloads ADD COLUMN public_faces TEXT NOT NULL DEFAULT '[]'`,
|
||||
`ALTER TABLE workloads ADD COLUMN parent_workload_id TEXT NOT NULL DEFAULT ''`,
|
||||
// GitOps config-as-code: a dockerfile/static workload may read its
|
||||
// deploy config from a .tinyforge.yml in its own repo. Opt-in per
|
||||
// workload; all four land additively so existing rows default to
|
||||
// "GitOps off" and stay byte-identical.
|
||||
`ALTER TABLE workloads ADD COLUMN gitops_enabled INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE workloads ADD COLUMN gitops_path TEXT NOT NULL DEFAULT '.tinyforge.yml'`,
|
||||
`ALTER TABLE workloads ADD COLUMN gitops_last_sync_at TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE workloads ADD COLUMN gitops_commit_sha TEXT NOT NULL DEFAULT ''`,
|
||||
// Schedule trigger needs a column to remember when it last fired so
|
||||
// the scheduler can compute next-fire windows across restarts.
|
||||
// Empty string = never fired. Pre-trigger-split DBs land the column
|
||||
|
||||
@@ -13,6 +13,7 @@ const workloadColumns = `id, kind, ref_id, name, app_id,
|
||||
public_faces, parent_workload_id,
|
||||
notification_url, notification_secret,
|
||||
webhook_secret, webhook_signing_secret, webhook_require_signature,
|
||||
gitops_enabled, gitops_path, gitops_last_sync_at, gitops_commit_sha,
|
||||
created_at, updated_at`
|
||||
|
||||
func scanWorkload(scanner interface{ Scan(...any) error }) (Workload, error) {
|
||||
@@ -23,6 +24,7 @@ func scanWorkload(scanner interface{ Scan(...any) error }) (Workload, error) {
|
||||
&w.PublicFaces, &w.ParentWorkloadID,
|
||||
&w.NotificationURL, &w.NotificationSecret,
|
||||
&w.WebhookSecret, &w.WebhookSigningSecret, &w.WebhookRequireSignature,
|
||||
&w.GitOpsEnabled, &w.GitOpsPath, &w.GitOpsLastSyncAt, &w.GitOpsCommitSHA,
|
||||
&w.CreatedAt, &w.UpdatedAt,
|
||||
)
|
||||
return w, err
|
||||
@@ -53,14 +55,18 @@ func (s *Store) CreateWorkload(w Workload) (Workload, error) {
|
||||
if w.PublicFaces == "" {
|
||||
w.PublicFaces = "[]"
|
||||
}
|
||||
if w.GitOpsPath == "" {
|
||||
w.GitOpsPath = ".tinyforge.yml"
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO workloads (`+workloadColumns+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
w.ID, w.Kind, w.RefID, w.Name, w.AppID,
|
||||
w.SourceKind, w.SourceConfig, w.TriggerKind, w.TriggerConfig,
|
||||
w.PublicFaces, w.ParentWorkloadID,
|
||||
w.NotificationURL, w.NotificationSecret,
|
||||
w.WebhookSecret, w.WebhookSigningSecret, BoolToInt(w.WebhookRequireSignature),
|
||||
BoolToInt(w.GitOpsEnabled), w.GitOpsPath, w.GitOpsLastSyncAt, w.GitOpsCommitSHA,
|
||||
w.CreatedAt, w.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -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)_
|
||||
@@ -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)_
|
||||
@@ -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)_
|
||||
@@ -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)_
|
||||
@@ -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)_
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Старт",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user