Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80868e0f7a |
@@ -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.
|
||||
@@ -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`).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 1–4) + `dockerfile/deploy_test.go`.
|
||||
- `static/static.go` (Config + Validate) + `static/deploy.go` (blue-green branch + deno
|
||||
gate, fixes 1–5) + `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`.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -394,12 +394,6 @@ 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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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": "Превью-окружения",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,7 +2628,6 @@
|
||||
<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}
|
||||
@@ -2735,18 +2639,6 @@
|
||||
</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>
|
||||
{/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;
|
||||
|
||||
Reference in New Issue
Block a user