1 Commits

Author SHA1 Message Date
alexei.dolgolyov 80868e0f7a ci: align Gitea CI/CD + Docker with the notify-bridge template
Adopt the proven notify-bridge pipeline pattern and fix deployment bugs.

Workflows:
- build.yml: split into parallel frontend / backend / build-image jobs.
  Run svelte-check + vitest + `go vet ./...` + `go test ./internal/...`
  (tests were never executed in CI). Use buildx with GHA layer cache and
  pin Go to 1.25. Quote the `if:` skip-guard so it is valid YAML.
- release.yml: gate the release on a passing test job, then build & push
  the image, then create the Gitea release LAST so a failed image build
  can no longer leave an orphan release. Use buildx + registry buildcache,
  a hard registry login (a push failure now fails the release), and
  auto-generate a changelog between tags.

Docker:
- Dockerfile: pin golang to 1.25 (matches go.mod's `go 1.25.0`), add
  BuildKit cache mounts for the module + build caches, an OCI source
  label, VOLUME /app/data, and a HEALTHCHECK on /readyz.
- docker-compose.yml: fix the healthcheck — it targeted POST-only
  /api/auth/login (405 -> always unhealthy); now /readyz. Point the image
  name at the Gitea registry tag with build-from-source as the default.
- .dockerignore: exclude ~95 MB of stray binaries, logs, env, and CI/doc
  files from the build context.
2026-06-21 20:51:13 +03:00
91 changed files with 216 additions and 8120 deletions
-27
View File
@@ -1,27 +0,0 @@
# 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.
-20
View File
@@ -17,26 +17,6 @@ Start/restart with: `./scripts/dev-server.sh`
- **"App" = workload with `source_kind !== ''`.** Triggers are first-class bindings (`workload_trigger_bindings`), NOT on the workload row — never gate app lists/counts on `trigger_kind` (it's empty for plugin workloads). Legacy pre-cutover `kind:project/stack/site` rows have an empty `source_kind` and must be excluded everywhere.
- **i18n parity is mandatory** — every key in BOTH `web/src/lib/i18n/{en,ru}.json`. A missing key is NOT a build error (`$t` returns the key string), so verify parity manually.
## Backend
- **Per-workload deploy lock.** Every deploy entrypoint (API deploy, rollback, promote,
generic-hooks, webhook trigger dispatch) funnels through `deployer.DispatchPlugin`, which
holds a per-workload `keyedmutex` lock (`internal/keyedmutex`) for the whole dispatch;
`DispatchTeardown` takes it too. This serializes all container/volume mutation per workload.
Do NOT add a deploy/teardown path that bypasses `DispatchPlugin`. Operations that must run
a deploy *while already holding* the lock (volume-snapshot restore) use
`Deployer.LockWorkload` + `RedeployLocked` (the unlocked dispatch) — calling `DispatchPlugin`
under the held lock would deadlock (Go mutexes are not reentrant). `activeWg` is a global
drain barrier for shutdown, NOT a per-workload lock.
- **Volume snapshot restore** lives in `volsnap.Engine.Restore` (engine-owned, not the API
handler): preflight re-resolves volumes from the workload's CURRENT config (never the
snapshot manifest — that's tamper-influenceable) → lock → stop → extract-to-tmp →
pre-restore snapshot → journal → atomic rename swap → redeploy. A startup
`RecoverInterruptedRestores` sweep replays the journal after a crash; it MUST be wired (with
`SetLifecycle`) before the API serves. The archive extractor treats the tar as untrusted
(zip-slip/type-allowlist/bomb-cap); the endpoint requires an `X-Confirm-Restore: <sid>`
header (CSRF), like the DB restore.
## Build & Test
- Frontend (from `web/`): `npm run check` (svelte-check — expect 0 errors), `npm run build`, `npm run test` (vitest; pure-logic units like `sourceForms.test.ts`).
-10
View File
@@ -419,16 +419,6 @@ func main() {
apiServer.SetLogScanReloader(logScanMgr)
apiServer.SetBackupEngine(backupEngine)
apiServer.SetSnapshotEngine(snapshotEngine)
// Wire the restore lifecycle seam and reconcile any restore interrupted by a
// crash, BEFORE the HTTP server starts serving — so a half-applied restore is
// completed/reverted first and the restore endpoint is never reachable
// without its safety net.
snapshotEngine.SetLifecycle(&restoreLifecycle{dep: dep, docker: dockerClient, store: db})
if n, err := snapshotEngine.RecoverInterruptedRestores(); err != nil {
slog.Warn("snapshots: recover interrupted restores on startup", "error", err)
} else if n > 0 {
slog.Info("snapshots: recovered interrupted restores on startup", "count", n)
}
apiServer.SetDBPath(dbPath)
apiServer.SetBackupSettingsChangedCallback(scheduleAutobackup)
apiServer.SetDNSProvider(dnsProvider)
-70
View File
@@ -1,70 +0,0 @@
package main
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/alexei/tinyforge/internal/deployer"
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// restoreStopTimeoutSeconds bounds the graceful-stop window per container during
// a restore quiesce before Docker kills it.
const restoreStopTimeoutSeconds = 10
// restoreLifecycle adapts the deployer + Docker client + store to the
// volsnap.Lifecycle seam the volume-snapshot restore flow needs. It lives in the
// composition root so the volsnap package stays decoupled from deployer/docker.
type restoreLifecycle struct {
dep *deployer.Deployer
docker *docker.Client
store *store.Store
}
// Lock takes the deployer's per-workload deploy lock so the restore serializes
// against every deploy entrypoint (C1).
func (l *restoreLifecycle) Lock(workloadID string) func() { return l.dep.LockWorkload(workloadID) }
// StopContainers stops every running container for the workload (quiesce before
// the volume swap, C4) and returns the image tag the newest running container
// was on, so the redeploy brings the SAME version back up. ListContainersByWorkload
// returns rows newest-first, so the first running row is the newest.
func (l *restoreLifecycle) StopContainers(ctx context.Context, workloadID string) (string, error) {
rows, err := l.store.ListContainersByWorkload(workloadID)
if err != nil {
return "", fmt.Errorf("list containers: %w", err)
}
tag := ""
for _, c := range rows {
if c.State != "running" || c.ContainerID == "" {
continue
}
if tag == "" && c.ImageTag != "" {
tag = c.ImageTag // newest running container's tag
}
if err := l.docker.StopContainer(ctx, c.ContainerID, restoreStopTimeoutSeconds); err != nil {
return "", fmt.Errorf("stop container %s: %w", c.ContainerID, err)
}
if err := l.store.UpdateContainerState(c.ID, "stopped"); err != nil {
slog.Warn("restore: mark container stopped", "container", c.ID, "error", err)
}
}
return tag, nil
}
// Redeploy re-dispatches the workload via the deployer's unlocked path (the
// restore already holds the per-workload lock). reference pins the image tag.
func (l *restoreLifecycle) Redeploy(ctx context.Context, w store.Workload, reference string) error {
intent := plugin.DeploymentIntent{
Reason: "restore",
Reference: reference,
Metadata: map[string]string{"note": "redeploy after volume snapshot restore"},
TriggeredAt: time.Now().UTC(),
TriggeredBy: "restore",
}
return l.dep.RedeployLocked(ctx, plugin.WorkloadFromStore(w), intent)
}
-77
View File
@@ -1,77 +0,0 @@
# 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.
-223
View File
@@ -1,223 +0,0 @@
# Deploy History + One-Click Rollback — Implementation Plan
**Status:** planned (review incorporated) · **Feature rank:** #1 · **Date:** 2026-06-19
## Review findings incorporated (adversarial pass)
- **BLOCKER — never persist the raw deploy error** (it can carry registry-auth bytes /
compose stdout — see `compose.go` SECURITY comment + `workloads_plugin.go:198`).
`deploy_history.error` only ever gets a **fixed generic marker**
(`"deploy failed (see server logs)"`) on failure; the raw error goes to `slog` only.
`capDeployStatus(err.Error())` is rejected.
- **BLOCKER — don't double-count metrics.** `DispatchPlugin` already calls
`metrics.DeploysTotal.Inc(...)`; recording slots into the **existing** outcome block,
not a re-added metrics line.
- **FIX — no runtime-state store getter exists.** static/dockerfile `LastCommitSHA`
lives in `containers.extra_json` on a deterministic-ID row
(`GetContainerByID(w.ID+":site")` / `+":dockerfile"`, decode `ExtraJSON`). Moot for
Phase-1 rollback (image-only) but the resolver must use this, not a fictional getter.
- **FIX — cascade is distrusted here.** `DeleteWorkload` explicitly deletes containers
rather than relying on the FK. Match that: add `DELETE FROM deploy_history WHERE
workload_id = ?` inside the `DeleteWorkload` transaction, and make the cascade test a
hard gate.
- **FIX — keep recording off the hot path's tail.** `DispatchPlugin` runs synchronously
on the request goroutine; the INSERT is cheap but `PruneDeployHistory` runs in a
goroutine. Draining-rejected attempts (beginDispatch fail) record nothing — correct,
a never-run deploy must not appear as a rollback target.
- **FIX — pagination:** use `parseLimit(raw, 50, 200)` (not the unclamped
`listWorkloadEvents` style); parse `offset` separately, clamp negatives to 0.
## Problem
Tinyforge has *failure* rollback (a failed deploy unwinds its own new container —
[image.go:258](../../internal/workload/plugin/source/image/image.go)), but **no way to
revert a *successful* deploy to a prior version.** Blue-green's `enforceMaxInstances`
deletes the old container rows after cutover, so once `v3` replaces `v2` there is no
record of `v2` and nothing to roll back to. The only "history" is free-text
`event_log` rows (`"deployed"`) — not structured, not version-pinned, not replayable.
This is the single most-requested capability for any deploy tool, and the plumbing is
90% there: every deploy flows through one choke point, and the manual-deploy endpoint
already accepts a `reference` override.
## Key architectural facts (verified against current code)
- **Single dispatch choke point:** `Deployer.DispatchPlugin(ctx, w, intent)` in
[internal/deployer/dispatch.go](../../internal/deployer/dispatch.go) routes *every*
source kind and already computes a success/failure `outcome`. This is where history
is recorded.
- **`intent.Reference` is the version handle:** image source resolves
`tag := intent.Reference` (falling back to `DefaultTag`/`latest`). The manual deploy
endpoint ([workloads_plugin.go](../../internal/api/workloads_plugin.go)) already accepts
`{reference, note}` and builds a `manual` intent. **Rollback = deploy with a pinned
reference + a distinct reason.**
- **Effective vs requested reference:** for a *manual* image deploy `intent.Reference`
is often `""` (means `DefaultTag`). The *effective* deployed tag is written onto the
freshest container row (`store.Container.ImageTag`). For static/dockerfile the
effective version is `runtime_state.LastCommitSHA`, resolved inside the source.
- **Built-from-source sources don't honor a SHA reference on Deploy** — static and
dockerfile clone `cfg.Branch` HEAD and capture `latestSHA`; they cannot yet check out
an arbitrary commit. So **SHA-pinned rollback for them needs a source change (later
phase).** Image-tag rollback works today.
- **Migration pattern:** additive statements in `runMigrations()` /
`workloadTables` in [store.go](../../internal/store/store.go); workload-scoped tables
use `REFERENCES workloads(id) ON DELETE CASCADE`. Per-table CRUD lives in its own
`internal/store/<table>.go`, model in `models.go`.
- **Idempotency note:** the image source's same-tag short-circuit returns *before* it
arms its `EmitDeployEvent` defer, so a no-op deploy emits no timeline event. History
recorded at `DispatchPlugin` will still log it as a `success` attempt — acceptable
(history = ledger of attempts), but called out so the divergence is intentional.
## Scope
### Phase 1 (this plan)
1. Persistent, structured **deploy-history ledger** for **all** source kinds (success
*and* failure) — powers an audit timeline and the rollback action.
2. **One-click rollback** for the **image** source (redeploy a pinned tag).
3. Read-only history panel on `/apps/[id]`; rollback button shown only for entries that
are `success` + have a non-empty reference + a rollback-capable source kind.
### Explicitly out of scope (future phases, table already supports them)
- SHA-pinned rebuild rollback for static/dockerfile (needs source checkout-by-commit).
- Config-snapshot rollback for compose (no artifact reference).
- Promotion (dev→staging→prod) — separate feature, will reuse this ledger.
## Data model
New table `deploy_history` (added to `workloadTables` in `runMigrations`):
```sql
CREATE TABLE IF NOT EXISTS deploy_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workload_id TEXT NOT NULL REFERENCES workloads(id) ON DELETE CASCADE,
source_kind TEXT NOT NULL DEFAULT '',
reference TEXT NOT NULL DEFAULT '', -- effective artifact: image tag | commit sha | ''
reason TEXT NOT NULL DEFAULT '', -- manual|registry-push|git-push|cron|rollback|promote
triggered_by TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT '', -- success | failure
error TEXT NOT NULL DEFAULT '', -- truncated, secret-free
started_at TEXT NOT NULL DEFAULT '',
finished_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_deploy_history_workload
ON deploy_history(workload_id, id DESC);
```
**Why a dedicated table (not `event_log`):** structured + queryable, version-pinned,
carries the replayable `reference`, and its retention is independent of the human event
feed. `event_log` stays the free-text timeline; `deploy_history` is the version ledger.
Go model in `models.go` (`DeployHistoryEntry`, mirrors `MetricAlertRule` style).
## Backend changes
### 1. Store — `internal/store/deploy_history.go` (new) + `models.go` + `store.go`
- `DeployHistoryEntry` struct.
- `InsertDeployHistory(e DeployHistoryEntry) (DeployHistoryEntry, error)`.
- `ListDeployHistory(workloadID string, limit, offset int) ([]DeployHistoryEntry, error)`
— ordered `id DESC`; default/clamped limit (e.g. 50, max 200) via existing `parseLimit`
conventions at the API layer.
- `GetDeployHistory(id int64) (DeployHistoryEntry, error)` — for rollback lookup;
`ErrNotFound` on miss.
- `PruneDeployHistory(workloadID string, keep int) error` — keep newest `keep` per
workload (mirror the stats-prune pattern). Called best-effort after insert.
- Migration: append `CREATE TABLE` + index to `workloadTables`.
- Table test `deploy_history_test.go` (insert/list/get/prune, cascade-on-workload-delete).
### 2. Deployer — record at the choke point (`internal/deployer/dispatch.go`)
Wrap the existing `src.Deploy(...)` call:
```go
started := store.Now()
err = src.Deploy(ctx, d.PluginDeps(), w, intent)
outcome := "success"; if err != nil { outcome = "failure" }
metrics.DeploysTotal.Inc(w.SourceKind, outcome)
d.recordDeployHistory(w, intent, outcome, err, started) // best-effort, never blocks
return err
```
- `recordDeployHistory` resolves the **effective reference** and inserts a row.
Best-effort: a store failure is logged, never propagated (same contract as
`maybeBackupBeforeDeploy` and `EmitDeployEvent`).
- **Effective-reference resolver** (`internal/deployer/deploy_ref.go`, unit-tested):
1. start from `intent.Reference`;
2. `image`: read newest `ListContainersByWorkload(w.ID)` row (by `CreatedAt`), prefer
its `ImageTag` when non-empty — captures the `DefaultTag`/`latest` resolution;
3. `static`/`dockerfile`: when still empty, read persisted runtime state
`LastCommitSHA` (verify exact store getter during impl);
4. `compose`/unknown: leave as-is (may be `""`).
- **Error sanitization:** reuse the `capDeployStatus` cap (256 runes) idea — store a
short, secret-free `error`. The raw error keeps going to `slog` only. (The deploy
error already carries a generic client message; the wrapped detail must not be
persisted verbatim because it can echo registry-auth / compose-stdout bytes — same
caller contract documented on `EmitDeployEvent`.)
- Recording does **not** run for `DispatchReconcile` (periodic, not a deploy) or
`DispatchTeardown`.
### 3. API — `internal/api/deploy_history.go` (new) + `router.go`
- `GET /api/workloads/{id}/deploys?limit=&offset=``listWorkloadDeploys` (read; any
authenticated user — mirrors `listWorkloadEvents`). Uses `parseLimit`.
- `POST /api/workloads/{id}/rollback``rollbackWorkload` (`auth.AdminOnly`), body
`{deploy_id}`:
1. load workload (404 if missing; 400 if `source_kind == ""`);
2. `GetDeployHistory(deploy_id)`; 404 if missing, 400 if its `workload_id` ≠ path id
(no cross-workload replay);
3. guard: `outcome == "success"`, `reference != ""`, and `source_kind` is
rollback-capable (`image` in Phase 1) → else 400 with a clear message;
4. build `manual`-shaped intent `{Reason: "rollback", Reference: row.reference,
Metadata: {"note": "rollback to " + row.reference, "rollback_of": <id>},
TriggeredBy: actor}`;
5. `deployer.DispatchPlugin(...)`; 202 on accept (same shape as deploy).
- Register both routes inside the existing `r.Route("/workloads/{id}", …)` block in
[router.go](../../internal/api/router.go), next to `/deploy` and `/events`.
- A `RollbackCapable(sourceKind) bool` helper (single source of truth, shared with the
list response so the frontend can render the button state without hardcoding kinds).
- The list response includes a per-entry `rollbackable bool` computed server-side.
## Frontend changes (`web/`)
- **`DeployHistoryPanel.svelte`** (new, in `lib/components/`): table of entries —
short reference, reason badge, `outcome` `StatusBadge` (ok/bad), `triggered_by`,
relative time. For `rollbackable` rows a **Roll back** button → `ConfirmDialog`
("Roll back <name> to <reference>?") → `POST …/rollback {deploy_id}` → `Toast` +
refresh history and container state. Loading via `Skeleton`; `EmptyState` when no
rows. Reuses existing components only.
- Mount the panel on **`/apps/[id]`** alongside the activity timeline (it is the
*structured, actionable* sibling of the free-text timeline).
- **i18n:** add keys under a `deployHistory.*` namespace to **both**
`web/src/lib/i18n/en.json` and `ru.json` (parity is mandatory and not a build error —
verify manually per CLAUDE.md).
- API client: add `listDeploys(id, params)` and `rollback(id, deployId)` to the existing
workload API module.
## Testing
- **Store:** `deploy_history_test.go` — insert/list ordering, get, prune-keeps-newest,
cascade delete with workload.
- **Deployer:** extend `deployer` tests — `DispatchPlugin` writes one `success` row and
one `failure` row (with sanitized error); reconcile/teardown write none. Resolver unit
test (`deploy_ref_test.go`) for the image read-back + empty fallbacks.
- **API:** rollback guards — cross-workload id → 400; non-success/empty-ref/
non-image → 400; happy path → 202 and a `rollback`-reason history row appears.
- **Web:** keep it light (the panel is mostly presentational); a `sourceForms`-style
pure-logic unit only if a non-trivial helper emerges.
- Gates: `go build ./...`, `go vet ./internal/...`, `go test ./internal/...`,
`cd web && npm run check && npm run test`, then `./scripts/dev-server.sh`.
## Risks / mitigations
- **Recording must never break a deploy** → best-effort insert, errors only logged
(matches existing `EmitDeployEvent` / pre-deploy-backup contracts).
- **Secret leakage via `error`** → store only a capped, generic reason; raw error to
`slog` only.
- **Unbounded growth** → `PruneDeployHistory` keeps newest N per workload.
- **Rollback to a vanished image tag** → the image source's `PullImage` fails and its
own failure-rollback leaves the live container untouched; the rollback attempt is
recorded as `failure`. No special handling needed.
- **No-op rollback (target already running, `MaxInstances>1`)** → image short-circuit
returns `nil`; recorded as `success`. Acceptable.
## Rollout
Single PR. Additive migration (no destructive DDL). No settings changes. Backward
compatible: existing workloads simply start accumulating history on their next deploy.
-98
View File
@@ -1,98 +0,0 @@
# Configurable Deploy Strategy — Implementation Plan
**Status:** planned (workflow-designed + adversarially reviewed) · **Feature rank:** #3 · **Date:** 2026-06-19
## Problem
`image` does zero-downtime blue-green; `dockerfile` and `static` **stop+remove the old
container before creating the new one** on every redeploy (a real downtime window).
`compose` is stack-managed. Give operators a per-workload **deploy strategy** and bring
blue-green to the built-from-source sources.
## Design (chosen via a 3-proposal judge panel; "minimal" won, 9/10)
Per-source `deploy_strategy` field **inside each source's `SourceConfig` JSON blob**
**no new DB column, no migration, no `dispatch.go` change**. Values: `""` (back-compat
default), `"recreate"`, `"blue-green"`. Round-trips opaquely through
`plugin.WorkloadFromStore` / `SourceConfigOf[Config]`; validated in each source's existing
`Validate(json.RawMessage)` (runs on create **and** update at `workloads_plugin.go:291`).
**Per-source default (load-bearing):** a single shared default would silently flip
image's native blue-green to recreate, so each source has a tiny `effectiveStrategy`:
- `image`: `""`**blue-green**
- `dockerfile` / `static` / `compose`: `""`**recreate**
The blue-green branch for dockerfile/static uses a **transient two-container / single-row
swap** so `state.go`, `teardown.go`, and `reconcile.go` (which read one deterministic row)
stay **untouched** — the lowest-risk way to ship gap-free cutover.
## Review fixes folded in (adversarial pass)
1. **BLOCKER — ordering / crash-safety.** Blue-green order MUST be: create+start green →
readiness-gate green → `ConfigureRoute(green)` (upsert) → **`saveState(green)` into the
single row FIRST** → only THEN stop+remove blue (captured before saveState). The single
row must always point at a running container; reaping blue before persisting green
orphans green and makes the reconciler flip a healthy workload to `failed`.
2. **Unique green name is load-bearing.** dockerfile/static names are deterministic
(`tf-build-<name>-<id>` / `dw-site-<name>-<id>`) and double as the proxy `forwardHost`.
The green container needs a genuinely unique name (`…-<ms-hex>`, lifted from
`image.buildContainerName`) set in **both** `cc.Name` **and** the `ConfigureRoute`
`forwardHost`.
3. **Readiness, not liveness.** Before cutover, use `deps.Health.Check(ctx, http://<green>:
<port><healthcheck>)` when a healthcheck path is configured (dockerfile has `Healthcheck`);
fall back to the existing 3s liveness gate otherwise. Don't advertise "zero-downtime" on
the liveness-only path.
4. **Pure upsert.** Drop the pre-`DeleteRoute`; call only `ConfigureRoute` (upsert-by-FQDN
for NPM repoints in place; Traefik is label-driven). **Traefik caveat:** blue+green
briefly carry the same host-rule labels → momentary dual-serve; documented as a
Traefik-only phase-1 limitation (NPM, the common case, is gap-free).
5. **deno + storage → force recreate.** When `static` has `StorageEnabled && mode==deno`,
`effectiveStrategy` forces `recreate` — blue-green would mount the same RW named volume
into both containers (a concurrent-writer window recreate never had).
6. **image `recreate` gets its own shape.** Don't reuse `rollbackNew` (assumes blue
survives). image `recreate` = reap existing running containers **after** a successful
pull, then create green; on green failure the downtime is the accepted recreate
contract (logged distinctly, not as a non-disruptive rollback).
7. Image tag `:latest` shared by blue/green is **safe** — containers pin image-by-id at
create (no fix needed).
## Files (phase 1, backend-only)
- **NEW** `internal/workload/plugin/strategy.go` — `StrategyRecreate`/`StrategyBlueGreen`
consts, `ValidateStrategy(value string, allowBlueGreen bool) error`,
`BuildGreenName(name, id string, ts time.Time) string` (lifted unique-suffix scheme).
`+ strategy_test.go`.
- `image/image.go` — `DeployStrategy` on Config; `effectiveStrategy` (""→blue-green);
Validate; honor `recreate` (reap-after-pull + dedicated log).
- `dockerfile/dockerfile.go` (Config + Validate) + `dockerfile/deploy.go` (blue-green
branch, fixes 14) + `dockerfile/deploy_test.go`.
- `static/static.go` (Config + Validate) + `static/deploy.go` (blue-green branch + deno
gate, fixes 15) + `static/deploy_test.go`.
- `compose/compose.go` — Config field + Validate rejects `blue-green` (allowBlueGreen=false)
+ test.
## Phase 1 backward-compat lock (mandatory, unit-tested)
`ValidateStrategy("", …)` returns nil; every `effectiveStrategy("")` returns the source's
historical default. Existing rows (no `deploy_strategy` key) decode `""` → today's exact
behavior, byte-for-byte.
## Later phases (deferred)
- **P2 (UI):** `sourceForms.ts` seed/serialize + `/apps/new` & `/apps/[id]` select +
en/ru i18n (hide blue-green for compose).
- **P3 (harden):** mandatory HTTP readiness probe for static; connection draining before
blue removal; Traefik label suppression at cutover.
- **P4 (architecture):** extract image's proven sequence into a shared
`plugin.DeploySingleContainer`; migrate dockerfile/static to the multi-row model
(crash-safe mid-swap; unlocks `MaxInstances>1`).
- **P5:** true `rolling` (needs a backend-pool primitive on `proxy.Provider`) + compose
green-project blue-green.
## Test plan
Table-driven, TDD: `ValidateStrategy` accept/reject matrix (incl. `allowBlueGreen=false`,
reserved `rolling` rejected, `""` accepted); per-source `effectiveStrategy` defaults +
deno-storage→recreate; dockerfile/static blue-green deploy tests asserting (a) green named
≠ deterministic name, (b) collision teardown NOT run, (c) `ConfigureRoute` called with
`forwardHost==green` and NO preceding `DeleteRoute`, (d) `saveState(green)` **before**
`RemoveContainer(blue)`, (e) single row ends at green; failure path: green fails gate →
green removed, blue + route untouched; compose rejects blue-green. Gates: `go build`,
`go vet`, `go test ./internal/...`, `npm run check/test`, `./scripts/dev-server.sh`.
-84
View File
@@ -1,84 +0,0 @@
# Per-Workload Metrics Graph — Implementation Plan
**Status:** planned · **Feature rank:** #2 · **Date:** 2026-06-19
## Problem
Stats are collected per container (`container_stats_samples`, CPU/mem/net/disk) and
charted **globally** on the dashboard (`SystemResourcesCard` + `ResourceChart`), but
`/apps/[id]` shows only live snapshots — there's no per-workload "is my app leaking
memory / pegging CPU over the last few hours" view. This is a daily question and the
data already exists; we just need a per-workload query + a panel that reuses the chart.
## Verified facts
- `ContainerStatsSample.OwnerID` == the **container row id** (`containers.id`), confirmed
by `lookupInstanceName``GetContainerByID(sm.OwnerID)` in
[stats_history.go](../../internal/api/stats_history.go). `OwnerType` ∈ {instance, site}.
- Each sample's `ts` is that container's own Docker-stats `Timestamp.Unix()`
([collector.go](../../internal/stats/collector.go)) — NOT one shared tick stamp. In a
multi-container tick the per-second truncation usually collapses them to the same
integer `ts`, so per-`ts` aggregation works; a ±1s split at a second boundary is
cosmetic for a trend line. (Reviewer-corrected.) The handler 404s on an unknown
workload id but returns `[]` for a known workload with no samples yet.
- `ResourceChart.svelte` takes a fully-built `EChartsOption` from the parent; the parent
owns series/axes (see `SystemResourcesCard`). Reads stay available when Docker is down
(samples come from SQLite, not the daemon).
- Per-workload reads (`/events`, `/runtime-state`) are open to any authenticated user;
this endpoint follows suit (no `AdminOnly`).
## Backend
1. **Store**`ListContainerStatsSamplesByWorkload(workloadID string, sinceTS int64)`:
```sql
SELECT cs.container_id, cs.owner_type, cs.owner_id, cs.ts,
cs.cpu_percent, cs.memory_usage, cs.memory_limit,
cs.network_rx, cs.network_tx, cs.block_read, cs.block_write
FROM container_stats_samples cs
JOIN containers c ON c.id = cs.owner_id
WHERE c.workload_id = ? AND cs.ts >= ?
ORDER BY cs.ts ASC
```
Returns `[]ContainerStatsSample`.
2. **API** — `getWorkloadStatsHistory` (GET `/api/workloads/{id}/stats/history?window=`):
reuse `parseWindow`/`sinceTimestamp`; aggregate samples **per ts** into a compact
series so multi-container workloads (compose) sum correctly:
```go
type workloadStatsPoint struct {
TS int64 `json:"ts"`
CPUPercent float64 `json:"cpu_percent"` // sum across the workload's containers
MemoryUsage int64 `json:"memory_usage"` // sum bytes
MemoryLimit int64 `json:"memory_limit"` // max (effective ceiling)
}
```
Always returns `[]` (never 503) — empty when stats are disabled / Docker was down /
the workload is new. Register in the `/workloads/{id}` route block.
3. **Tests** — store: join scopes to the right workload (A's samples ≠ B's); API:
per-ts aggregation sums two containers at the same tick.
## Frontend
4. **api.ts** — `WorkloadStatsPoint` type + `fetchWorkloadStatsHistory(id, window, signal)`.
5. **`WorkloadMetricsPanel.svelte`** — window selector (30m / 2h / 6h), fetch + 15s poll
(mirror `SystemResourcesCard`), build an `EChartsOption` with **two series**: CPU %
on the left axis, Memory (MiB) on the right axis (absolute bytes, because
`memory_limit` is often 0/unlimited so a % would divide by zero). `EmptyState`/ hint
when there are no samples. Render via `ResourceChart`. Mount on `/apps/[id]` near the
deploy-history panel.
6. **i18n** — `apps.detail.metrics.*` in both en.json and ru.json (parity mandatory).
## Risks / mitigations
- **Docker down / stats disabled** → empty series, friendly hint (no error). SQLite read
path is independent of the daemon.
- **memory_limit = 0 (unlimited)** → plot absolute MiB, not %, to avoid div-by-zero.
- **Sparse sampling** → chart shows whatever ticks exist; window selector lets the user
widen. No interpolation.
- **Auth** → read-only, any authenticated user (consistent with other per-workload reads).
## Rollout
Single change set, additive, no migration. Reuses the existing `echarts` dependency and
`ResourceChart` component.
+3 -3
View File
@@ -10,11 +10,8 @@ require (
github.com/moby/moby/api v1.54.0
github.com/moby/moby/client v0.3.0
github.com/robfig/cron/v3 v3.0.1
github.com/yuin/goldmark v1.8.2
golang.org/x/crypto v0.28.0
golang.org/x/oauth2 v0.25.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.33.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.34.5
)
@@ -37,12 +34,15 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/yuin/goldmark v1.8.2 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/tools v0.22.0 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
+2
View File
@@ -85,6 +85,8 @@ golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-151
View File
@@ -1,151 +0,0 @@
package api
import (
"errors"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// parseOffset parses a pagination offset, clamping anything invalid or
// negative to 0. parseLimit (secrets.go) handles the limit half.
func parseOffset(raw string) int {
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return 0
}
return n
}
// rollbackCapableKinds is the single source of truth for which source kinds
// support reference-pinned redeploy. The image source resolves
// intent.Reference as the tag, so replaying a prior tag is a real rollback.
// static/dockerfile clone branch HEAD and cannot yet check out an arbitrary
// commit (a later phase); compose has no single artifact handle.
var rollbackCapableKinds = map[string]bool{"image": true}
// RollbackCapable reports whether a source kind supports one-click rollback.
// Used by both the list response (per-row `rollbackable` flag) and the
// rollback guard so the UI and the server never disagree.
func RollbackCapable(sourceKind string) bool { return rollbackCapableKinds[sourceKind] }
// listWorkloadDeploys handles GET /api/workloads/{id}/deploys. Read-only,
// open to any authenticated user (mirrors the per-workload events feed).
// Returns the structured deploy ledger newest-first with a server-computed
// `rollbackable` flag per row.
func (s *Server) listWorkloadDeploys(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
respondError(w, http.StatusBadRequest, "workload id is required")
return
}
q := r.URL.Query()
limit := parseLimit(q.Get("limit"), 50, 200)
offset := parseOffset(q.Get("offset"))
rows, err := s.store.ListDeployHistory(id, limit, offset)
if err != nil {
slog.Error("failed to list deploy history", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list deploy history")
return
}
for i := range rows {
rows[i].Rollbackable = rows[i].Outcome == "success" &&
rows[i].Reference != "" &&
RollbackCapable(rows[i].SourceKind)
}
respondJSON(w, http.StatusOK, rows)
}
// rollbackWorkload handles POST /api/workloads/{id}/rollback. Admin-only
// (same gate as /deploy). Body: {"deploy_id": <id>}. It resolves the pinned
// reference from a prior successful, rollback-capable ledger row belonging
// to this workload and replays it as a `rollback`-reason deploy.
func (s *Server) rollbackWorkload(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
row, err := s.store.GetWorkloadByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "get workload")
return
}
if row.SourceKind == "" {
respondError(w, http.StatusBadRequest, "workload has no source_kind; cannot roll back")
return
}
var body struct {
DeployID int64 `json:"deploy_id"`
}
if !decodeJSONStrict(w, r, &body) {
return
}
if body.DeployID <= 0 {
respondError(w, http.StatusBadRequest, "deploy_id is required")
return
}
entry, err := s.store.GetDeployHistory(body.DeployID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "deploy history entry")
return
}
respondError(w, http.StatusInternalServerError, "get deploy history")
return
}
// No cross-workload replay: the entry must belong to the path workload.
if entry.WorkloadID != id {
respondError(w, http.StatusBadRequest, "deploy entry does not belong to this workload")
return
}
if entry.Outcome != "success" {
respondError(w, http.StatusBadRequest, "cannot roll back to a failed deploy")
return
}
if entry.Reference == "" || !RollbackCapable(row.SourceKind) {
respondError(w, http.StatusBadRequest, "this deploy is not rollback-capable")
return
}
actor := "manual"
if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.Username != "" {
actor = claims.Username
}
intent := plugin.DeploymentIntent{
Reason: "rollback",
Reference: entry.Reference,
Metadata: map[string]string{
"note": "rollback to " + entry.Reference,
"rollback_of": strconv.FormatInt(entry.ID, 10),
},
TriggeredAt: time.Now().UTC(),
TriggeredBy: actor,
}
if err := s.deployer.DispatchPlugin(r.Context(), toPluginWorkload(row), intent); err != nil {
// Raw error stays in the server log; client gets a generic message
// (the wrapped error can carry registry-auth bytes).
slog.Warn("rollback dispatch failed", "workload", id, "actor", actor,
"reference", entry.Reference, "error", err)
respondError(w, http.StatusInternalServerError, "rollback failed; see server logs")
return
}
respondJSON(w, http.StatusAccepted, map[string]any{
"workload_id": id,
"reference": entry.Reference,
"rollback_of": entry.ID,
"triggered_by": actor,
})
}
-126
View File
@@ -1,126 +0,0 @@
package api
import (
"net/http"
"testing"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// createImageWorkload creates an image-source workload through the API so
// source_kind is persisted exactly as production does, returning its id.
func createImageWorkload(t *testing.T, e *apiTestEnv, name string) string {
t.Helper()
resp := e.do(t, http.MethodPost, "/api/workloads", pluginWorkloadRequest{
Name: name, SourceKind: "image", SourceConfig: validImageSourceConfig(),
})
if resp.StatusCode != http.StatusCreated {
_ = decodeEnvelope(t, resp, nil)
t.Fatalf("create workload: status %d", resp.StatusCode)
}
var got plugin.Workload
if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
t.Fatalf("create workload envelope error: %q", errMsg)
}
return got.ID
}
func TestListWorkloadDeploys_ComputesRollbackable(t *testing.T) {
e := newAPITestEnv(t)
id := createImageWorkload(t, e, "app")
// success + reference + image => rollbackable
e.store.InsertDeployHistory(store.DeployHistoryEntry{
WorkloadID: id, SourceKind: "image", Reference: "v1", Outcome: "success",
})
// failure => not rollbackable
e.store.InsertDeployHistory(store.DeployHistoryEntry{
WorkloadID: id, SourceKind: "image", Reference: "v2", Outcome: "failure",
})
// success but empty reference => not rollbackable
e.store.InsertDeployHistory(store.DeployHistoryEntry{
WorkloadID: id, SourceKind: "image", Reference: "", Outcome: "success",
})
resp := e.do(t, http.MethodGet, "/api/workloads/"+id+"/deploys", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var rows []store.DeployHistoryEntry
if errMsg := decodeEnvelope(t, resp, &rows); errMsg != "" {
t.Fatalf("envelope error: %q", errMsg)
}
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
// Newest-first: empty-ref success, failure, then v1 success.
if !rows[2].Rollbackable {
t.Fatalf("v1 success row should be rollbackable: %+v", rows[2])
}
if rows[1].Rollbackable || rows[0].Rollbackable {
t.Fatalf("failure / empty-ref rows must not be rollbackable")
}
}
func TestRollback_HappyPath_DispatchesRollbackIntent(t *testing.T) {
e := newAPITestEnv(t)
id := createImageWorkload(t, e, "app")
entry, _ := e.store.InsertDeployHistory(store.DeployHistoryEntry{
WorkloadID: id, SourceKind: "image", Reference: "v1", Outcome: "success",
})
before := e.dispatcher.deployCount.Load()
resp := e.do(t, http.MethodPost, "/api/workloads/"+id+"/rollback",
map[string]any{"deploy_id": entry.ID})
if resp.StatusCode != http.StatusAccepted {
errMsg := decodeEnvelope(t, resp, nil)
t.Fatalf("status = %d, want 202 (err=%q)", resp.StatusCode, errMsg)
}
if got := e.dispatcher.deployCount.Load(); got != before+1 {
t.Fatalf("expected one dispatch, got delta %d", got-before)
}
intent := e.dispatcher.lastIntent.Load()
if intent == nil || intent.Reason != "rollback" || intent.Reference != "v1" {
t.Fatalf("expected rollback intent for v1, got %+v", intent)
}
}
func TestRollback_Guards(t *testing.T) {
e := newAPITestEnv(t)
imageID := createImageWorkload(t, e, "img")
otherID := createImageWorkload(t, e, "other")
success, _ := e.store.InsertDeployHistory(store.DeployHistoryEntry{
WorkloadID: imageID, SourceKind: "image", Reference: "v1", Outcome: "success",
})
failed, _ := e.store.InsertDeployHistory(store.DeployHistoryEntry{
WorkloadID: imageID, SourceKind: "image", Reference: "v2", Outcome: "failure",
})
otherWL, _ := e.store.InsertDeployHistory(store.DeployHistoryEntry{
WorkloadID: otherID, SourceKind: "image", Reference: "v1", Outcome: "success",
})
cases := []struct {
name string
workload string
body any
wantCode int
}{
{"missing deploy_id", imageID, map[string]any{}, http.StatusBadRequest},
{"zero deploy_id", imageID, map[string]any{"deploy_id": 0}, http.StatusBadRequest},
{"unknown deploy_id", imageID, map[string]any{"deploy_id": 999999}, http.StatusNotFound},
{"unknown workload", "nope", map[string]any{"deploy_id": success.ID}, http.StatusNotFound},
{"failed deploy", imageID, map[string]any{"deploy_id": failed.ID}, http.StatusBadRequest},
{"cross-workload entry", imageID, map[string]any{"deploy_id": otherWL.ID}, http.StatusBadRequest},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
resp := e.do(t, http.MethodPost, "/api/workloads/"+c.workload+"/rollback", c.body)
if resp.StatusCode != c.wantCode {
errMsg := decodeEnvelope(t, resp, nil)
t.Fatalf("status = %d, want %d (err=%q)", resp.StatusCode, c.wantCode, errMsg)
}
})
}
}
-364
View File
@@ -1,364 +0,0 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/tinyforge/internal/gitops"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// keyedMutex is a lazily-populated per-key lock. Used to serialize a critical
// section per workload id (the GitOps sync) without a global lock.
type keyedMutex struct {
mu sync.Mutex
m map[string]*sync.Mutex
}
// lock acquires the mutex for key and returns its unlock func.
func (k *keyedMutex) lock(key string) func() {
k.mu.Lock()
if k.m == nil {
k.m = make(map[string]*sync.Mutex)
}
mu, ok := k.m[key]
if !ok {
mu = &sync.Mutex{}
k.m[key] = mu
}
k.mu.Unlock()
mu.Lock()
return mu.Unlock
}
// gitOpsStatusResponse is the single rich payload the GitOps panel reads — it
// folds the file preview, parsed status, and drift into one response so the UI
// makes a single call (no separate /drift round-trip).
type gitOpsStatusResponse struct {
Eligible bool `json:"eligible"` // source kind supports GitOps
Enabled bool `json:"enabled"` // opt-in flag on the workload
Path string `json:"path"` // repo-relative config path
Status string `json:"status"` // disabled|ok|no_file|fetch_failed|invalid
Raw string `json:"raw"` // the .tinyforge.yml text, when present
Message string `json:"message"` // token-redacted detail for non-ok
CommitSHA string `json:"commit_sha"` // ref the file was read at
LastSyncAt string `json:"last_sync_at"` // last successful sync ("" = never)
Drift []gitops.DriftEntry `json:"drift"` // declared fields that differ from live
DriftCount int `json:"drift_count"`
// ManagedFields lists every source_config key the repo overlay declares
// (not just the drifting ones) so the UI can lock exactly those fields on
// the edit form. Populated only when the file parsed (status ok).
ManagedFields []string `json:"managed_fields"`
}
// getWorkloadGitOps handles GET /api/workloads/{id}/gitops. Read-only; open to
// any authenticated user. When GitOps is enabled it fetches the repo's
// .tinyforge.yml live and computes drift against the stored source_config.
func (s *Server) getWorkloadGitOps(w http.ResponseWriter, r *http.Request) {
row, ok := s.loadWorkload(w, chi.URLParam(r, "id"))
if !ok {
return
}
resp := gitOpsStatusResponse{
Eligible: gitops.IsEligibleSource(row.SourceKind),
Enabled: row.GitOpsEnabled,
Path: row.GitOpsPath,
Status: "disabled",
LastSyncAt: row.GitOpsLastSyncAt,
CommitSHA: row.GitOpsCommitSHA,
Drift: []gitops.DriftEntry{},
}
if resp.Path == "" {
resp.Path = ".tinyforge.yml"
}
// Only reach out to the repo when GitOps is actually on.
if row.GitOpsEnabled && resp.Eligible {
ref, err := s.gitOpsRepoRef(row)
if err != nil {
// Decoding/decrypt failure: surface as fetch_failed, never the raw
// error (it can carry the token / config bytes).
slog.Warn("gitops: build repo ref", "workload", row.ID, "error", err)
resp.Status = string(gitops.StatusFetchFailed)
resp.Message = "could not read repo settings for this workload"
respondJSON(w, http.StatusOK, resp)
return
}
res := gitops.Fetch(r.Context(), ref)
resp.Status = string(res.Status)
resp.CommitSHA = firstNonEmpty(res.CommitSHA, row.GitOpsCommitSHA)
resp.Message = res.Message
if len(res.Raw) > 0 {
resp.Raw = string(res.Raw)
}
if res.Status == gitops.StatusOK {
drift, derr := gitops.Drift(res.Spec, json.RawMessage(row.SourceConfig), row.SourceKind)
if derr != nil {
slog.Warn("gitops: drift", "workload", row.ID, "error", derr)
} else if drift != nil {
resp.Drift = drift
}
resp.DriftCount = len(resp.Drift)
resp.ManagedFields = planFields(gitops.BuildPlan(res.Spec, row.SourceKind))
}
}
respondJSON(w, http.StatusOK, resp)
}
// setWorkloadGitOps handles PUT /api/workloads/{id}/gitops. Admin-only.
// Body: {"enabled": bool, "path": string}. Enabling is refused for source
// kinds that aren't git-backed; the path is validated against traversal.
func (s *Server) setWorkloadGitOps(w http.ResponseWriter, r *http.Request) {
row, ok := s.loadWorkload(w, chi.URLParam(r, "id"))
if !ok {
return
}
var body struct {
Enabled bool `json:"enabled"`
Path string `json:"path"`
}
if !decodeJSONStrict(w, r, &body) {
return
}
if body.Enabled && !gitops.IsEligibleSource(row.SourceKind) {
respondError(w, http.StatusBadRequest,
"GitOps is only available for dockerfile and static sources")
return
}
path := strings.TrimSpace(body.Path)
if path != "" && !validGitOpsPath(path) {
respondError(w, http.StatusBadRequest,
"invalid path: must be a repo-relative file (no \"..\", no leading slash)")
return
}
if err := s.store.SetWorkloadGitOps(row.ID, body.Enabled, path); err != nil {
slog.Error("gitops: set", "workload", row.ID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to update GitOps settings")
return
}
if path == "" {
path = ".tinyforge.yml"
}
respondJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled, "path": path})
}
// syncWorkloadGitOps handles POST /api/workloads/{id}/gitops/sync. Admin-only.
// It fetches the repo's .tinyforge.yml, merges the declared overlay onto the
// live source_config (validate-then-commit), persists it, and records the sync.
// Explicit action only — there is no auto-apply on deploy in v1.
func (s *Server) syncWorkloadGitOps(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
respondError(w, http.StatusBadRequest, "workload id is required")
return
}
// Serialize the whole read→merge→write per workload so two concurrent
// syncs can't clobber each other (review S5). Load the row INSIDE the lock
// so each sync merges off the latest persisted config.
unlock := s.gitopsSync.lock(id)
defer unlock()
row, ok := s.loadWorkload(w, id)
if !ok {
return
}
if !gitops.IsEligibleSource(row.SourceKind) {
respondError(w, http.StatusBadRequest,
"GitOps is only available for dockerfile and static sources")
return
}
if !row.GitOpsEnabled {
respondError(w, http.StatusBadRequest, "enable GitOps for this workload first")
return
}
ref, err := s.gitOpsRepoRef(row)
if err != nil {
slog.Warn("gitops: build repo ref", "workload", row.ID, "error", err)
respondError(w, http.StatusBadGateway, "could not read repo settings for this workload")
return
}
res := gitops.Fetch(r.Context(), ref)
switch res.Status {
case gitops.StatusOK:
// proceed
case gitops.StatusNoFile:
respondError(w, http.StatusBadRequest, "no "+ref.Path+" found on branch "+ref.Branch)
return
case gitops.StatusInvalid:
respondError(w, http.StatusBadRequest, "invalid "+ref.Path+": "+res.Message)
return
default: // fetch_failed
slog.Warn("gitops: fetch failed", "workload", row.ID, "detail", res.Message)
respondError(w, http.StatusBadGateway, "could not fetch "+ref.Path+" from the repo")
return
}
src, err := plugin.GetSource(row.SourceKind)
if err != nil {
respondError(w, http.StatusInternalServerError, "unknown source kind")
return
}
plan := gitops.BuildPlan(res.Spec, row.SourceKind)
merged, err := gitops.MergeAndValidate(json.RawMessage(row.SourceConfig), plan, src.Validate)
if err != nil {
// The merged config failed the source's own Validate — the file
// declares something this workload can't accept. Safe to surface (it
// describes config shape, not secrets).
respondError(w, http.StatusBadRequest, "the repo config was rejected: "+err.Error())
return
}
// Persist via a full-row update off the row we loaded (single read →
// merge → write). A per-workload sync lock that closes the remaining
// edit-vs-sync window is a Phase 4 hardening item.
row.SourceConfig = string(merged)
if err := s.store.UpdateWorkload(row); err != nil {
slog.Error("gitops: persist merged config", "workload", row.ID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to apply the repo config")
return
}
if err := s.store.RecordGitOpsSync(row.ID, res.CommitSHA, store.Now()); err != nil {
slog.Warn("gitops: record sync", "workload", row.ID, "error", err)
}
actor := "manual"
if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.Username != "" {
actor = claims.Username
}
appliedFields := planFields(plan)
s.recordGitOpsEvent(row.ID, res.CommitSHA, actor, appliedFields)
respondJSON(w, http.StatusOK, map[string]any{
"status": "applied",
"commit_sha": res.CommitSHA,
"applied_fields": appliedFields,
"triggered_by": actor,
})
}
// loadWorkload fetches a workload by id, writing the appropriate error response
// and returning ok=false on miss. Shared by the GitOps handlers.
func (s *Server) loadWorkload(w http.ResponseWriter, id string) (store.Workload, bool) {
if id == "" {
respondError(w, http.StatusBadRequest, "workload id is required")
return store.Workload{}, false
}
row, err := s.store.GetWorkloadByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return store.Workload{}, false
}
respondError(w, http.StatusInternalServerError, "get workload")
return store.Workload{}, false
}
return row, true
}
// gitOpsRepoRef builds a gitops.RepoRef from a workload's source_config: it
// decodes the common git coords (identical keys across dockerfile + static)
// and decrypts the access token. The gitops package stays decoupled from the
// store/crypto by taking the plain coords.
func (s *Server) gitOpsRepoRef(row store.Workload) (gitops.RepoRef, error) {
var c struct {
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
AccessToken string `json:"access_token"`
}
if err := json.Unmarshal([]byte(row.SourceConfig), &c); err != nil {
return gitops.RepoRef{}, fmt.Errorf("decode source_config: %w", err)
}
token := ""
if c.AccessToken != "" {
dec, err := crypto.Decrypt(s.encKey, c.AccessToken)
if err != nil {
return gitops.RepoRef{}, fmt.Errorf("decrypt access token: %w", err)
}
token = dec
}
branch := c.Branch
if branch == "" {
branch = "main"
}
path := row.GitOpsPath
if path == "" {
path = ".tinyforge.yml"
}
return gitops.RepoRef{
Provider: c.Provider,
BaseURL: c.BaseURL,
Owner: c.RepoOwner,
Repo: c.RepoName,
Branch: branch,
Token: token,
Path: path,
}, nil
}
// recordGitOpsEvent writes a sync to the per-workload event log — the audit
// trail for a config-only sync, kept OUT of deploy_history (which the rollback
// feature treats as redeployable rows).
func (s *Server) recordGitOpsEvent(workloadID, sha, actor string, fields []string) {
meta, _ := json.Marshal(map[string]any{"commit_sha": sha, "by": actor, "fields": fields})
if _, err := s.store.InsertEvent(store.EventLog{
Source: "gitops",
WorkloadID: workloadID,
Severity: "info",
Message: "GitOps config synced from repo",
Metadata: string(meta),
}); err != nil {
slog.Warn("gitops: record event", "workload", workloadID, "error", err)
}
}
// validGitOpsPath rejects absolute paths, traversal, and URL-significant or
// control characters so a stored config path can't escape the repo (review M2)
// or smuggle a query/fragment onto the provider's raw-file URL (review LOW-1).
func validGitOpsPath(p string) bool {
if p == "" || len(p) > 255 {
return false
}
if strings.HasPrefix(p, "/") || strings.HasPrefix(p, "\\") {
return false
}
if strings.Contains(p, "..") {
return false
}
for _, r := range p {
if r < 0x20 || r == 0x7f || r == '?' || r == '#' || r == ' ' || r == '\\' {
return false
}
}
return true
}
// planFields returns the source_config keys an apply plan touches.
func planFields(plan gitops.ApplyPlan) []string {
fields := make([]string, 0, len(plan.SourceConfigPatch))
for k := range plan.SourceConfigPatch {
fields = append(fields, k)
}
return fields
}
-48
View File
@@ -1,48 +0,0 @@
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 }
-31
View File
@@ -14,7 +14,6 @@ import (
"github.com/alexei/tinyforge/internal/dns"
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/events"
"github.com/alexei/tinyforge/internal/keyedmutex"
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/npm"
"github.com/alexei/tinyforge/internal/proxy"
@@ -53,15 +52,6 @@ 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
// volRestoreInFlight is a per-workload single-flight guard for volume
// snapshot restore: a concurrent restore of the same workload is rejected
// fast with 409 (TryLock) rather than queuing behind the deployer lock.
volRestoreInFlight keyedmutex.Mutex
dnsProviderMu sync.RWMutex
dnsProvider dns.Provider
onDNSProviderChanged DNSProviderChangedFunc
@@ -346,39 +336,18 @@ func (s *Server) Router() chi.Router {
r.With(auth.AdminOnly).Post("/start", s.startPluginWorkload)
r.With(auth.AdminOnly).Delete("/", s.deletePluginWorkload)
// Deploy ledger + rollback. The history feed is read-only
// (any authenticated user); rollback is a redeploy, so it is
// admin-gated like /deploy.
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.
r.With(auth.AdminOnly).Get("/snapshots", s.listWorkloadSnapshots)
r.With(auth.AdminOnly).Get("/snapshotable", s.getWorkloadSnapshotable)
r.With(auth.AdminOnly).Post("/snapshots", s.createWorkloadSnapshot)
// Restore overwrites live volume data and restarts the app — the
// most destructive workload action. Admin-gated + X-Confirm-Restore
// header (CSRF) + per-workload single-flight, mirroring DB restore.
r.With(auth.AdminOnly).Post("/snapshots/{sid}/restore", s.restoreWorkloadSnapshot)
// Runtime view: per-source persisted state + storage usage.
// Read-only; safe for any authenticated user.
r.Get("/runtime-state", s.getWorkloadRuntimeState)
r.Get("/storage", s.getWorkloadStorage)
// Per-workload metrics history (CPU/memory time-series),
// aggregated across the workload's containers. Read-only.
r.Get("/stats/history", s.getWorkloadStatsHistory)
// Per-workload activity / deploy timeline (read-only). Scoped
// to this workload's event-log rows; the global feed lives at
// /events/log.
-73
View File
@@ -1,15 +1,12 @@
package api
import (
"errors"
"log/slog"
"net/http"
"sort"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/tinyforge/internal/store"
)
@@ -88,76 +85,6 @@ func (s *Server) getSystemStatsHistory(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, samples)
}
// workloadStatsPoint is one aggregated time bucket for a workload's metrics
// graph: every container the workload owns is summed at each timestamp so a
// multi-container (compose) workload reads as a single line. MemoryLimit is
// the max across containers — the effective ceiling — though the UI plots
// absolute MiB because the limit is often 0 (unlimited).
type workloadStatsPoint struct {
TS int64 `json:"ts"`
CPUPercent float64 `json:"cpu_percent"`
MemoryUsage int64 `json:"memory_usage"`
MemoryLimit int64 `json:"memory_limit"`
}
// getWorkloadStatsHistory handles GET /api/workloads/{id}/stats/history?window=1h.
// Read-only and open to any authenticated user (mirrors the per-workload
// events/runtime-state feeds). Always returns a (possibly empty) array — never
// 503 — because samples come from SQLite, which is available even when the
// Docker daemon is down or stats collection is disabled. Unknown workload id
// 404s; a known workload with no samples yet returns [].
func (s *Server) getWorkloadStatsHistory(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
respondError(w, http.StatusBadRequest, "workload id is required")
return
}
if _, err := s.store.GetWorkloadByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "get workload")
return
}
samples, err := s.store.ListContainerStatsSamplesByWorkload(id, sinceTimestamp(parseWindow(r)))
if err != nil {
slog.Error("failed to list workload stats samples", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list samples")
return
}
respondJSON(w, http.StatusOK, aggregateWorkloadStats(samples))
}
// aggregateWorkloadStats folds per-container samples into one series keyed by
// timestamp: CPU% and memory usage are summed across the workload's containers,
// memory limit takes the max. Samples arrive ts-ascending, so the output keeps
// that order without an extra sort.
func aggregateWorkloadStats(samples []store.ContainerStatsSample) []workloadStatsPoint {
points := make([]workloadStatsPoint, 0)
idx := make(map[int64]int) // ts → index in points
for _, sm := range samples {
if i, ok := idx[sm.TS]; ok {
points[i].CPUPercent += sm.CPUPercent
points[i].MemoryUsage += sm.MemoryUsage
if sm.MemoryLimit > points[i].MemoryLimit {
points[i].MemoryLimit = sm.MemoryLimit
}
continue
}
idx[sm.TS] = len(points)
points = append(points, workloadStatsPoint{
TS: sm.TS,
CPUPercent: sm.CPUPercent,
MemoryUsage: sm.MemoryUsage,
MemoryLimit: sm.MemoryLimit,
})
}
return points
}
// listTopContainers handles GET /api/system/stats/top?limit=5&by=cpu.
// Returns the top-N most recent samples across containers, sorted by CPU or
// memory. Container IDs are stripped for non-admins so a low-privilege viewer
-64
View File
@@ -1,64 +0,0 @@
package api
import (
"testing"
"github.com/alexei/tinyforge/internal/store"
)
func TestAggregateWorkloadStats_SumsPerTimestamp(t *testing.T) {
// Two containers reporting at the same two ticks → summed per ts.
samples := []store.ContainerStatsSample{
{TS: 100, CPUPercent: 10, MemoryUsage: 1000, MemoryLimit: 4000},
{TS: 100, CPUPercent: 5, MemoryUsage: 500, MemoryLimit: 8000},
{TS: 200, CPUPercent: 20, MemoryUsage: 2000, MemoryLimit: 4000},
}
pts := aggregateWorkloadStats(samples)
if len(pts) != 2 {
t.Fatalf("expected 2 buckets, got %d", len(pts))
}
if pts[0].TS != 100 || pts[0].CPUPercent != 15 || pts[0].MemoryUsage != 1500 {
t.Fatalf("ts=100 bucket wrong: %+v", pts[0])
}
// Memory limit takes the max across containers.
if pts[0].MemoryLimit != 8000 {
t.Fatalf("expected max memory limit 8000, got %d", pts[0].MemoryLimit)
}
if pts[1].TS != 200 || pts[1].CPUPercent != 20 {
t.Fatalf("ts=200 bucket wrong: %+v", pts[1])
}
}
func TestAggregateWorkloadStats_Empty(t *testing.T) {
pts := aggregateWorkloadStats(nil)
if pts == nil {
t.Fatal("expected non-nil empty slice for clean JSON []")
}
if len(pts) != 0 {
t.Fatalf("expected 0 points, got %d", len(pts))
}
}
func TestWorkloadStatsHistory_UnknownWorkload404(t *testing.T) {
e := newAPITestEnv(t)
resp := e.do(t, "GET", "/api/workloads/nope/stats/history", nil)
if resp.StatusCode != 404 {
t.Fatalf("expected 404 for unknown workload, got %d", resp.StatusCode)
}
}
func TestWorkloadStatsHistory_KnownWorkloadEmpty(t *testing.T) {
e := newAPITestEnv(t)
id := createImageWorkload(t, e, "metrics-app")
resp := e.do(t, "GET", "/api/workloads/"+id+"/stats/history", nil)
if resp.StatusCode != 200 {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var pts []workloadStatsPoint
if errMsg := decodeEnvelope(t, resp, &pts); errMsg != "" {
t.Fatalf("envelope error: %q", errMsg)
}
if len(pts) != 0 {
t.Fatalf("expected empty series for app with no samples, got %d", len(pts))
}
}
-66
View File
@@ -140,72 +140,6 @@ func (s *Server) deleteSnapshot(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// restoreWorkloadSnapshot handles POST /api/workloads/{id}/snapshots/{sid}/restore.
//
// This is the most destructive workload action: it overwrites the app's live
// volume data with the snapshot and recreates its containers. It is guarded like
// the DB restore — admin-only, an X-Confirm-Restore header that must echo the
// snapshot id (defeats CSRF form/img posts, which can't set custom headers), and
// a per-workload single-flight so a double-click can't stack two restores. All
// the dangerous lock/stop/swap/redeploy logic lives in Engine.Restore; this
// handler only validates and delegates.
func (s *Server) restoreWorkloadSnapshot(w http.ResponseWriter, r *http.Request) {
if s.snapshotEngine == nil {
respondError(w, http.StatusServiceUnavailable, "snapshot engine not initialized")
return
}
id := chi.URLParam(r, "id")
sid := chi.URLParam(r, "sid")
if confirm := r.Header.Get("X-Confirm-Restore"); confirm != sid {
respondError(w, http.StatusBadRequest,
"missing or mismatched X-Confirm-Restore header (must equal snapshot id)")
return
}
// Up-front validation for precise client errors (Engine.Restore re-checks
// ownership + source kind under the lock).
snap, err := s.snapshotEngine.Get(sid)
if err != nil {
respondError(w, http.StatusNotFound, "snapshot not found")
return
}
if snap.WorkloadID != id {
respondError(w, http.StatusBadRequest, "snapshot does not belong to this workload")
return
}
row, ok := s.loadWorkload(w, id)
if !ok {
return
}
if row.SourceKind != "image" {
respondError(w, http.StatusBadRequest, "restore is only supported for image-source workloads")
return
}
// Per-workload single-flight: reject a concurrent restore of the SAME
// workload with 409 rather than queuing it behind the deployer lock.
release, ok := s.volRestoreInFlight.TryLock(id)
if !ok {
respondError(w, http.StatusConflict, "a restore is already in progress for this workload")
return
}
defer release()
if err := s.snapshotEngine.Restore(r.Context(), sid, id); err != nil {
// Raw error (which can carry resolved host paths) stays in the log; the
// client gets a generic message.
slog.Error("snapshots: restore failed", "workload", id, "snapshot", sid, "error", err)
respondError(w, http.StatusInternalServerError, "restore failed; see server logs")
return
}
respondJSON(w, http.StatusOK, map[string]any{
"status": "restored",
"workload_id": id,
"snapshot_id": sid,
})
}
// downloadSnapshot handles GET /api/snapshots/{sid}/download, streaming the
// tar.gz archive. The resolved path is containment-checked against the
// snapshot directory.
+1 -208
View File
@@ -1,15 +1,12 @@
package api
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/tinyforge/internal/store"
@@ -56,211 +53,7 @@ func newSnapshotEnv(t *testing.T) (*apiTestEnv, string) {
t.Fatalf("update settings: %v", err)
}
return &apiTestEnv{srv: httpsrv, store: st, dispatcher: dispatcher, adminToken: tok.Token, encKey: encKey, snapEngine: snapEng}, baseVol
}
// doRestore issues an authenticated restore POST, optionally setting the
// X-Confirm-Restore header (pass confirm="" to omit it).
func (e *apiTestEnv) doRestore(t *testing.T, workloadID, sid, confirm string) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodPost,
e.srv.URL+"/api/workloads/"+workloadID+"/snapshots/"+sid+"/restore", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+e.adminToken)
if confirm != "" {
req.Header.Set("X-Confirm-Restore", confirm)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
return resp
}
// okLifecycle is a no-op volsnap.Lifecycle for HTTP-layer happy-path tests; the
// deep restore behavior is covered by the volsnap engine tests.
type okLifecycle struct{ tag string }
func (l *okLifecycle) Lock(string) func() { return func() {} }
func (l *okLifecycle) StopContainers(context.Context, string) (string, error) { return l.tag, nil }
func (l *okLifecycle) Redeploy(context.Context, store.Workload, string) error { return nil }
func TestRestoreSnapshot_RequiresConfirmHeader(t *testing.T) {
e, _ := newSnapshotEnv(t)
w, _ := e.store.CreateWorkload(store.Workload{Name: "a", Kind: "project", SourceKind: "image", SourceConfig: `{"image":"x","port":80}`})
snap, _ := e.store.CreateVolumeSnapshot(store.VolumeSnapshot{WorkloadID: w.ID, Filename: "f.tar.gz", Manifest: "[]"})
// Missing header → 400.
resp := e.doRestore(t, w.ID, snap.ID, "")
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("missing header status = %d, want 400", resp.StatusCode)
}
resp.Body.Close()
// Mismatched header → 400.
resp = e.doRestore(t, w.ID, snap.ID, "not-the-sid")
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("mismatched header status = %d, want 400", resp.StatusCode)
}
resp.Body.Close()
}
func TestRestoreSnapshot_WrongWorkload(t *testing.T) {
e, _ := newSnapshotEnv(t)
w, _ := e.store.CreateWorkload(store.Workload{Name: "a", Kind: "project", SourceKind: "image", SourceConfig: `{"image":"x","port":80}`})
snap, _ := e.store.CreateVolumeSnapshot(store.VolumeSnapshot{WorkloadID: w.ID, Filename: "f.tar.gz", Manifest: "[]"})
resp := e.doRestore(t, "some-other-workload", snap.ID, snap.ID)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("cross-workload restore status = %d, want 400", resp.StatusCode)
}
resp.Body.Close()
}
func TestRestoreSnapshot_NonImageWorkload(t *testing.T) {
e, _ := newSnapshotEnv(t)
w, _ := e.store.CreateWorkload(store.Workload{Name: "site", Kind: "project", SourceKind: "static", SourceConfig: `{}`})
snap, _ := e.store.CreateVolumeSnapshot(store.VolumeSnapshot{WorkloadID: w.ID, Filename: "f.tar.gz", Manifest: "[]"})
resp := e.doRestore(t, w.ID, snap.ID, snap.ID)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("non-image restore status = %d, want 400", resp.StatusCode)
}
resp.Body.Close()
}
func TestRestoreSnapshot_NotFound(t *testing.T) {
e, _ := newSnapshotEnv(t)
w, _ := e.store.CreateWorkload(store.Workload{Name: "a", Kind: "project", SourceKind: "image", SourceConfig: `{"image":"x","port":80}`})
resp := e.doRestore(t, w.ID, "missing-sid", "missing-sid")
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unknown snapshot status = %d, want 404", resp.StatusCode)
}
resp.Body.Close()
}
func TestRestoreSnapshot_HappyPath(t *testing.T) {
e, baseVol := newSnapshotEnv(t)
e.snapEngine.SetLifecycle(&okLifecycle{tag: "v1"})
w, err := e.store.CreateWorkload(store.Workload{
Name: "data-app", Kind: "project", SourceKind: "image",
SourceConfig: `{"image":"reg/app","port":80,"volumes":[{"source":"data","target":"/data","scope":"project"}]}`,
})
if err != nil {
t.Fatalf("create workload: %v", err)
}
if _, err := e.store.SetWorkloadVolume(store.WorkloadVolume{WorkloadID: w.ID, Target: "/data", Source: "data", Scope: "project"}); err != nil {
t.Fatalf("set volume: %v", err)
}
id8 := w.ID
if len(id8) > 8 {
id8 = id8[:8]
}
hostDir := filepath.Join(baseVol, "data-app-"+id8, "data")
if err := os.MkdirAll(hostDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(hostDir, "payload.txt"), []byte("ORIGINAL"), 0o644); err != nil {
t.Fatal(err)
}
settings, _ := e.store.GetSettings()
snap, err := e.snapEngine.Create(w, settings, "base")
if err != nil {
t.Fatalf("create snapshot: %v", err)
}
// Drift the live data, then restore.
if err := os.WriteFile(filepath.Join(hostDir, "payload.txt"), []byte("CHANGED"), 0o644); err != nil {
t.Fatal(err)
}
resp := e.doRestore(t, w.ID, snap.ID, snap.ID)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("restore status = %d, body=%s", resp.StatusCode, body)
}
resp.Body.Close()
if got, _ := os.ReadFile(filepath.Join(hostDir, "payload.txt")); string(got) != "ORIGINAL" {
t.Errorf("payload.txt = %q, want ORIGINAL (restored)", got)
}
}
// blockingLifecycle blocks in Lock until released, signaling when entered — so
// a test can hold one restore in-flight and assert a second is rejected 409.
type blockingLifecycle struct {
entered chan struct{}
release chan struct{}
once sync.Once
}
func (l *blockingLifecycle) Lock(string) func() {
l.once.Do(func() { close(l.entered) })
<-l.release
return func() {}
}
func (l *blockingLifecycle) StopContainers(context.Context, string) (string, error) { return "", nil }
func (l *blockingLifecycle) Redeploy(context.Context, store.Workload, string) error { return nil }
// seedRestorable creates an image workload with a project volume + live data and
// a captured snapshot, returning the workload and snapshot ids.
func seedRestorable(t *testing.T, e *apiTestEnv, baseVol string) (workloadID, snapshotID string) {
t.Helper()
w, err := e.store.CreateWorkload(store.Workload{
Name: "sf-app", Kind: "project", SourceKind: "image",
SourceConfig: `{"image":"reg/app","port":80,"volumes":[{"source":"data","target":"/data","scope":"project"}]}`,
})
if err != nil {
t.Fatalf("create workload: %v", err)
}
id8 := w.ID
if len(id8) > 8 {
id8 = id8[:8]
}
hostDir := filepath.Join(baseVol, "sf-app-"+id8, "data")
if err := os.MkdirAll(hostDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(hostDir, "f.txt"), []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
settings, _ := e.store.GetSettings()
snap, err := e.snapEngine.Create(w, settings, "base")
if err != nil {
t.Fatalf("create snapshot: %v", err)
}
return w.ID, snap.ID
}
func TestRestoreSnapshot_SingleFlight409(t *testing.T) {
e, baseVol := newSnapshotEnv(t)
wid, sid := seedRestorable(t, e, baseVol)
bl := &blockingLifecycle{entered: make(chan struct{}), release: make(chan struct{})}
e.snapEngine.SetLifecycle(bl)
// Restore #1: passes validation, takes the single-flight, then blocks inside
// the engine's Lock.
go func() {
resp := e.doRestore(t, wid, sid, sid)
resp.Body.Close()
}()
select {
case <-bl.entered:
case <-time.After(3 * time.Second):
t.Fatal("first restore never reached the lifecycle lock")
}
// Restore #2 for the same workload must be rejected fast with 409.
resp := e.doRestore(t, wid, sid, sid)
got := resp.StatusCode
resp.Body.Close()
close(bl.release) // let #1 finish
if got != http.StatusConflict {
t.Fatalf("concurrent restore status = %d, want 409", got)
}
return &apiTestEnv{srv: httpsrv, store: st, dispatcher: dispatcher, adminToken: tok.Token, encKey: encKey}, baseVol
}
func TestVolumeSnapshots_EndToEnd(t *testing.T) {
+36 -5
View File
@@ -2,17 +2,48 @@ package api
import (
"encoding/json"
"log/slog"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// toPluginWorkload is a local alias for the shared plugin.WorkloadFromStore
// converter, kept so the api package's many call sites read tersely and pair
// visually with fromPluginWorkload below. The conversion logic lives in the
// plugin package (the single home shared with reconciler / webhook).
// toPluginWorkload converts a persisted store.Workload row into the value
// shape that Source / Trigger plugins consume. Lives in the api package
// (rather than store or plugin) to keep plugin's dependency graph free of
// store imports and avoid the cycle that would form otherwise.
//
// SourceConfig / TriggerConfig are passed through as raw JSON; the matching
// plugin decodes them with plugin.SourceConfigOf[T] / TriggerConfigOf[T].
// PublicFaces is decoded eagerly because every consumer needs the parsed
// slice (proxy registration, UI, validation).
func toPluginWorkload(w store.Workload) plugin.Workload {
return plugin.WorkloadFromStore(w)
var faces []plugin.PublicFace
if w.PublicFaces != "" {
if err := json.Unmarshal([]byte(w.PublicFaces), &faces); err != nil {
slog.Warn("workload: invalid public_faces JSON, treating as empty",
"workload", w.ID, "error", err)
faces = nil
}
}
return plugin.Workload{
ID: w.ID,
Name: w.Name,
GroupID: w.AppID,
ParentWorkloadID: w.ParentWorkloadID,
SourceKind: w.SourceKind,
SourceConfig: json.RawMessage(w.SourceConfig),
TriggerKind: w.TriggerKind,
TriggerConfig: json.RawMessage(w.TriggerConfig),
PublicFaces: faces,
NotificationURL: w.NotificationURL,
NotificationSecret: w.NotificationSecret,
WebhookSecret: w.WebhookSecret,
WebhookSigningSecret: w.WebhookSigningSecret,
WebhookRequireSignature: w.WebhookRequireSignature,
CreatedAt: w.CreatedAt,
UpdatedAt: w.UpdatedAt,
}
}
// fromPluginWorkload is the symmetric direction — used by /api/workloads
+3 -5
View File
@@ -15,7 +15,6 @@ import (
"github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/volsnap"
"github.com/alexei/tinyforge/internal/webhook"
"github.com/alexei/tinyforge/internal/workload/plugin"
@@ -76,7 +75,6 @@ type apiTestEnv struct {
dispatcher *fakeAPIDispatcher
adminToken string
encKey [32]byte
snapEngine *volsnap.Engine // set by newSnapshotEnv; nil otherwise
}
func (e *apiTestEnv) close() { e.srv.Close() }
@@ -672,9 +670,9 @@ func TestGetWorkloadChain_ParentSelfChildren(t *testing.T) {
resp := e.do(t, http.MethodGet, "/api/workloads/"+parentID+"/chain", nil)
var got struct {
Parent *map[string]any `json:"parent"`
Self map[string]any `json:"self"`
Children []map[string]any `json:"children"`
Parent *map[string]any `json:"parent"`
Self map[string]any `json:"self"`
Children []map[string]any `json:"children"`
}
if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
t.Fatalf("envelope error: %q", errMsg)
-76
View File
@@ -1,76 +0,0 @@
package deployer
import (
"log/slog"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// deployHistoryKeepPerWorkload bounds the ledger per workload. Newer rows
// always have larger ids, so pruning keeps the most recent N — enough for a
// useful rollback menu without unbounded growth on hot workloads.
const deployHistoryKeepPerWorkload = 50
// recordDeployHistory appends one ledger row for a completed dispatch.
//
// Best-effort: a store failure is logged and swallowed — recording must
// never turn a successful deploy into a failed request (same contract as
// EmitDeployEvent and the pre-deploy backup). The raw deploy error is NEVER
// persisted: it can carry registry-auth bytes or compose stdout, so only a
// fixed, secret-free marker lands in the row (raw detail goes to slog at the
// call site). Called only from DispatchPlugin — reconcile/teardown ticks are
// not deploys and must not appear in the ledger.
func (d *Deployer) recordDeployHistory(w plugin.Workload, intent plugin.DeploymentIntent, outcome string, deployErr error, startedAt string) {
if d.store == nil {
return
}
entry := store.DeployHistoryEntry{
WorkloadID: w.ID,
SourceKind: w.SourceKind,
Reference: d.effectiveReference(w, intent),
Reason: intent.Reason,
TriggeredBy: intent.TriggeredBy,
Note: intent.Metadata["note"], // nil map read is safe
Outcome: outcome,
StartedAt: startedAt,
FinishedAt: store.Now(),
}
if deployErr != nil {
entry.Error = "deploy failed (see server logs)"
}
if _, err := d.store.InsertDeployHistory(entry); err != nil {
slog.Warn("deploy history: insert failed", "workload", w.ID, "error", err)
return
}
// Cheap indexed DELETE — negligible next to a multi-second deploy, so it
// stays inline rather than on an untracked goroutine that could outrace
// graceful shutdown's db.Close().
if err := d.store.PruneDeployHistory(w.ID, deployHistoryKeepPerWorkload); err != nil {
slog.Warn("deploy history: prune failed", "workload", w.ID, "error", err)
}
}
// effectiveReference resolves the artifact handle to record (and, for
// rollback-capable sources, to replay). It starts from the trigger-supplied
// intent.Reference and, for the image source, prefers the tag actually
// written onto the freshest container row — capturing the DefaultTag /
// "latest" resolution the source performs when intent.Reference is empty
// (e.g. a manual deploy with no override). ListContainersByWorkload returns
// newest-first, so rows[0] is the just-deployed container on success.
//
// For static/dockerfile the git trigger already supplies the commit SHA as
// intent.Reference; a manual deploy of those may record an empty reference
// (acceptable — they are not rollback-capable in this phase). compose has no
// single artifact handle.
func (d *Deployer) effectiveReference(w plugin.Workload, intent plugin.DeploymentIntent) string {
ref := intent.Reference
if w.SourceKind == "image" && d.store != nil {
if rows, err := d.store.ListContainersByWorkload(w.ID); err == nil && len(rows) > 0 {
if tag := rows[0].ImageTag; tag != "" {
ref = tag
}
}
}
return ref
}
-26
View File
@@ -5,7 +5,6 @@
package deployer
import (
"context"
"fmt"
"log/slog"
"sync"
@@ -15,11 +14,9 @@ import (
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/events"
"github.com/alexei/tinyforge/internal/health"
"github.com/alexei/tinyforge/internal/keyedmutex"
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/proxy"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// Deployer owns the dependency bundle each Source plugin needs at deploy
@@ -52,29 +49,6 @@ type Deployer struct {
drainMu sync.Mutex
activeWg sync.WaitGroup
shuttingDown atomic.Bool
// workloadLocks serializes deploy-class operations per workload id so two
// concurrent mutators of the same workload (a manual deploy, a webhook/
// trigger dispatch, a rollback, a promote, OR a volume-snapshot restore)
// can never interleave their container/volume changes. Every deploy
// entrypoint funnels through DispatchPlugin, so locking there gates them
// all at one choke point. This is the per-workload lock activeWg is NOT
// (activeWg is a global drain barrier for graceful shutdown).
workloadLocks keyedmutex.Mutex
}
// LockWorkload acquires the per-workload deploy lock for an external critical
// section (volume-snapshot restore) and returns the release func. The restore
// flow holds this across stop→swap→redeploy and redeploys via RedeployLocked
// (which does NOT re-acquire it).
func (d *Deployer) LockWorkload(id string) func() { return d.workloadLocks.Lock(id) }
// RedeployLocked re-dispatches w WITHOUT acquiring the per-workload lock,
// because the caller (restore) already holds it via LockWorkload. Calling the
// normal DispatchPlugin here would deadlock — Go mutexes are not reentrant.
// Not for general use.
func (d *Deployer) RedeployLocked(ctx context.Context, w plugin.Workload, intent plugin.DeploymentIntent) error {
return d.dispatchLocked(ctx, w, intent)
}
// EventPublisher is the interface for publishing events to the event bus.
-24
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"github.com/alexei/tinyforge/internal/metrics"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
@@ -15,18 +14,6 @@ import (
// operator enables auto_backup_before_deploy, a pre-deploy Tinyforge DB
// snapshot is taken here, after the source resolves and before it runs.
func (d *Deployer) DispatchPlugin(ctx context.Context, w plugin.Workload, intent plugin.DeploymentIntent) error {
// C1: serialize all deploy-class work per workload. Held across the whole
// deploy so a concurrent deploy/rollback/promote/trigger — or a volume
// restore (which redeploys via RedeployLocked while holding this) — can
// never interleave container changes for the same workload.
unlock := d.workloadLocks.Lock(w.ID)
defer unlock()
return d.dispatchLocked(ctx, w, intent)
}
// dispatchLocked is DispatchPlugin's body, assuming the per-workload lock is
// already held. RedeployLocked calls it directly during restore.
func (d *Deployer) dispatchLocked(ctx context.Context, w plugin.Workload, intent plugin.DeploymentIntent) error {
if err := d.beginDispatch(); err != nil {
metrics.DeploysTotal.Inc(w.SourceKind, "rejected_draining")
return err
@@ -46,17 +33,12 @@ func (d *Deployer) dispatchLocked(ctx context.Context, w plugin.Workload, intent
// check (e.g. the image source's same-tag short-circuit), so a same-tag
// redeploy still snapshots — "backup before every deploy attempt".
d.maybeBackupBeforeDeploy(w.ID)
startedAt := store.Now()
err = src.Deploy(ctx, d.PluginDeps(), w, intent)
outcome := "success"
if err != nil {
outcome = "failure"
}
metrics.DeploysTotal.Inc(w.SourceKind, outcome)
// Append to the structured deploy ledger (powers the per-app history
// panel + rollback). Best-effort and secret-free; see recordDeployHistory.
// Only DispatchPlugin records — reconcile/teardown are not deploys.
d.recordDeployHistory(w, intent, outcome, err, startedAt)
return err
}
@@ -64,12 +46,6 @@ func (d *Deployer) dispatchLocked(ctx context.Context, w plugin.Workload, intent
// Used when a workload is deleted. Tracked via activeWg so Drain() honours
// in-progress teardowns just like deploys.
func (d *Deployer) DispatchTeardown(ctx context.Context, w plugin.Workload) error {
// Teardown mutates the same containers/routes a deploy does, so it takes the
// per-workload lock too (C1). Callers tear down distinct workload ids
// sequentially (e.g. preview children then parent), never nested, so no
// self-deadlock.
unlock := d.workloadLocks.Lock(w.ID)
defer unlock()
if err := d.beginDispatch(); err != nil {
return err
}
-78
View File
@@ -250,84 +250,6 @@ func TestDispatchReconcile_PropagatesSourceError(t *testing.T) {
}
}
// ---- Deploy history recording ----------------------------------------------
// seedDispatchWorkload inserts a real workloads row so deploy_history's FK
// (workload_id REFERENCES workloads) is satisfied, then returns a plugin
// workload pointing at the fake source.
func seedDispatchWorkload(t *testing.T, d *Deployer) plugin.Workload {
t.Helper()
row, err := d.store.CreateWorkload(store.Workload{Kind: "project", RefID: "dh", Name: "dh"})
if err != nil {
t.Fatalf("CreateWorkload: %v", err)
}
return plugin.Workload{ID: row.ID, Name: "dh", SourceKind: "dispatchertest"}
}
func TestDispatchPlugin_RecordsSuccessHistory(t *testing.T) {
resetFake(t)
d := newTestDeployer(t)
w := seedDispatchWorkload(t, d)
intent := plugin.DeploymentIntent{Reason: "manual", Reference: "v9", TriggeredBy: "alice",
Metadata: map[string]string{"note": "ship it"}}
if err := d.DispatchPlugin(context.Background(), w, intent); err != nil {
t.Fatalf("DispatchPlugin: %v", err)
}
rows, err := d.store.ListDeployHistory(w.ID, 10, 0)
if err != nil {
t.Fatalf("ListDeployHistory: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 history row, got %d", len(rows))
}
got := rows[0]
if got.Outcome != "success" || got.Reason != "manual" || got.Reference != "v9" {
t.Fatalf("unexpected row: %+v", got)
}
if got.TriggeredBy != "alice" || got.Note != "ship it" {
t.Fatalf("intent fields not recorded: %+v", got)
}
if got.Error != "" {
t.Fatalf("success row must have empty error, got %q", got.Error)
}
}
func TestDispatchPlugin_RecordsFailureWithoutLeakingError(t *testing.T) {
resetFake(t)
d := newTestDeployer(t)
w := seedDispatchWorkload(t, d)
// A deploy error carrying a "secret" must never reach the persisted row.
dispatchTestSource.setDeployErr(errors.New("compose up failed (output: SUPER_SECRET=hunter2)"))
_ = d.DispatchPlugin(context.Background(), w, plugin.DeploymentIntent{Reason: "manual"})
rows, _ := d.store.ListDeployHistory(w.ID, 10, 0)
if len(rows) != 1 {
t.Fatalf("expected 1 history row, got %d", len(rows))
}
if rows[0].Outcome != "failure" {
t.Fatalf("expected failure outcome, got %q", rows[0].Outcome)
}
if strings.Contains(rows[0].Error, "hunter2") || strings.Contains(rows[0].Error, "SECRET") {
t.Fatalf("raw error leaked into history: %q", rows[0].Error)
}
}
func TestDispatchReconcile_RecordsNoHistory(t *testing.T) {
resetFake(t)
d := newTestDeployer(t)
w := seedDispatchWorkload(t, d)
if err := d.DispatchReconcile(context.Background(), w); err != nil {
t.Fatalf("DispatchReconcile: %v", err)
}
rows, _ := d.store.ListDeployHistory(w.ID, 10, 0)
if len(rows) != 0 {
t.Fatalf("reconcile must not write history, got %d rows", len(rows))
}
}
// ---- PluginDeps -------------------------------------------------------------
func TestPluginDeps_PassesStoreAndEncKey(t *testing.T) {
-83
View File
@@ -1,83 +0,0 @@
package gitops
// source_config JSON keys this package can overlay. Kept as constants so the
// apply, merge, and drift paths agree on the exact key strings.
const (
keyPort = "port"
keyHealthcheck = "healthcheck"
keyDeployStrategy = "deploy_strategy"
)
// Source kinds eligible for GitOps in v1 (git-backed sources only).
const (
SourceDockerfile = "dockerfile"
SourceStatic = "static"
)
// supportedKeys returns the source_config keys a given source kind accepts
// from a .tinyforge.yml overlay. A field declared in the file but not in this
// set is ignored (not applied, not drift-compared) so a shared file can target
// either source without producing dead keys or false drift.
//
// dockerfile: port + healthcheck + deploy_strategy (its real run knobs).
// static: deploy_strategy only (a static site has no port/healthcheck).
func supportedKeys(sourceKind string) map[string]bool {
switch sourceKind {
case SourceDockerfile:
return map[string]bool{keyPort: true, keyHealthcheck: true, keyDeployStrategy: true}
case SourceStatic:
return map[string]bool{keyDeployStrategy: true}
default:
return nil
}
}
// IsEligibleSource reports whether GitOps may be enabled for a source kind.
func IsEligibleSource(sourceKind string) bool {
return supportedKeys(sourceKind) != nil
}
// ApplyPlan is the typed, multi-target plan for applying an overlay. In v1 only
// SourceConfigPatch is populated; EnvUpserts/Faces are reserved so env (the
// workload_env table) and faces (the public_faces column) can be added later
// without reshaping the apply path — they are NOT in v1 (env would re-open the
// secrets-in-repo hole; faces live in a sibling store).
type ApplyPlan struct {
// SourceConfigPatch holds the source_config keys to overlay onto the live
// config. Only keys supported by the target source are present.
SourceConfigPatch map[string]any
// reserved for future phases — see package doc.
// EnvUpserts []store.WorkloadEnv
// Faces []plugin.PublicFace
}
// declaredValues returns the present (non-nil) overlay fields keyed by their
// source_config JSON key, before the per-source filter. Shared by BuildPlan and
// Drift so they agree on what the file declared.
func declaredValues(spec Spec) map[string]any {
out := map[string]any{}
if spec.Deploy.Port != nil {
out[keyPort] = *spec.Deploy.Port
}
if spec.Deploy.Healthcheck != nil {
out[keyHealthcheck] = *spec.Deploy.Healthcheck
}
if spec.Deploy.DeployStrategy != nil {
out[keyDeployStrategy] = *spec.Deploy.DeployStrategy
}
return out
}
// BuildPlan maps the present, source-supported overlay fields to a patch for
// the given source kind. Unsupported/absent fields are dropped.
func BuildPlan(spec Spec, sourceKind string) ApplyPlan {
allowed := supportedKeys(sourceKind)
patch := map[string]any{}
for k, v := range declaredValues(spec) {
if allowed[k] {
patch[k] = v
}
}
return ApplyPlan{SourceConfigPatch: patch}
}
-122
View File
@@ -1,122 +0,0 @@
package gitops
import (
"encoding/json"
"fmt"
"strconv"
)
// DriftEntry is one field where the repo-declared value differs from the live
// stored value. Values are display strings; comparison is done on normalized
// forms so cosmetic differences (default coercion, YAML int vs JSON number)
// don't register as drift.
type DriftEntry struct {
Field string `json:"field"`
RepoValue string `json:"repo_value"`
LiveValue string `json:"live_value"`
}
// driftFieldOrder is the stable order drift entries are reported in.
var driftFieldOrder = []string{keyPort, keyHealthcheck, keyDeployStrategy}
// Drift compares the declared overlay (the present, source-supported fields)
// against the live source_config and returns the fields that differ. Only
// declared fields are considered — a key the file omits is "unmanaged",
// neither drift nor clean (review C5). Comparison is post-normalization.
func Drift(spec Spec, live json.RawMessage, sourceKind string) ([]DriftEntry, error) {
liveMap := map[string]any{}
if len(live) > 0 {
if err := json.Unmarshal(live, &liveMap); err != nil {
return nil, fmt.Errorf("gitops: decode live source_config: %w", err)
}
}
allowed := supportedKeys(sourceKind)
declared := declaredValues(spec)
var entries []DriftEntry
for _, k := range driftFieldOrder {
repoVal, ok := declared[k]
if !ok || !allowed[k] {
continue
}
liveVal, livePresent := liveMap[k]
if normalizeField(k, repoVal) == normalizeField(k, liveVal) {
continue
}
entries = append(entries, DriftEntry{
Field: k,
RepoValue: displayField(k, repoVal, true),
LiveValue: displayField(k, liveVal, livePresent),
})
}
return entries, nil
}
// normalizeField returns the canonical comparison form of a field value.
func normalizeField(key string, v any) string {
switch key {
case keyDeployStrategy:
// "" and "recreate" are the same effective strategy for dockerfile and
// static (see each source's effectiveStrategy).
s := toStr(v)
if s == "" || s == "recreate" {
return "recreate"
}
return s
case keyPort:
return canonInt(v)
default:
return toStr(v)
}
}
// displayField renders a value for the UI. present=false means the key is
// absent from the live config.
func displayField(key string, v any, present bool) string {
if !present {
return "(unset)"
}
if key == keyDeployStrategy {
if s := toStr(v); s == "" {
return "recreate (default)"
}
}
switch n := v.(type) {
case float64:
// JSON numbers decode as float64; show whole numbers without ".0".
return strconv.FormatInt(int64(n), 10)
case nil:
return "(unset)"
default:
return fmt.Sprint(v)
}
}
// canonInt coerces any numeric representation (YAML int, JSON float64, etc.)
// to a base-10 integer string for value-equality comparison.
func canonInt(v any) string {
switch n := v.(type) {
case int:
return strconv.Itoa(n)
case int64:
return strconv.FormatInt(n, 10)
case float64:
return strconv.FormatInt(int64(n), 10)
case json.Number:
return n.String()
case nil:
return "0"
default:
return fmt.Sprint(v)
}
}
func toStr(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprint(v)
}
-96
View File
@@ -1,96 +0,0 @@
package gitops
import (
"context"
"errors"
"strings"
"github.com/alexei/tinyforge/internal/staticsite"
)
// maxConfigBytes caps the .tinyforge.yml fetch. The file is tiny; the cap
// stops a hostile/misconfigured repo from streaming an unbounded body.
const maxConfigBytes = 64 * 1024
// Status is the outcome of a Fetch. All outcomes are values (not errors) so a
// caller always has something to show: an absent file or a provider blip is a
// normal state, not a 500.
type Status string
const (
StatusOK Status = "ok" // file present and parsed
StatusNoFile Status = "no_file" // GitOps enabled, no file at path
StatusFetchFailed Status = "fetch_failed" // transport/auth/5xx error
StatusInvalid Status = "invalid" // file present but failed to parse
)
// RepoRef is the minimal repo locator Fetch needs. The caller (API layer)
// extracts these from the workload's source_config and decrypts the token —
// this package stays decoupled from the store and source plugins.
type RepoRef struct {
Provider string // "gitea" | "github" | "gitlab" | "" (autodetect from BaseURL)
BaseURL string
Owner string
Repo string
Branch string
Token string // decrypted; "" for public repos
Path string // repo-relative file path; defaults to .tinyforge.yml
}
// Result carries everything the API/UI needs about a fetch. Message is a
// human-safe, token-redacted detail for non-ok statuses.
type Result struct {
Status Status
Raw []byte
Spec Spec
CommitSHA string
Message string
}
// Fetch reads the .tinyforge.yml from a workload's repo and parses it. Every
// failure mode is encoded in Result.Status (never a returned error), with any
// detail token-redacted in Result.Message. A missing file is StatusNoFile, not
// a failure — never a reason to block or clear config.
func Fetch(ctx context.Context, ref RepoRef) Result {
provider, err := staticsite.NewGitProvider(staticsite.ProviderType(ref.Provider), ref.BaseURL, ref.Token)
if err != nil {
return Result{Status: StatusFetchFailed, Message: redact(err, ref.Token)}
}
// Best-effort: the SHA lets the UI show which ref the file came from. A
// failure here doesn't sink the fetch — the file read below is what matters.
sha, _ := provider.GetLatestCommitSHA(ctx, ref.Owner, ref.Repo, ref.Branch)
path := ref.Path
if path == "" {
path = ".tinyforge.yml"
}
data, err := provider.DownloadFile(ctx, ref.Owner, ref.Repo, ref.Branch, path, maxConfigBytes)
if err != nil {
if errors.Is(err, staticsite.ErrFileNotFound) {
return Result{Status: StatusNoFile, CommitSHA: sha}
}
return Result{Status: StatusFetchFailed, CommitSHA: sha, Message: redact(err, ref.Token)}
}
spec, err := ParseSpec(data)
if err != nil {
// Parse errors describe YAML structure (line/col), not the token.
return Result{Status: StatusInvalid, Raw: data, CommitSHA: sha, Message: err.Error()}
}
return Result{Status: StatusOK, Raw: data, Spec: spec, CommitSHA: sha}
}
// redact strips the access token from an error message so a fetch failure can
// be surfaced or persisted without leaking the credential (mirrors the
// sanitizeError convention in the static/dockerfile sources).
func redact(err error, token string) string {
if err == nil {
return ""
}
msg := err.Error()
if token != "" {
msg = strings.ReplaceAll(msg, token, "[redacted]")
}
return msg
}
-162
View File
@@ -1,162 +0,0 @@
package gitops
import (
"encoding/json"
"errors"
"strings"
"testing"
)
func strp(s string) *string { return &s }
func intp(i int) *int { return &i }
func TestParseSpec(t *testing.T) {
s, err := ParseSpec([]byte("version: 1\ndeploy:\n port: 8080\n deploy_strategy: blue-green\n"))
if err != nil {
t.Fatalf("valid parse: %v", err)
}
if s.Version != 1 || s.Deploy.Port == nil || *s.Deploy.Port != 8080 {
t.Fatalf("unexpected spec: %+v", s)
}
if s.Deploy.Healthcheck != nil {
t.Fatalf("omitted healthcheck must stay nil")
}
// Unknown keys are rejected — incl. an attempt to declare env (out of v1).
if _, err := ParseSpec([]byte("version: 1\ndeploy:\n env:\n FOO: bar\n")); err == nil {
t.Fatalf("expected unknown-field error for deploy.env")
}
if _, err := ParseSpec([]byte("version: 1\nworkloads: []\n")); err == nil {
t.Fatalf("expected unknown-field error for top-level workloads")
}
if _, err := ParseSpec([]byte("version: 2\n")); err == nil {
t.Fatalf("expected unsupported-version error")
}
if _, err := ParseSpec(nil); err == nil {
t.Fatalf("expected empty-file error")
}
}
func TestBuildPlan_SourceAware(t *testing.T) {
spec := Spec{Version: 1, Deploy: DeploySpec{
Port: intp(8080), Healthcheck: strp("/h"), DeployStrategy: strp("blue-green"),
}}
df := BuildPlan(spec, SourceDockerfile).SourceConfigPatch
if df[keyPort] != 8080 || df[keyHealthcheck] != "/h" || df[keyDeployStrategy] != "blue-green" {
t.Fatalf("dockerfile patch wrong: %+v", df)
}
// static has no port/healthcheck — they must NOT leak into its patch.
st := BuildPlan(spec, SourceStatic).SourceConfigPatch
if _, ok := st[keyPort]; ok {
t.Fatalf("static patch must not contain port")
}
if _, ok := st[keyHealthcheck]; ok {
t.Fatalf("static patch must not contain healthcheck")
}
if st[keyDeployStrategy] != "blue-green" {
t.Fatalf("static should keep deploy_strategy: %+v", st)
}
if IsEligibleSource("image") || IsEligibleSource("compose") {
t.Fatalf("only dockerfile/static are GitOps-eligible in v1")
}
if !IsEligibleSource(SourceDockerfile) || !IsEligibleSource(SourceStatic) {
t.Fatalf("dockerfile + static must be eligible")
}
}
func TestMergeAndValidate_PreservesOmittedFields(t *testing.T) {
live := json.RawMessage(`{"repo_owner":"o","repo_name":"r","port":3000,"healthcheck":"/old","deploy_strategy":""}`)
spec := Spec{Version: 1, Deploy: DeploySpec{Port: intp(8080)}} // only port declared
merged, err := MergeAndValidate(live, BuildPlan(spec, SourceDockerfile), func(json.RawMessage) error { return nil })
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(merged, &m); err != nil {
t.Fatal(err)
}
if m["port"].(float64) != 8080 {
t.Fatalf("declared port not applied: %v", m["port"])
}
if m["healthcheck"] != "/old" {
t.Fatalf("undeclared healthcheck must be preserved, got %v", m["healthcheck"])
}
if m["repo_owner"] != "o" {
t.Fatalf("untouched repo_owner lost")
}
}
func TestMergeAndValidate_RejectsInvalidMergedConfig(t *testing.T) {
live := json.RawMessage(`{"port":3000}`)
spec := Spec{Version: 1, Deploy: DeploySpec{DeployStrategy: strp("rolling")}}
_, err := MergeAndValidate(live, BuildPlan(spec, SourceDockerfile), func(c json.RawMessage) error {
var x struct {
DeployStrategy string `json:"deploy_strategy"`
}
_ = json.Unmarshal(c, &x)
if x.DeployStrategy == "rolling" {
return errors.New("invalid deploy_strategy")
}
return nil
})
if err == nil {
t.Fatalf("expected the merged config to be rejected as a whole")
}
}
func TestDrift_DeclaredOnly_WithNormalization(t *testing.T) {
// live: port 3000, healthcheck "/h", strategy "" (== recreate effective).
live := json.RawMessage(`{"port":3000,"healthcheck":"/h","deploy_strategy":"","registry_name":"x"}`)
// declare: port (changed) + deploy_strategy "recreate" (equal to "" -> no drift).
spec := Spec{Version: 1, Deploy: DeploySpec{Port: intp(8080), DeployStrategy: strp("recreate")}}
d, err := Drift(spec, live, SourceDockerfile)
if err != nil {
t.Fatal(err)
}
if len(d) != 1 {
t.Fatalf("want exactly 1 drift (port), got %d: %+v", len(d), d)
}
if d[0].Field != keyPort || d[0].RepoValue != "8080" || d[0].LiveValue != "3000" {
t.Fatalf("port drift wrong: %+v", d[0])
}
}
func TestDrift_StaticIgnoresUnsupportedFields(t *testing.T) {
live := json.RawMessage(`{"deploy_strategy":"recreate","mode":"static"}`)
// port declared but unsupported for static -> ignored; strategy differs -> drift.
spec := Spec{Version: 1, Deploy: DeploySpec{Port: intp(8080), DeployStrategy: strp("blue-green")}}
d, err := Drift(spec, live, SourceStatic)
if err != nil {
t.Fatal(err)
}
if len(d) != 1 || d[0].Field != keyDeployStrategy {
t.Fatalf("static should only drift on deploy_strategy: %+v", d)
}
}
func TestDrift_UnsetLiveValue(t *testing.T) {
spec := Spec{Version: 1, Deploy: DeploySpec{Healthcheck: strp("/up")}}
d, err := Drift(spec, json.RawMessage(`{}`), SourceDockerfile)
if err != nil {
t.Fatal(err)
}
if len(d) != 1 || d[0].RepoValue != "/up" || d[0].LiveValue != "(unset)" {
t.Fatalf("unset live should render as (unset): %+v", d)
}
}
func TestRedact_StripsToken(t *testing.T) {
msg := redact(errors.New("execute request: token ghp_SECRET rejected"), "ghp_SECRET")
if strings.Contains(msg, "ghp_SECRET") {
t.Fatalf("token leaked: %s", msg)
}
if !strings.Contains(msg, "[redacted]") {
t.Fatalf("expected redaction marker: %s", msg)
}
if redact(nil, "x") != "" {
t.Fatalf("nil error should redact to empty string")
}
}
-48
View File
@@ -1,48 +0,0 @@
package gitops
import (
"encoding/json"
"fmt"
)
// MergeAndValidate overlays the plan's SourceConfigPatch onto a copy of the
// live source_config and returns the merged JSON — but only after the target
// source's own Validate accepts the *merged* result. This is the hard apply
// gate (review C4):
//
// - omitted-field-preserving: keys the file doesn't declare are untouched, so
// a partial .tinyforge.yml never clears live config;
// - validate-then-commit: a patch that would produce an invalid config (e.g.
// deploy_strategy "blue-green" on a source that rejects it, or a bad port)
// is refused as a whole — the function never returns a partial/empty config;
// - pure: it does not write anything; the caller persists the returned bytes.
//
// validate is the matching Source.Validate (passed in to keep this package
// decoupled from the source plugins).
func MergeAndValidate(live json.RawMessage, plan ApplyPlan, validate func(json.RawMessage) error) (json.RawMessage, error) {
// Decode the live config into a generic map we can overlay. An empty/null
// live config starts from an empty object rather than failing.
merged := map[string]any{}
if len(live) > 0 {
if err := json.Unmarshal(live, &merged); err != nil {
return nil, fmt.Errorf("gitops: decode live source_config: %w", err)
}
}
// Overlay only the declared patch keys — everything else is preserved.
for k, v := range plan.SourceConfigPatch {
merged[k] = v
}
out, err := json.Marshal(merged)
if err != nil {
return nil, fmt.Errorf("gitops: encode merged source_config: %w", err)
}
if validate != nil {
if err := validate(out); err != nil {
return nil, fmt.Errorf("gitops: merged config rejected: %w", err)
}
}
return out, nil
}
-57
View File
@@ -1,57 +0,0 @@
// 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
}
-48
View File
@@ -1,48 +0,0 @@
// Package keyedmutex provides a lazily-populated per-key mutex, so a critical
// section can be serialized per key (e.g. per workload id) without a global
// lock. It is the shared form of the pattern that originated inline in the
// GitOps sync handler; the deployer (per-workload deploy serialization) and the
// volume-snapshot restore single-flight both use it.
package keyedmutex
import "sync"
// Mutex hands out one *sync.Mutex per key on demand. The zero value is ready to
// use. The internal map only grows (one entry per distinct key ever locked),
// which is bounded in practice by the number of workloads.
type Mutex struct {
mu sync.Mutex
m map[string]*sync.Mutex
}
func (k *Mutex) get(key string) *sync.Mutex {
k.mu.Lock()
defer k.mu.Unlock()
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
}
return mu
}
// Lock blocks until the mutex for key is acquired, then returns its unlock func.
func (k *Mutex) Lock(key string) func() {
mu := k.get(key)
mu.Lock()
return mu.Unlock
}
// TryLock attempts to acquire the mutex for key without blocking. On success it
// returns the unlock func and true; if the key is already locked it returns nil
// and false so the caller can reject (e.g. HTTP 409) instead of queuing.
func (k *Mutex) TryLock(key string) (func(), bool) {
mu := k.get(key)
if !mu.TryLock() {
return nil, false
}
return mu.Unlock, true
}
-83
View File
@@ -1,83 +0,0 @@
package keyedmutex
import (
"sync"
"testing"
"time"
)
func TestLockSerializesSameKey(t *testing.T) {
var m Mutex
unlock := m.Lock("a")
acquired := make(chan struct{})
go func() {
u := m.Lock("a")
close(acquired)
u()
}()
select {
case <-acquired:
t.Fatal("second Lock on the same key acquired while the first was held")
case <-time.After(50 * time.Millisecond):
// expected: blocked
}
unlock()
select {
case <-acquired:
// expected: now acquired
case <-time.After(time.Second):
t.Fatal("second Lock did not acquire after release")
}
}
func TestLockIndependentKeys(t *testing.T) {
var m Mutex
unlockA := m.Lock("a")
defer unlockA()
// A different key must not block.
done := make(chan struct{})
go func() { u := m.Lock("b"); u(); close(done) }()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("Lock on an independent key blocked")
}
}
func TestTryLock(t *testing.T) {
var m Mutex
unlock, ok := m.TryLock("a")
if !ok {
t.Fatal("TryLock should succeed on a free key")
}
if _, ok := m.TryLock("a"); ok {
t.Fatal("TryLock should fail while the key is held")
}
unlock()
u2, ok := m.TryLock("a")
if !ok {
t.Fatal("TryLock should succeed after release")
}
u2()
}
func TestConcurrentLockNoRace(t *testing.T) {
var m Mutex
var wg sync.WaitGroup
counter := 0
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
u := m.Lock("shared")
counter++ // protected by the keyed lock
u()
}()
}
wg.Wait()
if counter != 50 {
t.Errorf("counter = %d, want 50 (lost updates ⇒ lock not serializing)", counter)
}
}
+29 -1
View File
@@ -15,6 +15,7 @@ package reconciler
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -168,7 +169,7 @@ func (r *Reconciler) reconcilePluginWorkloads(ctx context.Context, rows []store.
if w.SourceKind == "" {
continue
}
pw := plugin.WorkloadFromStore(w)
pw := toPluginWorkload(w)
if err := r.plugins.DispatchReconcile(ctx, pw); err != nil {
slog.Warn("reconciler: plugin reconcile failed",
"workload", w.ID, "kind", w.SourceKind, "error", err)
@@ -176,6 +177,33 @@ func (r *Reconciler) reconcilePluginWorkloads(ctx context.Context, rows []store.
}
}
// toPluginWorkload mirrors the api / webhook converters; kept local to
// avoid an import dependency between those packages.
func toPluginWorkload(w store.Workload) plugin.Workload {
var faces []plugin.PublicFace
if w.PublicFaces != "" {
_ = json.Unmarshal([]byte(w.PublicFaces), &faces)
}
return plugin.Workload{
ID: w.ID,
Name: w.Name,
GroupID: w.AppID,
ParentWorkloadID: w.ParentWorkloadID,
SourceKind: w.SourceKind,
SourceConfig: json.RawMessage(w.SourceConfig),
TriggerKind: w.TriggerKind,
TriggerConfig: json.RawMessage(w.TriggerConfig),
PublicFaces: faces,
NotificationURL: w.NotificationURL,
NotificationSecret: w.NotificationSecret,
WebhookSecret: w.WebhookSecret,
WebhookSigningSecret: w.WebhookSigningSecret,
WebhookRequireSignature: w.WebhookRequireSignature,
CreatedAt: w.CreatedAt,
UpdatedAt: w.UpdatedAt,
}
}
func (r *Reconciler) loop(ctx context.Context) {
defer r.wg.Done()
@@ -44,9 +44,6 @@ func (*fakeReporterProvider) ListTree(context.Context, string, string, string) (
func (*fakeReporterProvider) DownloadFolder(context.Context, string, string, string, string, string) error {
panic("unused")
}
func (*fakeReporterProvider) DownloadFile(context.Context, string, string, string, string, int64) ([]byte, error) {
panic("unused")
}
// Enabled: forwards to the provider with the captured identifiers + target.
func TestCommitStatusReporter_Enabled_Calls(t *testing.T) {
-9
View File
@@ -295,15 +295,6 @@ func (f *GiteaContentFetcher) DownloadFolder(ctx context.Context, owner, repo, b
return nil
}
// DownloadFile fetches a single file's raw bytes via Gitea's raw endpoint
// (also serves Forgejo/Gogs), capped at maxBytes. Returns ErrFileNotFound on
// a 404 so an absent config file reads as a non-error state.
func (f *GiteaContentFetcher) DownloadFile(ctx context.Context, owner, repo, ref, path string, maxBytes int64) ([]byte, error) {
p := strings.TrimPrefix(path, "/")
fileURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s", f.baseURL, owner, repo, p, ref)
return getFileBytes(ctx, f.httpClient, fileURL, maxBytes, f.setAuth)
}
// TestConnection verifies that the repository is accessible.
func (f *GiteaContentFetcher) TestConnection(ctx context.Context, owner, repo string) error {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s", f.baseURL, owner, repo)
-13
View File
@@ -288,19 +288,6 @@ func (g *GitHubProvider) DownloadFolder(ctx context.Context, owner, repo, branch
return nil
}
// DownloadFile fetches a single file's raw bytes via the GitHub contents API
// using the raw media type (works for both github.com and GHE), capped at
// maxBytes. Returns ErrFileNotFound on a 404.
func (g *GitHubProvider) DownloadFile(ctx context.Context, owner, repo, ref, path string, maxBytes int64) ([]byte, error) {
p := strings.TrimPrefix(path, "/")
fileURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", g.apiBase, owner, repo, p, ref)
auth := func(r *http.Request) {
g.setAuth(r)
r.Header.Set("Accept", "application/vnd.github.raw+json")
}
return getFileBytes(ctx, g.httpClient, fileURL, maxBytes, auth)
}
func (g *GitHubProvider) doGet(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
-16
View File
@@ -273,22 +273,6 @@ func (g *GitLabProvider) DownloadFolder(ctx context.Context, owner, repo, branch
return nil
}
// DownloadFile fetches a single file's raw bytes via GitLab's raw endpoint,
// capped at maxBytes. Returns ErrFileNotFound on a 404. owner/repo/ref are
// path-escaped; the file path is passed through verbatim to preserve its `/`
// separators (a `..` segment is harmless — the bytes are only parsed in
// memory, never written to disk, so there is no local-traversal sink).
func (g *GitLabProvider) DownloadFile(ctx context.Context, owner, repo, ref, path string, maxBytes int64) ([]byte, error) {
p := strings.TrimPrefix(path, "/")
fileURL := fmt.Sprintf("%s/%s/%s/-/raw/%s/%s",
g.rawBase,
url.PathEscape(owner),
url.PathEscape(repo),
url.PathEscape(ref),
p)
return getFileBytes(ctx, g.httpClient, fileURL, maxBytes, g.setAuth)
}
func (g *GitLabProvider) doGet(ctx context.Context, apiURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
-50
View File
@@ -3,7 +3,6 @@ package staticsite
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
@@ -13,11 +12,6 @@ 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"`
@@ -87,12 +81,6 @@ 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-
@@ -218,44 +206,6 @@ 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)
-123
View File
@@ -1,123 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
)
// InsertDeployHistory appends one row to the per-workload deploy ledger.
// Callers (the deployer choke point) treat this as best-effort: a failure
// here must never fail an otherwise-successful deploy. Error is expected to
// be a fixed, secret-free marker — never the raw source error.
func (s *Store) InsertDeployHistory(e DeployHistoryEntry) (DeployHistoryEntry, error) {
if e.StartedAt == "" {
e.StartedAt = Now()
}
if e.FinishedAt == "" {
e.FinishedAt = Now()
}
res, err := s.db.Exec(
`INSERT INTO deploy_history
(workload_id, source_kind, reference, reason, triggered_by,
note, outcome, error, started_at, finished_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.WorkloadID, e.SourceKind, e.Reference, e.Reason, e.TriggeredBy,
e.Note, e.Outcome, e.Error, e.StartedAt, e.FinishedAt,
)
if err != nil {
return DeployHistoryEntry{}, fmt.Errorf("insert deploy history: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return DeployHistoryEntry{}, fmt.Errorf("get deploy history id: %w", err)
}
e.ID = id
return e, nil
}
// ListDeployHistory returns a workload's ledger newest-first. limit/offset
// are assumed pre-clamped by the API layer; a non-positive limit falls back
// to a sane default so a bad query can't return the whole table.
func (s *Store) ListDeployHistory(workloadID string, limit, offset int) ([]DeployHistoryEntry, error) {
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
rows, err := s.db.Query(
`SELECT id, workload_id, source_kind, reference, reason, triggered_by,
note, outcome, error, started_at, finished_at
FROM deploy_history
WHERE workload_id = ?
ORDER BY id DESC
LIMIT ? OFFSET ?`,
workloadID, limit, offset,
)
if err != nil {
return nil, fmt.Errorf("query deploy history: %w", err)
}
defer rows.Close()
out := make([]DeployHistoryEntry, 0, limit)
for rows.Next() {
var e DeployHistoryEntry
if err := rows.Scan(
&e.ID, &e.WorkloadID, &e.SourceKind, &e.Reference, &e.Reason,
&e.TriggeredBy, &e.Note, &e.Outcome, &e.Error, &e.StartedAt, &e.FinishedAt,
); err != nil {
return nil, fmt.Errorf("scan deploy history: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
// GetDeployHistory fetches one ledger row by id, or ErrNotFound. The
// rollback handler uses this to resolve the pinned reference to replay.
func (s *Store) GetDeployHistory(id int64) (DeployHistoryEntry, error) {
row := s.db.QueryRow(
`SELECT id, workload_id, source_kind, reference, reason, triggered_by,
note, outcome, error, started_at, finished_at
FROM deploy_history WHERE id = ?`, id,
)
var e DeployHistoryEntry
err := row.Scan(
&e.ID, &e.WorkloadID, &e.SourceKind, &e.Reference, &e.Reason,
&e.TriggeredBy, &e.Note, &e.Outcome, &e.Error, &e.StartedAt, &e.FinishedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return DeployHistoryEntry{}, fmt.Errorf("deploy history %d: %w", id, ErrNotFound)
}
if err != nil {
return DeployHistoryEntry{}, fmt.Errorf("scan deploy history: %w", err)
}
return e, nil
}
// PruneDeployHistory keeps only the newest `keep` rows for a workload,
// deleting older ones. Bounds unbounded growth on hot workloads. Best-
// effort and id-monotonic (newer rows always have larger ids), so it
// deletes everything below the keep-th id. A non-positive keep is treated
// as "keep a sane default" rather than "delete everything".
func (s *Store) PruneDeployHistory(workloadID string, keep int) error {
if keep <= 0 {
keep = 50
}
_, err := s.db.Exec(
`DELETE FROM deploy_history
WHERE workload_id = ?
AND id NOT IN (
SELECT id FROM deploy_history
WHERE workload_id = ?
ORDER BY id DESC
LIMIT ?
)`,
workloadID, workloadID, keep,
)
if err != nil {
return fmt.Errorf("prune deploy history: %w", err)
}
return nil
}
-133
View File
@@ -1,133 +0,0 @@
package store
import (
"errors"
"testing"
)
func seedWorkload(t *testing.T, s *Store, name string) Workload {
t.Helper()
w, err := s.CreateWorkload(Workload{Kind: "project", RefID: name, Name: name})
if err != nil {
t.Fatalf("CreateWorkload(%s): %v", name, err)
}
return w
}
func TestDeployHistory_InsertListGet(t *testing.T) {
s := newTestStore(t)
w := seedWorkload(t, s, "app1")
first, err := s.InsertDeployHistory(DeployHistoryEntry{
WorkloadID: w.ID, SourceKind: "image", Reference: "v1",
Reason: "manual", TriggeredBy: "admin", Outcome: "success",
})
if err != nil {
t.Fatalf("InsertDeployHistory: %v", err)
}
if first.ID == 0 {
t.Fatal("expected non-zero id")
}
if first.StartedAt == "" || first.FinishedAt == "" {
t.Fatal("expected timestamps to be defaulted")
}
second, _ := s.InsertDeployHistory(DeployHistoryEntry{
WorkloadID: w.ID, SourceKind: "image", Reference: "v2",
Reason: "registry-push", Outcome: "success",
})
list, err := s.ListDeployHistory(w.ID, 10, 0)
if err != nil {
t.Fatalf("ListDeployHistory: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected 2 rows, got %d", len(list))
}
// Newest-first ordering.
if list[0].ID != second.ID || list[1].ID != first.ID {
t.Fatalf("expected newest-first ordering, got %d then %d", list[0].ID, list[1].ID)
}
got, err := s.GetDeployHistory(first.ID)
if err != nil {
t.Fatalf("GetDeployHistory: %v", err)
}
if got.Reference != "v1" || got.SourceKind != "image" {
t.Fatalf("unexpected row: %+v", got)
}
}
func TestDeployHistory_GetNotFound(t *testing.T) {
s := newTestStore(t)
_, err := s.GetDeployHistory(999)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestDeployHistory_ListScopedToWorkload(t *testing.T) {
s := newTestStore(t)
a := seedWorkload(t, s, "a")
b := seedWorkload(t, s, "b")
s.InsertDeployHistory(DeployHistoryEntry{WorkloadID: a.ID, Outcome: "success"})
s.InsertDeployHistory(DeployHistoryEntry{WorkloadID: b.ID, Outcome: "success"})
list, _ := s.ListDeployHistory(a.ID, 10, 0)
if len(list) != 1 || list[0].WorkloadID != a.ID {
t.Fatalf("expected only workload a's rows, got %+v", list)
}
}
func TestDeployHistory_Pagination(t *testing.T) {
s := newTestStore(t)
w := seedWorkload(t, s, "paged")
for i := 0; i < 5; i++ {
s.InsertDeployHistory(DeployHistoryEntry{WorkloadID: w.ID, Outcome: "success"})
}
page1, _ := s.ListDeployHistory(w.ID, 2, 0)
page2, _ := s.ListDeployHistory(w.ID, 2, 2)
if len(page1) != 2 || len(page2) != 2 {
t.Fatalf("expected 2 per page, got %d and %d", len(page1), len(page2))
}
if page1[0].ID == page2[0].ID {
t.Fatal("expected distinct rows across pages")
}
}
func TestDeployHistory_Prune(t *testing.T) {
s := newTestStore(t)
w := seedWorkload(t, s, "noisy")
for i := 0; i < 10; i++ {
s.InsertDeployHistory(DeployHistoryEntry{WorkloadID: w.ID, Outcome: "success"})
}
if err := s.PruneDeployHistory(w.ID, 3); err != nil {
t.Fatalf("PruneDeployHistory: %v", err)
}
list, _ := s.ListDeployHistory(w.ID, 100, 0)
if len(list) != 3 {
t.Fatalf("expected 3 rows after prune, got %d", len(list))
}
// Prune keeps the newest rows.
all, _ := s.ListDeployHistory(w.ID, 100, 0)
for i := 1; i < len(all); i++ {
if all[i-1].ID < all[i].ID {
t.Fatal("expected newest-first after prune")
}
}
}
func TestDeployHistory_CascadeOnWorkloadDelete(t *testing.T) {
s := newTestStore(t)
w := seedWorkload(t, s, "doomed")
s.InsertDeployHistory(DeployHistoryEntry{WorkloadID: w.ID, Outcome: "success"})
s.InsertDeployHistory(DeployHistoryEntry{WorkloadID: w.ID, Outcome: "failure"})
if err := s.DeleteWorkload(w.ID); err != nil {
t.Fatalf("DeleteWorkload: %v", err)
}
list, _ := s.ListDeployHistory(w.ID, 100, 0)
if len(list) != 0 {
t.Fatalf("expected history removed with workload, got %d rows", len(list))
}
}
-48
View File
@@ -1,48 +0,0 @@
package store
import "fmt"
// SetWorkloadGitOps toggles GitOps and sets the config path for a workload.
// Targeted column update (not UpdateWorkload) so it never clobbers the
// source_config / faces / webhook fields — and conversely, the edit-form save
// (UpdateWorkload) never touches these columns.
func (s *Store) SetWorkloadGitOps(id string, enabled bool, path string) error {
if path == "" {
path = ".tinyforge.yml"
}
result, err := s.db.Exec(
`UPDATE workloads SET gitops_enabled=?, gitops_path=?, updated_at=? WHERE id=?`,
BoolToInt(enabled), path, Now(), id,
)
if err != nil {
return fmt.Errorf("set workload gitops: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("workload %s: %w", id, ErrNotFound)
}
return nil
}
// RecordGitOpsSync stamps the commit SHA + timestamp of the last successful
// sync, so the UI can show "last synced <when> at <sha>".
func (s *Store) RecordGitOpsSync(id, commitSHA, syncedAt string) error {
result, err := s.db.Exec(
`UPDATE workloads SET gitops_last_sync_at=?, gitops_commit_sha=?, updated_at=? WHERE id=?`,
syncedAt, commitSHA, Now(), id,
)
if err != nil {
return fmt.Errorf("record gitops sync: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("workload %s: %w", id, ErrNotFound)
}
return nil
}
-63
View File
@@ -1,63 +0,0 @@
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")
}
}
+2 -33
View File
@@ -394,14 +394,8 @@ type Workload struct {
WebhookSecret string `json:"-"` // URL-identifier secret; never serialized
WebhookSigningSecret string `json:"-"` // HMAC key; never serialized
WebhookRequireSignature bool `json:"webhook_require_signature"`
// 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"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// WorkloadNotification is one configured outbound notification route for
@@ -513,28 +507,3 @@ type App struct {
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// DeployHistoryEntry is one row in the per-workload deploy ledger. Unlike
// event_log (free-text human timeline), this is the structured, version-
// pinned record the rollback action replays from. Reference is the
// effective deployed artifact handle (image tag for image sources, commit
// sha for git-built sources, "" when none applies). Error is NEVER the raw
// source error — that can carry registry-auth bytes or compose stdout; it
// holds only a fixed, secret-free marker. Raw detail goes to slog.
type DeployHistoryEntry struct {
ID int64 `json:"id"`
WorkloadID string `json:"workload_id"`
SourceKind string `json:"source_kind"`
Reference string `json:"reference"` // effective tag | commit sha | ""
Reason string `json:"reason"` // manual|registry-push|git-push|cron|rollback|promote
TriggeredBy string `json:"triggered_by"`
Note string `json:"note"`
Outcome string `json:"outcome"` // success | failure
Error string `json:"error"` // generic, secret-free marker on failure
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
// Rollbackable is computed at the API layer (not persisted): a row is
// rollbackable when it succeeded, has a non-empty Reference, and its
// source kind supports reference-pinned redeploy.
Rollbackable bool `json:"rollbackable"`
}
-56
View File
@@ -1,56 +0,0 @@
package store
import "testing"
func TestListContainerStatsSamplesByWorkload_ScopedToWorkload(t *testing.T) {
s := newTestStore(t)
wa := seedWorkload(t, s, "wa")
wb := seedWorkload(t, s, "wb")
ca, err := s.CreateContainer(Container{WorkloadID: wa.ID, WorkloadKind: "image", ContainerID: "da", Host: "local", State: "running"})
if err != nil {
t.Fatalf("CreateContainer a: %v", err)
}
cb, err := s.CreateContainer(Container{WorkloadID: wb.ID, WorkloadKind: "image", ContainerID: "db", Host: "local", State: "running"})
if err != nil {
t.Fatalf("CreateContainer b: %v", err)
}
// owner_id is the container ROW id.
mustInsertSample(t, s, ca.ID, 100, 12.5, 2048)
mustInsertSample(t, s, ca.ID, 200, 15.0, 3072)
mustInsertSample(t, s, cb.ID, 150, 99.0, 9999)
got, err := s.ListContainerStatsSamplesByWorkload(wa.ID, 0)
if err != nil {
t.Fatalf("ListContainerStatsSamplesByWorkload: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 samples for workload a, got %d", len(got))
}
// ts ascending.
if got[0].TS != 100 || got[1].TS != 200 {
t.Fatalf("expected ts-ascending 100,200, got %d,%d", got[0].TS, got[1].TS)
}
for _, sm := range got {
if sm.OwnerID != ca.ID {
t.Fatalf("leaked a sample from another workload: %+v", sm)
}
}
// Since-cutoff filters older samples.
recent, _ := s.ListContainerStatsSamplesByWorkload(wa.ID, 150)
if len(recent) != 1 || recent[0].TS != 200 {
t.Fatalf("expected only ts=200 after cutoff, got %+v", recent)
}
}
func mustInsertSample(t *testing.T, s *Store, ownerID string, ts int64, cpu float64, mem int64) {
t.Helper()
if err := s.InsertContainerStatsSample(ContainerStatsSample{
ContainerID: "c-" + ownerID, OwnerType: "instance", OwnerID: ownerID, TS: ts,
CPUPercent: cpu, MemoryUsage: mem, MemoryLimit: mem * 2,
}); err != nil {
t.Fatalf("InsertContainerStatsSample: %v", err)
}
}
-37
View File
@@ -74,43 +74,6 @@ func (s *Store) ListContainerStatsSamples(ownerType, ownerID string, sinceTS int
return out, rows.Err()
}
// ListContainerStatsSamplesByWorkload returns every container sample owned by
// a workload since the given unix timestamp, ordered by ts ascending. Samples
// are linked to their workload through the containers index (owner_id is the
// container row id), so this joins through it. Powers the per-workload metrics
// graph on /apps/[id].
func (s *Store) ListContainerStatsSamplesByWorkload(workloadID string, sinceTS int64) ([]ContainerStatsSample, error) {
rows, err := s.db.Query(
`SELECT cs.container_id, cs.owner_type, cs.owner_id, cs.ts,
cs.cpu_percent, cs.memory_usage, cs.memory_limit,
cs.network_rx, cs.network_tx, cs.block_read, cs.block_write
FROM container_stats_samples cs
JOIN containers c ON c.id = cs.owner_id
WHERE c.workload_id = ? AND cs.ts >= ?
ORDER BY cs.ts ASC`,
workloadID, sinceTS,
)
if err != nil {
return nil, fmt.Errorf("list container stats samples by workload: %w", err)
}
defer rows.Close()
var out []ContainerStatsSample
for rows.Next() {
var s ContainerStatsSample
if err := rows.Scan(
&s.ContainerID, &s.OwnerType, &s.OwnerID, &s.TS,
&s.CPUPercent, &s.MemoryUsage, &s.MemoryLimit,
&s.NetworkRxBytes, &s.NetworkTxBytes,
&s.BlockReadBytes, &s.BlockWriteBytes,
); err != nil {
return nil, fmt.Errorf("scan container stats sample: %w", err)
}
out = append(out, s)
}
return out, rows.Err()
}
// ListAllRecentContainerStatsSamples returns samples across every owner since
// the given unix timestamp, ordered by ts ascending. Used by the system
// dashboard "top containers" widget where the UI wants a mixed pool.
-30
View File
@@ -173,14 +173,6 @@ 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
@@ -467,28 +459,6 @@ func (s *Store) runMigrations() error {
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_shared_secrets_scope_name ON shared_secrets(scope, app_id, name)`,
`CREATE INDEX IF NOT EXISTS idx_shared_secrets_app ON shared_secrets(app_id)`,
// deploy_history: structured, version-pinned ledger of every deploy
// dispatch (success AND failure) per workload. Distinct from the
// free-text event_log — this carries the replayable `reference` the
// rollback action redeploys from. `error` holds only a generic,
// secret-free marker (the raw source error can echo registry-auth /
// compose stdout, so it goes to slog only). FK cascade is backed by
// PRAGMA foreign_keys=ON, but DeleteWorkload also deletes these rows
// explicitly (matching the containers cleanup convention).
`CREATE TABLE IF NOT EXISTS deploy_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workload_id TEXT NOT NULL REFERENCES workloads(id) ON DELETE CASCADE,
source_kind TEXT NOT NULL DEFAULT '',
reference TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL DEFAULT '',
triggered_by TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
started_at TEXT NOT NULL DEFAULT '',
finished_at TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_deploy_history_workload ON deploy_history(workload_id, id DESC)`,
}
for _, t := range observabilityTables {
if _, err := s.db.Exec(t); err != nil {
+1 -13
View File
@@ -13,7 +13,6 @@ 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) {
@@ -24,7 +23,6 @@ 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
@@ -55,18 +53,14 @@ 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 {
@@ -196,12 +190,6 @@ func (s *Store) DeleteWorkload(id string) error {
if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, id); err != nil {
return fmt.Errorf("delete containers: %w", err)
}
// Deploy ledger rows are FK-cascaded, but we delete them explicitly in
// the same transaction — consistent with the containers cleanup above
// and robust even if the cascade is ever disabled.
if _, err := tx.Exec(`DELETE FROM deploy_history WHERE workload_id = ?`, id); err != nil {
return fmt.Errorf("delete deploy history: %w", err)
}
result, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete workload: %w", err)
-21
View File
@@ -1,21 +0,0 @@
//go:build !windows
package volsnap
import (
"fmt"
"golang.org/x/sys/unix"
)
// freeDiskBytes returns the bytes available to an unprivileged process on the
// filesystem backing path (used by the restore disk pre-check, C5). path must
// exist; callers pass the live dir's parent.
func freeDiskBytes(path string) (uint64, error) {
var st unix.Statfs_t
if err := unix.Statfs(path, &st); err != nil {
return 0, fmt.Errorf("statfs %s: %w", path, err)
}
// Bavail is blocks available to non-root; Bsize is the fragment size.
return st.Bavail * uint64(st.Bsize), nil
}
-24
View File
@@ -1,24 +0,0 @@
//go:build windows
package volsnap
import (
"fmt"
"golang.org/x/sys/windows"
)
// freeDiskBytes returns the bytes available to the caller on the volume backing
// path (used by the restore disk pre-check, C5). Windows is the dev platform;
// production runs on Linux (see disk_unix.go).
func freeDiskBytes(path string) (uint64, error) {
p, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, fmt.Errorf("encode path %s: %w", path, err)
}
var freeAvail, total, totalFree uint64
if err := windows.GetDiskFreeSpaceEx(p, &freeAvail, &total, &totalFree); err != nil {
return 0, fmt.Errorf("GetDiskFreeSpaceEx %s: %w", path, err)
}
return freeAvail, nil
}
-6
View File
@@ -31,12 +31,6 @@ type Engine struct {
mu sync.Mutex
store *store.Store
snapDir string
// lifecycle is the deploy-side seam restore needs (per-workload lock, stop,
// redeploy). Wired post-construction via SetLifecycle from the composition
// root so volsnap stays decoupled from the deployer/docker packages. nil
// until wired; Restore refuses to run without it.
lifecycle Lifecycle
}
// New creates the snapshot engine, ensuring the snapshot directory exists.
-162
View File
@@ -1,162 +0,0 @@
package volsnap
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strconv"
"strings"
)
// safeExtractIndex extracts the files archived under the integer subdirectory
// `index` of a snapshot tar.gz into dest, returning the total bytes written.
//
// On RESTORE the archive is treated as UNTRUSTED (it may have been downloaded,
// hand-edited, or swapped on disk), so this is hardened well beyond what the
// capture writer emits:
//
// - zip-slip: every resolved target must stay within dest (HasPrefix check on
// the cleaned absolute path) — a "../" or absolute member is rejected.
// - type allow-list: ONLY regular files and directories are materialized;
// symlinks, hardlinks, char/block devices, fifos, and sockets are rejected
// outright (never created, never followed) — they could redirect a write
// outside the volume or smuggle in a device node.
// - decompression bomb: a running byte counter is capped at bombCap; the first
// byte past the cap aborts the extraction.
//
// dest must be a fresh staging directory (files are created O_EXCL). The caller
// performs the atomic rename-swap of dest onto the live path separately.
func safeExtractIndex(archivePath string, index int, dest string, bombCap int64) (int64, error) {
f, err := os.Open(archivePath)
if err != nil {
return 0, fmt.Errorf("open archive: %w", err)
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return 0, fmt.Errorf("gzip reader: %w", err)
}
defer gz.Close()
cleanDest, err := filepath.Abs(dest)
if err != nil {
return 0, fmt.Errorf("resolve dest: %w", err)
}
if err := os.MkdirAll(cleanDest, 0o700); err != nil {
return 0, fmt.Errorf("create dest: %w", err)
}
tr := tar.NewReader(gz)
var written int64
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return written, fmt.Errorf("read tar: %w", err)
}
// Archive paths are always forward-slash. path.Clean collapses any
// "./" / "../" so the prefix and containment checks see a normal form.
name := path.Clean(hdr.Name)
if name == "manifest.json" {
continue
}
rel, ok := stripIndexPrefix(name, index)
if !ok {
continue // belongs to a different volume's subtree
}
switch hdr.Typeflag {
case tar.TypeReg, tar.TypeDir:
// allowed
default:
return written, fmt.Errorf("archive entry %q has disallowed type %q", hdr.Name, string(hdr.Typeflag))
}
target := cleanDest
if rel != "" {
target = filepath.Join(cleanDest, filepath.FromSlash(rel))
}
if !withinDir(cleanDest, target) {
return written, fmt.Errorf("archive entry %q escapes destination", hdr.Name)
}
if hdr.Typeflag == tar.TypeDir {
if err := os.MkdirAll(target, 0o700); err != nil {
return written, fmt.Errorf("mkdir %s: %w", target, err)
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return written, fmt.Errorf("mkdir parent of %s: %w", target, err)
}
remaining := bombCap - written
if remaining <= 0 {
return written, fmt.Errorf("archive exceeds decompression cap of %d bytes", bombCap)
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return written, fmt.Errorf("create %s: %w", target, err)
}
// LimitReader to remaining+1: if the entry is larger than the cap allows,
// the extra byte is copied and written>bombCap trips the guard below.
n, copyErr := io.Copy(out, io.LimitReader(tr, remaining+1))
closeErr := out.Close()
written += n
if copyErr != nil {
return written, fmt.Errorf("write %s: %w", target, copyErr)
}
if closeErr != nil {
return written, fmt.Errorf("close %s: %w", target, closeErr)
}
if written > bombCap {
return written, fmt.Errorf("archive exceeds decompression cap of %d bytes", bombCap)
}
}
return written, nil
}
// stripIndexPrefix returns the path relative to the "<index>/" subtree and
// whether name belongs to it. name=="<index>" (the subtree root) yields ("", true).
// The "/" boundary keeps index 1 from matching "10/...".
func stripIndexPrefix(name string, index int) (string, bool) {
p := strconv.Itoa(index)
if name == p {
return "", true
}
if strings.HasPrefix(name, p+"/") {
return name[len(p)+1:], true
}
return "", false
}
// leadingIndex parses the first path segment of an archive entry name as the
// volume index. Returns false for manifest.json or any non-integer prefix.
func leadingIndex(name string) (int, bool) {
seg := name
if i := strings.IndexByte(name, '/'); i >= 0 {
seg = name[:i]
}
idx, err := strconv.Atoi(seg)
if err != nil {
return 0, false
}
return idx, true
}
// withinDir reports whether target is base itself or lives beneath it. Both
// args must already be cleaned absolute paths.
func withinDir(base, target string) bool {
if target == base {
return true
}
return strings.HasPrefix(target, base+string(filepath.Separator))
}
-166
View File
@@ -1,166 +0,0 @@
package volsnap
import (
"archive/tar"
"compress/gzip"
"os"
"path/filepath"
"strings"
"testing"
)
type tentry struct {
name string
typeflag byte
body string
linkname string
}
// buildTarGz writes an arbitrary (possibly hostile) tar.gz so the extractor's
// untrusted-input hardening can be exercised — writeArchive only emits well-
// formed reg/dir entries, which is the wrong shape for these tests.
func buildTarGz(t *testing.T, entries []tentry) string {
t.Helper()
dest := filepath.Join(t.TempDir(), "snap.tar.gz")
f, err := os.Create(dest)
if err != nil {
t.Fatal(err)
}
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
for _, e := range entries {
hdr := &tar.Header{Name: e.name, Typeflag: e.typeflag, Mode: 0o600, Linkname: e.linkname}
switch e.typeflag {
case tar.TypeReg:
hdr.Size = int64(len(e.body))
case tar.TypeChar, tar.TypeBlock:
hdr.Devmajor, hdr.Devminor = 1, 1
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if e.typeflag == tar.TypeReg {
if _, err := tw.Write([]byte(e.body)); err != nil {
t.Fatal(err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
return dest
}
func TestSafeExtractIndex_RoundTrip(t *testing.T) {
arc := buildTarGz(t, []tentry{
{name: "0/", typeflag: tar.TypeDir},
{name: "0/a.txt", typeflag: tar.TypeReg, body: "hello"},
{name: "0/sub/", typeflag: tar.TypeDir},
{name: "0/sub/b.txt", typeflag: tar.TypeReg, body: "world"},
{name: "manifest.json", typeflag: tar.TypeReg, body: "[]"},
})
dest := filepath.Join(t.TempDir(), "out")
n, err := safeExtractIndex(arc, 0, dest, maxRestoreUncompressedBytes)
if err != nil {
t.Fatalf("extract: %v", err)
}
if n != int64(len("hello")+len("world")) {
t.Errorf("written = %d, want %d", n, len("hello")+len("world"))
}
if got, _ := os.ReadFile(filepath.Join(dest, "a.txt")); string(got) != "hello" {
t.Errorf("a.txt = %q", got)
}
if got, _ := os.ReadFile(filepath.Join(dest, "sub", "b.txt")); string(got) != "world" {
t.Errorf("sub/b.txt = %q", got)
}
if _, err := os.Stat(filepath.Join(dest, "manifest.json")); !os.IsNotExist(err) {
t.Error("manifest.json must not be extracted into the volume")
}
}
func TestSafeExtractIndex_IsolatesIndex(t *testing.T) {
arc := buildTarGz(t, []tentry{
{name: "1/keep.txt", typeflag: tar.TypeReg, body: "one"},
{name: "10/other.txt", typeflag: tar.TypeReg, body: "ten"},
{name: "2/nope.txt", typeflag: tar.TypeReg, body: "two"},
})
dest := filepath.Join(t.TempDir(), "out")
if _, err := safeExtractIndex(arc, 1, dest, maxRestoreUncompressedBytes); err != nil {
t.Fatalf("extract: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "keep.txt")); err != nil {
t.Errorf("index 1 file missing: %v", err)
}
// "10/" must not bleed into index 1 (prefix boundary), nor "2/".
for _, leaked := range []string{"other.txt", "nope.txt"} {
if _, err := os.Stat(filepath.Join(dest, leaked)); !os.IsNotExist(err) {
t.Errorf("index 1 extraction leaked %q from another index", leaked)
}
}
}
func TestSafeExtractIndex_RejectsDisallowedTypes(t *testing.T) {
cases := map[string]tentry{
"symlink": {name: "0/link", typeflag: tar.TypeSymlink, linkname: "/etc/passwd"},
"hardlink": {name: "0/hard", typeflag: tar.TypeLink, linkname: "0/real"},
"chardev": {name: "0/cdev", typeflag: tar.TypeChar},
"blockdev": {name: "0/bdev", typeflag: tar.TypeBlock},
"fifo": {name: "0/fifo", typeflag: tar.TypeFifo},
"sparse": {name: "0/sparse", typeflag: tar.TypeCont}, // GNU sparse / contiguous
}
for name, ent := range cases {
t.Run(name, func(t *testing.T) {
arc := buildTarGz(t, []tentry{ent})
dest := filepath.Join(t.TempDir(), "out")
if _, err := safeExtractIndex(arc, 0, dest, maxRestoreUncompressedBytes); err == nil {
t.Fatalf("expected %s entry to be rejected", name)
}
})
}
}
func TestSafeExtractIndex_RejectsBomb(t *testing.T) {
arc := buildTarGz(t, []tentry{
{name: "0/big.bin", typeflag: tar.TypeReg, body: strings.Repeat("x", 4096)},
})
dest := filepath.Join(t.TempDir(), "out")
if _, err := safeExtractIndex(arc, 0, dest, 1024); err == nil {
t.Fatal("expected extraction to abort past the decompression cap")
}
}
func TestSafeExtractIndex_NoEscapeOutsideDest(t *testing.T) {
// A "../" climb and an absolute member must never materialize a file
// outside dest, regardless of which guard catches it. Note the backslash
// case is platform-split: on Windows `..\winslip.txt` is a real climb that
// withinDir rejects; on Linux it is a literal one-segment filename that
// stays harmlessly inside dest (no guard fires). Both satisfy containment.
arc := buildTarGz(t, []tentry{
{name: "0/../../escape.txt", typeflag: tar.TypeReg, body: "pwned"},
{name: "/abs-escape.txt", typeflag: tar.TypeReg, body: "pwned"},
{name: `0/..\winslip.txt`, typeflag: tar.TypeReg, body: "pwned"},
{name: "0/ok.txt", typeflag: tar.TypeReg, body: "fine"},
})
outParent := t.TempDir()
dest := filepath.Join(outParent, "out")
// May or may not error depending on platform/guard; the invariant is that
// nothing escapes dest.
_, _ = safeExtractIndex(arc, 0, dest, maxRestoreUncompressedBytes)
for _, escaped := range []string{
filepath.Join(outParent, "escape.txt"),
filepath.Join(filepath.Dir(outParent), "escape.txt"),
filepath.Join(outParent, "abs-escape.txt"),
filepath.Join(outParent, "winslip.txt"),
} {
if _, err := os.Stat(escaped); err == nil {
t.Errorf("zip-slip escaped to %s", escaped)
}
}
}
-235
View File
@@ -1,235 +0,0 @@
package volsnap
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"os"
"path"
"path/filepath"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/volume"
)
// maxRestoreUncompressedBytes caps the total decompressed size accepted from a
// snapshot archive during restore (decompression-bomb defence). 50 GiB is far
// above any realistic app data volume while still bounding a hostile archive.
const maxRestoreUncompressedBytes int64 = 50 << 30
// diskFreeHeadroomBytes is extra free space required beyond the extracted size
// so a restore never fills the target filesystem to the brim. The live copy is
// renamed aside (no new space), so the new allocation is ~the extracted size;
// this headroom covers filesystem overhead and metadata.
const diskFreeHeadroomBytes int64 = 256 << 20
// resolvedVol is a manifest volume whose live host path has been re-resolved
// against the workload's CURRENT config (all-or-nothing pre-flight, C3).
type resolvedVol struct {
Index int
Target string
Scope string
LivePath string
}
// parseManifest decodes the snapshot row's manifest JSON ([]SnapshotVolume).
func parseManifest(snap store.VolumeSnapshot) ([]SnapshotVolume, error) {
var m []SnapshotVolume
if err := json.Unmarshal([]byte(snap.Manifest), &m); err != nil {
return nil, fmt.Errorf("parse snapshot manifest: %w", err)
}
if len(m) == 0 {
return nil, fmt.Errorf("snapshot manifest is empty")
}
return m, nil
}
// preflightResolve re-derives every manifest volume's live host path from the
// workload's CURRENT config, ALL-OR-NOTHING (C3): if any snapshotted target is
// no longer declared, its scope is unsupported, or it can't resolve, it returns
// an error and the caller MUST abort BEFORE stopping containers or touching
// disk — config drift mid-restore is silent corruption.
//
// SECURITY: the swap target is keyed on the manifest's container Target path but
// its host directory is derived from the CURRENT (trusted, operator-set)
// Source/Scope — never from the snapshot manifest's persisted Source/Scope. The
// manifest column is attacker-influenceable (e.g. a restored/tampered DB), and
// trusting its Source for stage/project scope would let `Source:"../../etc"`
// redirect the destructive rename-swap outside the volume tree. As defence in
// depth, base-relative resolved paths are asserted to stay under BaseVolumePath.
func preflightResolve(st *store.Store, w store.Workload, settings store.Settings, manifest []SnapshotVolume) ([]resolvedVol, error) {
current, err := volumesByTarget(st, w)
if err != nil {
return nil, fmt.Errorf("load current volumes: %w", err)
}
params := volume.ResolveWorkloadParams{
BasePath: settings.BaseVolumePath,
WorkloadID: w.ID,
WorkloadName: w.Name,
AllowedVolumePaths: settings.AllowedVolumePaths,
}
out := make([]resolvedVol, 0, len(manifest))
for _, mv := range manifest {
// A negative index can never name an archive subtree.
if mv.Index < 0 {
return nil, fmt.Errorf("volume %q has invalid index %d", mv.Target, mv.Index)
}
cur, ok := current[mv.Target]
if !ok {
return nil, fmt.Errorf("volume %q is no longer declared by the workload", mv.Target)
}
if !supportedScopes[cur.Scope] {
return nil, fmt.Errorf("volume %q scope %q is not restorable", mv.Target, cur.Scope)
}
live, err := volume.ResolveWorkloadPath(cur, params)
if err != nil {
return nil, fmt.Errorf("resolve volume %q (%s): %w", mv.Target, cur.Scope, err)
}
// Containment: the destructive swap target must stay inside the volume
// root. Base-relative scopes must resolve under BaseVolumePath; absolute
// scope is already constrained to AllowedVolumePaths by the resolver.
if cur.Scope != string(store.VolumeScopeAbsolute) {
contained, cerr := pathWithinBase(settings.BaseVolumePath, live)
if cerr != nil || !contained {
return nil, fmt.Errorf("resolved path for volume %q escapes the volume root", mv.Target)
}
}
out = append(out, resolvedVol{Index: mv.Index, Target: mv.Target, Scope: cur.Scope, LivePath: live})
}
return out, nil
}
// pathWithinBase reports whether target resolves to base or a path beneath it.
// An empty base is treated as non-containing (refuse rather than allow).
func pathWithinBase(base, target string) (bool, error) {
if base == "" {
return false, nil
}
absBase, err := filepath.Abs(base)
if err != nil {
return false, err
}
absTarget, err := filepath.Abs(target)
if err != nil {
return false, err
}
return withinDir(absBase, absTarget), nil
}
// archiveUncompressedSize scans the archive's tar headers and returns the
// per-index and total uncompressed sizes, enforcing bombCap so a hostile
// archive can't make the disk pre-check allocate unbounded. Feeds the
// per-filesystem free-space pre-check (C5).
//
// The total is a LOWER-BOUND estimate of on-disk consumption: it sums regular-
// file bytes only, ignoring directory entries and per-file inode/block-rounding
// overhead, so a volume of many tiny files consumes more than reported. The
// real safety net is the staged extract + atomic swap (a mid-extract ENOSPC
// discards the staging dir and leaves live untouched), not this pre-check.
//
// "No body copy" is at the API level only — tar.Next still inflates and
// discards each skipped body, so a 50 GiB-of-headers archive does 50 GiB of
// gzip work; bombCap bounds that.
func archiveUncompressedSize(archivePath string, bombCap int64) (perIndex map[int]int64, total int64, err error) {
f, err := os.Open(archivePath)
if err != nil {
return nil, 0, fmt.Errorf("open archive: %w", err)
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return nil, 0, fmt.Errorf("gzip reader: %w", err)
}
defer gz.Close()
perIndex = map[int]int64{}
tr := tar.NewReader(gz)
for {
hdr, e := tr.Next()
if e == io.EOF {
break
}
if e != nil {
return nil, 0, fmt.Errorf("read tar: %w", e)
}
if hdr.Typeflag != tar.TypeReg {
continue
}
name := path.Clean(hdr.Name)
if name == "manifest.json" {
continue
}
idx, ok := leadingIndex(name)
if !ok {
continue
}
total += hdr.Size
if total > bombCap {
return nil, 0, fmt.Errorf("archive exceeds decompression cap of %d bytes", bombCap)
}
perIndex[idx] += hdr.Size
}
return perIndex, total, nil
}
// swap records one volume's atomic dir replacement so it can be rolled back.
type swap struct {
live string
old string // where the prior live dir was set aside ("" if live didn't exist)
tmp string // staging dir holding the freshly-extracted data
hadOld bool // whether a prior live dir existed and was moved to old
}
// stagingDirs returns the per-volume tmp and old staging paths as SIBLINGS of
// the live dir's parent, so every rename in the swap is intra-filesystem and
// therefore atomic (R2). A cross-device rename (live is itself a mountpoint)
// fails loudly in swapVolumeDir rather than silently degrading to a copy.
func stagingDirs(live, token string, index int) (tmp, old string) {
parent := filepath.Dir(live)
base := fmt.Sprintf(".tf-restore-%s-%d", token, index)
return filepath.Join(parent, base+".tmp"), filepath.Join(parent, base+".old")
}
// swapVolumeDir performs the crash-minimal two-rename swap: set the live dir
// aside to old (if it exists), then move the staged tmp into place (C2). On the
// second rename failing it reverts the first so live is never left missing.
// Returns whether a prior live dir was preserved at old (for rollback).
func swapVolumeDir(live, tmp, old string) (hadOld bool, err error) {
if _, statErr := os.Lstat(live); statErr == nil {
if rerr := os.Rename(live, old); rerr != nil {
return false, fmt.Errorf("set aside live %s: %w", live, rerr)
}
hadOld = true
} else if !os.IsNotExist(statErr) {
return false, fmt.Errorf("stat live %s: %w", live, statErr)
}
if mkErr := os.MkdirAll(filepath.Dir(live), 0o700); mkErr != nil {
if hadOld {
_ = os.Rename(old, live)
}
return hadOld, fmt.Errorf("ensure parent of %s: %w", live, mkErr)
}
if rerr := os.Rename(tmp, live); rerr != nil {
if hadOld {
_ = os.Rename(old, live) // revert: live is never left missing
}
return hadOld, fmt.Errorf("promote restored data into %s: %w", live, rerr)
}
return hadOld, nil
}
// rollbackSwaps reverts completed swaps in reverse order: drop the restored
// live dir and move the preserved original back. Best-effort — each step is
// logged by the caller; rollback must attempt every volume regardless.
func rollbackSwaps(done []swap) {
for i := len(done) - 1; i >= 0; i-- {
s := done[i]
_ = os.RemoveAll(s.live)
if s.hadOld {
_ = os.Rename(s.old, s.live)
}
}
}
-400
View File
@@ -1,400 +0,0 @@
package volsnap
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/alexei/tinyforge/internal/store"
)
// Lifecycle is the deploy-side seam Engine.Restore needs but volsnap must not
// import directly (it would couple the snapshot package to the deployer/docker
// packages). The composition root supplies an adapter over the Deployer +
// Docker client via Engine.SetLifecycle.
type Lifecycle interface {
// Lock acquires the per-workload deploy lock (C1) and returns the release
// func. Held by Restore across stop→swap→redeploy.
Lock(workloadID string) func()
// StopContainers stops every running container for the workload (C4 quiesce)
// and returns the image tag the newest running container was on, so the
// redeploy can bring the SAME version back up ("" ⇒ source default tag).
StopContainers(ctx context.Context, workloadID string) (runningTag string, err error)
// Redeploy re-dispatches the workload's current config WITHOUT re-acquiring
// the per-workload lock (the caller holds it). reference pins the image tag.
Redeploy(ctx context.Context, w store.Workload, reference string) error
}
// SetLifecycle wires the deploy-side seam. Pass nil to leave restore disabled.
func (e *Engine) SetLifecycle(lc Lifecycle) { e.lifecycle = lc }
// restoreJournal is the on-disk write-ahead record of an in-flight restore.
// Written before the first destructive rename and deleted on completion; the
// startup RecoverInterruptedRestores sweep replays it after a crash.
type restoreJournal struct {
SnapshotID string `json:"snapshot_id"`
WorkloadID string `json:"workload_id"`
Volumes []journalVolume `json:"volumes"`
}
type journalVolume struct {
Live string `json:"live"`
Old string `json:"old"`
Tmp string `json:"tmp"`
Swapped bool `json:"swapped"`
HadOld bool `json:"had_old"`
}
// staged pairs a resolved volume with its per-restore staging dirs.
type staged struct {
rv resolvedVol
tmp string
old string
}
// Restore overwrites the workload's live host-bind volumes with a snapshot's
// contents and brings the app back up. It is the single, engine-owned entry
// point for the data-loss-sensitive restore flow (image-source workloads only).
//
// Ordering is deliberate and crash-aware:
//
// pre-flight (re-resolve all volumes C3, size + per-fs disk check C5) — abort
// here touches nothing
// → Lock (C1) → re-validate workload → StopContainers (C4 quiesce)
// → extract ALL volumes to sibling .tmp staging dirs (reads the source archive
// fully BEFORE the next step can prune it; shrinks the later destructive
// window to pure renames — R3)
// → capture a pre-restore snapshot (durable escape hatch, after quiesce,
// before any destructive rename — folded suggestion)
// → write the restore journal (R3 crash recovery)
// → swap each volume atomically (rename live→.old, .tmp→live — C2)
// → Redeploy (C4 — image containers are recreated, never reused)
// → remove .old + journal, emit audit event
//
// Engine.Restore holds NO e.mu (R1): per-workload serialization is the
// Lifecycle lock; e.Create takes its own e.mu for the pre-restore archive
// write, so calling it here cannot self-deadlock.
func (e *Engine) Restore(ctx context.Context, snapshotID, workloadID string) error {
if e.lifecycle == nil {
return fmt.Errorf("restore: lifecycle not configured")
}
snap, err := e.store.GetVolumeSnapshot(snapshotID)
if err != nil {
return err
}
if snap.WorkloadID != workloadID {
return fmt.Errorf("snapshot %s does not belong to workload %s", snapshotID, workloadID)
}
w, err := e.store.GetWorkloadByID(workloadID)
if err != nil {
return err
}
if w.SourceKind != "image" {
return fmt.Errorf("restore is only supported for image-source workloads")
}
settings, err := e.store.GetSettings()
if err != nil {
return fmt.Errorf("load settings: %w", err)
}
manifest, err := parseManifest(snap)
if err != nil {
return err
}
resolved, err := preflightResolve(e.store, w, settings, manifest) // C3 all-or-nothing
if err != nil {
return fmt.Errorf("pre-flight: %w", err)
}
archivePath, err := e.FilePath(snap)
if err != nil {
return err
}
perIndex, _, err := archiveUncompressedSize(archivePath, maxRestoreUncompressedBytes)
if err != nil {
return fmt.Errorf("size snapshot: %w", err)
}
if err := checkDiskSpace(resolved, perIndex); err != nil { // C5
return err
}
// ── past pre-flight: take the per-workload lock and quiesce ──────────────
unlock := e.lifecycle.Lock(workloadID)
defer unlock()
// A teardown may have won the lock and deleted the workload while we waited.
if _, err := e.store.GetWorkloadByID(workloadID); err != nil {
return fmt.Errorf("workload disappeared before restore: %w", err)
}
tag, err := e.lifecycle.StopContainers(ctx, workloadID) // C4 stop
if err != nil {
return fmt.Errorf("stop containers: %w", err)
}
// Extract every volume to its staging dir FIRST. This reads the source
// archive fully before the pre-restore capture below can prune it, and
// leaves only pure renames for the destructive phase (R3).
token := uuid.New().String()[:8]
stagedVols := make([]staged, 0, len(resolved))
for _, rv := range resolved {
tmp, old := stagingDirs(rv.LivePath, token, rv.Index)
if _, exErr := safeExtractIndex(archivePath, rv.Index, tmp, maxRestoreUncompressedBytes); exErr != nil {
cleanupStaging(stagedVols)
_ = os.RemoveAll(tmp)
// Nothing swapped yet — bring the app back up on its original data.
e.redeployAfterAbort(ctx, w, tag)
return fmt.Errorf("extract volume %q: %w", rv.Target, exErr)
}
stagedVols = append(stagedVols, staged{rv: rv, tmp: tmp, old: old})
}
// Durable pre-restore snapshot (escape hatch). Quiesced (after stop), and
// the source archive is already fully extracted so a prune here is harmless.
// Best-effort, matching the DB-restore precedent: a failure is logged but
// does not abort — the .old dirs + journal are the in-operation safety net.
if _, err := e.Create(w, settings, "pre-restore"); err != nil {
slog.Warn("restore: pre-restore snapshot failed (continuing)",
"workload", workloadID, "error", err)
}
// Journal before the first destructive rename so a crash can be recovered.
jr := restoreJournal{SnapshotID: snapshotID, WorkloadID: workloadID}
for _, sv := range stagedVols {
jr.Volumes = append(jr.Volumes, journalVolume{Live: sv.rv.LivePath, Old: sv.old, Tmp: sv.tmp})
}
if err := e.writeJournal(jr); err != nil {
cleanupStaging(stagedVols)
e.redeployAfterAbort(ctx, w, tag)
return fmt.Errorf("write restore journal: %w", err)
}
// ── destructive phase: pure atomic renames ──────────────────────────────
done := make([]swap, 0, len(stagedVols))
for i, sv := range stagedVols {
hadOld, swErr := swapVolumeDir(sv.rv.LivePath, sv.tmp, sv.old)
if swErr != nil {
rollbackSwaps(done) // restore already-swapped volumes
cleanupStagingFrom(stagedVols, i) // drop remaining un-swapped tmp/old
e.removeJournal(workloadID)
e.redeployAfterAbort(ctx, w, tag)
return fmt.Errorf("swap volume %q: %w", sv.rv.Target, swErr)
}
done = append(done, swap{live: sv.rv.LivePath, old: sv.old, tmp: sv.tmp, hadOld: hadOld})
jr.Volumes[i].Swapped = true
jr.Volumes[i].HadOld = hadOld
_ = e.writeJournal(jr) // progress checkpoint (best-effort)
}
// Bring the app back up against the restored data (C4 — recreate, redeploy).
if err := e.lifecycle.Redeploy(ctx, w, tag); err != nil {
// The data IS restored; only the app failed to come back. Do NOT roll
// back the volumes — surface the redeploy error so the operator retries
// a deploy. Clean up the .old set-asides and the journal.
cleanupOld(done)
e.removeJournal(workloadID)
return fmt.Errorf("redeploy after restore: %w", err)
}
cleanupOld(done)
e.removeJournal(workloadID)
e.emitRestoreEvent(workloadID, snapshotID, len(done))
slog.Info("volume snapshot restored", "workload", workloadID, "snapshot", snapshotID, "volumes", len(done))
return nil
}
// redeployAfterAbort re-dispatches after an aborted restore so a stopped app
// does not stay down. Best-effort: the error is logged, not returned (the
// restore failure is the primary error the caller surfaces).
func (e *Engine) redeployAfterAbort(ctx context.Context, w store.Workload, tag string) {
if err := e.lifecycle.Redeploy(ctx, w, tag); err != nil {
slog.Warn("restore: redeploy after abort failed", "workload", w.ID, "error", err)
}
}
// RecoverInterruptedRestores replays restore journals left by a crash mid-
// restore, mirroring CleanOrphans (run once at startup, before serving). For
// each volume: a completed swap keeps the restored live dir and drops the set-
// aside original; an incomplete swap that left live missing is reverted from
// .old; stray staging dirs are removed. Returns the number of journals handled.
func (e *Engine) RecoverInterruptedRestores() (int, error) {
e.mu.Lock()
defer e.mu.Unlock()
entries, err := os.ReadDir(e.snapDir)
if err != nil {
return 0, fmt.Errorf("read snapshot dir: %w", err)
}
recovered := 0
for _, ent := range entries {
name := ent.Name()
if ent.IsDir() || !strings.HasPrefix(name, "restore-") || !strings.HasSuffix(name, ".json") {
continue
}
path := filepath.Join(e.snapDir, name)
data, rerr := os.ReadFile(path)
if rerr != nil {
slog.Warn("restore recovery: read journal", "file", name, "error", rerr)
continue
}
var jr restoreJournal
if jerr := json.Unmarshal(data, &jr); jerr != nil {
slog.Warn("restore recovery: parse journal", "file", name, "error", jerr)
continue
}
slog.Warn("restore recovery: replaying interrupted restore",
"workload", jr.WorkloadID, "snapshot", jr.SnapshotID, "volumes", len(jr.Volumes))
for _, v := range jr.Volumes {
recoverVolume(v)
}
if rmErr := os.Remove(path); rmErr != nil {
slog.Warn("restore recovery: remove journal", "file", name, "error", rmErr)
}
recovered++
}
return recovered, nil
}
// recoverVolume reconciles a single volume's on-disk state from its journal
// entry after a crash. Each branch leaves the live dir intact (either restored
// or original) and removes staging leftovers.
func recoverVolume(v journalVolume) {
if v.Swapped {
// Swap completed: live already holds restored data. Drop the set-aside.
_ = os.RemoveAll(v.Old)
_ = os.RemoveAll(v.Tmp)
return
}
if _, err := os.Lstat(v.Live); os.IsNotExist(err) {
if _, oerr := os.Lstat(v.Old); oerr == nil {
// Crashed mid-rename (live→old done, tmp→live not): revert.
_ = os.Rename(v.Old, v.Live)
}
} else {
// live is intact (original). Any .old is a dangling partial copy.
_ = os.RemoveAll(v.Old)
}
_ = os.RemoveAll(v.Tmp)
}
// ── journal + cleanup helpers ───────────────────────────────────────────────
func (e *Engine) journalPath(workloadID string) string {
// workloadID is a server-generated id (loaded from the DB before we get
// here). filepath.Base defends against any separator sneaking into the name.
return filepath.Join(e.snapDir, "restore-"+filepath.Base(workloadID)+".json")
}
func (e *Engine) writeJournal(jr restoreJournal) error {
data, err := json.Marshal(jr)
if err != nil {
return fmt.Errorf("encode journal: %w", err)
}
// Write atomically (tmp + rename): a torn journal would silently disable the
// recovery sweep (RecoverInterruptedRestores skips unparseable journals), so
// a crash mid-write must never leave a half-written WAL on disk. The .tmp
// suffix is ignored by the recovery scan (it matches *.json only).
final := e.journalPath(jr.WorkloadID)
tmp := final + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write journal: %w", err)
}
if err := os.Rename(tmp, final); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("commit journal: %w", err)
}
return nil
}
func (e *Engine) removeJournal(workloadID string) {
if err := os.Remove(e.journalPath(workloadID)); err != nil && !os.IsNotExist(err) {
slog.Warn("restore: remove journal", "workload", workloadID, "error", err)
}
}
func (e *Engine) emitRestoreEvent(workloadID, snapshotID string, volumes int) {
meta, _ := json.Marshal(map[string]any{"snapshot_id": snapshotID, "volumes": volumes})
if _, err := e.store.InsertEvent(store.EventLog{
Source: "volsnap",
WorkloadID: workloadID,
Severity: "info",
Message: "volume snapshot restored",
Metadata: string(meta),
}); err != nil {
slog.Warn("restore: record event", "workload", workloadID, "error", err)
}
}
// cleanupStaging removes the tmp + old staging dirs for every staged volume
// (used when aborting before the swap phase).
func cleanupStaging(sv []staged) {
for _, s := range sv {
_ = os.RemoveAll(s.tmp)
_ = os.RemoveAll(s.old)
}
}
// cleanupStagingFrom removes staging dirs from index `from` onward (the volumes
// not yet swapped when a swap failed).
func cleanupStagingFrom(sv []staged, from int) {
for i := from; i < len(sv); i++ {
_ = os.RemoveAll(sv[i].tmp)
_ = os.RemoveAll(sv[i].old)
}
}
// cleanupOld removes the .old set-aside dirs after a successful (or data-
// committed) restore to reclaim disk; the pre-restore snapshot is the durable
// rollback target.
func cleanupOld(done []swap) {
for _, s := range done {
_ = os.RemoveAll(s.old)
}
}
// checkDiskSpace verifies each target filesystem has room for the volumes that
// will be staged on it (C5). Peak usage co-locates the live copy (renamed
// aside, no new space) and the extracted copy (new space ≈ uncompressed size),
// so the new allocation per filesystem is the sum of its volumes' extracted
// sizes plus headroom. The estimate is a lower bound (see archiveUncompressedSize);
// a mid-extract ENOSPC is still caught and rolled back.
func checkDiskSpace(resolved []resolvedVol, perIndex map[int]int64) error {
needByParent := map[string]int64{}
for _, rv := range resolved {
needByParent[filepath.Dir(rv.LivePath)] += perIndex[rv.Index]
}
for parent, need := range needByParent {
probe := firstExistingAncestor(parent)
free, err := freeDiskBytes(probe)
if err != nil {
return fmt.Errorf("check disk space at %s: %w", probe, err)
}
if int64(free) < need+diskFreeHeadroomBytes {
return fmt.Errorf("insufficient disk space at %s: need ~%d bytes, have %d",
parent, need+diskFreeHeadroomBytes, free)
}
}
return nil
}
// firstExistingAncestor walks up p until it finds a path that exists, so the
// free-space probe has a real filesystem to stat even when the volume dir (or
// its parent) hasn't been created yet.
func firstExistingAncestor(p string) string {
for {
if _, err := os.Stat(p); err == nil {
return p
}
parent := filepath.Dir(p)
if parent == p {
return p
}
p = parent
}
}
-345
View File
@@ -1,345 +0,0 @@
package volsnap
import (
"archive/tar"
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/volume"
)
// fakeLifecycle records the order of deploy-side calls and lets tests inject
// failures, without a real deployer/docker.
type fakeLifecycle struct {
mu sync.Mutex
calls []string
tag string
stopErr error
redeployErr error
redeployRef string
}
func (f *fakeLifecycle) rec(s string) {
f.mu.Lock()
f.calls = append(f.calls, s)
f.mu.Unlock()
}
func (f *fakeLifecycle) Lock(string) func() { f.rec("lock"); return func() { f.rec("unlock") } }
func (f *fakeLifecycle) StopContainers(context.Context, string) (string, error) {
f.rec("stop")
return f.tag, f.stopErr
}
func (f *fakeLifecycle) Redeploy(_ context.Context, _ store.Workload, ref string) error {
f.rec("redeploy:" + ref)
f.redeployRef = ref
return f.redeployErr
}
func (f *fakeLifecycle) saw(s string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.calls {
if c == s {
return true
}
}
return false
}
func newRestoreEngine(t *testing.T) (*Engine, *store.Store, string) {
t.Helper()
st, err := store.New(":memory:")
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { st.Close() })
base := t.TempDir()
s, _ := st.GetSettings()
s.BaseVolumePath = base
if err := st.UpdateSettings(s); err != nil {
t.Fatalf("settings: %v", err)
}
eng, err := New(st, t.TempDir())
if err != nil {
t.Fatalf("engine: %v", err)
}
return eng, st, base
}
// seedImageWorkload creates an image workload with one project-scope volume and
// returns it plus the resolved live host dir.
func seedImageWorkload(t *testing.T, st *store.Store) (store.Workload, string) {
t.Helper()
w, err := st.CreateWorkload(store.Workload{
Name: "data-app",
Kind: "project",
SourceKind: "image",
SourceConfig: `{"image":"reg/app","port":80,"volumes":[{"source":"data","target":"/data","scope":"project"}]}`,
})
if err != nil {
t.Fatalf("create workload: %v", err)
}
settings, _ := st.GetSettings()
live, err := volume.ResolveWorkloadPath(
store.WorkloadVolume{Source: "data", Target: "/data", Scope: "project"},
volume.ResolveWorkloadParams{BasePath: settings.BaseVolumePath, WorkloadID: w.ID, WorkloadName: w.Name},
)
if err != nil {
t.Fatalf("resolve: %v", err)
}
return w, live
}
func TestEngineRestore_HappyPath(t *testing.T) {
eng, st, _ := newRestoreEngine(t)
w, live := seedImageWorkload(t, st)
mkDirWith(t, live, "orig.txt", "ORIGINAL")
settings, _ := st.GetSettings()
snap, err := eng.Create(w, settings, "base")
if err != nil {
t.Fatalf("create snapshot: %v", err)
}
// Drift: the live dir now differs from the snapshot.
if err := os.WriteFile(filepath.Join(live, "orig.txt"), []byte("CHANGED"), 0o600); err != nil {
t.Fatal(err)
}
mkDirWith(t, live, "extra.txt", "NEW") // not in the snapshot
fake := &fakeLifecycle{tag: "v1.2.3"}
eng.SetLifecycle(fake)
// Uses the REAL eng.Create for the pre-restore capture — if Restore held
// e.mu this would deadlock (R1), failing the test instead of production.
if err := eng.Restore(context.Background(), snap.ID, w.ID); err != nil {
t.Fatalf("restore: %v", err)
}
if got := readIn(t, live, "orig.txt"); got != "ORIGINAL" {
t.Errorf("orig.txt = %q, want ORIGINAL (restored)", got)
}
if _, err := os.Stat(filepath.Join(live, "extra.txt")); !os.IsNotExist(err) {
t.Error("extra.txt should be gone — restore replaces the volume dir wholesale")
}
for _, want := range []string{"lock", "stop", "redeploy:v1.2.3", "unlock"} {
if !fake.saw(want) {
t.Errorf("expected lifecycle call %q; calls=%v", want, fake.calls)
}
}
if fake.redeployRef != "v1.2.3" {
t.Errorf("redeploy reference = %q, want the running tag v1.2.3", fake.redeployRef)
}
// A durable pre-restore snapshot was captured (base + pre-restore).
snaps, _ := eng.List(w.ID)
if len(snaps) != 2 {
t.Errorf("expected 2 snapshots (base + pre-restore), got %d", len(snaps))
}
// No journal left behind.
assertNoJournal(t, eng)
}
func TestEngineRestore_RedeployFailureKeepsRestoredData(t *testing.T) {
eng, st, _ := newRestoreEngine(t)
w, live := seedImageWorkload(t, st)
mkDirWith(t, live, "orig.txt", "ORIGINAL")
settings, _ := st.GetSettings()
snap, _ := eng.Create(w, settings, "base")
if err := os.WriteFile(filepath.Join(live, "orig.txt"), []byte("CHANGED"), 0o600); err != nil {
t.Fatal(err)
}
fake := &fakeLifecycle{tag: "v1", redeployErr: errors.New("boom")}
eng.SetLifecycle(fake)
err := eng.Restore(context.Background(), snap.ID, w.ID)
if err == nil || !strings.Contains(err.Error(), "redeploy") {
t.Fatalf("expected a redeploy error, got %v", err)
}
// Data is committed despite the redeploy failure — we must NOT roll it back.
if got := readIn(t, live, "orig.txt"); got != "ORIGINAL" {
t.Errorf("orig.txt = %q, want ORIGINAL (restore committed)", got)
}
assertNoJournal(t, eng)
}
func TestEngineRestore_PreflightFailDoesNotLockOrStop(t *testing.T) {
eng, st, _ := newRestoreEngine(t)
w, _ := seedImageWorkload(t, st)
// A snapshot whose manifest names an unsupported scope ⇒ pre-flight aborts.
bad, err := st.CreateVolumeSnapshot(store.VolumeSnapshot{
WorkloadID: w.ID, Filename: "bad.tar.gz",
Manifest: `[{"index":0,"target":"/x","scope":"named","source":"x"}]`,
})
if err != nil {
t.Fatalf("seed snapshot: %v", err)
}
fake := &fakeLifecycle{}
eng.SetLifecycle(fake)
if err := eng.Restore(context.Background(), bad.ID, w.ID); err == nil {
t.Fatal("expected pre-flight to abort on an unsupported scope")
}
if fake.saw("lock") || fake.saw("stop") {
t.Errorf("pre-flight abort must happen BEFORE lock/stop; calls=%v", fake.calls)
}
}
func TestEngineRestore_NilLifecycle(t *testing.T) {
eng, _, _ := newRestoreEngine(t)
if err := eng.Restore(context.Background(), "s", "w"); err == nil ||
!strings.Contains(err.Error(), "lifecycle") {
t.Fatalf("expected a lifecycle-not-configured error, got %v", err)
}
}
func TestEngineRestore_WrongWorkload(t *testing.T) {
eng, st, _ := newRestoreEngine(t)
w, live := seedImageWorkload(t, st)
mkDirWith(t, live, "f.txt", "x")
settings, _ := st.GetSettings()
snap, _ := eng.Create(w, settings, "base")
fake := &fakeLifecycle{}
eng.SetLifecycle(fake)
if err := eng.Restore(context.Background(), snap.ID, "some-other-workload"); err == nil {
t.Fatal("expected cross-workload restore to be rejected")
}
if fake.saw("lock") {
t.Error("must reject before taking the lock")
}
}
func TestEngineRestore_ExtractFailureAbortsAfterLock(t *testing.T) {
eng, st, _ := newRestoreEngine(t)
// The workload must CURRENTLY declare both targets so pre-flight passes and
// the failure happens during extraction (post-lock), not pre-flight.
w, err := st.CreateWorkload(store.Workload{
Name: "two-vol", Kind: "project", SourceKind: "image",
SourceConfig: `{"image":"x","port":80,"volumes":[` +
`{"source":"data","target":"/data","scope":"project"},` +
`{"source":"other","target":"/other","scope":"project"}]}`,
})
if err != nil {
t.Fatalf("create workload: %v", err)
}
// Hand-build a 2-volume archive where volume 1 carries a symlink entry the
// untrusted extractor rejects — forcing a post-lock extract failure after
// volume 0 has already been staged.
arc := buildTarGz(t, []tentry{
{name: "0/f.txt", typeflag: tar.TypeReg, body: "x"},
{name: "1/evil", typeflag: tar.TypeSymlink, linkname: "/etc/passwd"},
})
data, err := os.ReadFile(arc)
if err != nil {
t.Fatal(err)
}
fname := "extract-fail.tar.gz"
if err := os.WriteFile(filepath.Join(eng.snapDir, fname), data, 0o600); err != nil {
t.Fatal(err)
}
snap, err := st.CreateVolumeSnapshot(store.VolumeSnapshot{
WorkloadID: w.ID, Filename: fname,
Manifest: `[{"index":0,"target":"/data","scope":"project","source":"data"},` +
`{"index":1,"target":"/other","scope":"project","source":"other"}]`,
})
if err != nil {
t.Fatalf("seed snapshot: %v", err)
}
fake := &fakeLifecycle{tag: "v1"}
eng.SetLifecycle(fake)
if err := eng.Restore(context.Background(), snap.ID, w.ID); err == nil {
t.Fatal("expected extract failure to abort the restore")
}
// Post-lock abort: it stopped, then brought the app back (no swaps happened).
if !fake.saw("lock") || !fake.saw("stop") || !fake.saw("redeploy:v1") {
t.Errorf("expected lock+stop+redeploy after a post-lock abort; calls=%v", fake.calls)
}
// No staging or journal left behind.
assertNoJournal(t, eng)
entries, _ := os.ReadDir(eng.snapDir)
for _, e := range entries {
if strings.Contains(e.Name(), ".tf-restore-") {
t.Errorf("leftover staging dir: %s", e.Name())
}
}
}
func TestRecoverInterruptedRestores(t *testing.T) {
eng, _, _ := newRestoreEngine(t)
root := t.TempDir()
// A: swap completed — keep restored live, drop old.
liveA := filepath.Join(root, "A")
oldA := filepath.Join(root, ".A.old")
mkDirWith(t, liveA, "f", "RESTORED-A")
mkDirWith(t, oldA, "f", "ORIGINAL-A")
// B: not swapped, live present — keep original, drop tmp.
liveB := filepath.Join(root, "B")
tmpB := filepath.Join(root, ".B.tmp")
mkDirWith(t, liveB, "f", "ORIGINAL-B")
mkDirWith(t, tmpB, "f", "STAGED-B")
// C: crashed mid-rename — live missing, old present — revert from old.
liveC := filepath.Join(root, "C")
oldC := filepath.Join(root, ".C.old")
tmpC := filepath.Join(root, ".C.tmp")
mkDirWith(t, oldC, "f", "ORIGINAL-C")
mkDirWith(t, tmpC, "f", "STAGED-C")
jr := restoreJournal{SnapshotID: "snap", WorkloadID: "wl-recover", Volumes: []journalVolume{
{Live: liveA, Old: oldA, Swapped: true, HadOld: true},
{Live: liveB, Tmp: tmpB, Swapped: false},
{Live: liveC, Old: oldC, Tmp: tmpC, Swapped: false},
}}
if err := eng.writeJournal(jr); err != nil {
t.Fatalf("write journal: %v", err)
}
n, err := eng.RecoverInterruptedRestores()
if err != nil {
t.Fatalf("recover: %v", err)
}
if n != 1 {
t.Fatalf("recovered %d journals, want 1", n)
}
if got := readIn(t, liveA, "f"); got != "RESTORED-A" {
t.Errorf("A live = %q, want RESTORED-A (swap kept)", got)
}
if _, err := os.Stat(oldA); !os.IsNotExist(err) {
t.Error("A old should be removed")
}
if got := readIn(t, liveB, "f"); got != "ORIGINAL-B" {
t.Errorf("B live = %q, want ORIGINAL-B (untouched)", got)
}
if _, err := os.Stat(tmpB); !os.IsNotExist(err) {
t.Error("B tmp should be removed")
}
if got := readIn(t, liveC, "f"); got != "ORIGINAL-C" {
t.Errorf("C live = %q, want ORIGINAL-C (reverted from old)", got)
}
if _, err := os.Stat(tmpC); !os.IsNotExist(err) {
t.Error("C tmp should be removed")
}
assertNoJournal(t, eng)
}
func assertNoJournal(t *testing.T, eng *Engine) {
t.Helper()
entries, err := os.ReadDir(eng.snapDir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), "restore-") && strings.HasSuffix(e.Name(), ".json") {
t.Errorf("leftover restore journal: %s", e.Name())
}
}
}
-270
View File
@@ -1,270 +0,0 @@
package volsnap
import (
"os"
"path/filepath"
"testing"
"github.com/alexei/tinyforge/internal/store"
)
func mkDirWith(t *testing.T, dir, fname, content string) {
t.Helper()
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, fname), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
func readIn(t *testing.T, dir, fname string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(dir, fname))
if err != nil {
t.Fatalf("read %s/%s: %v", dir, fname, err)
}
return string(b)
}
func TestSwapVolumeDir_ReplacesAndPreservesOld(t *testing.T) {
root := t.TempDir()
live := filepath.Join(root, "data")
tmp := filepath.Join(root, ".data.tmp")
old := filepath.Join(root, ".data.old")
mkDirWith(t, live, "f.txt", "ORIGINAL")
mkDirWith(t, tmp, "f.txt", "RESTORED")
hadOld, err := swapVolumeDir(live, tmp, old)
if err != nil {
t.Fatalf("swap: %v", err)
}
if !hadOld {
t.Error("hadOld should be true when a live dir existed")
}
if got := readIn(t, live, "f.txt"); got != "RESTORED" {
t.Errorf("live = %q, want RESTORED", got)
}
if got := readIn(t, old, "f.txt"); got != "ORIGINAL" {
t.Errorf("old = %q, want ORIGINAL (prior live preserved)", got)
}
}
func TestSwapVolumeDir_MissingLive(t *testing.T) {
root := t.TempDir()
live := filepath.Join(root, "data") // does not exist
tmp := filepath.Join(root, ".data.tmp")
old := filepath.Join(root, ".data.old")
mkDirWith(t, tmp, "f.txt", "RESTORED")
hadOld, err := swapVolumeDir(live, tmp, old)
if err != nil {
t.Fatalf("swap: %v", err)
}
if hadOld {
t.Error("hadOld should be false when no live dir existed")
}
if got := readIn(t, live, "f.txt"); got != "RESTORED" {
t.Errorf("live = %q, want RESTORED", got)
}
}
func TestSwapVolumeDir_RevertsOnSecondRenameFailure(t *testing.T) {
// The data-loss-critical path: the live→old rename succeeds, then tmp→live
// fails (here: tmp is absent). swapVolumeDir MUST self-revert old→live so
// the live dir is never left missing, and old must not be left dangling.
root := t.TempDir()
live := filepath.Join(root, "data")
old := filepath.Join(root, ".data.old")
mkDirWith(t, live, "f.txt", "ORIGINAL")
hadOld, err := swapVolumeDir(live, filepath.Join(root, ".data.tmp" /* absent */), old)
if err == nil {
t.Fatal("expected swap to fail when tmp is absent")
}
if got := readIn(t, live, "f.txt"); got != "ORIGINAL" {
t.Errorf("live = %q, want ORIGINAL restored by self-revert", got)
}
if _, statErr := os.Stat(old); !os.IsNotExist(statErr) {
t.Errorf("old dir should have been renamed back to live, not left dangling")
}
_ = hadOld
}
func TestStagingDirs_SameParentAsLive(t *testing.T) {
// R2 invariant: tmp and old must be siblings of the live dir's parent so
// every rename in the swap is intra-filesystem (atomic).
live := filepath.FromSlash("/srv/data/postgres")
tmp, old := stagingDirs(live, "tok", 3)
wantParent := filepath.Dir(live)
if filepath.Dir(tmp) != wantParent || filepath.Dir(old) != wantParent {
t.Errorf("staging dirs not siblings of live's parent: tmp=%s old=%s parent=%s", tmp, old, wantParent)
}
if tmp == old {
t.Error("tmp and old must be distinct paths")
}
}
func TestRollbackSwaps_RestoresOriginals(t *testing.T) {
root := t.TempDir()
var done []swap
for _, name := range []string{"vol0", "vol1"} {
live := filepath.Join(root, name)
tmp := filepath.Join(root, "."+name+".tmp")
old := filepath.Join(root, "."+name+".old")
mkDirWith(t, live, "f.txt", "ORIGINAL-"+name)
mkDirWith(t, tmp, "f.txt", "RESTORED-"+name)
hadOld, err := swapVolumeDir(live, tmp, old)
if err != nil {
t.Fatalf("swap %s: %v", name, err)
}
done = append(done, swap{live: live, old: old, tmp: tmp, hadOld: hadOld})
}
// Both are now RESTORED; rolling back must return both to ORIGINAL.
rollbackSwaps(done)
for _, name := range []string{"vol0", "vol1"} {
if got := readIn(t, filepath.Join(root, name), "f.txt"); got != "ORIGINAL-"+name {
t.Errorf("%s after rollback = %q, want ORIGINAL-%s", name, got, name)
}
}
}
func TestRollbackSwaps_PartialLeavesUnswappedIntact(t *testing.T) {
root := t.TempDir()
// vol0 swaps successfully; vol1 "fails" (its tmp is absent) so only vol0 is
// recorded in done. Rollback of vol0 must restore the original.
live0 := filepath.Join(root, "vol0")
tmp0 := filepath.Join(root, ".vol0.tmp")
old0 := filepath.Join(root, ".vol0.old")
mkDirWith(t, live0, "f.txt", "ORIGINAL-0")
mkDirWith(t, tmp0, "f.txt", "RESTORED-0")
hadOld, err := swapVolumeDir(live0, tmp0, old0)
if err != nil {
t.Fatalf("swap vol0: %v", err)
}
live1 := filepath.Join(root, "vol1")
mkDirWith(t, live1, "f.txt", "ORIGINAL-1")
if _, err := swapVolumeDir(live1, filepath.Join(root, ".vol1.tmp" /* absent */), filepath.Join(root, ".vol1.old")); err == nil {
t.Fatal("expected vol1 swap to fail (tmp absent)")
}
rollbackSwaps([]swap{{live: live0, old: old0, tmp: tmp0, hadOld: hadOld}})
if got := readIn(t, live0, "f.txt"); got != "ORIGINAL-0" {
t.Errorf("vol0 after rollback = %q, want ORIGINAL-0", got)
}
// vol1 was never swapped; its original must be untouched.
if got := readIn(t, live1, "f.txt"); got != "ORIGINAL-1" {
t.Errorf("vol1 = %q, want ORIGINAL-1 (never swapped)", got)
}
}
func TestPreflightResolve_AllOrNothing(t *testing.T) {
eng, st, base := newRestoreEngine(t)
_ = eng
w, err := st.CreateWorkload(store.Workload{
Name: "app", Kind: "project", SourceKind: "image",
SourceConfig: `{"image":"x","port":80,"volumes":[` +
`{"source":"data","target":"/data","scope":"project"},` +
`{"source":"var","target":"/var","scope":"stage"}]}`,
})
if err != nil {
t.Fatalf("create workload: %v", err)
}
settings, _ := st.GetSettings()
// A snapshotted target no longer declared by the workload ⇒ whole abort (C3).
if _, err := preflightResolve(st, w, settings, []SnapshotVolume{
{Index: 0, Target: "/data", Scope: "project", Source: "data"},
{Index: 1, Target: "/gone", Scope: "project", Source: "gone"},
}); err == nil {
t.Fatal("expected all-or-nothing abort when a target is no longer declared")
}
// All declared ⇒ resolves every volume under BaseVolumePath.
resolved, err := preflightResolve(st, w, settings, []SnapshotVolume{
{Index: 0, Target: "/data", Scope: "project", Source: "data"},
{Index: 1, Target: "/var", Scope: "stage", Source: "var"},
})
if err != nil {
t.Fatalf("preflight: %v", err)
}
if len(resolved) != 2 {
t.Fatalf("resolved %d volumes, want 2", len(resolved))
}
for _, rv := range resolved {
if ok, _ := pathWithinBase(base, rv.LivePath); !ok {
t.Errorf("volume %q resolved to %q, outside base %q", rv.Target, rv.LivePath, base)
}
}
}
// TestPreflightResolve_IgnoresManifestSource is the regression guard for the
// security fix: a tampered manifest whose Source tries to escape (../../etc)
// must NOT redirect the swap target — the host path is re-derived from the
// workload's CURRENT trusted config (Source "data"), staying under the base.
func TestPreflightResolve_IgnoresManifestSource(t *testing.T) {
_, st, base := newRestoreEngine(t)
w, err := st.CreateWorkload(store.Workload{
Name: "app", Kind: "project", SourceKind: "image",
SourceConfig: `{"image":"x","port":80,"volumes":[{"source":"data","target":"/data","scope":"project"}]}`,
})
if err != nil {
t.Fatalf("create workload: %v", err)
}
settings, _ := st.GetSettings()
resolved, err := preflightResolve(st, w, settings, []SnapshotVolume{
{Index: 0, Target: "/data", Scope: "project", Source: "../../../../etc"},
})
if err != nil {
t.Fatalf("preflight: %v", err)
}
if len(resolved) != 1 {
t.Fatalf("resolved %d, want 1", len(resolved))
}
if ok, _ := pathWithinBase(base, resolved[0].LivePath); !ok {
t.Errorf("manifest Source escaped containment: resolved %q outside base %q",
resolved[0].LivePath, base)
}
if filepath.Base(resolved[0].LivePath) != "data" {
t.Errorf("expected target derived from current config (data), got %q", resolved[0].LivePath)
}
}
func TestArchiveUncompressedSize(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "a.txt"), "hello") // 5
if err := os.MkdirAll(filepath.Join(root, "sub"), 0o700); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(root, "sub", "b.txt"), "world!") // 6
dest := filepath.Join(t.TempDir(), "snap.tar.gz")
if _, err := writeArchive(dest, []VolumeRef{{Target: "/d", Scope: "project", Source: "d", HostPath: root}}); err != nil {
t.Fatal(err)
}
per, total, err := archiveUncompressedSize(dest, maxRestoreUncompressedBytes)
if err != nil {
t.Fatalf("size: %v", err)
}
if total != 11 {
t.Errorf("total = %d, want 11", total)
}
if per[0] != 11 {
t.Errorf("perIndex[0] = %d, want 11", per[0])
}
if _, _, err := archiveUncompressedSize(dest, 4); err == nil {
t.Error("expected sizing to abort past the decompression cap")
}
}
func TestFreeDiskBytes(t *testing.T) {
n, err := freeDiskBytes(t.TempDir())
if err != nil {
t.Fatalf("freeDiskBytes: %v", err)
}
if n == 0 {
t.Error("expected non-zero free space on the temp filesystem")
}
}
+18 -32
View File
@@ -80,10 +80,27 @@ func SnapshotableVolumes(st *store.Store, w store.Workload, settings store.Setti
return nil, nil, nil
}
byTarget, perr := volumesByTarget(st, w)
byTarget := map[string]store.WorkloadVolume{}
var cfg scVolumes
if w.SourceConfig != "" {
// Best-effort: a malformed config simply yields no inline volumes; the
// persisted rows below still apply.
_ = json.Unmarshal([]byte(w.SourceConfig), &cfg)
}
for _, v := range cfg.Volumes {
if v.Target == "" {
continue
}
byTarget[v.Target] = store.WorkloadVolume{Source: v.Source, Target: v.Target, Scope: v.Scope, Name: v.Name}
}
persisted, perr := st.ListWorkloadVolumes(w.ID)
if perr != nil {
return nil, nil, perr
}
for _, p := range persisted {
byTarget[p.Target] = store.WorkloadVolume{Source: p.Source, Target: p.Target, Scope: p.Scope, Name: p.Name}
}
params := volume.ResolveWorkloadParams{
BasePath: settings.BaseVolumePath,
@@ -115,37 +132,6 @@ func SnapshotableVolumes(st *store.Store, w store.Workload, settings store.Setti
return refs, skipped, nil
}
// volumesByTarget merges a workload's source_config inline volumes with its
// persisted workload_volumes rows (persisted wins on a target conflict), keyed
// by container target path. It is the authoritative current volume set, shared
// by capture enumeration (SnapshotableVolumes) and restore pre-flight so both
// resolve host paths the same way — restore must never trust a snapshot's
// persisted manifest to name a host directory.
func volumesByTarget(st *store.Store, w store.Workload) (map[string]store.WorkloadVolume, error) {
byTarget := map[string]store.WorkloadVolume{}
var cfg scVolumes
if w.SourceConfig != "" {
// Best-effort: a malformed config simply yields no inline volumes; the
// persisted rows below still apply.
_ = json.Unmarshal([]byte(w.SourceConfig), &cfg)
}
for _, v := range cfg.Volumes {
if v.Target == "" {
continue
}
byTarget[v.Target] = store.WorkloadVolume{Source: v.Source, Target: v.Target, Scope: v.Scope, Name: v.Name}
}
persisted, err := st.ListWorkloadVolumes(w.ID)
if err != nil {
return nil, err
}
for _, p := range persisted {
byTarget[p.Target] = store.WorkloadVolume{Source: p.Source, Target: p.Target, Scope: p.Scope, Name: p.Name}
}
return byTarget, nil
}
func skipReason(scope string) string {
switch scope {
case string(store.VolumeScopeInstance):
+28
View File
@@ -338,3 +338,31 @@ func buildInboundEvent(body []byte, headers http.Header) (plugin.InboundEvent, e
gitEvt.Headers = headers
return gitEvt, nil
}
// toPluginWorkload mirrors the api-layer converter but kept local so the
// webhook package does not depend on internal/api. Inlining is cheap and
// avoids elevating that converter to a shared package.
func toPluginWorkload(w store.Workload) plugin.Workload {
var faces []plugin.PublicFace
if w.PublicFaces != "" {
_ = json.Unmarshal([]byte(w.PublicFaces), &faces)
}
return plugin.Workload{
ID: w.ID,
Name: w.Name,
GroupID: w.AppID,
ParentWorkloadID: w.ParentWorkloadID,
SourceKind: w.SourceKind,
SourceConfig: json.RawMessage(w.SourceConfig),
TriggerKind: w.TriggerKind,
TriggerConfig: json.RawMessage(w.TriggerConfig),
PublicFaces: faces,
NotificationURL: w.NotificationURL,
NotificationSecret: w.NotificationSecret,
WebhookSecret: w.WebhookSecret,
WebhookSigningSecret: w.WebhookSigningSecret,
WebhookRequireSignature: w.WebhookRequireSignature,
CreatedAt: w.CreatedAt,
UpdatedAt: w.UpdatedAt,
}
}
+4 -3
View File
@@ -318,7 +318,7 @@ func (h *Handler) fireBinding(
b store.WorkloadTriggerBinding,
evt plugin.InboundEvent,
) (bool, string) {
pwl := plugin.WorkloadFromStore(row)
pwl := toPluginWorkload(row)
pwl, err := plugin.WithEffectiveTrigger(pwl, trg.Kind,
json.RawMessage(trg.Config), json.RawMessage(b.BindingConfig))
if err != nil {
@@ -395,7 +395,7 @@ func (h *Handler) handlePreviewIntent(
// it isn't bucketed as "no binding matched".
return false, ReasonPreviewNoop
}
childPwl := plugin.WorkloadFromStore(child)
childPwl := toPluginWorkload(child)
if err := h.plugins.DispatchTeardown(ctx, childPwl); err != nil {
slog.Warn("webhook: preview teardown dispatch failed",
"template", template.Name, "preview", child.Name, "error", err)
@@ -421,7 +421,7 @@ func (h *Handler) handlePreviewIntent(
"template", template.Name, "branch", branch, "error", err)
return false, ReasonPreviewError
}
childPwl := plugin.WorkloadFromStore(child)
childPwl := toPluginWorkload(child)
if err := h.plugins.DispatchPlugin(ctx, childPwl, *intent); err != nil {
slog.Warn("webhook: preview dispatch failed",
"template", template.Name, "preview", child.Name, "error", err)
@@ -431,3 +431,4 @@ func (h *Handler) handlePreviewIntent(
"template", template.Name, "branch", branch, "preview", child.Name, "reason", intent.Reason)
return true, intent.Reason
}
-35
View File
@@ -1,35 +0,0 @@
package plugin
import "context"
// RemoveContainerByName is a best-effort cleanup of a name conflict before a
// CreateContainer retry: enumerate the managed containers and stop+remove the
// one whose name matches. Docker enforces unique container names per daemon,
// so at most one managed container can match — the loop returns after the
// first hit.
//
// Shared by the source plugins (dockerfile, static) that previously each kept
// their own copy. Those copies had diverged: the dockerfile copy removed every
// match (defending against a since-debunked "a partial deploy can leave more
// than one matching artifact" case), while the static copy stopped at the
// first. We deliberately converge on stop-at-first: ManagedContainer.Name is
// the primary Docker name (Names[0]), which the daemon keeps globally unique
// across every container state, so the remove-all loop could never actually
// match more than one container — the two behaviours are equivalent for every
// reachable state.
//
// Failures are intentionally swallowed: the caller treats this as opportunistic
// and re-attempts CreateContainer regardless.
func RemoveContainerByName(ctx context.Context, deps Deps, name string) {
containers, err := deps.Docker.ListContainers(ctx, nil)
if err != nil {
return
}
for _, c := range containers {
if c.Name == name {
deps.Docker.StopContainer(ctx, c.ID, 10)
deps.Docker.RemoveContainer(ctx, c.ID, true)
return
}
}
}
-52
View File
@@ -1,52 +0,0 @@
package plugin
import (
"encoding/json"
"log/slog"
"github.com/alexei/tinyforge/internal/store"
)
// WorkloadFromStore converts a persisted store.Workload row into the value
// shape that Source / Trigger plugins consume. It is the single converter
// shared by every caller (api, reconciler, webhook) — previously each kept
// its own byte-identical copy, which drifted (only the api copy logged bad
// PublicFaces JSON; the others swallowed it).
//
// Living in the plugin package is safe: plugin already imports store (Deps
// holds a *store.Store), so this adds no new edge to the dependency graph
// and store does not import plugin.
//
// SourceConfig / TriggerConfig are passed through as raw JSON; the matching
// plugin decodes them with plugin.SourceConfigOf[T] / TriggerConfigOf[T].
// PublicFaces is decoded eagerly because every consumer needs the parsed
// slice (proxy registration, UI, validation); invalid JSON is logged and
// treated as empty rather than failing the conversion.
func WorkloadFromStore(w store.Workload) Workload {
var faces []PublicFace
if w.PublicFaces != "" {
if err := json.Unmarshal([]byte(w.PublicFaces), &faces); err != nil {
slog.Warn("workload: invalid public_faces JSON, treating as empty",
"workload", w.ID, "error", err)
faces = nil
}
}
return Workload{
ID: w.ID,
Name: w.Name,
GroupID: w.AppID,
ParentWorkloadID: w.ParentWorkloadID,
SourceKind: w.SourceKind,
SourceConfig: json.RawMessage(w.SourceConfig),
TriggerKind: w.TriggerKind,
TriggerConfig: json.RawMessage(w.TriggerConfig),
PublicFaces: faces,
NotificationURL: w.NotificationURL,
NotificationSecret: w.NotificationSecret,
WebhookSecret: w.WebhookSecret,
WebhookSigningSecret: w.WebhookSigningSecret,
WebhookRequireSignature: w.WebhookRequireSignature,
CreatedAt: w.CreatedAt,
UpdatedAt: w.UpdatedAt,
}
}
-129
View File
@@ -1,129 +0,0 @@
package plugin
import (
"encoding/json"
"testing"
"github.com/alexei/tinyforge/internal/store"
)
// TestWorkloadFromStore_MapsEveryField pins the full field-for-field contract
// of the consolidated converter. The chief risk of extracting the three former
// per-package copies into one shared function is a silently dropped field —
// especially the three secrets (json:"-", so serialization-based tests can
// never catch a regression here) and the GroupID<-AppID rename.
func TestWorkloadFromStore_MapsEveryField(t *testing.T) {
faces := []PublicFace{
{Subdomain: "app", Domain: "example.com", TargetService: "web", TargetPort: 8080, AccessListID: 3, EnableSSL: true},
{Subdomain: "api", Domain: "example.com", TargetPort: 9090},
}
facesJSON, err := json.Marshal(faces)
if err != nil {
t.Fatalf("marshal faces: %v", err)
}
src := store.Workload{
ID: "wl-1",
Name: "my-workload",
AppID: "grp-7",
SourceKind: "dockerfile",
SourceConfig: `{"repo":"x"}`,
TriggerKind: "git",
TriggerConfig: `{"branch":"main"}`,
PublicFaces: string(facesJSON),
ParentWorkloadID: "parent-2",
NotificationURL: "https://hooks.example.com/notify",
NotificationSecret: "notif-secret",
WebhookSecret: "wh-secret",
WebhookSigningSecret: "wh-signing-secret",
WebhookRequireSignature: true,
CreatedAt: "2026-01-01T00:00:00Z",
UpdatedAt: "2026-01-02T00:00:00Z",
}
got := WorkloadFromStore(src)
if got.ID != src.ID {
t.Errorf("ID = %q, want %q", got.ID, src.ID)
}
if got.Name != src.Name {
t.Errorf("Name = %q, want %q", got.Name, src.Name)
}
if got.GroupID != src.AppID {
t.Errorf("GroupID = %q, want AppID %q", got.GroupID, src.AppID)
}
if got.ParentWorkloadID != src.ParentWorkloadID {
t.Errorf("ParentWorkloadID = %q, want %q", got.ParentWorkloadID, src.ParentWorkloadID)
}
if got.SourceKind != src.SourceKind {
t.Errorf("SourceKind = %q, want %q", got.SourceKind, src.SourceKind)
}
if string(got.SourceConfig) != src.SourceConfig {
t.Errorf("SourceConfig = %q, want %q", string(got.SourceConfig), src.SourceConfig)
}
if got.TriggerKind != src.TriggerKind {
t.Errorf("TriggerKind = %q, want %q", got.TriggerKind, src.TriggerKind)
}
if string(got.TriggerConfig) != src.TriggerConfig {
t.Errorf("TriggerConfig = %q, want %q", string(got.TriggerConfig), src.TriggerConfig)
}
if got.NotificationURL != src.NotificationURL {
t.Errorf("NotificationURL = %q, want %q", got.NotificationURL, src.NotificationURL)
}
if got.NotificationSecret != src.NotificationSecret {
t.Errorf("NotificationSecret not carried through")
}
if got.WebhookSecret != src.WebhookSecret {
t.Errorf("WebhookSecret not carried through")
}
if got.WebhookSigningSecret != src.WebhookSigningSecret {
t.Errorf("WebhookSigningSecret not carried through")
}
if got.WebhookRequireSignature != src.WebhookRequireSignature {
t.Errorf("WebhookRequireSignature = %v, want %v", got.WebhookRequireSignature, src.WebhookRequireSignature)
}
if got.CreatedAt != src.CreatedAt {
t.Errorf("CreatedAt = %q, want %q", got.CreatedAt, src.CreatedAt)
}
if got.UpdatedAt != src.UpdatedAt {
t.Errorf("UpdatedAt = %q, want %q", got.UpdatedAt, src.UpdatedAt)
}
if len(got.PublicFaces) != len(faces) {
t.Fatalf("PublicFaces len = %d, want %d", len(got.PublicFaces), len(faces))
}
if got.PublicFaces[0] != faces[0] || got.PublicFaces[1] != faces[1] {
t.Errorf("PublicFaces = %+v, want %+v", got.PublicFaces, faces)
}
}
// TestWorkloadFromStore_PublicFaces covers the PublicFaces decode branch,
// including the malformed-JSON path that the consolidation newly unified onto
// "log and treat as empty" for every caller (the old reconciler/webhook copies
// silently swallowed the error). A decode failure must never fail the
// conversion or panic — it yields nil faces.
func TestWorkloadFromStore_PublicFaces(t *testing.T) {
tests := []struct {
name string
raw string
wantLen int
wantNil bool
}{
{name: "empty string yields nil", raw: "", wantLen: 0, wantNil: true},
{name: "empty array yields empty", raw: "[]", wantLen: 0, wantNil: false},
{name: "malformed json yields nil", raw: "{not-json", wantLen: 0, wantNil: true},
{name: "wrong-shape json yields nil", raw: `{"a":1}`, wantLen: 0, wantNil: true},
{name: "single valid face", raw: `[{"Subdomain":"a"}]`, wantLen: 1, wantNil: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := WorkloadFromStore(store.Workload{ID: "wl", PublicFaces: tt.raw})
if len(got.PublicFaces) != tt.wantLen {
t.Errorf("PublicFaces len = %d, want %d", len(got.PublicFaces), tt.wantLen)
}
if tt.wantNil && got.PublicFaces != nil {
t.Errorf("PublicFaces = %+v, want nil", got.PublicFaces)
}
})
}
}
@@ -28,12 +28,6 @@ import (
type Config struct {
ComposeYAML string `json:"compose_yaml"`
ComposeProjectName string `json:"compose_project_name"`
// DeployStrategy is accepted for parity with the other sources but a
// compose stack only supports recreate (docker compose up -d
// --remove-orphans). "" and "recreate" are honored; "blue-green" is
// rejected at Validate so the contract is honest in the UI rather than
// silently accepting a value compose can't deliver.
DeployStrategy string `json:"deploy_strategy,omitempty"`
}
type source struct{}
@@ -76,11 +70,6 @@ func (*source) Validate(cfg json.RawMessage) error {
if strings.TrimSpace(c.ComposeYAML) == "" {
return fmt.Errorf("compose source: compose_yaml is required")
}
// allowBlueGreen=false: a whole-stack blue-green is not implemented, so
// reject it here rather than silently running recreate.
if err := plugin.ValidateStrategy(c.DeployStrategy, false); err != nil {
return fmt.Errorf("compose source: %w", err)
}
spec, err := stack.Parse(c.ComposeYAML)
if err != nil {
return fmt.Errorf("compose source: parse yaml: %w", err)
@@ -1,37 +0,0 @@
package compose
import (
"encoding/json"
"testing"
)
const validComposeYAML = "services:\n web:\n image: nginx:alpine\n ports:\n - \"80\"\n"
func composeCfg(strategy string) json.RawMessage {
m := map[string]any{"compose_yaml": validComposeYAML}
if strategy != "" {
m["deploy_strategy"] = strategy
}
b, _ := json.Marshal(m)
return b
}
func TestValidate_Strategy_RejectsBlueGreen(t *testing.T) {
cases := []struct {
strategy string
wantErr bool
}{
{"", false}, // backward-compat
{"recreate", false}, // the only thing compose can do
{"blue-green", true}, // not supported for a whole stack
{"rolling", true},
}
for _, c := range cases {
t.Run("strategy="+c.strategy, func(t *testing.T) {
err := (&source{}).Validate(composeCfg(c.strategy))
if (err != nil) != c.wantErr {
t.Fatalf("Validate(strategy=%q) err=%v, wantErr=%v", c.strategy, err, c.wantErr)
}
})
}
}
@@ -48,11 +48,6 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
return fmt.Errorf("dockerfile source: decode config: %w", err)
}
// bg selects the zero-downtime path: a unique green name so the new
// container coexists with the still-serving blue, an in-place route
// upsert, and blue reaped only AFTER green is persisted + routed.
bg := effectiveStrategy(cfg) == plugin.StrategyBlueGreen
prev, prevContainer, err := loadState(deps, w)
if err != nil {
return err
@@ -229,13 +224,6 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
}
containerName := containerNameFor(w)
if bg {
// Unique green name so the new container coexists with the still-
// serving blue one — the deterministic name would collide on
// Docker's per-daemon unique-name constraint. This name is also the
// proxy forwardHost below, so green receives traffic after cutover.
containerName = plugin.BuildGreenName(containerName, time.Now())
}
// Per-face proxy labels (Traefik consumes these; NPM ignores them).
labels := map[string]string{}
@@ -266,21 +254,13 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
containerID, err := deps.Docker.CreateContainer(ctx, cc)
if err != nil {
if bg {
// Green has a unique name, so this is a genuine create failure, not
// a name conflict — must NOT remove the still-serving blue.
updateStatus(deps, w, "failed", latestSHA,
sanitizeError(fmt.Sprintf("create container: %v", err), token))
return fmt.Errorf("create container: %w", err)
}
// recreate: the deterministic name may still be held by the prior
// container — best-effort cleanup (by ID first; by name fallback) and
// one retry. This is the recreate downtime window.
// Name conflict — best-effort cleanup of any prior container
// (by ID first; by name as a fallback) and one retry.
if prevContainerID != "" {
deps.Docker.StopContainer(ctx, prevContainerID, 10)
deps.Docker.RemoveContainer(ctx, prevContainerID, true)
}
plugin.RemoveContainerByName(ctx, deps, containerName)
removeContainerByName(ctx, deps, containerName)
containerID, err = deps.Docker.CreateContainer(ctx, cc)
if err != nil {
@@ -328,22 +308,6 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
return fmt.Errorf("container not running: %s", logMsg)
}
// Blue-green readiness gate: the 3s window above only proves green did not
// crash, not that it is SERVING. Before swapping the route, probe green's
// healthcheck over the network (when configured) so traffic never flips to
// a not-yet-listening container. On failure, remove green and leave blue +
// its route untouched — a non-disruptive rollback. recreate skips this (it
// already removed blue, so there is no live fallback to protect).
if bg && cfg.Healthcheck != "" && deps.Health != nil {
healthURL := fmt.Sprintf("http://%s:%d%s", containerName, cfg.Port, cfg.Healthcheck)
if herr := deps.Health.Check(ctx, healthURL); herr != nil {
deps.Docker.RemoveContainer(ctx, containerID, true)
updateStatus(deps, w, "failed", latestSHA,
sanitizeError(fmt.Sprintf("readiness check %s: %v", cfg.Healthcheck, herr), token))
return fmt.Errorf("readiness check failed: %w", herr)
}
}
// Resolve proxy target: in-network DNS by default, NPM-remote
// override uses (settings.ServerIP, hostPort).
forwardHost := containerName
@@ -365,12 +329,7 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
// in-place so traffic shifts atomically over to the new container.
proxyRouteID := prevProxyRouteID
if domain != "" {
// Blue-green relies on ConfigureRoute being an upsert-by-FQDN (NPM
// finds the host by domain and repoints it in place, gap-free), so we
// must NOT delete blue's route first — that would open a window.
// recreate already removed blue, so the pre-delete is harmless there
// but kept to preserve its exact prior behavior.
if !bg && prevProxyRouteID != "" {
if prevProxyRouteID != "" {
deps.Proxy.DeleteRoute(ctx, prevProxyRouteID)
}
routeID, rerr := deps.Proxy.ConfigureRoute(ctx, domain, forwardHost, forwardPort, proxy.RouteOptions{
@@ -388,12 +347,10 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
}
}
// recreate: drop the previous container now that the new one is healthy +
// routed. Blue-green DEFERS this until AFTER saveState (below) so the
// persisted single row always points at a running container — a crash
// between cutover and saveState must not orphan green or leave the row
// pointing at a reaped blue (which the reconciler would then flag failed).
if !bg && prevContainerID != "" && prevContainerID != containerID {
// Drop the previous container only after the new one is healthy
// + routed. Different-ID-than-previous tells us we created a
// fresh one (vs returning the same ID via UpsertContainer reuse).
if prevContainerID != "" && prevContainerID != containerID {
deps.Docker.StopContainer(ctx, prevContainerID, 10)
deps.Docker.RemoveContainer(ctx, prevContainerID, true)
}
@@ -427,14 +384,6 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
return fmt.Errorf("persist deploy state: %w", err)
}
// Blue-green: green is now persisted in the single row AND serving behind
// the swapped route — only now is it safe to reap blue. (recreate already
// removed blue before saveState.)
if bg && prevContainerID != "" && prevContainerID != containerID {
deps.Docker.StopContainer(ctx, prevContainerID, 10)
deps.Docker.RemoveContainer(ctx, prevContainerID, true)
}
publishEvent(deps, w, "deployed")
dispatchBuildNotification(deps, w, domain, "deployed", "")
@@ -568,6 +517,25 @@ func healUnchanged(deps plugin.Deps, w plugin.Workload, prev runtimeState, lates
return nil
}
// removeContainerByName enumerates Docker's view and best-effort drops
// EVERY matching container so a name conflict in CreateContainer is
// recoverable. Container names are unique per daemon, but the recovery
// path exists precisely because a conflict occurred — a prior partial
// deploy can leave more than one matching artifact, so we must not stop
// at the first. Mirrors the static plugin's helper of the same name.
func removeContainerByName(ctx context.Context, deps plugin.Deps, name string) {
containers, err := deps.Docker.ListContainers(ctx, nil)
if err != nil {
return
}
for _, c := range containers {
if c.Name == name {
deps.Docker.StopContainer(ctx, c.ID, 10)
deps.Docker.RemoveContainer(ctx, c.ID, true)
}
}
}
// primaryDomain mirrors the static plugin's helper of the same name —
// derives an FQDN from the workload's first enabled public face, with
// the same bare-subdomain + settings.Domain fall-through.
@@ -64,23 +64,6 @@ type Config struct {
// git provider as a commit status (pending/success/failure) on the
// built SHA. Best-effort — a reporting failure never fails a deploy.
ReportCommitStatus bool `json:"report_commit_status"`
// DeployStrategy selects how a redeploy cuts over. "" (default) and
// "recreate" stop the old container before starting the new one (a brief
// downtime window). "blue-green" starts the new build alongside the old,
// gates it, swaps the proxy route in place, then reaps the old —
// zero-downtime under NPM. Validated via plugin.ValidateStrategy.
DeployStrategy string `json:"deploy_strategy,omitempty"`
}
// effectiveStrategy resolves the configured strategy for the dockerfile
// source. Empty maps to recreate — the source's historical behavior — so
// existing workloads are unchanged.
func effectiveStrategy(cfg Config) string {
if cfg.DeployStrategy == "" {
return plugin.StrategyRecreate
}
return cfg.DeployStrategy
}
type source struct{}
@@ -137,9 +120,6 @@ func (*source) Validate(cfg json.RawMessage) error {
return fmt.Errorf("dockerfile source: %q must not contain '..'", p)
}
}
if err := plugin.ValidateStrategy(c.DeployStrategy, true); err != nil {
return fmt.Errorf("dockerfile source: %w", err)
}
return nil
}
@@ -1,49 +0,0 @@
package dockerfile
import (
"encoding/json"
"testing"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// validCfg is the smallest config that passes the non-strategy checks, so a
// test isolates the deploy_strategy behavior.
func validCfg(strategy string) json.RawMessage {
m := map[string]any{"repo_owner": "o", "repo_name": "r", "port": 8080}
if strategy != "" {
m["deploy_strategy"] = strategy
}
b, _ := json.Marshal(m)
return b
}
func TestValidate_Strategy(t *testing.T) {
cases := []struct {
strategy string
wantErr bool
}{
{"", false}, // backward-compat: no key -> valid
{"recreate", false},
{"blue-green", false}, // dockerfile supports blue-green
{"rolling", true}, // reserved, not yet implemented
{"junk", true},
}
for _, c := range cases {
t.Run("strategy="+c.strategy, func(t *testing.T) {
err := (&source{}).Validate(validCfg(c.strategy))
if (err != nil) != c.wantErr {
t.Fatalf("Validate(strategy=%q) err=%v, wantErr=%v", c.strategy, err, c.wantErr)
}
})
}
}
func TestEffectiveStrategy_Default(t *testing.T) {
if got := effectiveStrategy(Config{}); got != plugin.StrategyRecreate {
t.Fatalf("empty strategy = %q, want recreate (historical default)", got)
}
if got := effectiveStrategy(Config{DeployStrategy: plugin.StrategyBlueGreen}); got != plugin.StrategyBlueGreen {
t.Fatalf("explicit blue-green = %q, want blue-green", got)
}
}
@@ -40,21 +40,6 @@ type Config struct {
MemoryLimit int `json:"memory_limit"` // megabytes; 0 = unlimited
DefaultTag string `json:"default_tag"` // tag used when intent.Reference is empty
MaxInstances int `json:"max_instances"` // simultaneous containers to keep; 0/1 = strict blue-green
// DeployStrategy selects how a redeploy cuts over. "" defaults to the
// image source's native zero-downtime blue-green; "recreate" reaps the
// old container before the new one comes up (opt-in downtime). Validated
// via plugin.ValidateStrategy. Orthogonal to MaxInstances.
DeployStrategy string `json:"deploy_strategy,omitempty"`
}
// effectiveStrategy resolves the configured strategy for the image source.
// Empty maps to blue-green so every existing image workload keeps its
// current zero-downtime behavior byte-for-byte.
func effectiveStrategy(cfg Config) string {
if cfg.DeployStrategy == "" {
return plugin.StrategyBlueGreen
}
return cfg.DeployStrategy
}
// VolumeMount mirrors the existing store.Volume scope shape but as a flat
@@ -103,9 +88,6 @@ func (*source) Validate(cfg json.RawMessage) error {
if c.Port < 0 || c.Port > 65535 {
return fmt.Errorf("image source: port must be 0-65535")
}
if err := plugin.ValidateStrategy(c.DeployStrategy, true); err != nil {
return fmt.Errorf("image source: %w", err)
}
for i, v := range c.Volumes {
if strings.TrimSpace(v.Target) == "" {
return fmt.Errorf("image source: volumes[%d].target is required", i)
@@ -207,33 +189,6 @@ func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload,
return fmt.Errorf("image source: ensure network: %w", err)
}
// recreate strategy (opt-in): tear down the existing containers BEFORE
// the new one comes up — the operator chose a downtime window. The
// default blue-green path skips this; its new container coexists with
// the old and the proxy route swaps atomically (enforceMaxInstances
// reaps the old AFTER cutover). Reaped here (after a successful pull, so
// a pull failure doesn't take the workload down for nothing). On a
// later create/health/route failure the recreate path has no blue to
// fall back to — inherent to recreate, distinct from blue-green's
// non-disruptive rollbackNew.
if effectiveStrategy(cfg) == plugin.StrategyRecreate {
for _, c := range existing {
if c.ContainerID != "" {
_ = deps.Docker.RemoveContainer(ctx, c.ContainerID, true)
}
if c.ProxyRouteID != "" {
_ = deps.Proxy.DeleteRoute(ctx, c.ProxyRouteID)
}
if delErr := deps.Store.DeleteContainer(c.ID); delErr != nil && !errors.Is(delErr, store.ErrNotFound) {
slog.Warn("image source: recreate reap old row", "workload", w.ID, "row", c.ID, "error", delErr)
}
}
if len(existing) > 0 {
slog.Info("image source: recreate strategy reaped old containers before cutover",
"workload", w.ID, "count", len(existing))
}
}
// Unique-per-deploy name so the new container can run alongside the
// old one. The suffix is monotonic ms; collisions are not a real
// concern for human-driven or webhook-driven deploys.
@@ -1,49 +0,0 @@
package image
import (
"encoding/json"
"testing"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
func imageCfg(strategy string) json.RawMessage {
m := map[string]any{"image": "registry.example.com/o/app", "port": 8080}
if strategy != "" {
m["deploy_strategy"] = strategy
}
b, _ := json.Marshal(m)
return b
}
func TestValidate_Strategy(t *testing.T) {
cases := []struct {
strategy string
wantErr bool
}{
{"", false},
{"recreate", false},
{"blue-green", false},
{"rolling", true},
{"junk", true},
}
for _, c := range cases {
t.Run("strategy="+c.strategy, func(t *testing.T) {
err := (&source{}).Validate(imageCfg(c.strategy))
if (err != nil) != c.wantErr {
t.Fatalf("Validate(strategy=%q) err=%v, wantErr=%v", c.strategy, err, c.wantErr)
}
})
}
}
func TestEffectiveStrategy_DefaultsToBlueGreen(t *testing.T) {
// image's historical default is blue-green — empty must NOT flip it to
// recreate (the load-bearing per-source default).
if got := effectiveStrategy(Config{}); got != plugin.StrategyBlueGreen {
t.Fatalf("empty strategy = %q, want blue-green (image default)", got)
}
if got := effectiveStrategy(Config{DeployStrategy: plugin.StrategyRecreate}); got != plugin.StrategyRecreate {
t.Fatalf("explicit recreate = %q, want recreate", got)
}
}
@@ -44,12 +44,6 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
return fmt.Errorf("static source: decode config: %w", err)
}
// bg selects the zero-downtime path: a unique green name so the new
// container coexists with the still-serving blue, an in-place route
// upsert, and blue reaped only AFTER green is persisted + routed.
// effectiveStrategy forces recreate for storage-backed deno sites.
bg := effectiveStrategy(cfg) == plugin.StrategyBlueGreen
prev, prevContainer, err := loadState(deps, w)
if err != nil {
return err
@@ -244,13 +238,6 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
}
containerName := containerNameFor(w)
if bg {
// Unique green name so the new container coexists with the still-
// serving blue one — the deterministic name would collide on
// Docker's per-daemon unique-name constraint. This name is also the
// proxy forwardHost below, so green receives traffic after cutover.
containerName = plugin.BuildGreenName(containerName, time.Now())
}
var mounts []mount.Mount
if cfg.StorageEnabled && mode == "deno" {
@@ -296,21 +283,13 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
containerID, err := deps.Docker.CreateContainer(ctx, cc)
if err != nil {
if bg {
// Green has a unique name, so this is a genuine create failure, not
// a name conflict — must NOT remove the still-serving blue.
updateStatus(deps, w, "failed", latestSHA,
sanitizeError(fmt.Sprintf("create container: %v", err), token))
return fmt.Errorf("create container: %w", err)
}
// recreate: the deterministic name might still be held by the prior
// container — best-effort cleanup (by ID, then by name) and one retry.
// This is the recreate downtime window.
// Container with this name might already exist — best-effort
// cleanup of any prior container by ID and by name, then retry.
if prevContainerID != "" {
deps.Docker.StopContainer(ctx, prevContainerID, 10)
deps.Docker.RemoveContainer(ctx, prevContainerID, true)
}
plugin.RemoveContainerByName(ctx, deps, containerName)
removeContainerByName(ctx, deps, containerName)
containerID, err = deps.Docker.CreateContainer(ctx, cc)
if err != nil {
@@ -374,11 +353,7 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
// place so traffic shifts atomically.
proxyRouteID := prevProxyRouteID
if domain != "" {
// Blue-green relies on ConfigureRoute being an upsert-by-FQDN (NPM
// repoints the host in place, gap-free), so we must NOT delete blue's
// route first. recreate already removed blue, so the pre-delete is
// harmless there but kept to preserve its exact prior behavior.
if !bg && prevProxyRouteID != "" {
if prevProxyRouteID != "" {
deps.Proxy.DeleteRoute(ctx, prevProxyRouteID)
}
routeID, rerr := deps.Proxy.ConfigureRoute(ctx, domain, forwardHost, forwardPort, proxy.RouteOptions{
@@ -396,12 +371,8 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
}
}
// recreate: drop the old container now that the new one is healthy +
// routed. Blue-green DEFERS this until AFTER saveState (below) so the
// persisted single row always points at a running container — a crash
// between cutover and saveState must not orphan green or leave the row
// pointing at a reaped blue (which the reconciler would then flag failed).
if !bg && prevContainerID != "" && prevContainerID != containerID {
// Drop the old container if a fresh one was created (different ID).
if prevContainerID != "" && prevContainerID != containerID {
deps.Docker.StopContainer(ctx, prevContainerID, 10)
deps.Docker.RemoveContainer(ctx, prevContainerID, true)
}
@@ -438,14 +409,6 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
return fmt.Errorf("persist deploy state: %w", err)
}
// Blue-green: green is now persisted in the single row AND serving behind
// the swapped route — only now is it safe to reap blue. (recreate already
// removed blue before saveState.)
if bg && prevContainerID != "" && prevContainerID != containerID {
deps.Docker.StopContainer(ctx, prevContainerID, 10)
deps.Docker.RemoveContainer(ctx, prevContainerID, true)
}
publishEvent(deps, w, "deployed")
// updateStatus normally fires the terminal-state notification; the
@@ -554,6 +517,23 @@ func publishEvent(deps plugin.Deps, w plugin.Workload, status string) {
plugin.EmitDeployEvent(deps, w, "static_site", status)
}
// removeContainerByName mirrors the legacy helper: enumerate Docker's
// view and best-effort drop the matching container so a name conflict
// in CreateContainer is recoverable. Best-effort.
func removeContainerByName(ctx context.Context, deps plugin.Deps, name string) {
containers, err := deps.Docker.ListContainers(ctx, nil)
if err != nil {
return
}
for _, c := range containers {
if c.Name == name {
deps.Docker.StopContainer(ctx, c.ID, 10)
deps.Docker.RemoveContainer(ctx, c.ID, true)
return
}
}
}
// primaryDomain derives the public-facing FQDN from the workload's
// first enabled public face. Static workloads support at most one
// face today, but iterate defensively in case the API contract
@@ -41,30 +41,6 @@ type Config struct {
// git provider as a commit status (pending/success/failure) on the
// deployed SHA. Best-effort — a reporting failure never fails a deploy.
ReportCommitStatus bool `json:"report_commit_status"`
// DeployStrategy selects how a redeploy cuts over. "" (default) and
// "recreate" stop the old container before the new one comes up (a brief
// downtime window). "blue-green" starts the new container alongside the
// old, gates it, swaps the proxy route in place, then reaps the old —
// zero-downtime under NPM. Validated via plugin.ValidateStrategy.
DeployStrategy string `json:"deploy_strategy,omitempty"`
}
// effectiveStrategy resolves the configured strategy for the static source.
// Empty maps to recreate — the source's historical behavior. Storage-backed
// deno sites are forced to recreate even when blue-green is requested: a
// blue-green overlap would mount the same RW named volume into BOTH
// containers at once (a concurrent-writer window recreate never has, since
// recreate stops blue before green starts).
func effectiveStrategy(cfg Config) string {
s := cfg.DeployStrategy
if s == "" {
s = plugin.StrategyRecreate
}
if s == plugin.StrategyBlueGreen && cfg.StorageEnabled && cfg.Mode == "deno" {
return plugin.StrategyRecreate
}
return s
}
type source struct{}
@@ -102,9 +78,6 @@ func (*source) Validate(cfg json.RawMessage) error {
if c.Mode != "" && c.Mode != "static" && c.Mode != "deno" {
return fmt.Errorf("static source: mode must be \"static\" or \"deno\"")
}
if err := plugin.ValidateStrategy(c.DeployStrategy, true); err != nil {
return fmt.Errorf("static source: %w", err)
}
return nil
}
@@ -1,58 +0,0 @@
package static
import (
"encoding/json"
"testing"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
func validCfg(extra map[string]any) json.RawMessage {
m := map[string]any{"repo_owner": "o", "repo_name": "r"}
for k, v := range extra {
m[k] = v
}
b, _ := json.Marshal(m)
return b
}
func TestValidate_Strategy(t *testing.T) {
cases := []struct {
strategy string
wantErr bool
}{
{"", false},
{"recreate", false},
{"blue-green", false},
{"rolling", true},
{"junk", true},
}
for _, c := range cases {
t.Run("strategy="+c.strategy, func(t *testing.T) {
err := (&source{}).Validate(validCfg(map[string]any{"deploy_strategy": c.strategy}))
if (err != nil) != c.wantErr {
t.Fatalf("Validate(strategy=%q) err=%v, wantErr=%v", c.strategy, err, c.wantErr)
}
})
}
}
func TestEffectiveStrategy_DefaultAndDenoGate(t *testing.T) {
if got := effectiveStrategy(Config{}); got != plugin.StrategyRecreate {
t.Fatalf("empty strategy = %q, want recreate", got)
}
if got := effectiveStrategy(Config{DeployStrategy: plugin.StrategyBlueGreen}); got != plugin.StrategyBlueGreen {
t.Fatalf("plain blue-green = %q, want blue-green", got)
}
// Storage-backed deno site requesting blue-green is forced to recreate to
// avoid a concurrent-writer overlap on the shared /app/data volume.
denoStorage := Config{DeployStrategy: plugin.StrategyBlueGreen, StorageEnabled: true, Mode: "deno"}
if got := effectiveStrategy(denoStorage); got != plugin.StrategyRecreate {
t.Fatalf("deno+storage blue-green = %q, want recreate (forced)", got)
}
// A deno site WITHOUT storage may use blue-green.
denoNoStorage := Config{DeployStrategy: plugin.StrategyBlueGreen, Mode: "deno"}
if got := effectiveStrategy(denoNoStorage); got != plugin.StrategyBlueGreen {
t.Fatalf("deno (no storage) blue-green = %q, want blue-green", got)
}
}
-50
View File
@@ -1,50 +0,0 @@
package plugin
import (
"fmt"
"time"
)
// Deploy strategy values for a source's DeployStrategy config field.
//
// - "" (empty) — back-compat default; each source resolves it to its
// historical behavior (image -> blue-green, others -> recreate). Every
// pre-existing workload row decodes to this.
// - StrategyRecreate — stop the old container, then start the new one
// (a brief downtime window; what dockerfile/static/compose do today).
// - StrategyBlueGreen — start the new container alongside the old, gate it,
// swap the proxy route, then reap the old (zero-downtime under NPM).
const (
StrategyRecreate = "recreate"
StrategyBlueGreen = "blue-green"
)
// ValidateStrategy checks a deploy_strategy config value. "" is always valid
// (the back-compat default). StrategyRecreate is always valid. StrategyBlueGreen
// is valid only when the source supports it (allowBlueGreen) — compose passes
// false because a whole-stack blue-green is not implemented. Reserved values
// such as "rolling" are rejected until implemented so a config can't silently
// claim a behavior the deployer won't honor.
func ValidateStrategy(value string, allowBlueGreen bool) error {
switch value {
case "", StrategyRecreate:
return nil
case StrategyBlueGreen:
if allowBlueGreen {
return nil
}
return fmt.Errorf("deploy_strategy %q is not supported for this source kind; use \"recreate\"", value)
default:
return fmt.Errorf("invalid deploy_strategy %q (valid: \"\", %q, %q)", value, StrategyRecreate, StrategyBlueGreen)
}
}
// BuildGreenName appends a unique millisecond-hex suffix to a source's
// otherwise-deterministic container name so a new "green" container can run
// alongside the old "blue" during a blue-green cutover. Sources whose names
// are deterministic (dockerfile, static) collide on Docker's per-daemon
// unique-name constraint without this; the suffix lets both coexist until the
// route swaps and blue is reaped. Mirrors the image source's ms-hex scheme.
func BuildGreenName(base string, ts time.Time) string {
return fmt.Sprintf("%s-%x", base, ts.UnixMilli())
}
-48
View File
@@ -1,48 +0,0 @@
package plugin
import (
"testing"
"time"
)
func TestValidateStrategy(t *testing.T) {
cases := []struct {
name string
value string
allowBlueGreen bool
wantErr bool
}{
{"empty always ok (backward compat)", "", true, false},
{"empty ok when blue-green disallowed", "", false, false},
{"recreate ok", StrategyRecreate, true, false},
{"recreate ok when blue-green disallowed", StrategyRecreate, false, false},
{"blue-green ok when allowed", StrategyBlueGreen, true, false},
{"blue-green rejected when disallowed (compose)", StrategyBlueGreen, false, true},
{"reserved rolling rejected (allowed)", "rolling", true, true},
{"reserved rolling rejected (disallowed)", "rolling", false, true},
{"junk rejected", "banana", true, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := ValidateStrategy(c.value, c.allowBlueGreen)
if (err != nil) != c.wantErr {
t.Fatalf("ValidateStrategy(%q, %v) err=%v, wantErr=%v", c.value, c.allowBlueGreen, err, c.wantErr)
}
})
}
}
func TestBuildGreenName_UniqueSuffixAndDistinct(t *testing.T) {
base := "tf-build-app-1234abcd"
a := BuildGreenName(base, time.Unix(1000, 0))
b := BuildGreenName(base, time.Unix(2000, 0))
if a == base || b == base {
t.Fatal("green name must differ from the deterministic base")
}
if a == b {
t.Fatal("different timestamps must yield different green names")
}
if len(a) <= len(base) {
t.Fatalf("green name %q should extend the base %q", a, base)
}
}
+2 -122
View File
@@ -579,8 +579,8 @@ export function backupDownloadUrl(id: string): string {
}
// ── Volume Snapshots ───────────────────────────────────────────────
// Per-workload archives of host-bind data volumes: create/list/delete/
// download, plus restore (overwrites live data + restarts the app).
// Per-workload archives of host-bind data volumes. Capture-only for now
// (create/list/delete/download); restore is a separate later phase.
export interface SnapshotInfo {
id: string;
@@ -629,20 +629,6 @@ export function snapshotDownloadUrl(sid: string): string {
return `/api/snapshots/${sid}/download`;
}
export function restoreSnapshot(
workloadId: string,
sid: string
): Promise<{ status: string; workload_id: string; snapshot_id: string }> {
// X-Confirm-Restore echoes the snapshot id (same CSRF guard as the DB
// restore): the backend rejects any POST whose header doesn't match the
// path param, defeating blind cross-origin POSTs that can't set custom
// headers without a preflight. Sent alongside the bearer JWT.
return request(`/api/workloads/${workloadId}/snapshots/${sid}/restore`, {
method: 'POST',
headers: { 'X-Confirm-Restore': sid }
});
}
// ── Health ──────────────────────────────────────────────────────────
export function getHealth(): Promise<{ docker: DockerHealth; proxy?: ProxyHealth }> {
@@ -952,112 +938,6 @@ export function deployPluginWorkload(
return post(`/api/workloads/${id}/deploy`, body ?? {});
}
// ── Deploy history + rollback ───────────────────────────────────────
// Structured, version-pinned ledger of every deploy dispatch (success and
// failure). `rollbackable` is computed server-side: a successful deploy of a
// source kind that supports reference-pinned redeploy (image today).
export interface DeployHistoryEntry {
id: number;
workload_id: string;
source_kind: string;
reference: string;
reason: string;
triggered_by: string;
note: string;
outcome: 'success' | 'failure';
error: string;
started_at: string;
finished_at: string;
rollbackable: boolean;
}
export function fetchWorkloadDeploys(
id: string,
params?: { limit?: number; offset?: number },
signal?: AbortSignal
): Promise<DeployHistoryEntry[]> {
const query = new URLSearchParams();
if (params?.limit) query.set('limit', String(params.limit));
if (params?.offset) query.set('offset', String(params.offset));
const qs = query.toString();
return get<DeployHistoryEntry[]>(`/api/workloads/${id}/deploys${qs ? `?${qs}` : ''}`, signal);
}
export function rollbackWorkload(
id: string,
deployId: number
): Promise<{ workload_id: string; reference: string; rollback_of: number; triggered_by: string }> {
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
// was down / the workload is new.
export interface WorkloadStatsPoint {
ts: number;
cpu_percent: number;
memory_usage: number;
memory_limit: number;
}
export function fetchWorkloadStatsHistory(
id: string,
window = '2h',
signal?: AbortSignal
): Promise<WorkloadStatsPoint[]> {
return get<WorkloadStatsPoint[]>(
`/api/workloads/${id}/stats/history?window=${encodeURIComponent(window)}`,
signal
);
}
export function listHookKinds(signal?: AbortSignal): Promise<import('./types').HookKinds> {
return get<import('./types').HookKinds>('/api/hooks/kinds', signal);
}
@@ -1,190 +0,0 @@
<script lang="ts">
/**
* DeployHistoryPanel
*
* Per-workload structured deploy ledger (success + failure) with one-click
* rollback. This is the actionable sibling of the free-text activity
* timeline: each row carries a version-pinned `reference` the rollback
* action replays. The "Roll back" affordance only appears on rows the
* server marked `rollbackable` (a successful deploy of a reference-pinned
* source kind — image today); the server re-checks on POST, so the button
* is a convenience, not the authority.
*/
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import { toasts } from '$lib/stores/toast';
import { fmt } from '$lib/format/datetime';
import StatusBadge from './StatusBadge.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import { IconRestart, IconRefresh } from './icons';
interface Props {
workloadId: string;
}
let { workloadId }: Props = $props();
let deploys = $state<api.DeployHistoryEntry[]>([]);
let loading = $state(true);
let error = $state('');
let rollingBack = $state(false);
let confirmEntry = $state<api.DeployHistoryEntry | null>(null);
async function load(): Promise<void> {
loading = true;
error = '';
try {
deploys = await api.fetchWorkloadDeploys(workloadId, { limit: 50 });
} catch (e) {
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(() => {
const _ = workloadId;
load();
});
async function doRollback(entry: api.DeployHistoryEntry): Promise<void> {
if (rollingBack) return;
rollingBack = true;
try {
await api.rollbackWorkload(workloadId, entry.id);
toasts.success($t('apps.detail.deployHistory.rolledBack', { ref: shortRef(entry.reference) }));
await load();
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('apps.detail.deployHistory.rollbackFailed'));
} finally {
rollingBack = false;
confirmEntry = null;
}
}
// Tags render in full; long commit SHAs are clipped to the first 10 chars.
function shortRef(ref: string): string {
if (!ref) return '—';
return /^[0-9a-f]{16,}$/i.test(ref) ? ref.slice(0, 10) : ref;
}
</script>
<section class="panel dh-panel" aria-labelledby="dh-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">
<h2 class="panel-title" id="dh-heading">
{$t('apps.detail.deployHistory.title')}<span class="title-accent">.</span>
</h2>
<span class="panel-sub">{$t('apps.detail.deployHistory.sub')}</span>
<button
class="forge-btn-ghost dh-refresh"
onclick={load}
disabled={loading}
aria-label={$t('common.refresh')}
>
<IconRefresh size={13} />
</button>
</header>
{#if error}
<div class="alert inline-alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
{#if loading}
<p class="hint">{$t('apps.detail.deployHistory.loading')}</p>
{:else if deploys.length === 0}
<p class="hint">{$t('apps.detail.deployHistory.empty')}</p>
{:else}
<table class="forge-table dh-table">
<thead>
<tr>
<th>{$t('apps.detail.deployHistory.colWhen')}</th>
<th>{$t('apps.detail.deployHistory.colReason')}</th>
<th>{$t('apps.detail.deployHistory.colReference')}</th>
<th>{$t('apps.detail.deployHistory.colOutcome')}</th>
<th>{$t('apps.detail.deployHistory.colBy')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each deploys as d (d.id)}
<tr>
<td class="mono-time" title={$fmt.dateTime(d.finished_at)}>{$fmt.relative(d.finished_at)}</td>
<td><span class="reason-tag">{d.reason || '—'}</span></td>
<td class="mono" title={d.reference}>{shortRef(d.reference)}</td>
<td><StatusBadge status={d.outcome} size="sm" /></td>
<td>{d.triggered_by || '—'}</td>
<td class="dh-actions">
{#if d.rollbackable}
<button
class="forge-btn-ghost"
onclick={() => (confirmEntry = d)}
disabled={rollingBack}
>
<IconRestart size={13} />
<span>{$t('apps.detail.deployHistory.rollback')}</span>
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
{#if confirmEntry}
<ConfirmDialog
open={true}
title={$t('apps.detail.deployHistory.confirmTitle')}
message={$t('apps.detail.deployHistory.confirmMessage', { ref: shortRef(confirmEntry.reference) })}
confirmLabel={$t('apps.detail.deployHistory.rollback')}
confirmVariant="danger"
onconfirm={() => confirmEntry && doRollback(confirmEntry)}
oncancel={() => (confirmEntry = null)}
/>
{/if}
<style>
.dh-panel {
margin-top: 1rem;
}
.panel-head {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.dh-refresh {
margin-left: auto;
}
.hint {
font-size: 0.72rem;
color: var(--text-tertiary);
margin: 0.3rem 0;
}
.dh-table {
width: 100%;
margin-top: 0.5rem;
}
.reason-tag {
font-size: 0.68rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
}
.mono {
font-family: var(--font-mono, monospace);
font-size: 0.74rem;
}
.dh-actions {
display: flex;
justify-content: flex-end;
}
</style>
-818
View File
@@ -1,818 +0,0 @@
<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>
@@ -1,223 +0,0 @@
<script lang="ts">
/**
* WorkloadMetricsPanel
*
* Per-workload CPU + memory time-series for /apps/[id]. Reuses the global
* ResourceChart (ECharts) but scoped to one workload's containers, summed
* per timestamp server-side. CPU is plotted as a percentage on the left
* axis; memory as absolute MiB on the right axis (the container memory
* limit is often 0/unlimited, so a percentage would be meaningless).
*
* Stats live in SQLite, so the chart works even when the Docker daemon is
* down; an empty series simply means collection is off or the app is new.
*/
import * as api from '$lib/api';
import type { EChartsOption } from 'echarts';
import { t } from '$lib/i18n';
import { formatBytes } from '$lib/format/bytes';
import ResourceChart from './ResourceChart.svelte';
interface Props {
workloadId: string;
}
let { workloadId }: Props = $props();
type Window = '30m' | '2h' | '6h';
const WINDOWS: Window[] = ['30m', '2h', '6h'];
let points = $state<api.WorkloadStatsPoint[]>([]);
let window = $state<Window>('2h');
let loading = $state(true);
let error = $state('');
async function load(signal?: AbortSignal): Promise<void> {
try {
points = await api.fetchWorkloadStatsHistory(workloadId, window, signal);
error = '';
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
// Reload on workloadId/window change and poll while mounted (matches the
// dashboard resources card cadence).
$effect(() => {
void workloadId;
void window;
loading = true;
const controller = new AbortController();
load(controller.signal);
const id = setInterval(() => load(controller.signal), 15_000);
return () => {
controller.abort();
clearInterval(id);
};
});
const MIB = 1024 * 1024;
const chartOption = $derived<EChartsOption>({
animation: false,
grid: { top: 8, right: 48, bottom: 24, left: 44 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'line' }
},
legend: {
data: [$t('apps.detail.metrics.cpuSeries'), $t('apps.detail.metrics.memorySeries')],
bottom: 0,
textStyle: { fontSize: 11 }
},
xAxis: {
type: 'time',
axisLabel: { fontSize: 10, color: '#94a3b8' }
},
yAxis: [
{
type: 'value',
min: 0,
axisLabel: { fontSize: 10, color: '#94a3b8', formatter: '{value}%' },
splitLine: { lineStyle: { color: 'rgba(148,163,184,0.15)' } }
},
{
type: 'value',
min: 0,
axisLabel: { fontSize: 10, color: '#94a3b8', formatter: '{value} MiB' },
splitLine: { show: false }
}
],
series: [
{
name: $t('apps.detail.metrics.cpuSeries'),
type: 'line',
yAxisIndex: 0,
smooth: true,
showSymbol: false,
data: points.map((p) => [p.ts * 1000, Number(p.cpu_percent.toFixed(2))]),
lineStyle: { color: '#10b981', width: 2 },
areaStyle: { color: 'rgba(16, 185, 129, 0.15)' }
},
{
name: $t('apps.detail.metrics.memorySeries'),
type: 'line',
yAxisIndex: 1,
smooth: true,
showSymbol: false,
data: points.map((p) => [p.ts * 1000, Number((p.memory_usage / MIB).toFixed(1))]),
lineStyle: { color: '#3b82f6', width: 2 },
areaStyle: { color: 'rgba(59, 130, 246, 0.15)' }
}
]
});
// Latest reading for the at-a-glance summary line above the chart.
const latest = $derived(points.length > 0 ? points[points.length - 1] : null);
</script>
<section class="panel wm-panel" aria-labelledby="wm-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">
<h2 class="panel-title" id="wm-heading">
{$t('apps.detail.metrics.title')}<span class="title-accent">.</span>
</h2>
<span class="panel-sub">{$t('apps.detail.metrics.sub')}</span>
<div class="wm-windows" role="group" aria-label={$t('apps.detail.metrics.windowLabel')}>
{#each WINDOWS as w (w)}
<button class="wm-win" class:active={window === w} onclick={() => (window = w)}>{w}</button>
{/each}
</div>
</header>
{#if error}
<div class="alert inline-alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
{#if loading && points.length === 0}
<p class="hint">{$t('apps.detail.metrics.loading')}</p>
{:else if points.length === 0}
<p class="hint">{$t('apps.detail.metrics.empty')}</p>
{:else}
{#if latest}
<p class="wm-summary">
<span class="wm-stat"
><span class="wm-dot cpu"></span>{$t('apps.detail.metrics.cpuSeries')}:
<strong>{latest.cpu_percent.toFixed(1)}%</strong></span
>
<span class="wm-stat"
><span class="wm-dot mem"></span>{$t('apps.detail.metrics.memorySeries')}:
<strong>{formatBytes(latest.memory_usage)}</strong></span
>
</p>
{/if}
<ResourceChart option={chartOption} height="180px" ariaLabel={$t('apps.detail.metrics.title')} />
{/if}
</section>
<style>
.wm-panel {
margin-top: 1rem;
}
.panel-head {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
}
.wm-windows {
margin-left: auto;
display: flex;
gap: 0.25rem;
}
.wm-win {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border: 1px solid var(--border-primary);
border-radius: 4px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.wm-win.active {
background: var(--accent-soft, rgba(16, 185, 129, 0.15));
color: var(--text-primary);
border-color: var(--accent, #10b981);
}
.hint {
font-size: 0.72rem;
color: var(--text-tertiary);
margin: 0.3rem 0;
}
.wm-summary {
display: flex;
gap: 1rem;
font-size: 0.74rem;
color: var(--text-secondary);
margin: 0.3rem 0 0.5rem;
}
.wm-stat {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.wm-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.wm-dot.cpu {
background: #10b981;
}
.wm-dot.mem {
background: #3b82f6;
}
</style>
@@ -3,9 +3,8 @@
* WorkloadSnapshotsPanel
*
* Per-workload capture of host-bind data volumes (tar.gz). Create / list /
* download / delete / restore. Restore overwrites the app's live volume data
* and recreates its containers — it quiesces the app, atomically swaps each
* volume dir, then redeploys, and auto-captures a pre-restore snapshot first.
* download / delete. Restore is intentionally NOT here yet — overwriting
* live data needs container quiesce + atomic swap and ships separately.
*
* "Snapshotable" coverage is shown up-front (and which volumes are skipped,
* with why) so users are never misled about what is actually captured.
@@ -30,8 +29,6 @@
let error = $state('');
let label = $state('');
let confirmDeleteId = $state<string | null>(null);
let confirmRestoreId = $state<string | null>(null);
let restoringId = $state<string | null>(null);
const canSnapshot = $derived((snapshotable?.volumes.length ?? 0) > 0);
@@ -84,20 +81,6 @@
}
}
async function doRestore(id: string): Promise<void> {
confirmRestoreId = null;
restoringId = id;
try {
await api.restoreSnapshot(workloadId, id);
toasts.success($t('apps.detail.snapshots.restored'));
await load();
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('apps.detail.snapshots.restoreFailed'));
} finally {
restoringId = null;
}
}
async function download(snap: api.SnapshotInfo): Promise<void> {
try {
const token = getAuthToken();
@@ -211,26 +194,12 @@
<td>{volCount(snap.manifest)}</td>
<td class="mono-time">{formatBytes(snap.size_bytes)}</td>
<td class="snap-actions">
<button
class="forge-btn-ghost"
onclick={() => (confirmRestoreId = snap.id)}
disabled={restoringId !== null}
>
{restoringId === snap.id
? $t('apps.detail.snapshots.restoring')
: $t('apps.detail.snapshots.restore')}
</button>
<button
class="forge-btn-ghost"
onclick={() => download(snap)}
disabled={restoringId !== null}
>
<button class="forge-btn-ghost" onclick={() => download(snap)}>
{$t('apps.detail.snapshots.download')}
</button>
<button
class="forge-btn-ghost danger"
onclick={() => (confirmDeleteId = snap.id)}
disabled={restoringId !== null}
aria-label={$t('apps.detail.snapshots.delete')}
>
<IconTrash size={13} />
@@ -256,18 +225,6 @@
/>
{/if}
{#if confirmRestoreId}
<ConfirmDialog
open={true}
title={$t('apps.detail.snapshots.confirmRestoreTitle')}
message={$t('apps.detail.snapshots.confirmRestoreMessage')}
confirmLabel={$t('apps.detail.snapshots.restore')}
confirmVariant="danger"
onconfirm={() => confirmRestoreId && doRestore(confirmRestoreId)}
oncancel={() => (confirmRestoreId = null)}
/>
{/if}
<style>
.snap-panel {
margin-top: 1rem;
@@ -1,468 +0,0 @@
<!--
Deploy-strategy selector — phase-2 UI for the per-workload `deploy_strategy`
(backend: internal/workload/plugin/strategy.go). A two-card radiogroup that
picks between `recreate` (stop old → start new, brief downtime) and
`blue-green` (start new alongside old, health-check, swap traffic, retire
old — no downtime). Each card carries a CSS-only motion glyph that animates
the actual deploy semantics: recreate shows a downtime GAP between versions;
blue-green shows the two versions OVERLAP while traffic cuts over.
Empty-string canonical default
──────────────────────────────
`strategy === ''` means "use the source's historical default". We keep it as
`''` (rather than writing the explicit value) whenever the operator picks the
source default, so an untouched `source_config` stays byte-identical — see
DeployStrategy in $lib/workload/sourceForms. The card matching the source
default therefore reads as selected when `strategy` is empty, and clicking it
clears `strategy` back to `''`.
-->
<script lang="ts">
import type { DeployStrategy } from '$lib/workload/sourceForms';
import { t } from '$lib/i18n';
interface Props {
/** Bound strategy. "" = source default (kept canonical for byte-shape). */
strategy: DeployStrategy;
/** The source's effective default — labels the "default" pill and is
* the value `strategy === ''` resolves to visually. */
defaultStrategy: 'recreate' | 'blue-green';
/** Static-only: warn that storage-backed Deno sites fall back to recreate. */
denoCaveat?: boolean;
/** Unique id stem so multiple instances don't collide on the DOM. */
idPrefix?: string;
}
let {
strategy = $bindable(),
defaultStrategy,
denoCaveat = false,
idPrefix = 'deploy-strategy'
}: Props = $props();
type Concrete = 'recreate' | 'blue-green';
const OPTIONS: Concrete[] = ['recreate', 'blue-green'];
// What the backend will actually do given the current (possibly empty) value.
const effective = $derived<Concrete>(strategy === '' ? defaultStrategy : strategy);
function pick(value: Concrete) {
// Selecting the source default collapses back to "" so we never persist
// a redundant explicit value (keeps existing configs byte-identical).
strategy = value === defaultStrategy ? '' : value;
}
// WAI-ARIA radiogroup keyboard pattern: arrows move + select, roving
// tabindex keeps a single tab stop.
function onKey(e: KeyboardEvent, index: number) {
const next =
e.key === 'ArrowRight' || e.key === 'ArrowDown'
? (index + 1) % OPTIONS.length
: e.key === 'ArrowLeft' || e.key === 'ArrowUp'
? (index - 1 + OPTIONS.length) % OPTIONS.length
: -1;
if (next === -1) return;
e.preventDefault();
pick(OPTIONS[next]);
// Move focus to the newly-selected radio (roving tabindex).
const el = document.getElementById(`${idPrefix}-${OPTIONS[next]}`);
el?.focus();
}
function nameFor(o: Concrete): string {
return o === 'recreate'
? $t('apps.new.deployStrategy.recreateName')
: $t('apps.new.deployStrategy.blueGreenName');
}
function descFor(o: Concrete): string {
return o === 'recreate'
? $t('apps.new.deployStrategy.recreateDesc')
: $t('apps.new.deployStrategy.blueGreenDesc');
}
const showDenoCaveat = $derived(denoCaveat && effective === 'blue-green');
</script>
<section class="strategy-field">
<div class="strategy-head">
<span class="forge-eyebrow">
<span class="forge-ember"></span>
<span>{$t('apps.new.deployStrategy.label')}</span>
</span>
</div>
<div
class="strategy-grid"
role="radiogroup"
aria-label={$t('apps.new.deployStrategy.label')}
>
{#each OPTIONS as opt, i (opt)}
{@const selected = effective === opt}
<button
type="button"
id={`${idPrefix}-${opt}`}
role="radio"
aria-checked={selected}
tabindex={selected ? 0 : -1}
class="opt"
class:selected
class:is-recreate={opt === 'recreate'}
class:is-bluegreen={opt === 'blue-green'}
onclick={() => pick(opt)}
onkeydown={(e) => onKey(e, i)}
>
<!-- Motion glyph: animates only when the card is selected. -->
<span class="glyph" aria-hidden="true">
{#if opt === 'recreate'}
<span class="rc-bar v1"></span>
<span class="rc-gap"><span class="rc-gap-mark"></span></span>
<span class="rc-bar v2"></span>
{:else}
<span class="bg-bar v1"></span>
<span class="bg-bar v2"></span>
<span class="bg-traffic"></span>
{/if}
</span>
<span class="opt-meta">
<span class="opt-name">
{nameFor(opt)}
{#if opt === defaultStrategy}
<span class="opt-default">{$t('apps.new.deployStrategy.defaultBadge')}</span>
{/if}
</span>
<span class="opt-desc">{descFor(opt)}</span>
</span>
<span class="opt-tick" aria-hidden="true">
<svg viewBox="0 0 16 16" width="13" height="13">
<path
d="M3.5 8.5l3 3 6-7"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</button>
{/each}
</div>
{#if showDenoCaveat}
<p class="strategy-caveat" role="note">{$t('apps.new.deployStrategy.denoCaveat')}</p>
{/if}
</section>
<style>
.strategy-field {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.strategy-head {
display: flex;
align-items: center;
}
.strategy-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
@media (max-width: 560px) {
.strategy-grid {
grid-template-columns: 1fr;
}
}
/* ── Option card ──────────────────────────────────────────── */
.opt {
position: relative;
display: grid;
grid-template-rows: auto auto;
gap: 0.55rem;
text-align: left;
padding: 0.75rem 0.8rem 0.7rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--surface-card);
color: var(--text-secondary);
cursor: pointer;
transition:
border-color 140ms ease,
background 140ms ease,
box-shadow 140ms ease,
transform 140ms ease;
}
.opt:hover:not(.selected) {
border-color: color-mix(in srgb, var(--forge-accent) 45%, var(--border-primary));
background: var(--surface-card-hover);
}
.opt:focus-visible {
outline: none;
border-color: var(--forge-accent);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
.opt.selected {
border-color: var(--forge-accent);
background: var(--surface-card-hover);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
/* ── Meta (title + description) ───────────────────────────── */
.opt-meta {
display: flex;
flex-direction: column;
gap: 0.22rem;
min-width: 0;
}
.opt-name {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
font-size: 0.9rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.opt-default {
font-family: var(--forge-mono);
font-size: 0.54rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
padding: 0.12rem 0.34rem;
border-radius: var(--radius-sm);
color: var(--text-tertiary);
background: color-mix(in srgb, var(--text-tertiary) 12%, transparent);
border: 1px solid var(--border-primary);
}
.opt.selected .opt-default {
color: var(--forge-accent);
background: var(--forge-accent-soft);
border-color: color-mix(in srgb, var(--forge-accent) 35%, transparent);
}
.opt-desc {
font-size: 0.76rem;
line-height: 1.45;
color: var(--text-tertiary);
}
/* ── Selected tick ────────────────────────────────────────── */
.opt-tick {
position: absolute;
top: 0.6rem;
right: 0.6rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
color: #fff;
background: var(--forge-accent);
opacity: 0;
transform: scale(0.6);
transition:
opacity 140ms ease,
transform 160ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.opt.selected .opt-tick {
opacity: 1;
transform: scale(1);
}
/* ── Motion glyph shell ───────────────────────────────────── */
.glyph {
position: relative;
display: block;
height: 30px;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--text-tertiary) 7%, transparent);
border: 1px solid color-mix(in srgb, var(--border-primary) 70%, transparent);
overflow: hidden;
}
/* Version-bar fills (amber = v1/old, green = v2/new). The amber stays on
brand; green reads as "the new one going live", anchoring blue-green. */
.v1 {
--bar: var(--forge-accent);
}
.v2 {
--bar: var(--color-success);
}
/* ── Recreate glyph: v1 | gap | v2 laid out as a timeline ─── */
.is-recreate .glyph {
display: flex;
align-items: center;
gap: 0;
padding: 0 0.45rem;
}
.rc-bar {
height: 9px;
border-radius: 999px;
background: var(--bar);
flex: 1 1 0;
}
.rc-bar.v1 {
opacity: 0.85;
}
.rc-bar.v2 {
opacity: 0.85;
}
.rc-gap {
flex: 0 0 22%;
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
}
/* The "downtime" notch between versions — a dashed seam tinted with the
warning hue. This visible gap is the whole point of recreate. */
.rc-gap-mark {
width: 100%;
height: 0;
border-top: 2px dashed color-mix(in srgb, var(--color-warning) 65%, transparent);
opacity: 0.7;
}
/* ── Blue-green glyph: stacked bars + a traffic dot cutting over ── */
.is-bluegreen .glyph {
padding: 0.45rem 0.5rem;
}
.bg-bar {
position: absolute;
left: 0.5rem;
right: 1.4rem;
height: 8px;
border-radius: 999px;
background: var(--bar);
}
.bg-bar.v1 {
top: 0.5rem;
opacity: 0.85;
}
.bg-bar.v2 {
bottom: 0.5rem;
opacity: 0.85;
}
/* Traffic indicator: a brand dot parked on whichever bar serves requests.
Static = on v2 (new). When selected it travels v1 → v2 (the cutover). */
.bg-traffic {
position: absolute;
right: 0.55rem;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--forge-accent);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
bottom: 0.45rem;
}
/* ── Animation — only the SELECTED card moves (less visual noise) ── */
.opt.selected.is-recreate .rc-bar.v1 {
animation: rc-v1 2.6s ease-in-out infinite;
}
.opt.selected.is-recreate .rc-bar.v2 {
animation: rc-v2 2.6s ease-in-out infinite;
}
.opt.selected.is-recreate .rc-gap-mark {
animation: rc-gap 2.6s ease-in-out infinite;
}
@keyframes rc-v1 {
0%,
25% {
opacity: 0.9;
transform: scaleX(1);
}
45%,
100% {
opacity: 0.12;
transform: scaleX(0.96);
}
}
@keyframes rc-v2 {
0%,
55% {
opacity: 0.12;
transform: scaleX(0.96);
}
78%,
100% {
opacity: 0.9;
transform: scaleX(1);
}
}
@keyframes rc-gap {
0%,
30% {
opacity: 0.25;
}
45%,
60% {
opacity: 1;
}
80%,
100% {
opacity: 0.25;
}
}
.opt.selected.is-bluegreen .bg-bar.v1 {
animation: bg-v1 2.8s ease-in-out infinite;
}
.opt.selected.is-bluegreen .bg-traffic {
animation: bg-traffic 2.8s ease-in-out infinite;
}
@keyframes bg-v1 {
0%,
55% {
opacity: 0.9;
}
/* v1 dims to "retired" AFTER traffic has moved — never a service gap. */
80%,
100% {
opacity: 0.3;
}
}
@keyframes bg-traffic {
0%,
20% {
top: 0.45rem;
bottom: auto;
}
50%,
100% {
top: auto;
bottom: 0.45rem;
}
}
/* Respect reduced-motion: drop the keyframes, keep the legible end-state
(recreate keeps its visible gap; blue-green keeps both bars + traffic). */
@media (prefers-reduced-motion: reduce) {
.opt .rc-bar,
.opt .rc-gap-mark,
.opt .bg-bar,
.opt .bg-traffic {
animation: none !important;
}
.opt {
transition: none;
}
}
/* ── Deno fallback caveat ─────────────────────────────────── */
.strategy-caveat {
margin: 0;
font-size: 0.74rem;
line-height: 1.45;
color: var(--color-warning);
padding: 0.4rem 0.55rem;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-warning) 9%, transparent);
border: 1px solid color-mix(in srgb, var(--color-warning) 28%, transparent);
}
</style>
@@ -15,7 +15,6 @@
import type { DockerfileFormState } from '$lib/workload/sourceForms';
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import DeployStrategyField from '$lib/components/workload/DeployStrategyField.svelte';
import { IconX } from '$lib/components/icons';
import { t } from '$lib/i18n';
@@ -148,7 +147,6 @@
{@html $t('apps.new.sourceReportCommitStatusDesc')}
</span>
</label>
<DeployStrategyField bind:strategy={form.deployStrategy} defaultStrategy="recreate" idPrefix="app-df-strategy" />
<p class="hint image-form-foot">{$t('apps.new.dockerfileFoot')}</p>
</div>
@@ -26,7 +26,6 @@
import * as api from '$lib/api';
import { IconSearch, IconLoader } from '$lib/components/icons';
import RegistryImagePicker from '$lib/components/RegistryImagePicker.svelte';
import DeployStrategyField from '$lib/components/workload/DeployStrategyField.svelte';
import { t } from '$lib/i18n';
interface Props {
@@ -389,7 +388,6 @@
<p class="hint">{$t('apps.new.imageMaxHint')}</p>
</label>
</div>
<DeployStrategyField bind:strategy={form.deployStrategy} defaultStrategy="blue-green" idPrefix="app-image-strategy" />
<p class="hint image-form-foot">{$t('apps.new.imageFoot')}</p>
</div>
@@ -15,7 +15,6 @@
import type { FolderEntry } from '$lib/api';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
import DeployStrategyField from '$lib/components/workload/DeployStrategyField.svelte';
import { t } from '$lib/i18n';
interface Props {
@@ -119,12 +118,6 @@
{@html $t('apps.new.sourceReportCommitStatusDesc')}
</span>
</label>
<DeployStrategyField
bind:strategy={form.deployStrategy}
defaultStrategy="recreate"
denoCaveat={form.mode === 'deno'}
idPrefix="app-static-strategy"
/>
<p class="hint image-form-foot">{$t('apps.new.staticFoot')}</p>
</div>
+2 -106
View File
@@ -1407,9 +1407,7 @@
"colTrigger": "Trigger",
"colCreated": "Created",
"colActions": "Actions",
"rowOpen": "Open",
"gitopsBadge": "GitOps",
"gitopsBadgeTitle": "Deploy config for this app is managed from its repo."
"rowOpen": "Open"
},
"new": {
"pageTitle": "New App · Tinyforge",
@@ -1503,15 +1501,6 @@
"staticRenderMarkdownDesc": "— auto-render <code>.md</code> files as HTML pages.",
"sourceReportCommitStatus": "Report commit status",
"sourceReportCommitStatusDesc": "— report deploy status back to the Git provider as a commit status on the deployed commit.",
"deployStrategy": {
"label": "Deploy strategy",
"recreateName": "Recreate",
"recreateDesc": "Stop the old container, then start the new one. A brief window of downtime during the swap.",
"blueGreenName": "Zero-downtime",
"blueGreenDesc": "Start the new container, health-check it, then switch traffic and retire the old one. No downtime.",
"defaultBadge": "default",
"denoCaveat": "Deno sites with persistent storage fall back to recreate to avoid two writers on the same volume."
},
"staticFoot": "The webhook secret for git push triggers lives on the workload's Webhook panel after creation.",
"staticDetectProvider": "Detect",
"staticDetectedOk": "Detected: {provider}",
@@ -1706,94 +1695,7 @@
"downloadFailed": "Failed to download snapshot",
"deleteFailed": "Failed to delete snapshot",
"confirmDeleteTitle": "Delete snapshot?",
"confirmDeleteMessage": "This permanently deletes the snapshot archive. This cannot be undone.",
"restore": "Restore",
"restoring": "Restoring…",
"restored": "Snapshot restored and the app redeployed.",
"restoreFailed": "Failed to restore snapshot",
"confirmRestoreTitle": "Restore this snapshot?",
"confirmRestoreMessage": "This OVERWRITES the app's live volume data with this snapshot and restarts the app. A pre-restore snapshot of the current data is captured automatically first, so you can roll back. Continue?"
},
"deployHistory": {
"title": "Deploy history",
"sub": "Every deploy of this app, newest first. Roll back to redeploy a previous version.",
"loading": "Loading deploy history…",
"empty": "No deploys recorded yet.",
"colWhen": "When",
"colReason": "Reason",
"colReference": "Version",
"colOutcome": "Outcome",
"colBy": "By",
"rollback": "Roll back",
"rolledBack": "Rolling back to {ref}…",
"rollbackFailed": "Rollback failed",
"confirmTitle": "Roll back to this version?",
"confirmMessage": "This redeploys {ref} as a new deploy. The current version is replaced once the rollback is healthy."
},
"metrics": {
"title": "Resource usage",
"sub": "CPU and memory for this app's containers over time.",
"loading": "Loading metrics…",
"empty": "No metrics yet. Collection may be disabled, or this app hasn't run long enough to sample.",
"windowLabel": "Time window",
"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"
}
"confirmDeleteMessage": "This permanently deletes the snapshot archive. This cannot be undone."
},
"toolbar": {
"stop": "Stop",
@@ -1935,13 +1837,7 @@
"chainSelfLabel": "This",
"chainChildrenLabel": "Children",
"chainPromoteButton": "Promote from parent",
"chainPromoteToChild": "Promote to this",
"chainPromoting": "Promoting…",
"chainPromoteConfirmTitle": "Promote version?",
"chainPromoteConfirmMessage": "Promote the running version of {source} to {target} and deploy it?",
"chainPromoteConfirmYes": "Promote & deploy",
"chainPromoteOk": "Promoted {tag} to {target}",
"chainPromoteFailed": "Promotion failed",
"chainHint": "Set <code>parent_workload_id</code> on a workload to build a chain. Image-source children can promote the parent's currently-running tag with one click.",
"previews": {
"title": "Preview environments",
+2 -106
View File
@@ -1407,9 +1407,7 @@
"colTrigger": "Триггер",
"colCreated": "Создано",
"colActions": "Действия",
"rowOpen": "Открыть",
"gitopsBadge": "GitOps",
"gitopsBadgeTitle": "Конфигурация деплоя этого приложения управляется из репозитория."
"rowOpen": "Открыть"
},
"new": {
"pageTitle": "Новое приложение · Tinyforge",
@@ -1503,15 +1501,6 @@
"staticRenderMarkdownDesc": "— автоматически отдавать <code>.md</code> файлы как HTML-страницы.",
"sourceReportCommitStatus": "Отправлять статус коммита",
"sourceReportCommitStatusDesc": "— отправлять статус деплоя обратно в Git-провайдер как статус коммита для развёрнутого коммита.",
"deployStrategy": {
"label": "Стратегия деплоя",
"recreateName": "Пересоздание",
"recreateDesc": "Остановить старый контейнер, затем запустить новый. Короткий простой во время замены.",
"blueGreenName": "Без простоя",
"blueGreenDesc": "Запустить новый контейнер, проверить health-check, переключить трафик и убрать старый. Без простоя.",
"defaultBadge": "по умолчанию",
"denoCaveat": "Deno-сайты с постоянным хранилищем используют пересоздание, чтобы избежать двух писателей на одном томе."
},
"staticFoot": "Секрет вебхука для git push-триггеров появляется в панели вебхука нагрузки после создания.",
"staticDetectProvider": "Определить",
"staticDetectedOk": "Определено: {provider}",
@@ -1706,94 +1695,7 @@
"downloadFailed": "Не удалось скачать снимок",
"deleteFailed": "Не удалось удалить снимок",
"confirmDeleteTitle": "Удалить снимок?",
"confirmDeleteMessage": "Архив снимка будет удалён безвозвратно. Это действие нельзя отменить.",
"restore": "Восстановить",
"restoring": "Восстановление…",
"restored": "Снимок восстановлен, приложение переразвёрнуто.",
"restoreFailed": "Не удалось восстановить снимок",
"confirmRestoreTitle": "Восстановить этот снимок?",
"confirmRestoreMessage": "Это ПЕРЕЗАПИШЕТ текущие данные томов приложения этим снимком и перезапустит приложение. Сначала автоматически создаётся снимок текущих данных, чтобы можно было откатиться. Продолжить?"
},
"deployHistory": {
"title": "История деплоев",
"sub": "Все деплои этого приложения, новые сверху. Откатитесь, чтобы повторно развернуть предыдущую версию.",
"loading": "Загрузка истории деплоев…",
"empty": "Деплоев пока нет.",
"colWhen": "Когда",
"colReason": "Причина",
"colReference": "Версия",
"colOutcome": "Результат",
"colBy": "Кем",
"rollback": "Откатить",
"rolledBack": "Откат до {ref}…",
"rollbackFailed": "Не удалось откатить",
"confirmTitle": "Откатиться к этой версии?",
"confirmMessage": "Версия {ref} будет развёрнута заново. Текущая версия заменяется после успешной проверки отката."
},
"metrics": {
"title": "Использование ресурсов",
"sub": "CPU и память контейнеров этого приложения во времени.",
"loading": "Загрузка метрик…",
"empty": "Метрик пока нет. Сбор может быть отключён, или приложение работало слишком недолго для замера.",
"windowLabel": "Период",
"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": "Стратегия деплоя"
}
"confirmDeleteMessage": "Архив снимка будет удалён безвозвратно. Это действие нельзя отменить."
},
"toolbar": {
"stop": "Стоп",
@@ -1935,13 +1837,7 @@
"chainSelfLabel": "Эта",
"chainChildrenLabel": "Дочерние",
"chainPromoteButton": "Продвинуть от родителя",
"chainPromoteToChild": "Продвинуть сюда",
"chainPromoting": "Продвижение…",
"chainPromoteConfirmTitle": "Продвинуть версию?",
"chainPromoteConfirmMessage": "Продвинуть запущенную версию {source} в {target} и развернуть её?",
"chainPromoteConfirmYes": "Продвинуть и развернуть",
"chainPromoteOk": "Версия {tag} продвинута в {target}",
"chainPromoteFailed": "Не удалось продвинуть",
"chainHint": "Задайте <code>parent_workload_id</code> у нагрузки, чтобы построить цепочку. Дочерние image-источники могут одним кликом продвинуть текущий запущенный тег родителя.",
"previews": {
"title": "Превью-окружения",
-7
View File
@@ -372,13 +372,6 @@ 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;
}
+1 -55
View File
@@ -46,8 +46,7 @@ describe('image source', () => {
registryName: 'docker.io',
cpuLimit: 2,
memoryLimit: 512,
maxInstances: 3,
deployStrategy: ''
maxInstances: 3
});
});
@@ -295,56 +294,3 @@ describe('dockerfile source', () => {
expect(isDockerfileValid({ ...base, port: -1 })).toBe(false);
});
});
describe('deploy_strategy (cross-source)', () => {
it('seeds recognized strategies and drops junk to ""', () => {
expect(seedImageState(JSON.stringify({ image: 'x', deploy_strategy: 'recreate' })).deployStrategy).toBe('recreate');
expect(seedImageState(JSON.stringify({ image: 'x', deploy_strategy: 'blue-green' })).deployStrategy).toBe('blue-green');
expect(seedImageState(JSON.stringify({ image: 'x', deploy_strategy: 'rolling' })).deployStrategy).toBe('');
expect(seedDockerfileState(JSON.stringify({ deploy_strategy: 'blue-green' })).deployStrategy).toBe('blue-green');
expect(seedStaticState(JSON.stringify({ deploy_strategy: 'recreate' })).deployStrategy).toBe('recreate');
expect(seedStaticState(JSON.stringify({ deploy_strategy: 'nope' })).deployStrategy).toBe('');
});
it('omits deploy_strategy when empty so existing configs stay byte-identical', () => {
expect('deploy_strategy' in imageToConfig(emptyImageState(), '{}')).toBe(false);
expect('deploy_strategy' in dockerfileToConfig(emptyDockerfileState(), '{}')).toBe(false);
expect('deploy_strategy' in staticToConfig(emptyStaticState(), '{}')).toBe(false);
});
it('emits deploy_strategy at the end of the owned block when set', () => {
const img = imageToConfig({ ...emptyImageState(), deployStrategy: 'recreate' }, '{}');
expect(img.deploy_strategy).toBe('recreate');
expect(Object.keys(img).at(-1)).toBe('deploy_strategy');
const df = dockerfileToConfig({ ...emptyDockerfileState(), deployStrategy: 'blue-green' }, '{}');
expect(df.deploy_strategy).toBe('blue-green');
expect(Object.keys(df).at(-1)).toBe('deploy_strategy');
const st = staticToConfig({ ...emptyStaticState(), deployStrategy: 'blue-green' }, '{}');
expect(st.deploy_strategy).toBe('blue-green');
expect(Object.keys(st).at(-1)).toBe('deploy_strategy');
});
it('dockerfile owns deploy_strategy: form value wins, stale value scrubbed', () => {
// Form value overrides an existing stored strategy (owned key).
const overridden = dockerfileToConfig(
{ ...emptyDockerfileState(), deployStrategy: 'blue-green' },
JSON.stringify({ deploy_strategy: 'recreate', healthcheck: '/up' })
);
expect(overridden.deploy_strategy).toBe('blue-green');
expect(overridden.healthcheck).toBe('/up'); // unknown key still preserved
// Clearing back to default scrubs the stored key (no orphan recreate).
const cleared = dockerfileToConfig(
emptyDockerfileState(),
JSON.stringify({ deploy_strategy: 'blue-green' })
);
expect('deploy_strategy' in cleared).toBe(false);
});
it('round-trips an explicit strategy through serialize -> seed', () => {
const s = seedImageState(JSON.stringify({ image: 'app', deploy_strategy: 'recreate' }));
expect(seedImageState(stringifyConfig(imageToConfig(s, '{}'))).deployStrategy).toBe('recreate');
});
});
+10 -52
View File
@@ -21,18 +21,6 @@
export type GitProvider = 'gitea' | 'github' | 'gitlab';
/**
* Per-workload deploy strategy.
*
* `''` means "use the source's historical default" (image blue-green,
* dockerfile / static recreate). It is the canonical value we persist
* whenever the operator has NOT deviated from that default, so existing
* `source_config` blobs stay byte-identical and the key is only ever
* written when it actually differs. The backend's `effectiveStrategy()`
* resolves `''` to the same per-source default, so the two are equivalent.
*/
export type DeployStrategy = '' | 'recreate' | 'blue-green';
/** Image source: deploy a pre-built image from a registry. */
export interface ImageFormState {
ref: string;
@@ -43,8 +31,6 @@ export interface ImageFormState {
cpuLimit: number;
memoryLimit: number;
maxInstances: number;
/** "" = source default (blue-green for image); else explicit. */
deployStrategy: DeployStrategy;
}
/** Compose source: a docker-compose stack. */
@@ -75,8 +61,6 @@ export interface StaticFormState extends GitSourceState {
renderMarkdown: boolean;
/** Report deploy outcome back to the git provider as a commit status. */
reportCommitStatus: boolean;
/** "" = source default (recreate for static); else explicit. */
deployStrategy: DeployStrategy;
}
/** Dockerfile source: build an image from a Dockerfile in a repo. */
@@ -86,8 +70,6 @@ export interface DockerfileFormState extends GitSourceState {
port: number;
/** Report deploy outcome back to the git provider as a commit status. */
reportCommitStatus: boolean;
/** "" = source default (recreate for dockerfile); else explicit. */
deployStrategy: DeployStrategy;
}
// ── Defaults ────────────────────────────────────────────────────────
@@ -101,8 +83,7 @@ export function emptyImageState(): ImageFormState {
registryName: '',
cpuLimit: 0,
memoryLimit: 0,
maxInstances: 1,
deployStrategy: ''
maxInstances: 1
};
}
@@ -127,8 +108,7 @@ export function emptyStaticState(): StaticFormState {
folderPath: '',
mode: 'static',
renderMarkdown: false,
reportCommitStatus: false,
deployStrategy: ''
reportCommitStatus: false
};
}
@@ -138,8 +118,7 @@ export function emptyDockerfileState(): DockerfileFormState {
contextPath: '',
dockerfilePath: 'Dockerfile',
port: 0,
reportCommitStatus: false,
deployStrategy: ''
reportCommitStatus: false
};
}
@@ -188,11 +167,6 @@ function normProvider(value: unknown): GitProvider {
return value === 'github' || value === 'gitlab' ? value : 'gitea';
}
/** Recognized explicit strategies; anything else (incl. absent) -> "". */
function normStrategy(value: unknown): DeployStrategy {
return value === 'recreate' || value === 'blue-green' ? value : '';
}
// ── Seed: source_config JSON -> form state ──────────────────────────
export function seedImageState(jsonText: string): ImageFormState {
@@ -205,8 +179,7 @@ export function seedImageState(jsonText: string): ImageFormState {
registryName: strOr(o.registry_name, ''),
cpuLimit: numOr(o.cpu_limit, 0),
memoryLimit: numOr(o.memory_limit, 0),
maxInstances: numOr(o.max_instances, 1),
deployStrategy: normStrategy(o.deploy_strategy)
maxInstances: numOr(o.max_instances, 1)
};
}
@@ -231,8 +204,7 @@ export function seedStaticState(jsonText: string): StaticFormState {
mode: o.mode === 'deno' ? 'deno' : 'static',
renderMarkdown: typeof o.render_markdown === 'boolean' ? o.render_markdown : false,
reportCommitStatus:
typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false,
deployStrategy: normStrategy(o.deploy_strategy)
typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false
};
}
@@ -249,8 +221,7 @@ export function seedDockerfileState(jsonText: string): DockerfileFormState {
dockerfilePath: strOrTruthy(o.dockerfile_path, 'Dockerfile'),
port: numOr(o.port, 0),
reportCommitStatus:
typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false,
deployStrategy: normStrategy(o.deploy_strategy)
typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false
};
}
@@ -281,7 +252,7 @@ function preserveEnvVolumes(existingJson: string): {
export function imageToConfig(s: ImageFormState, existingJson: string): Record<string, unknown> {
const { env, volumes } = preserveEnvVolumes(existingJson);
const out: Record<string, unknown> = {
return {
image: s.ref,
registry_name: s.registryName,
port: s.port,
@@ -293,10 +264,6 @@ export function imageToConfig(s: ImageFormState, existingJson: string): Record<s
default_tag: s.defaultTag,
max_instances: s.maxInstances
};
// Only written when the operator deviates from the source default, so an
// untouched workload's config stays byte-identical (see DeployStrategy).
if (s.deployStrategy) out.deploy_strategy = s.deployStrategy;
return out;
}
export function composeToConfig(s: ComposeFormState): Record<string, unknown> {
@@ -319,10 +286,6 @@ export function staticToConfig(s: StaticFormState, existingJson: string): Record
// only when present in the existing config) trail this on edit.
report_commit_status: s.reportCommitStatus
};
// deploy_strategy only when the operator deviated from the static default
// (recreate). Backend force-downgrades blue-green for storage-backed deno
// sites; we still persist the operator's choice and surface a UI caveat.
if (s.deployStrategy) out.deploy_strategy = s.deployStrategy;
// Preserve storage_* keys set via the raw JSON editor (not yet surfaced
// as form controls) so a form round-trip doesn't silently drop them.
const existing = tryParse(existingJson);
@@ -351,7 +314,6 @@ const DOCKERFILE_OWNED_KEYS: ReadonlySet<string> = new Set([
'dockerfile_path',
'port',
'report_commit_status',
'deploy_strategy',
'folder_path',
'mode',
'render_markdown',
@@ -370,7 +332,7 @@ export function dockerfileToConfig(
if (!DOCKERFILE_OWNED_KEYS.has(k)) preserved[k] = v;
}
}
const out: Record<string, unknown> = {
return {
provider: s.provider,
base_url: s.baseURL,
repo_owner: s.repoOwner,
@@ -382,13 +344,9 @@ export function dockerfileToConfig(
port: s.port || 0,
// New owned key appended at the END of the owned block (before any
// preserved unknown keys) so existing byte-shape assertions hold.
report_commit_status: s.reportCommitStatus
report_commit_status: s.reportCommitStatus,
...preserved
};
// Owned (see DOCKERFILE_OWNED_KEYS) so it's never double-written as a
// preserved unknown; emitted only when the operator picked a non-default.
if (s.deployStrategy) out.deploy_strategy = s.deployStrategy;
Object.assign(out, preserved);
return out;
}
/** Pretty-print a config object for the Advanced-JSON editor view. */
-15
View File
@@ -169,11 +169,6 @@
<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>
@@ -511,16 +506,6 @@
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);
+19 -241
View File
@@ -23,21 +23,16 @@
IconGlobe,
IconHardDrive,
IconClock,
IconLoader,
IconLock
IconLoader
} from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { toasts } from '$lib/stores/toast';
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
import ContainerLogs from '$lib/components/ContainerLogs.svelte';
import ContainerStats from '$lib/components/ContainerStats.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import WorkloadNotificationsPanel from '$lib/components/WorkloadNotificationsPanel.svelte';
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,
@@ -142,19 +137,6 @@
// 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
@@ -406,15 +388,7 @@
// ── Chain (parent / self / children) ──────────────────────
let chain = $state<api.WorkloadChain | null>(null);
let chainError = $state('');
let promoting = $state<string | null>(null); // active targetID, for UI lock
// Pending promotion awaiting confirmation. Promote copies the source's
// running image tag onto the target and deploys it, so it's confirmed first.
let confirmPromote = $state<{
targetID: string;
sourceID: string;
targetName: string;
sourceName: string;
} | null>(null);
let promoting = $state<string | null>(null); // sourceID we're promoting FROM, for UI lock
// ── Branch preview environments ───────────────────────────
// Preview children are the chain children the backend flagged
@@ -1092,27 +1066,14 @@
}
}
// Open the confirm dialog for a promotion. Direction is encoded by which
// (target, source) pair is passed: parent→self pulls the parent's version
// into this workload; self→child pushes this workload's version to a child.
function askPromote(targetID: string, sourceID: string, targetName: string, sourceName: string) {
confirmPromote = { targetID, sourceID, targetName, sourceName };
}
async function doPromote() {
const p = confirmPromote;
confirmPromote = null;
if (!p) return;
async function promoteFrom(sourceID: string) {
chainError = '';
promoting = p.targetID;
promoting = sourceID;
try {
const res = await api.promoteFromWorkload(p.targetID, p.sourceID, { deploy: true });
toasts.success(
$t('apps.detail.chainPromoteOk', { tag: res.promoted_tag, target: p.targetName })
);
await api.promoteFromWorkload(id, sourceID, { deploy: true });
await load();
} catch (e) {
chainError = e instanceof Error ? e.message : $t('apps.detail.chainPromoteFailed');
chainError = e instanceof Error ? e.message : 'Promote failed';
} finally {
promoting = null;
}
@@ -1246,23 +1207,6 @@
.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;
}
@@ -1546,18 +1490,6 @@
// 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();
@@ -1772,12 +1704,6 @@
<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')
@@ -1822,26 +1748,6 @@
</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>
@@ -2693,10 +2599,9 @@
<button
class="forge-btn-ghost"
disabled={promoting !== null}
onclick={() =>
askPromote(id, chain!.parent!.id, workload?.name ?? '', chain!.parent!.name)}
onclick={() => promoteFrom(chain!.parent!.id)}
>
{promoting === id ? $t('apps.detail.chainPromoting') : $t('apps.detail.chainPromoteButton')}
{promoting === chain.parent.id ? $t('apps.detail.chainPromoting') : $t('apps.detail.chainPromoteButton')}
</button>
{/if}
</div>
@@ -2723,30 +2628,17 @@
<span class="chain-label">{$t('apps.detail.chainChildrenLabel')}</span>
<div class="chain-children-list">
{#each chain.children as child (child.id)}
<div class="chain-child-item">
<a class="chain-card" href={`/apps/${child.id}`}>
<span class="chain-name">
{child.name}
{#if child.is_preview}
<span class="preview-tag" title={$t('apps.detail.previews.tagTitle')}
>{$t('apps.detail.previews.tag')}</span
>
{/if}
</span>
<span class="mono muted">{child.source_kind}</span>
</a>
{#if workload?.source_kind === 'image' && child.source_kind === 'image' && !child.is_preview}
<button
class="forge-btn-ghost"
disabled={promoting !== null}
onclick={() => askPromote(child.id, id, child.name, workload?.name ?? '')}
>
{promoting === child.id
? $t('apps.detail.chainPromoting')
: $t('apps.detail.chainPromoteToChild')}
</button>
{/if}
</div>
<a class="chain-card" href={`/apps/${child.id}`}>
<span class="chain-name">
{child.name}
{#if child.is_preview}
<span class="preview-tag" title={$t('apps.detail.previews.tagTitle')}
>{$t('apps.detail.previews.tag')}</span
>
{/if}
</span>
<span class="mono muted">{child.source_kind}</span>
</a>
{/each}
</div>
</div>
@@ -2916,26 +2808,6 @@
</section>
{/if}
<!-- ── Per-workload metrics (CPU/memory) ──────────── -->
{#if !editing}
<WorkloadMetricsPanel workloadId={id} />
{/if}
<!-- ── Deploy history + rollback ──────────────────── -->
{#if !editing}
<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} />
@@ -3319,19 +3191,6 @@
}}
/>
<ConfirmDialog
open={confirmPromote !== null}
title={$t('apps.detail.chainPromoteConfirmTitle')}
message={$t('apps.detail.chainPromoteConfirmMessage', {
source: confirmPromote?.sourceName ?? '',
target: confirmPromote?.targetName ?? ''
})}
confirmLabel={$t('apps.detail.chainPromoteConfirmYes')}
confirmVariant="primary"
onconfirm={doPromote}
oncancel={() => (confirmPromote = null)}
/>
<ConfirmDialog
open={confirmUnbindId !== null}
title={$t('apps.detail.bindings.unbindTitle')}
@@ -3606,81 +3465,6 @@
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
@@ -4477,12 +4261,6 @@
flex-wrap: wrap;
gap: 0.5rem;
}
/* A child entry pairs its card with an optional "Promote to this" button. */
.chain-child-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (max-width: 600px) {
.chain-row {
flex-direction: column;