From 739b67856a26844f32c55b9ffd8919c8cfe647b7 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 16 May 2026 06:00:21 +0300 Subject: [PATCH] =?UTF-8?q?feat(cutover):=20hard=20legacy=20cutover=20?= =?UTF-8?q?=E2=80=94=20drop=20projects/stacks/sites/deploys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clean-break delete that closes the workload-first refactor arc. Net diff: ~30 backend files deleted, ~20 modified, ~12k LOC removed on the Go side; entire /projects /stacks /sites /deploy frontend trees gone; ~6.7k LOC removed on the Svelte/TypeScript side. Backend - API handlers gone: internal/api/{projects,stages,stage_env,stacks, static_sites,deploys,instances,volume_browser}.go - Store CRUD + tests gone: internal/store/{projects,stages,stage_env, stacks,static_sites,static_site_secrets,deploys,poll_state,volumes, workload_sync}.go (+ _test.go siblings) - Legacy deployer pipeline gone: internal/deployer/{bluegreen,promote, rollback,subdomain,resolver_test}.go; deployer.go trimmed to just the dispatch surface used by the plugin pipeline - internal/staticsite/{manager,healthcheck}.go and internal/stack/manager.go gone (the rest of those packages stay as helpers imported by the static + compose plugins) - internal/registry/poller.go gone (legacy registry poller) - internal/volume.ResolvePath gone; ResolveWorkloadPath stays - internal/webhook: handleWebhook (project) + handleSiteWebhook (site) gone; only POST /api/webhook/triggers/{secret} remains - workload-side webhook URL handlers (getWorkloadWebhook + regenerateWorkloadWebhook + EnsureWorkloadWebhookSecret + SetWorkloadWebhookSecret + GetWorkloadByWebhookSecret) gone — they minted URLs that would 404 against the new trigger-only ingress - cmd/server/main.go: dropped staticsite.Manager, stack.Manager, staticsite.HealthChecker, registry poller, SetSiteSyncTriggerer, SetStaticSiteManager, SetStackManager, wireStaticBackend - store/store.go: idempotent DROP TABLE IF EXISTS for every legacy table (projects, stages, stage_env, volumes, deploys, deploy_logs, poll_states, stacks, stack_revisions, stack_deploys, static_sites, static_site_secrets); FK order children-then-parents - store/models.go: dropped Project, Stage, Deploy, DeployLog, StageEnv, Volume, StaticSite, StaticSiteSecret, Stack, StackRevision, StackDeploy types; kept WorkloadKind constants as documented strings - internal/store/helpers.go (new): BoolToInt, rowScanner, GenerateWebhookSecret extracted from deleted CRUD files - internal/api/secrets.go (new): forwards to store.GenerateWebhookSecret so api + store paths share one secret-generation impl (no panic-vs-UUID-fallback divergence) - internal/reconciler/reconciler.go: dropped legacy stack-by-compose + static-site label paths; only canonical tinyforge.workload.id dispatch remains - providers (gitea_content/github_provider/gitlab_provider) gained path-traversal rejection on every tree entry - internal/webhook ParsedImage / ParseImageRef demoted to package- private (no external callers) Frontend - /projects /stacks /sites /deploy routes deleted (entire trees) - ProjectCard / InstanceCard / StaleContainerCard components deleted - api.ts: dropped every project/stage/stack/site/deploy/instance helper + types (Project, Stage, Stack, StaticSite, Deploy, Instance, Volume, etc.); kept Workload, Container, App, Settings, Registry, EventTrigger, LogScanRule, webhook envelopes - WorkloadWebhook type + getWorkloadWebhook/regenerateWorkloadWebhook api functions gone (mirror of the backend deletion above) - web/src/routes/+layout.svelte: dropped /projects /sites /stacks /deploy nav entries, trimmed quick-nav keymap - web/src/routes/+page.svelte: dashboard rewrite — reads listWorkloads + listContainers only; 4-card stat grid (workloads/running/failed/stale) + recent workloads strip - navCounts.ts, SystemHealthCard.svelte, ContainerLogs.svelte, ContainerStats.svelte, StatusBadge.svelte, TagCombobox.svelte, proxies/+page.svelte, containers/+page.svelte all rewired to the workload-first surface - AbortController plumbing on dashboard, nav-counts, stale page, SystemHealthCard so navigation doesn't leave dangling fetches - i18n: dropped projects.*, projectDetail.*, envEditor.*, volumeEditor.*, volumeBrowser.*, quickDeploy.*, sites.*, stacks.*, instance.*, confirm.* namespaces; en/ru parity preserved (1042 keys each) Hardening from go-reviewer + security-reviewer + typescript-reviewer subagent passes (0 CRITICAL across all three; 1 HIGH + ~12 MEDIUM addressed inline before commit): - Sec H1: dead-end workload webhook URL handlers (would mint URLs that 404 the new trigger-only ingress) deleted across backend + frontend - Go M1: IsTerminalDeployStatus dropped (no production callers) - Go M2: ParsedImage/ParseImageRef lowercased (in-package only) - Go M6: generateWebhookSecret unified — api shim forwards to store.GenerateWebhookSecret - Doc/comment freshness: stage_id (no longer FK), ProxyRoute legacy field names, workloadIDRow rationale, webhook_deliveries.target_type enum, WebhookDeliveryLog component header Doc - WORKLOAD_REFACTOR_TODO: cutover marked DONE; all three Priority 1 items are now shipped. Next focus is Priority 3 polish (apps.* i18n + codemap entries) and Priority 4 tests. Behavioral notes for operators upgrading from a pre-cutover build - Existing rows in the dropped tables disappear on first boot. - Legacy webhook URLs at /api/webhook/{secret} and /api/webhook/sites/{secret} return 404; CI configs must repoint to /api/webhook/triggers/{secret} (the trigger-split boot backfill lifted any embedded workload secret onto a Trigger row, so the secret value itself carries over). - Frontend routes /projects /stacks /sites /deploy are gone; nav links replaced with /apps and /triggers. --- cmd/server/main.go | 94 +- docs/WORKLOAD_REFACTOR_TODO.md | 121 ++- internal/api/deploys.go | 236 ----- internal/api/dns.go | 75 +- internal/api/docker.go | 187 ++-- internal/api/health.go | 12 +- internal/api/instances.go | 293 ------ internal/api/middleware.go | 30 +- internal/api/notifications.go | 307 +----- internal/api/projects.go | 225 ----- internal/api/proxies.go | 18 +- internal/api/router.go | 247 +---- internal/api/secrets.go | 43 + internal/api/sse.go | 138 --- internal/api/stacks.go | 285 ------ internal/api/stage_env.go | 186 ---- internal/api/stages.go | 202 ---- internal/api/static_sites.go | 662 ------------ internal/api/stats_history.go | 131 +-- internal/api/volume_browser.go | 209 ---- internal/api/volumes.go | 327 ------ internal/api/webhooks.go | 370 ------- internal/api/workload_env.go | 61 +- internal/api/workload_volumes.go | 60 ++ internal/config/config.go | 50 +- internal/config/export.go | 61 +- internal/config/seed.go | 80 +- internal/deployer/bluegreen.go | 210 ---- internal/deployer/deployer.go | 815 +-------------- internal/deployer/promote.go | 49 - internal/deployer/resolver_test.go | 89 -- internal/deployer/rollback.go | 63 -- internal/deployer/subdomain.go | 84 -- internal/reconciler/reconciler.go | 132 +-- internal/reconciler/reconciler_test.go | 92 +- internal/registry/poller.go | 189 ---- internal/registry/registry.go | 7 - internal/stack/manager.go | 405 -------- internal/staticsite/healthcheck.go | 111 -- internal/staticsite/manager.go | 834 --------------- internal/staticsite/resolver_test.go | 63 -- internal/store/containers.go | 63 +- internal/store/deploys.go | 212 ---- internal/store/helpers.go | 44 + internal/store/models.go | 187 +--- internal/store/poll_state.go | 75 -- internal/store/projects.go | 342 ------- internal/store/proxy_routes_test.go | 173 ---- internal/store/settings.go | 21 + internal/store/stacks.go | 398 -------- internal/store/stage_env.go | 112 -- internal/store/stages.go | 168 --- internal/store/static_site_secrets.go | 112 -- internal/store/static_sites.go | 502 --------- internal/store/store.go | 308 +----- internal/store/store_test.go | 236 ----- internal/store/volumes.go | 119 --- internal/store/webhook_deliveries.go | 8 +- internal/store/workload_sync.go | 150 --- internal/store/workload_sync_test.go | 190 ---- internal/store/workloads.go | 58 +- internal/store/workloads_test.go | 25 +- internal/volume/resolver.go | 52 - internal/webhook/handler.go | 491 +-------- internal/webhook/handler_test.go | 457 --------- internal/webhook/matcher.go | 94 -- internal/webhook/matcher_test.go | 98 -- web/src/lib/api.ts | 627 ++---------- web/src/lib/components/ContainerLogs.svelte | 36 +- web/src/lib/components/ContainerStats.svelte | 34 +- web/src/lib/components/InstanceCard.svelte | 176 ---- web/src/lib/components/ProjectCard.svelte | 83 -- .../lib/components/StaleContainerCard.svelte | 85 -- web/src/lib/components/StatusBadge.svelte | 8 +- .../lib/components/SystemHealthCard.svelte | 49 +- web/src/lib/components/TagCombobox.svelte | 2 +- .../lib/components/WebhookDeliveryLog.svelte | 7 +- web/src/lib/i18n/en.json | 606 +---------- web/src/lib/i18n/ru.json | 604 +---------- web/src/lib/sse.ts | 29 +- web/src/lib/stores/navCounts.ts | 45 +- web/src/lib/types.ts | 237 +---- web/src/routes/+layout.svelte | 24 +- web/src/routes/+page.svelte | 253 ++--- web/src/routes/apps/[id]/+page.svelte | 86 +- web/src/routes/containers/+page.svelte | 26 +- web/src/routes/containers/stale/+page.svelte | 137 ++- web/src/routes/deploy/+page.svelte | 389 ------- web/src/routes/projects/+page.svelte | 302 ------ web/src/routes/projects/[id]/+page.svelte | 915 ----------------- web/src/routes/projects/[id]/env/+page.svelte | 471 --------- .../routes/projects/[id]/volumes/+page.svelte | 324 ------ .../[id]/volumes/[volId]/browse/+page.svelte | 233 ----- .../[id]/volumes/[volId]/browse/+page.ts | 1 - web/src/routes/proxies/+page.svelte | 7 +- web/src/routes/sites/+page.svelte | 271 ----- web/src/routes/sites/[id]/+page.svelte | 484 --------- web/src/routes/sites/new/+page.svelte | 702 ------------- web/src/routes/stacks/+page.svelte | 535 ---------- web/src/routes/stacks/[id]/+page.svelte | 953 ------------------ web/src/routes/stacks/new/+page.svelte | 595 ----------- 101 files changed, 1116 insertions(+), 20768 deletions(-) delete mode 100644 internal/api/deploys.go delete mode 100644 internal/api/instances.go delete mode 100644 internal/api/projects.go create mode 100644 internal/api/secrets.go delete mode 100644 internal/api/stacks.go delete mode 100644 internal/api/stage_env.go delete mode 100644 internal/api/stages.go delete mode 100644 internal/api/static_sites.go delete mode 100644 internal/api/volume_browser.go delete mode 100644 internal/api/volumes.go delete mode 100644 internal/api/webhooks.go delete mode 100644 internal/deployer/bluegreen.go delete mode 100644 internal/deployer/promote.go delete mode 100644 internal/deployer/resolver_test.go delete mode 100644 internal/deployer/rollback.go delete mode 100644 internal/deployer/subdomain.go delete mode 100644 internal/registry/poller.go delete mode 100644 internal/stack/manager.go delete mode 100644 internal/staticsite/healthcheck.go delete mode 100644 internal/staticsite/manager.go delete mode 100644 internal/staticsite/resolver_test.go delete mode 100644 internal/store/deploys.go create mode 100644 internal/store/helpers.go delete mode 100644 internal/store/poll_state.go delete mode 100644 internal/store/projects.go delete mode 100644 internal/store/proxy_routes_test.go delete mode 100644 internal/store/stacks.go delete mode 100644 internal/store/stage_env.go delete mode 100644 internal/store/stages.go delete mode 100644 internal/store/static_site_secrets.go delete mode 100644 internal/store/static_sites.go delete mode 100644 internal/store/volumes.go delete mode 100644 internal/store/workload_sync.go delete mode 100644 internal/store/workload_sync_test.go delete mode 100644 internal/webhook/handler_test.go delete mode 100644 internal/webhook/matcher.go delete mode 100644 internal/webhook/matcher_test.go delete mode 100644 web/src/lib/components/InstanceCard.svelte delete mode 100644 web/src/lib/components/ProjectCard.svelte delete mode 100644 web/src/lib/components/StaleContainerCard.svelte delete mode 100644 web/src/routes/deploy/+page.svelte delete mode 100644 web/src/routes/projects/+page.svelte delete mode 100644 web/src/routes/projects/[id]/+page.svelte delete mode 100644 web/src/routes/projects/[id]/env/+page.svelte delete mode 100644 web/src/routes/projects/[id]/volumes/+page.svelte delete mode 100644 web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte delete mode 100644 web/src/routes/projects/[id]/volumes/[volId]/browse/+page.ts delete mode 100644 web/src/routes/sites/+page.svelte delete mode 100644 web/src/routes/sites/[id]/+page.svelte delete mode 100644 web/src/routes/sites/new/+page.svelte delete mode 100644 web/src/routes/stacks/+page.svelte delete mode 100644 web/src/routes/stacks/[id]/+page.svelte delete mode 100644 web/src/routes/stacks/new/+page.svelte diff --git a/cmd/server/main.go b/cmd/server/main.go index a3d20fa..579bc9f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -18,9 +18,9 @@ import ( tinyforge "github.com/alexei/tinyforge" "github.com/alexei/tinyforge/internal/api" "github.com/alexei/tinyforge/internal/auth" + "github.com/alexei/tinyforge/internal/backup" "github.com/alexei/tinyforge/internal/config" "github.com/alexei/tinyforge/internal/crypto" - "github.com/alexei/tinyforge/internal/backup" "github.com/alexei/tinyforge/internal/deployer" "github.com/alexei/tinyforge/internal/dns" "github.com/alexei/tinyforge/internal/docker" @@ -32,11 +32,8 @@ import ( "github.com/alexei/tinyforge/internal/npm" "github.com/alexei/tinyforge/internal/proxy" "github.com/alexei/tinyforge/internal/reconciler" - "github.com/alexei/tinyforge/internal/registry" "github.com/alexei/tinyforge/internal/stale" - "github.com/alexei/tinyforge/internal/stack" "github.com/alexei/tinyforge/internal/stats" - "github.com/alexei/tinyforge/internal/staticsite" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/webhook" @@ -85,13 +82,6 @@ func main() { os.Exit(1) } - // Backfill workload rows for any project / stack / static site that - // predates the workload refactor. Idempotent — safe on every boot. - if err := db.BackfillWorkloads(); err != nil { - slog.Error("workload backfill", "error", err) - os.Exit(1) - } - // Ensure default admin user exists on first launch. if err := ensureDefaultAdmin(db); err != nil { slog.Error("ensure default admin", "error", err) @@ -109,16 +99,10 @@ func main() { // Start the container index reconciler. Runs one boot pass and then // ticks every 30s. Boot pass populates the containers table from any // running containers that predate the workload refactor; subsequent - // ticks catch state drift the deployer didn't witness (e.g., a stack - // service that exited on its own). Stop() cancels its own child context - // before waiting on the goroutine, so a hung `docker ps` doesn't block - // shutdown. + // ticks catch state drift the deployer didn't witness. rec := reconciler.New(db, dockerClient, 30*time.Second) rec.Start(context.Background()) defer rec.Stop() - // The plugin pass is wired after the deployer is constructed (below); - // the reconciler tolerates a nil dispatcher until then. SetPluginReconciler - // is safe to call at any time, including mid-tick. // Read settings for NPM URL and polling interval. settings, err := db.GetSettings() @@ -181,33 +165,19 @@ func main() { defer stopLogger() // Event-trigger dispatcher: consume EventLog publishes off the bus - // and fan out to operator-configured webhook actions. Loop-prevention - // is structural — the dispatcher never writes back to event_log; all - // delivery outcomes land in notifier audit logging. + // and fan out to operator-configured webhook actions. stopTriggerDispatcher := events.RegisterEventTriggerDispatcher(eventBus, db, notifier) defer stopTriggerDispatcher() dep := deployer.New(dockerClient, proxyProvider, db, healthChecker, notifier, eventBus, encKey) rec.SetPluginReconciler(dep) - // Initialize webhook handler. Per-project and per-site secrets are stored - // on their respective rows; the static-site triggerer is wired in below - // once the site manager has been constructed. - webhookHandler := webhook.NewHandler(db, dep, nil) - // Plugin-pipeline dispatcher for /api/webhook/workloads/{secret}. - // Wired here so the same *deployer.Deployer serves both legacy and - // plugin-native paths from one place. + // Initialize webhook handler. The single inbound surface is + // /api/webhook/triggers/{secret}; the plugin dispatcher wires the + // trigger fan-out to the deployer. + webhookHandler := webhook.NewHandler(db) webhookHandler.SetPluginDispatcher(dep) - // Initialize registry poller. - poller := registry.NewPoller(db, dep, encKey) - pollingInterval := envOrDefault("POLLING_INTERVAL", settings.PollingInterval) - if pollingInterval != "" { - if err := poller.Start(pollingInterval); err != nil { - slog.Warn("failed to start poller", "error", err) - } - } - // Initialize stale container scanner. staleScanner := stale.New(db, dockerClient, eventBus) if err := staleScanner.Start("1h"); err != nil { @@ -228,9 +198,7 @@ func main() { }); err != nil { slog.Warn("failed to schedule event prune cron", "error", err) } - // Webhook delivery log: keep 14 days of audit trail. Same daily cadence - // so an admin always has a recent window for debugging without - // unbounded growth on a noisy CI. + // Webhook delivery log: keep 14 days of audit trail. if _, err := cronScheduler.AddFunc("@daily", func() { cutoff := time.Now().UTC().AddDate(0, 0, -14).Format("2006-01-02 15:04:05") pruned, err := db.PruneWebhookDeliveriesBefore(cutoff) @@ -339,41 +307,12 @@ func main() { } scheduleAutobackup(settings.BackupEnabled, settings.BackupIntervalHours) - // Initialize resource stats collector. Interval + retention are read from - // settings on each tick, so configuration changes take effect within one - // tick without a restart. + // Initialize resource stats collector. statsCollector := stats.New(db, dockerClient) statsCollector.Start() - // Initialize static site manager and health checker. - staticSiteMgr := staticsite.NewManager(db, dockerClient, proxyProvider, eventBus, notifier, encKey) - webhookHandler.SetSiteSyncTriggerer(staticSiteMgr) - // The plugin static source registers itself eagerly in its init() - // now that the deploy pipeline is implemented inline (see - // internal/workload/plugin/source/static). The legacy Manager kept - // here keeps the /api/sites/* HTTP routes alive during the cutover - // window. - staticSiteHealth := staticsite.NewHealthChecker(db, dockerClient, staticSiteMgr) - if err := staticSiteHealth.Start("2m"); err != nil { - slog.Warn("failed to start static site health checker", "error", err) - } - - // Initialize stack (docker-compose) manager. Disabled gracefully if - // `docker compose` is not available on the host. - stackWorkDir := filepath.Join(filepath.Dir(dbPath), "stacks") - stackMgr, err := stack.NewManager(db, stack.NewCompose(""), eventBus, stackWorkDir) - if err != nil { - slog.Warn("failed to init stack manager", "error", err) - stackMgr = nil - } else if err := stackMgr.Available(context.Background()); err != nil { - slog.Warn("docker compose not available — stacks feature disabled", "error", err) - stackMgr = nil - } - // Log-scan manager: tails running containers and emits event_log // entries when log lines match operator-configured regex rules. - // Start before the API server is wired so the reload callback can - // be plugged in via SetLogScanReloader. logScanMgr := logscanner.NewManager(logscanner.Config{ Rules: db, Containers: db, @@ -382,9 +321,6 @@ func main() { Bus: eventBus, PollInterval: 5 * time.Second, }) - // Manager owns its own cancellation; Stop() drives the loop and - // every tail to exit. Using Background here matches the - // reconciler + stale-scanner pattern elsewhere in this file. if err := logScanMgr.Start(context.Background()); err != nil { slog.Warn("logscanner: initial rule load failed", "error", err) } @@ -392,10 +328,6 @@ func main() { // Build API server. apiServer := api.NewServer(db, dockerClient, npmClient, proxyProvider, dep, notifier, webhookHandler, eventBus, encKey) - apiServer.SetStaticSiteManager(staticSiteMgr) - if stackMgr != nil { - apiServer.SetStackManager(stackMgr) - } apiServer.SetStaleScanner(staleScanner) apiServer.SetLogScanReloader(logScanMgr) apiServer.SetBackupEngine(backupEngine) @@ -411,13 +343,11 @@ func main() { router := apiServer.Router() // Serve embedded static files for the SPA frontend. - // The embed.FS has "web/build" as a prefix, so we sub it to get the root. webBuildFS, err := fs.Sub(tinyforge.WebBuildFS, "web/build") if err != nil { slog.Warn("embedded frontend not available", "error", err) } else { staticHandler := api.StaticHandler(webBuildFS) - // Handle all non-API routes with the static file server. router.NotFound(staticHandler.ServeHTTP) } @@ -428,7 +358,6 @@ func main() { Handler: router, ReadTimeout: 30 * time.Second, // WriteTimeout is disabled (0) to support SSE long-lived connections. - // Individual non-SSE handlers should use context timeouts as needed. WriteTimeout: 0, IdleTimeout: 120 * time.Second, } @@ -456,12 +385,10 @@ func main() { // Stop accepting new work. cronScheduler.Stop() eventBus.Unsubscribe(notifySub) - staticSiteHealth.Stop() staleScanner.Stop() - poller.Stop() statsCollector.Stop() - // Drain in-progress deploys, site syncs, and notifications. + // Drain in-progress deploys and notifications. dep.Drain() webhookHandler.Drain() notifier.Drain() @@ -491,7 +418,6 @@ func envOrDefault(key, fallback string) string { } // ensureDefaultAdmin creates a default admin user on first launch if no users exist. -// The password comes from ADMIN_PASSWORD env var, defaulting to "admin". func ensureDefaultAdmin(db *store.Store) error { count, err := db.UserCount() if err != nil { diff --git a/docs/WORKLOAD_REFACTOR_TODO.md b/docs/WORKLOAD_REFACTOR_TODO.md index 6d52a35..367f760 100644 --- a/docs/WORKLOAD_REFACTOR_TODO.md +++ b/docs/WORKLOAD_REFACTOR_TODO.md @@ -9,22 +9,26 @@ order. > ## Current focus (read this first) > -> **Triggers as first-class reusable entities — DONE** (2026-05-16) and -> **Static source inline port — DONE** (2026-05-16). The phantom-row -> adapter (`cmd/server/static_backend.go`) is gone; the static plugin -> now operates directly on `plugin.Workload` + `containers` + -> `workload_env`, with runtime state (`last_commit_sha`, `last_sync_at`, -> `last_error`, `status`) carried in `containers.extra_json`. Provider -> downloads enforce path-traversal rejection, error strings are -> sanitized before persistence, and Docker resource names are suffixed -> with the workload ID short prefix to dodge name collisions. +> **Hard legacy cutover — DONE** (2026-05-16). All three Priority 1 items +> are now shipped. The legacy `/api/{projects,stages,stacks,sites, +> deploys,instances}/*` HTTP surface, every backing table (`projects`, +> `stages`, `stage_env`, `volumes`, `deploys`, `deploy_logs`, +> `poll_states`, `stacks`, `stack_revisions`, `stack_deploys`, +> `static_sites`, `static_site_secrets`), the project-deploy pipeline +> (`bluegreen.go`, `promote.go`, `rollback.go`, `subdomain.go` + most of +> `deployer.go`), the legacy webhook routes (`/api/webhook/{secret}`, +> `/api/webhook/sites/{secret}`, `/api/webhook/workloads/{secret}`), and +> the legacy frontend (`/projects`, `/stacks`, `/sites`, `/deploy`) are +> gone. The `internal/staticsite/{provider,gitea_content, +> github_provider,gitlab_provider,markdown,deno}` and +> `internal/stack/{compose,parse,validate}` files survive only as +> helpers imported by the static + compose plugins. > -> **Next on Priority 1** is the **hard legacy cutover** — drop -> `/api/projects`, `/api/stacks`, `/api/sites`, `/api/stages` handlers, -> drop their tables, delete `internal/stack/` + `internal/staticsite/` -> packages, delete frontend `/projects` / `/stacks` / `/sites` routes. -> The `internal/staticsite` package stays alive only for the legacy -> `/api/sites/*` HTTP routes — once those drop, it dies with them. +> **Next focus** is **Priority 3 polish** — the `apps.*` i18n namespace +> still has ~60 hardcoded English strings on `/apps` and `/apps/new`, +> and `docs/CODEMAPS/` lacks an entry for `internal/workload/plugin/`. +> After that, **Priority 4 tests** — `/api/workloads/*` integration tests +> and dispatcher coverage. ## Status at a glance @@ -32,7 +36,7 @@ order. | ---- | -------- | ------ | | Triggers as first-class reusable entities | 1 | **DONE** (2026-05-16) | | Static source inline port | 1 | **DONE** (2026-05-16) | -| Hard legacy cutover | 1 | **PENDING — current focus** | +| Hard legacy cutover | 1 | **DONE** (2026-05-16) | | Generalized volume scopes | 2 | DONE | | Kind-aware editors (compose / image / static) | 2 | DONE | | Vendor-specific webhook parsing | 2 | DONE | @@ -241,19 +245,82 @@ addressed before merge): keep their old `dw-site-mysite` shape until they're redeployed through the plugin path. -### Hard legacy cutover +### ~~Hard legacy cutover~~ — DONE (2026-05-16) -The static-source inline port (above) is now complete; the cutover is -unblocked. Proceeding with the cutover means: +The clean-break delete that closed the workload-first arc. Net diff: +~30 files deleted, ~20 modified, ~12k LOC removed. -- Delete `/api/projects`, `/api/stacks`, `/api/sites`, `/api/stages` handlers. -- Drop tables: `projects`, `stages`, `stacks`, `stack_revisions`, - `stack_deploys`, `static_sites`, `static_site_secrets`, `deploys`, - `poll_states`. -- Delete `internal/stack/`, `internal/staticsite/` packages. -- Delete frontend `/projects`, `/sites`, `/stacks` routes. -- Delete legacy `volume.ResolvePath` + `internal/api/volume_browser.go` - callers (the only remaining users). +**Backend deletions:** + +- API handlers: `internal/api/{projects,stages,stage_env,stacks, + static_sites,deploys,instances,volume_browser}.go`. +- Store CRUD + tests: `internal/store/{projects,stages,stage_env, + stacks,static_sites,static_site_secrets,deploys,poll_state,volumes, + workload_sync}.go` + their `_test.go`. +- Deployer pipeline: `internal/deployer/{bluegreen,promote,rollback, + subdomain,resolver_test}.go`; `deployer.go` trimmed to just the + dispatch surface. +- `internal/staticsite/{manager,healthcheck}.go` and + `internal/stack/manager.go` (the rest of those packages are still + imported by the static + compose plugins as helpers). +- Webhook routes: `handleWebhook` (project) + `handleSiteWebhook` + (site) handlers gone; `/api/webhook/triggers/{secret}` is the only + inbound surface left. The workload-side webhook URL handlers + (`getWorkloadWebhook` + `regenerateWorkloadWebhook`) were removed + in the cutover-followup pass when a security review caught them + minting URLs that 404'd. +- `internal/registry/poller.go` (legacy registry poller). +- `internal/volume/ResolvePath` (legacy resolver; the workload + resolver `ResolveWorkloadPath` stays). +- `cmd/server/main.go`: dropped `staticsite.Manager`, + `stack.Manager`, `staticsite.HealthChecker`, registry poller, + `SetSiteSyncTriggerer`, `SetStaticSiteManager`, `SetStackManager`. + +**Schema migrations:** `internal/store/store.go` ends with +idempotent `DROP TABLE IF EXISTS` for every legacy table +(`projects`, `stages`, `stage_env`, `volumes`, `deploys`, +`deploy_logs`, `poll_states`, `stacks`, `stack_revisions`, +`stack_deploys`, `static_sites`, `static_site_secrets`). FK order is +children-then-parents. + +**Frontend deletions:** `web/src/routes/{projects,stacks,sites, +deploy}/` (entire trees); legacy components +(`ProjectCard.svelte`, `InstanceCard.svelte`, +`StaleContainerCard.svelte`); `api.ts` legacy functions + types +(`Project`, `Stage`, `Stack`, `StaticSite`, `Deploy`, `Instance`, +plus their helpers); i18n namespaces (`projects.*`, `projectDetail.*`, +`envEditor.*`, `volumeEditor.*`, `volumeBrowser.*`, `quickDeploy.*`, +`sites.*`, `stacks.*`, `instance.*`, `confirm.*`); nav entries. +Dashboard rewritten to read `listWorkloads()` + `listContainers()` +only. + +**Helper extractions** (to keep deletions atomic): +`internal/store/helpers.go` (`BoolToInt`, `rowScanner`, +`GenerateWebhookSecret`); `internal/api/secrets.go` (api shim that +forwards to the store helper so the api + store paths share one +secret-generation impl, no panic-vs-UUID-fallback divergence). + +**Reviews shipped through go-reviewer + security-reviewer + +typescript-reviewer subagents** — 0 CRITICAL across all three; 1 +HIGH (dead-end workload webhook surface) + ~12 MEDIUMs all +addressed inline before commit. + +**Behavioral notes for operators upgrading from a pre-cutover +build:** + +- Existing rows in `projects` / `stages` / `stacks` / `static_sites` + / `static_site_secrets` / `deploys` / `deploy_logs` / `volumes` + / `poll_states` / `stage_env` / `stack_revisions` / `stack_deploys` + are dropped on first boot. +- The legacy webhook URLs at `/api/webhook/{secret}` and + `/api/webhook/sites/{secret}` return 404 — operators with old CI + configs must repoint to `/api/webhook/triggers/{secret}` (the boot + backfill from the trigger-split refactor lifted any embedded + workload secret onto a Trigger row, so the secret value itself + carries over). +- Frontend routes `/projects`, `/stacks`, `/sites`, `/deploy` are + gone. Nav links replaced with `/apps` (+ `/triggers` from the + prior arc). ## Priority 2 — Behavior gaps diff --git a/internal/api/deploys.go b/internal/api/deploys.go deleted file mode 100644 index d2f4588..0000000 --- a/internal/api/deploys.go +++ /dev/null @@ -1,236 +0,0 @@ -package api - -import ( - "log/slog" - "net/http" - "strconv" - "strings" - - "github.com/alexei/tinyforge/internal/docker" - "github.com/alexei/tinyforge/internal/store" -) - -// listDeploys handles GET /api/deploys. -func (s *Server) listDeploys(w http.ResponseWriter, r *http.Request) { - limitStr := r.URL.Query().Get("limit") - limit := 50 - if limitStr != "" { - if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 { - limit = parsed - } - } - - offsetStr := r.URL.Query().Get("offset") - offset := 0 - if offsetStr != "" { - if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 { - offset = parsed - } - } - - projectID := r.URL.Query().Get("project_id") - stageID := r.URL.Query().Get("stage_id") - - deploys, err := s.store.GetDeploys(projectID, stageID, limit, offset) - if err != nil { - slog.Error("failed to list deploys", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - respondJSON(w, http.StatusOK, deploys) -} - -// NOTE: getDeployLogs has been replaced by streamDeployLogs in sse.go. -// The new handler supports both SSE streaming and JSON fallback via Accept header. - -// inspectRequest is the expected JSON body for POST /api/deploy/inspect. -type inspectRequest struct { - Image string `json:"image"` -} - -// inspectResponse is the response body for POST /api/deploy/inspect. -type inspectResponse struct { - Image string `json:"image"` - Port int `json:"port"` - Healthcheck string `json:"healthcheck"` -} - -// inspectImage handles POST /api/deploy/inspect. -// Pulls the image and inspects it for EXPOSE ports and healthcheck config. -func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) { - var req inspectRequest - if !decodeJSON(w, r, &req) { - return - } - - if req.Image == "" { - respondError(w, http.StatusBadRequest, "image is required") - return - } - - ctx := r.Context() - - // Pull the image first so it's available locally for inspection. - // Split image:tag for the pull call. - imageRef, tag := splitImageTag(req.Image) - if err := s.docker.PullImage(ctx, imageRef, tag, ""); err != nil { - slog.Warn("pull image for inspect", "image", req.Image, "error", err) - // Try to inspect anyway in case the image is already local. - } - - info, err := s.docker.InspectImage(ctx, req.Image) - if err != nil { - slog.Error("failed to inspect image", "image", req.Image, "error", err) - errMsg := "Failed to inspect image. " - if strings.Contains(err.Error(), "docker_engine") || strings.Contains(err.Error(), "docker.sock") { - errMsg += "Docker is not available on this machine. Enter port and project name manually." - } else { - errMsg += "Image may not exist or registry requires authentication." - } - respondError(w, http.StatusBadGateway, errMsg) - return - } - - port := docker.ExtractPort(info.ExposedPorts) - - respondJSON(w, http.StatusOK, inspectResponse{ - Image: req.Image, - Port: port, - Healthcheck: info.Healthcheck, - }) -} - -// quickDeployRequest is the expected JSON body for POST /api/deploy/quick. -type quickDeployRequest struct { - Name string `json:"name"` - Image string `json:"image"` - Tag string `json:"tag"` - Registry string `json:"registry"` - Port int `json:"port"` - Force bool `json:"force"` // skip duplicate check - EnableProxy *bool `json:"enable_proxy"` // nil defaults to true - AutoDeploy *bool `json:"auto_deploy"` // nil defaults to true (deploy immediately) -} - -// quickDeploy handles POST /api/deploy/quick. -// Creates a project, a default stage, and triggers a deploy in one call. -func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) { - var req quickDeployRequest - if !decodeJSON(w, r, &req) { - return - } - - if req.Image == "" { - respondError(w, http.StatusBadRequest, "image is required") - return - } - - // Split tag from image if the image URL contains one (e.g., "registry/app:v1"). - if req.Tag == "" { - imageRef, tag := splitImageTag(req.Image) - if tag != "" { - req.Image = imageRef - req.Tag = tag - } else { - req.Tag = "latest" - } - } - - if req.Name == "" { - // Derive name from image (without tag). - parts := strings.Split(req.Image, "/") - req.Name = parts[len(parts)-1] - } - - // Check for existing projects with the same image. - if !req.Force { - existing, err := s.store.GetProjectsByImage(req.Image) - if err != nil { - slog.Error("failed to check existing projects", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - if len(existing) > 0 { - respondJSON(w, http.StatusConflict, map[string]any{ - "message": "A project with this image already exists", - "existing_projects": existing, - }) - return - } - } - - // Create project. - project, err := s.store.CreateProject(store.Project{ - Name: req.Name, - Image: req.Image, - Registry: req.Registry, - Port: req.Port, - Env: "{}", - Volumes: "{}", - }) - if err != nil { - slog.Error("failed to create project", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - // Create default stage. - enableProxy := true - if req.EnableProxy != nil { - enableProxy = *req.EnableProxy - } - shouldDeploy := true - if req.AutoDeploy != nil { - shouldDeploy = *req.AutoDeploy - } - stage, err := s.store.CreateStage(store.Stage{ - ProjectID: project.ID, - Name: "dev", - TagPattern: "*", - AutoDeploy: shouldDeploy, - MaxInstances: 1, - EnableProxy: enableProxy, - }) - if err != nil { - slog.Error("failed to create stage", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - // Only trigger deploy if auto_deploy is enabled. - var deployID string - if shouldDeploy { - deployID, err = s.deployer.AsyncTriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag) - if err != nil { - slog.Error("failed to trigger deploy", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - } - - status := "created" - if shouldDeploy { - status = "deploying" - } - - respondJSON(w, http.StatusAccepted, map[string]any{ - "project": project, - "stage": stage, - "tag": req.Tag, - "deploy_id": deployID, - "status": status, - }) -} - -// splitImageTag splits "image:tag" into image and tag parts. -// Returns the full string and empty tag if no colon separator is found. -func splitImageTag(ref string) (string, string) { - if idx := strings.LastIndex(ref, ":"); idx != -1 { - afterColon := ref[idx+1:] - if !strings.Contains(afterColon, "/") { - return ref[:idx], afterColon - } - } - return ref, "" -} - diff --git a/internal/api/dns.go b/internal/api/dns.go index 442046a..f7acc34 100644 --- a/internal/api/dns.go +++ b/internal/api/dns.go @@ -190,27 +190,34 @@ func (s *Server) deleteDNSRecord(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } -// buildConsumerNameMap builds a lookup of "type:id" -> display name for DNS consumers. +// buildConsumerNameMap builds a lookup of "type:id" -> display name for DNS +// consumers. Sourced from the containers index now that legacy project/stage +// tables are gone — the workload's name + the container's role + tag is what +// operators see in the UI. func (s *Server) buildConsumerNameMap() map[string]string { names := make(map[string]string) - - // Instance consumers: "instance:id" -> "project/stage:tag" - projects, _ := s.store.GetAllProjects() - projectNames := make(map[string]string, len(projects)) - for _, p := range projects { - projectNames[p.ID] = p.Name + containers, err := s.store.ListContainers(store.ContainerFilter{}) + if err != nil { + return names } - - for _, p := range projects { - stages, _ := s.store.GetStagesByProjectID(p.ID) - for _, st := range stages { - rows, _ := s.store.ListContainersByStageID(st.ID) - for _, c := range rows { - names["instance:"+c.ID] = p.Name + "/" + st.Name + ":" + c.ImageTag + workloadNames := make(map[string]string) + for _, c := range containers { + wname, ok := workloadNames[c.WorkloadID] + if !ok { + if w, err := s.store.GetWorkloadByID(c.WorkloadID); err == nil { + wname = w.Name } + workloadNames[c.WorkloadID] = wname } + label := wname + if c.Role != "" { + label = label + "/" + c.Role + } + if c.ImageTag != "" { + label = label + ":" + c.ImageTag + } + names["instance:"+c.ID] = label } - return names } @@ -343,38 +350,26 @@ func (s *Server) syncDNSRecords(w http.ResponseWriter, r *http.Request) { }) } -// computeExpectedFQDNs returns a map of FQDN -> "consumerType:consumerID" for all active DNS consumers. +// computeExpectedFQDNs returns a map of FQDN -> "consumerType:consumerID" +// for every running container that has a proxy route configured. Sourced +// directly from the containers index — the workload-first cutover dropped +// the per-stage enable_proxy toggle in favour of "if a proxy route ID +// exists, the workload wanted a route." func (s *Server) computeExpectedFQDNs(settings store.Settings) (map[string]string, error) { expected := make(map[string]string) - - // Instances with proxy enabled. - projects, err := s.store.GetAllProjects() + containers, err := s.store.ListContainers(store.ContainerFilter{}) if err != nil { - return nil, fmt.Errorf("get projects: %w", err) + return nil, fmt.Errorf("list containers: %w", err) } - for _, p := range projects { - stages, err := s.store.GetStagesByProjectID(p.ID) - if err != nil { - slog.Warn("dns: failed to get stages", "project_id", p.ID, "error", err) + for _, c := range containers { + if c.Subdomain == "" || c.State != "running" { continue } - for _, st := range stages { - if !st.EnableProxy { - continue - } - rows, err := s.store.ListContainersByStageID(st.ID) - if err != nil { - slog.Warn("dns: failed to get containers", "stage_id", st.ID, "error", err) - continue - } - for _, c := range rows { - if c.NpmProxyID > 0 && c.Subdomain != "" && c.State == "running" { - fqdn := c.Subdomain + "." + settings.Domain - expected[fqdn] = "instance:" + c.ID - } - } + if c.NpmProxyID == 0 && c.ProxyRouteID == "" { + continue } + fqdn := c.Subdomain + "." + settings.Domain + expected[fqdn] = "instance:" + c.ID } - return expected, nil } diff --git a/internal/api/docker.go b/internal/api/docker.go index 59ab250..970a9b6 100644 --- a/internal/api/docker.go +++ b/internal/api/docker.go @@ -3,7 +3,6 @@ package api import ( "bufio" "encoding/json" - "errors" "fmt" "io" "log/slog" @@ -14,17 +13,15 @@ import ( "sync" "time" - "github.com/go-chi/chi/v5" - "github.com/alexei/tinyforge/internal/store" ) // Limits and constants for the log endpoints. const ( - defaultLogTail = 200 - maxLogTail = 5000 - maxJSONLogBytes = 4 << 20 // 4 MiB cap for non-streaming log responses - maxLogLineBytes = 1 << 20 // 1 MiB max line length for the bufio.Scanner + defaultLogTail = 200 + maxLogTail = 5000 + maxJSONLogBytes = 4 << 20 // 4 MiB cap for non-streaming log responses + maxLogLineBytes = 1 << 20 // 1 MiB max line length for the bufio.Scanner logHeartbeatPeriod = 20 * time.Second ) @@ -37,82 +34,8 @@ var ( ctlBytePattern = regexp.MustCompile(`[\x00-\x08\x0b-\x1a\x1c-\x1f\x7f]`) ) -// listProjectImages handles GET /api/projects/{id}/images. -// Returns all local Docker images matching the project's image reference. -func (s *Server) listProjectImages(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - project, err := s.store.GetProjectByID(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - slog.Error("failed to get project", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - if s.docker == nil || project.Image == "" { - respondJSON(w, http.StatusOK, []any{}) - return - } - - images, err := s.docker.ListImagesByRef(r.Context(), project.Image) - if err != nil { - slog.Warn("list project images", "project", project.Name, "error", err) - respondJSON(w, http.StatusOK, []any{}) - return - } - - respondJSON(w, http.StatusOK, images) -} - -// streamContainerLogs handles GET /api/projects/{id}/stages/{stage}/instances/{iid}/logs. -// Streams container logs via SSE. {iid} is the container row ID. Ownership is -// verified by joining through workload + stage so an attacker cannot stream -// logs for a foreign container by guessing IDs under the wrong project URL. -func (s *Server) streamContainerLogs(w http.ResponseWriter, r *http.Request) { - projectID := chi.URLParam(r, "id") - stageID := chi.URLParam(r, "stage") - containerRowID := chi.URLParam(r, "iid") - - c, err := s.store.GetContainerByID(containerRowID) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "container") - return - } - slog.Error("failed to get container", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - wl, err := s.store.GetWorkloadByID(c.WorkloadID) - if err != nil { - respondNotFound(w, "container") - return - } - stage, err := s.store.GetStageByID(stageID) - if err != nil || stage.ProjectID != projectID { - respondNotFound(w, "container") - return - } - if wl.Kind != string(store.WorkloadKindProject) || wl.RefID != projectID || c.Role != stage.Name { - respondNotFound(w, "container") - return - } - - if c.ContainerID == "" { - respondError(w, http.StatusBadRequest, "container row has no docker container bound") - return - } - - s.streamLogsForContainer(w, r, c.ContainerID) -} - // streamLogsForContainer streams logs for an arbitrary container ID using the -// shared SSE/JSON dual-mode pattern. Owner-specific handlers (instance, site) +// shared SSE/JSON dual-mode pattern. Owner-specific handlers (workload-container) // should validate ownership and then delegate here. func (s *Server) streamLogsForContainer(w http.ResponseWriter, r *http.Request, containerID string) { if s.docker == nil { @@ -255,11 +178,7 @@ func sanitizeDockerLogLine(line string) string { // by any container, computed in a single DB pass against the normalized // containers index. Returning an error (rather than swallowing) prevents // prune logic from treating a transient DB failure as "nothing is active". -func buildActiveImagesSet(st *store.Store, projects []store.Project) (map[string]bool, error) { - // `projects` is unused now — kept in the signature for back-compat with - // callers that already happen to have the slice. The image_ref column - // holds the full "image:tag" string written by the deployer. - _ = projects +func buildActiveImagesSet(st *store.Store) (map[string]bool, error) { containers, err := st.ListContainers(store.ContainerFilter{}) if err != nil { return nil, fmt.Errorf("list containers: %w", err) @@ -274,8 +193,43 @@ func buildActiveImagesSet(st *store.Store, projects []store.Project) (map[string return active, nil } -// unusedImageStats handles GET /api/docker/unused-images. -// Returns the total size of unused project images and whether the threshold is exceeded. +// workloadImageBases returns the set of "image" strings (no tag) that +// some workload currently mounts to, derived from container.image_ref. +// This replaces the legacy "list all projects → projects[].Image" view +// after the workload-first cutover. +func workloadImageBases(st *store.Store) (map[string]bool, error) { + containers, err := st.ListContainers(store.ContainerFilter{}) + if err != nil { + return nil, fmt.Errorf("list containers: %w", err) + } + bases := make(map[string]bool, len(containers)) + for _, c := range containers { + if c.ImageRef == "" { + continue + } + ref, _ := splitImageTag(c.ImageRef) + if ref != "" { + bases[ref] = true + } + } + return bases, nil +} + +// splitImageTag splits "image:tag" into image and tag parts. Returns the +// full string and empty tag if no colon separator is found. Inlined here +// because the legacy deploys.go that owned it was removed. +func splitImageTag(ref string) (string, string) { + if idx := strings.LastIndex(ref, ":"); idx != -1 { + afterColon := ref[idx+1:] + if !strings.Contains(afterColon, "/") { + return ref[:idx], afterColon + } + } + return ref, "" +} + +// unusedImageStats handles GET /api/docker/unused-images. Returns the total +// size of unused workload images and whether the threshold is exceeded. func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) { if s.docker == nil { respondJSON(w, http.StatusOK, map[string]any{ @@ -291,32 +245,25 @@ func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) { return } - projects, err := s.store.GetAllProjects() + imageBases, err := workloadImageBases(s.store) if err != nil { - slog.Error("unused images: list projects", "error", err) + slog.Error("unused images: list workload images", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } - // Build set of active image refs in one DB pass instead of N×K queries. - // A flaky read here previously masqueraded as "no images are active", - // which on the prune endpoint would have deleted *running* images. - activeImages, err := buildActiveImagesSet(s.store, projects) + activeImages, err := buildActiveImagesSet(s.store) if err != nil { slog.Error("unused images: build active set", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } - // Sum unused image sizes. ctx := r.Context() var totalSize int64 var count int - for _, p := range projects { - if p.Image == "" { - continue - } - images, err := s.docker.ListImagesByRef(ctx, p.Image) + for base := range imageBases { + images, err := s.docker.ListImagesByRef(ctx, base) if err != nil { continue } @@ -339,69 +286,53 @@ func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) { }) } -// pruneImages handles POST /api/docker/prune-images. -// Only removes images that belong to Tinyforge projects (not all system images). +// pruneImages handles POST /api/docker/prune-images. Only removes images that +// some workload references (via container.image_ref), never arbitrary host +// images. func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) { if s.docker == nil { respondError(w, http.StatusServiceUnavailable, "Docker is not available") return } - // Collect all image references from our projects. - projects, err := s.store.GetAllProjects() + imageBases, err := workloadImageBases(s.store) if err != nil { - slog.Error("prune: failed to list projects", "error", err) + slog.Error("prune: list workload images", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } - // Build a set of image refs used by active instances. Bail out on error - // — silently treating a DB blip as "no active images" would prune - // images currently in use by running containers. - activeImages, err := buildActiveImagesSet(s.store, projects) + activeImages, err := buildActiveImagesSet(s.store) if err != nil { slog.Error("prune: build active set", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } - // Collect all unique image bases from projects (without tags). - projectImages := make(map[string]bool) - for _, p := range projects { - if p.Image != "" { - projectImages[p.Image] = true - } - } - - if len(projectImages) == 0 { + if len(imageBases) == 0 { respondJSON(w, http.StatusOK, map[string]any{ "images_removed": 0, "space_reclaimed_mb": 0, - "message": "No project images to clean up", + "message": "No workload images to clean up", }) return } - // List all local Docker images and find ones matching our projects but not actively used. ctx := r.Context() removed := 0 var reclaimedBytes int64 - for imageBase := range projectImages { - // List all tags for this image. - images, err := s.docker.ListImagesByRef(ctx, imageBase) + for base := range imageBases { + images, err := s.docker.ListImagesByRef(ctx, base) if err != nil { - slog.Warn("prune: list images", "image", imageBase, "error", err) + slog.Warn("prune: list images", "image", base, "error", err) continue } for _, img := range images { - // Skip images that are actively used by running instances. if activeImages[img.Ref] { continue } - - // Remove unused image. if err := s.docker.RemoveImage(ctx, img.ID); err != nil { slog.Warn("prune: remove image", "image", img.Ref, "error", err) continue diff --git a/internal/api/health.go b/internal/api/health.go index ec49f63..1bd6045 100644 --- a/internal/api/health.go +++ b/internal/api/health.go @@ -239,17 +239,13 @@ func (s *Server) proxyHealth(ctx context.Context) map[string]any { return out } -// managedRouteCount returns the number of proxy routes Tinyforge manages -// (Docker instances + static sites combined). The domain argument doesn't +// managedRouteCount returns the number of proxy routes Tinyforge manages, +// reading from the unified containers index. The domain argument doesn't // affect the count so we pass an empty string to skip FQDN rendering. func (s *Server) managedRouteCount() (int, error) { - instanceRoutes, err := s.store.ListProxyRoutes("") + routes, err := s.store.ListProxyRoutes("") if err != nil { return 0, err } - siteRoutes, err := s.store.ListStaticSiteProxyRoutes("") - if err != nil { - return 0, err - } - return len(instanceRoutes) + len(siteRoutes), nil + return len(routes), nil } diff --git a/internal/api/instances.go b/internal/api/instances.go deleted file mode 100644 index 5bbedc7..0000000 --- a/internal/api/instances.go +++ /dev/null @@ -1,293 +0,0 @@ -package api - -import ( - "context" - "errors" - "fmt" - "log/slog" - "net/http" - - "github.com/go-chi/chi/v5" - - "github.com/alexei/tinyforge/internal/store" - "github.com/alexei/tinyforge/internal/workload/plugin" -) - -// listInstances handles GET /api/projects/{id}/stages/{stage}/instances. -// Reads the normalized container index — the legacy `instances` table is gone. -// JSON shape stays Container-shaped (id, container_id, image_tag, subdomain, -// state, port, etc.), so the frontend type may show some renamed fields -// (status→state, last_alive_at→last_seen_at). -func (s *Server) listInstances(w http.ResponseWriter, r *http.Request) { - stageID := chi.URLParam(r, "stage") - - if _, err := s.store.GetStageByID(stageID); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "stage") - return - } - slog.Error("failed to get stage", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - containers, err := s.store.ListContainersByStageID(stageID) - if err != nil { - slog.Error("failed to list containers", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - // Reconcile container state with Docker's actual state — covers the - // case where a container was killed externally between deployer writes - // and the next reconciler tick. - ctx := r.Context() - for i, c := range containers { - if c.ContainerID == "" || c.State == "removing" { - continue - } - running, err := s.docker.IsContainerRunning(ctx, c.ContainerID) - if err != nil { - continue - } - actual := "stopped" - if running { - actual = "running" - } - if c.State != actual { - containers[i].State = actual - _ = s.store.UpdateContainerState(c.ID, actual) - } - } - - respondJSON(w, http.StatusOK, containers) -} - -// deployRequest is the expected JSON body for triggering a deploy. -type deployRequest struct { - ImageTag string `json:"image_tag"` -} - -// deployInstance handles POST /api/projects/{id}/stages/{stage}/instances. -func (s *Server) deployInstance(w http.ResponseWriter, r *http.Request) { - projectID := chi.URLParam(r, "id") - stageID := chi.URLParam(r, "stage") - - if _, err := s.store.GetProjectByID(projectID); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - slog.Error("failed to get project", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - if _, err := s.store.GetStageByID(stageID); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "stage") - return - } - slog.Error("failed to get stage", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - var req deployRequest - if !decodeJSON(w, r, &req) { - return - } - - if req.ImageTag == "" { - respondError(w, http.StatusBadRequest, "image_tag is required") - return - } - - deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), projectID, stageID, req.ImageTag) - if err != nil { - slog.Error("failed to trigger deploy", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - respondJSON(w, http.StatusAccepted, map[string]string{ - "status": "deploying", - "deploy_id": deployID, - "project_id": projectID, - "stage_id": stageID, - "image_tag": req.ImageTag, - }) -} - -// removeInstance handles DELETE /api/projects/{id}/stages/{stage}/instances/{iid}. -// {iid} is the container row ID (same UUID as the legacy instance ID). -// Verifies that the container belongs to the project + stage in the URL — -// without this check, a stale URL could delete an unrelated stack/site row. -func (s *Server) removeInstance(w http.ResponseWriter, r *http.Request) { - c, ok := s.resolveAndAuthorizeInstance(w, r) - if !ok { - return - } - id := c.ID - - // Remove the Docker container if it has one. - if c.ContainerID != "" { - if err := s.docker.RemoveContainer(r.Context(), c.ContainerID, true); err != nil { - slog.Error("remove container", "container_id", c.ContainerID, "error", err) - } - } - - // Delete proxy route if it has one. - if c.ProxyRouteID != "" { - if err := s.proxyProvider.DeleteRoute(r.Context(), c.ProxyRouteID); err != nil { - slog.Warn("delete proxy route on container removal", "route_id", c.ProxyRouteID, "error", err) - } - } - - // Delete container row. - if err := s.store.DeleteContainer(id); err != nil { - respondError(w, http.StatusInternalServerError, "failed to delete container") - return - } - respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) -} - -// stopInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/stop. -func (s *Server) stopInstance(w http.ResponseWriter, r *http.Request) { - s.controlInstance(w, r, "stop") -} - -// startInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/start. -func (s *Server) startInstance(w http.ResponseWriter, r *http.Request) { - s.controlInstance(w, r, "start") -} - -// restartInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/restart. -func (s *Server) restartInstance(w http.ResponseWriter, r *http.Request) { - s.controlInstance(w, r, "restart") -} - -// controlInstance performs a stop/start/restart action on a container. -// The container's ownership of the URL-provided project + stage is verified -// before any Docker call — see resolveAndAuthorizeInstance for rationale. -func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action string) { - c, ok := s.resolveAndAuthorizeInstance(w, r) - if !ok { - return - } - id := c.ID - - if c.ContainerID == "" { - respondError(w, http.StatusBadRequest, "container row has no docker container bound") - return - } - - ctx := r.Context() - var controlErr error - var newState string - - switch action { - case "stop": - controlErr = s.docker.StopContainer(ctx, c.ContainerID, 10) - newState = "stopped" - case "start": - controlErr = s.docker.StartContainer(ctx, c.ContainerID) - newState = "running" - case "restart": - controlErr = s.docker.RestartContainer(ctx, c.ContainerID, 10) - newState = "running" - default: - respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown action: %s", action)) - return - } - - if controlErr != nil { - slog.Error("failed to control container", "action", action, "id", id, "error", controlErr) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - if err := s.store.UpdateContainerState(id, newState); err != nil { - slog.Error("update container state", "id", id, "state", newState, "error", err) - } - - respondJSON(w, http.StatusOK, map[string]string{ - "instance_id": id, - "action": action, - "status": newState, - }) -} - -// DeployTriggerer is the interface for triggering deployments. The legacy -// project/stage methods continue to drive image-tag CI promotions; the -// plugin methods (DispatchPlugin / DispatchTeardown / DispatchReconcile) -// route through the unified Source registry. Both surfaces are kept on -// one interface so the API layer holds a single deployer reference and -// the type assertion in hooks.go / workloads_plugin.go is replaced with -// compile-time checking. -type DeployTriggerer interface { - TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error - AsyncTriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) (string, error) - - DispatchPlugin(ctx context.Context, w plugin.Workload, intent plugin.DeploymentIntent) error - DispatchTeardown(ctx context.Context, w plugin.Workload) error - DispatchReconcile(ctx context.Context, w plugin.Workload) error - PluginDeps() plugin.Deps -} - -// resolveAndAuthorizeInstance loads the container row identified by {iid} and -// verifies it actually belongs to the project + stage in the URL path. -// Without this, a stale or hand-crafted URL like -// -// DELETE /api/projects//stages//instances/ -// -// would happily delete an unrelated stack/site container — admin-only doesn't -// excuse the cross-project bypass. Returns the container on success or -// nothing (with the response already written) on failure. -func (s *Server) resolveAndAuthorizeInstance(w http.ResponseWriter, r *http.Request) (store.Container, bool) { - projectID := chi.URLParam(r, "id") - stageName := "" - if stageID := chi.URLParam(r, "stage"); stageID != "" { - st, err := s.store.GetStageByID(stageID) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "stage") - return store.Container{}, false - } - slog.Error("failed to get stage", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return store.Container{}, false - } - if st.ProjectID != projectID { - respondNotFound(w, "stage") - return store.Container{}, false - } - stageName = st.Name - } - - id := chi.URLParam(r, "iid") - c, err := s.store.GetContainerByID(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "container") - return store.Container{}, false - } - slog.Error("failed to get container", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return store.Container{}, false - } - - w2, err := s.store.GetWorkloadByRef(store.WorkloadKindProject, projectID) - if err != nil { - respondNotFound(w, "container") - return store.Container{}, false - } - if c.WorkloadID != w2.ID { - respondNotFound(w, "container") - return store.Container{}, false - } - if stageName != "" && c.Role != stageName { - respondNotFound(w, "container") - return store.Container{}, false - } - return c, true -} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index b9b4d7a..1ffdf78 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -30,14 +30,12 @@ func logging(next http.Handler) http.Handler { } // redactPath strips secrets from URL paths that carry them in segments. +// Only the canonical /api/webhook/triggers/{secret} surface remains after +// the hard cutover. func redactPath(path string) string { - const projectPrefix = "/api/webhook/" - const sitePrefix = "/api/webhook/sites/" - switch { - case strings.HasPrefix(path, sitePrefix): - return sitePrefix + "***" - case strings.HasPrefix(path, projectPrefix): - return projectPrefix + "***" + const triggerPrefix = "/api/webhook/triggers/" + if strings.HasPrefix(path, triggerPrefix) { + return triggerPrefix + "***" } return path } @@ -161,24 +159,6 @@ func jsonContentType(next http.Handler) http.Handler { }) } -// deprecated marks responses with RFC-8594-style headers so API consumers -// can detect that an endpoint is on its way out. The Workload-first -// refactor is migrating away from /api/projects, /api/stages, -// /api/static_sites, and /api/stacks toward /api/workloads; this signals -// it to integrators without breaking them. Date is the operator-facing -// sunset hint, not a hard switch. -func deprecated(replacement string) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Deprecation", "true") - if replacement != "" { - w.Header().Set("Link", `<`+replacement+`>; rel="successor-version"`) - } - next.ServeHTTP(w, r) - }) - } -} - // rateLimitMiddleware wraps a handler with per-IP rate limiting using the // supplied limiter. Requests over the limit get 429. func rateLimitMiddleware(rl *rateLimiter) func(http.Handler) http.Handler { diff --git a/internal/api/notifications.go b/internal/api/notifications.go index e862967..498d3be 100644 --- a/internal/api/notifications.go +++ b/internal/api/notifications.go @@ -1,24 +1,17 @@ package api -// Outgoing-webhook signing-secret + send-test endpoints. There are four -// tiers — settings, project, stage, site — each exposing the same three -// operations: reveal (lazy-gen), regenerate, and send a synthetic test -// event. Returning a 200 from "send test" doesn't mean the receiver -// processed the event correctly — only that it answered with 2xx. The UI -// surfaces the receiver's status code + body preview so operators can -// distinguish "wired" from "wired and accepted". +// Outgoing-webhook signing-secret + send-test endpoints. After the hard +// cutover only the settings tier survives at the API surface; per-workload +// notification settings live on the workload row itself and are accessed +// via the workload endpoints. import ( "context" - "errors" "log/slog" "net/http" "time" - "github.com/go-chi/chi/v5" - "github.com/alexei/tinyforge/internal/notify" - "github.com/alexei/tinyforge/internal/store" ) // notificationSecretResponse is what the GET / regenerate endpoints return. @@ -92,8 +85,7 @@ func (s *Server) disableSettingsNotificationSigning(w http.ResponseWriter, r *ht // settingsNotificationTest handles POST /api/settings/notification-test. // Sends a synthetic test event to the global webhook URL using the global -// secret. No tier resolution — that's the whole point: each tier's test -// button proves *that* tier is wired correctly. +// secret. func (s *Server) settingsNotificationTest(w http.ResponseWriter, r *http.Request) { settings, err := s.store.GetSettings() if err != nil { @@ -112,292 +104,3 @@ func (s *Server) settingsNotificationTest(w http.ResponseWriter, r *http.Request ) respondJSON(w, http.StatusOK, result) } - -// --------------------------------------------------------------------------- -// Project tier -// --------------------------------------------------------------------------- - -func (s *Server) getProjectNotificationSecret(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - secret, err := s.store.EnsureProjectNotificationSecret(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - slog.Error("get project notification secret", "project", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to load secret") - return - } - respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""}) -} - -func (s *Server) regenerateProjectNotificationSecret(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if _, err := s.store.GetProjectByID(id); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - respondError(w, http.StatusInternalServerError, "failed to load project") - return - } - secret := generateWebhookSecret() - if err := s.store.SetProjectNotificationSecret(id, secret); err != nil { - slog.Error("regenerate project notification secret", "project", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to rotate secret") - return - } - slog.Info("project notification secret rotated", "project", id) - respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true}) -} - -func (s *Server) disableProjectNotificationSigning(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if err := s.store.SetProjectNotificationSecret(id, ""); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - respondError(w, http.StatusInternalServerError, "failed to disable signing") - return - } - respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false}) -} - -func (s *Server) projectNotificationTest(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - project, err := s.store.GetProjectByID(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - respondError(w, http.StatusInternalServerError, "failed to load project") - return - } - settings, err := s.store.GetSettings() - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to load settings") - return - } - url, secret, tier := resolveProjectTestTarget(project, settings) - if url == "" { - respondError(w, http.StatusBadRequest, "no notification URL configured for this project (and no global fallback)") - return - } - ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout) - defer cancel() - result := s.notifier.SendSyncForTest( - ctx, url, secret, tier, - buildTestEvent(project.Name, ""), - ) - respondJSON(w, http.StatusOK, result) -} - -// resolveProjectTestTarget mirrors the deploy-time stage→project→global -// resolution but without a stage in scope. Used by the project-level test -// button so the operator sees exactly what a project-only event would do. -func resolveProjectTestTarget(project store.Project, settings store.Settings) (string, string, notify.Tier) { - if project.NotificationURL != "" { - return project.NotificationURL, project.NotificationSecret, notify.TierProject - } - return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings -} - -// --------------------------------------------------------------------------- -// Stage tier -// --------------------------------------------------------------------------- - -func (s *Server) getStageNotificationSecret(w http.ResponseWriter, r *http.Request) { - stageID := chi.URLParam(r, "stage") - secret, err := s.store.EnsureStageNotificationSecret(stageID) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "stage") - return - } - slog.Error("get stage notification secret", "stage", stageID, "error", err) - respondError(w, http.StatusInternalServerError, "failed to load secret") - return - } - respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""}) -} - -func (s *Server) regenerateStageNotificationSecret(w http.ResponseWriter, r *http.Request) { - stageID := chi.URLParam(r, "stage") - if _, err := s.store.GetStageByID(stageID); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "stage") - return - } - respondError(w, http.StatusInternalServerError, "failed to load stage") - return - } - secret := generateWebhookSecret() - if err := s.store.SetStageNotificationSecret(stageID, secret); err != nil { - slog.Error("regenerate stage notification secret", "stage", stageID, "error", err) - respondError(w, http.StatusInternalServerError, "failed to rotate secret") - return - } - slog.Info("stage notification secret rotated", "stage", stageID) - respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true}) -} - -func (s *Server) disableStageNotificationSigning(w http.ResponseWriter, r *http.Request) { - stageID := chi.URLParam(r, "stage") - if err := s.store.SetStageNotificationSecret(stageID, ""); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "stage") - return - } - respondError(w, http.StatusInternalServerError, "failed to disable signing") - return - } - respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false}) -} - -func (s *Server) stageNotificationTest(w http.ResponseWriter, r *http.Request) { - projectID := chi.URLParam(r, "id") - stageID := chi.URLParam(r, "stage") - stage, err := s.store.GetStageByID(stageID) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "stage") - return - } - respondError(w, http.StatusInternalServerError, "failed to load stage") - return - } - project, err := s.store.GetProjectByID(projectID) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - respondError(w, http.StatusInternalServerError, "failed to load project") - return - } - settings, err := s.store.GetSettings() - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to load settings") - return - } - // Reuse the production resolver so the test button exercises the exact - // fall-through logic a real deploy would. - url, secret, tier := resolveDeployTarget(stage, project, settings) - if url == "" { - respondError(w, http.StatusBadRequest, "no notification URL configured for this stage, project, or globally") - return - } - ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout) - defer cancel() - result := s.notifier.SendSyncForTest( - ctx, url, secret, tier, - buildTestEvent(project.Name, stage.Name), - ) - respondJSON(w, http.StatusOK, result) -} - -// resolveDeployTarget here mirrors the deployer's helper. Duplicated rather -// than imported to avoid an api → deployer dependency cycle and to keep the -// test-endpoint code self-contained. If divergence becomes a risk we can -// move this into a shared internal/notify subpackage. -func resolveDeployTarget(stage store.Stage, project store.Project, settings store.Settings) (string, string, notify.Tier) { - if stage.NotificationURL != "" { - return stage.NotificationURL, stage.NotificationSecret, notify.TierStage - } - if project.NotificationURL != "" { - return project.NotificationURL, project.NotificationSecret, notify.TierProject - } - return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings -} - -// --------------------------------------------------------------------------- -// Static-site tier -// --------------------------------------------------------------------------- - -func (s *Server) getStaticSiteNotificationSecret(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - secret, err := s.store.EnsureStaticSiteNotificationSecret(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "static site") - return - } - slog.Error("get static site notification secret", "site", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to load secret") - return - } - respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""}) -} - -func (s *Server) regenerateStaticSiteNotificationSecret(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if _, err := s.store.GetStaticSiteByID(id); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "static site") - return - } - respondError(w, http.StatusInternalServerError, "failed to load static site") - return - } - secret := generateWebhookSecret() - if err := s.store.SetStaticSiteNotificationSecret(id, secret); err != nil { - slog.Error("regenerate static site notification secret", "site", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to rotate secret") - return - } - slog.Info("static site notification secret rotated", "site", id) - respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true}) -} - -func (s *Server) disableStaticSiteNotificationSigning(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if err := s.store.SetStaticSiteNotificationSecret(id, ""); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "static site") - return - } - respondError(w, http.StatusInternalServerError, "failed to disable signing") - return - } - respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false}) -} - -func (s *Server) staticSiteNotificationTest(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - site, err := s.store.GetStaticSiteByID(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "static site") - return - } - respondError(w, http.StatusInternalServerError, "failed to load static site") - return - } - settings, err := s.store.GetSettings() - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to load settings") - return - } - url, secret, tier := resolveSiteTestTarget(site, settings) - if url == "" { - respondError(w, http.StatusBadRequest, "no notification URL configured for this site (and no global fallback)") - return - } - ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout) - defer cancel() - result := s.notifier.SendSyncForTest( - ctx, url, secret, tier, - buildTestEvent(site.Name, ""), - ) - respondJSON(w, http.StatusOK, result) -} - -func resolveSiteTestTarget(site store.StaticSite, settings store.Settings) (string, string, notify.Tier) { - if site.NotificationURL != "" { - return site.NotificationURL, site.NotificationSecret, notify.TierSite - } - return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings -} diff --git a/internal/api/projects.go b/internal/api/projects.go deleted file mode 100644 index b49f740..0000000 --- a/internal/api/projects.go +++ /dev/null @@ -1,225 +0,0 @@ -package api - -import ( - "errors" - "log/slog" - "net/http" - - "github.com/go-chi/chi/v5" - - "github.com/alexei/tinyforge/internal/events" - "github.com/alexei/tinyforge/internal/store" -) - -// projectRequest is the expected JSON body for creating/updating a project. -type projectRequest struct { - Name string `json:"name"` - Registry string `json:"registry"` - Image string `json:"image"` - Port int `json:"port"` - Healthcheck string `json:"healthcheck"` - Env string `json:"env"` - Volumes string `json:"volumes"` - NpmAccessListID *int `json:"npm_access_list_id,omitempty"` - NotificationURL *string `json:"notification_url,omitempty"` -} - -// listProjects handles GET /api/projects. -func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) { - projects, err := s.store.GetAllProjects() - if err != nil { - slog.Error("failed to list projects", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - respondJSON(w, http.StatusOK, projects) -} - -// createProject handles POST /api/projects. -func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { - var req projectRequest - if !decodeJSON(w, r, &req) { - return - } - - if req.Name == "" { - respondError(w, http.StatusBadRequest, "name is required") - return - } - if req.Image == "" { - respondError(w, http.StatusBadRequest, "image is required") - return - } - if req.Env == "" { - req.Env = "{}" - } - if req.Volumes == "" { - req.Volumes = "{}" - } - - npmAccessListID := 0 - if req.NpmAccessListID != nil { - npmAccessListID = *req.NpmAccessListID - } - - project, err := s.store.CreateProject(store.Project{ - Name: req.Name, - Registry: req.Registry, - Image: req.Image, - Port: req.Port, - Healthcheck: req.Healthcheck, - Env: req.Env, - Volumes: req.Volumes, - NpmAccessListID: npmAccessListID, - }) - if err != nil { - slog.Error("failed to create project", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - s.eventBus.Publish(events.Event{ - Type: events.EventLog, - Payload: events.EventLogPayload{ - Source: "admin", - Severity: "info", - Message: "project created: " + project.Name, - }, - }) - - respondJSON(w, http.StatusCreated, project) -} - -// getProject handles GET /api/projects/{id}. -func (s *Server) getProject(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - project, err := s.store.GetProjectByID(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - slog.Error("failed to get project", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - // Also fetch stages for this project. - stages, err := s.store.GetStagesByProjectID(id) - if err != nil { - slog.Error("failed to get stages", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - respondJSON(w, http.StatusOK, map[string]any{ - "project": project, - "stages": stages, - }) -} - -// updateProject handles PUT /api/projects/{id}. -func (s *Server) updateProject(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - existing, err := s.store.GetProjectByID(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - slog.Error("failed to get project", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - var req projectRequest - if !decodeJSON(w, r, &req) { - return - } - - // Apply updates to existing project, preserving fields not provided. - updated := existing - if req.Name != "" { - updated.Name = req.Name - } - if req.Image != "" { - updated.Image = req.Image - } - updated.Registry = req.Registry - updated.Port = req.Port - updated.Healthcheck = req.Healthcheck - if req.Env != "" { - updated.Env = req.Env - } - if req.Volumes != "" { - updated.Volumes = req.Volumes - } - if req.NpmAccessListID != nil { - updated.NpmAccessListID = *req.NpmAccessListID - } - if req.NotificationURL != nil { - updated.NotificationURL = *req.NotificationURL - } - - if err := s.store.UpdateProject(updated); err != nil { - slog.Error("failed to update project", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - s.eventBus.Publish(events.Event{ - Type: events.EventLog, - Payload: events.EventLogPayload{ - Source: "admin", - Severity: "info", - Message: "project updated: " + updated.Name, - }, - }) - - respondJSON(w, http.StatusOK, updated) -} - -// deleteProject handles DELETE /api/projects/{id}. -func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - // Clean up Docker containers and proxy routes before deleting the project. - ctx := r.Context() - stages, _ := s.store.GetStagesByProjectID(id) - for _, stage := range stages { - rows, _ := s.store.ListContainersByStageID(stage.ID) - for _, c := range rows { - if c.ContainerID != "" { - if err := s.docker.RemoveContainer(ctx, c.ContainerID, true); err != nil { - slog.Warn("delete project: remove container", "container", c.ContainerID, "error", err) - } - } - if c.ProxyRouteID != "" { - if err := s.proxyProvider.DeleteRoute(ctx, c.ProxyRouteID); err != nil { - slog.Warn("delete project: delete proxy route", "route", c.ProxyRouteID, "error", err) - } - } - } - } - - if err := s.store.DeleteProject(id); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - slog.Error("failed to delete project", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - s.eventBus.Publish(events.Event{ - Type: events.EventLog, - Payload: events.EventLogPayload{ - Source: "admin", - Severity: "info", - Message: "project deleted: " + id, - }, - }) - - respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) -} diff --git a/internal/api/proxies.go b/internal/api/proxies.go index 2a2572b..01d1160 100644 --- a/internal/api/proxies.go +++ b/internal/api/proxies.go @@ -6,9 +6,9 @@ import ( "sort" ) -// listProxyRoutes handles GET /api/proxies. -// Returns proxy routes from both Docker instances and static sites, -// merged and sorted by domain. +// listProxyRoutes handles GET /api/proxies. Returns proxy routes derived +// from the containers index — the legacy static-site / project split is +// gone; any workload whose container carries a proxy route ID is listed. func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) { settings, err := s.store.GetSettings() if err != nil { @@ -17,21 +17,13 @@ func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) { return } - instanceRoutes, err := s.store.ListProxyRoutes(settings.Domain) + routes, err := s.store.ListProxyRoutes(settings.Domain) if err != nil { - slog.Error("failed to list instance proxy routes", "error", err) + slog.Error("failed to list proxy routes", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } - siteRoutes, err := s.store.ListStaticSiteProxyRoutes(settings.Domain) - if err != nil { - slog.Error("failed to list static site proxy routes", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - routes := append(instanceRoutes, siteRoutes...) sort.SliceStable(routes, func(i, j int) bool { if routes[i].Domain == routes[j].Domain { return routes[i].ProjectName < routes[j].ProjectName diff --git a/internal/api/router.go b/internal/api/router.go index 9921cc1..25a8c83 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -16,43 +16,49 @@ import ( "github.com/alexei/tinyforge/internal/notify" "github.com/alexei/tinyforge/internal/npm" "github.com/alexei/tinyforge/internal/proxy" - "github.com/alexei/tinyforge/internal/stack" "github.com/alexei/tinyforge/internal/stale" - "github.com/alexei/tinyforge/internal/staticsite" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/webhook" + "github.com/alexei/tinyforge/internal/workload/plugin" ) // DNSProviderChangedFunc is called when DNS settings change so the caller can // update the provider on the deployer. type DNSProviderChangedFunc func(provider dns.Provider) +// PluginDispatcher is the subset of the deployer the API layer uses for the +// plugin-native dispatch surface (generic-hooks endpoint + workload teardown +// + future surfaces). Defined here so the API does not import the deployer +// package directly. +type PluginDispatcher interface { + webhook.PluginDispatcher + DispatchTeardown(ctx context.Context, w plugin.Workload) error +} + // Server holds all dependencies for the API layer. type Server struct { store *store.Store docker *docker.Client - npm *npm.Client // optional: only for NPM-specific endpoints (certificates) + npm *npm.Client // optional: only for NPM-specific endpoints (certificates) proxyProvider proxy.Provider - deployer DeployTriggerer + deployer PluginDispatcher notifier *notify.Notifier - webhook *webhook.Handler - eventBus *events.Bus - encKey [32]byte - localAuth *auth.LocalAuth - oidcProvider *auth.OIDCProvider - staleScanner *stale.Scanner + webhook *webhook.Handler + eventBus *events.Bus + encKey [32]byte + localAuth *auth.LocalAuth + oidcProvider *auth.OIDCProvider + staleScanner *stale.Scanner dnsProviderMu sync.RWMutex dnsProvider dns.Provider onDNSProviderChanged DNSProviderChangedFunc - staticSiteManager *staticsite.Manager - stackManager *stack.Manager - backupEngine *backup.Engine - sseGate *sseGate - logScanReloader LogScanReloader - dbPath string - shutdownFunc func() // called after restore to trigger graceful shutdown + backupEngine *backup.Engine + sseGate *sseGate + logScanReloader LogScanReloader + dbPath string + shutdownFunc func() // called after restore to trigger graceful shutdown onBackupSettingsChanged func(enabled bool, intervalHours int) // called when backup settings change onProxyProviderChanged func(provider proxy.Provider) // called when proxy provider changes } @@ -63,7 +69,7 @@ func NewServer( dockerClient *docker.Client, npmClient *npm.Client, proxyProvider proxy.Provider, - deployer DeployTriggerer, + deployer PluginDispatcher, notifier *notify.Notifier, webhookHandler *webhook.Handler, eventBus *events.Bus, @@ -94,16 +100,6 @@ func NewServer( return s } -// SetStaticSiteManager sets the static site manager on the server. -func (s *Server) SetStaticSiteManager(mgr *staticsite.Manager) { - s.staticSiteManager = mgr -} - -// SetStackManager sets the docker-compose stack manager on the server. -func (s *Server) SetStackManager(mgr *stack.Manager) { - s.stackManager = mgr -} - // SetStaleScanner sets the stale scanner on the server. // Called after both the API server and scanner are initialized. func (s *Server) SetStaleScanner(scanner *stale.Scanner) { @@ -218,12 +214,7 @@ func (s *Server) Router() chi.Router { r.Group(func(r chi.Router) { r.Use(auth.Middleware(s.localAuth)) - // Plugin registry inspection + unified ingress (Workload refactor). - // /hooks/kinds is informational and visible to any authenticated - // caller. /hooks/generic dispatches deploys and is admin-gated — - // vendor-specific webhooks (with their own per-target HMAC - // secrets) live under /webhook/* and remain the only ingress - // reachable by external CI systems until Phase 5 consolidates them. + // Plugin registry inspection + unified ingress. r.Get("/hooks/kinds", s.listHookKinds) r.Get("/hooks/kinds/{kind}/schema", s.getHookKindSchema) r.With(auth.AdminOnly).Post("/hooks/generic", s.dispatchGeneric) @@ -234,134 +225,6 @@ func (s *Server) Router() chi.Router { r.Post("/auth/logout", s.logout) r.Get("/proxies", s.listProxyRoutes) r.Get("/docker/unused-images", s.unusedImageStats) - // Legacy project/stage/site/stack endpoints carry a Deprecation - // header pointing at /api/workloads. Functional behavior is - // unchanged until the hard cutover removes them. - r.With(deprecated("/api/workloads")).Get("/projects", s.listProjects) - r.Route("/projects/{id}", func(r chi.Router) { - r.Get("/", s.getProject) - r.Get("/stages/{stage}/env", s.listStageEnv) - r.Get("/stages/{stage}/instances", s.listInstances) - r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats) - r.Get("/stages/{stage}/instances/{iid}/stats/history", s.getInstanceStatsHistory) - r.Get("/stages/{stage}/instances/{iid}/logs", s.streamContainerLogs) - r.Get("/images", s.listProjectImages) - r.Get("/volumes", s.listVolumes) - r.Get("/volumes/{volId}/browse", s.browseVolume) - r.Get("/volumes/{volId}/download", s.downloadVolume) - - // Admin-only project mutations. - r.Group(func(r chi.Router) { - r.Use(auth.AdminOnly) - r.Put("/", s.updateProject) - r.Delete("/", s.deleteProject) - - // Per-project webhook URL management. - r.Get("/webhook", s.getProjectWebhook) - r.Post("/webhook/regenerate", s.regenerateProjectWebhook) - // Inbound HMAC signing — secret rotation + enforcement toggle. - r.Post("/webhook/signing-secret/regenerate", s.regenerateProjectSigningSecret) - r.Delete("/webhook/signing-secret", s.disableProjectSigningSecret) - r.Put("/webhook/require-signature", s.updateProjectSigningRequirement) - r.Get("/webhook/deliveries", s.listProjectWebhookDeliveries) - - // Per-project outgoing-webhook signing & test. - r.Get("/notification-secret", s.getProjectNotificationSecret) - r.Post("/notification-secret/regenerate", s.regenerateProjectNotificationSecret) - r.Post("/notification-secret/disable", s.disableProjectNotificationSigning) - r.Post("/notification-test", s.projectNotificationTest) - - // Stage endpoints. - r.Post("/stages", s.createStage) - r.Put("/stages/{stage}", s.updateStage) - r.Delete("/stages/{stage}", s.deleteStage) - - // Per-stage outgoing-webhook signing & test. - r.Get("/stages/{stage}/notification-secret", s.getStageNotificationSecret) - r.Post("/stages/{stage}/notification-secret/regenerate", s.regenerateStageNotificationSecret) - r.Post("/stages/{stage}/notification-secret/disable", s.disableStageNotificationSigning) - r.Post("/stages/{stage}/notification-test", s.stageNotificationTest) - - // Stage env override endpoints. - r.Post("/stages/{stage}/env", s.createStageEnv) - r.Put("/stages/{stage}/env/{envId}", s.updateStageEnv) - r.Delete("/stages/{stage}/env/{envId}", s.deleteStageEnv) - - // Instance endpoints. - r.Post("/stages/{stage}/instances", s.deployInstance) - r.Delete("/stages/{stage}/instances/{iid}", s.removeInstance) - - // Instance control endpoints. - r.Post("/stages/{stage}/instances/{iid}/stop", s.stopInstance) - r.Post("/stages/{stage}/instances/{iid}/start", s.startInstance) - r.Post("/stages/{stage}/instances/{iid}/restart", s.restartInstance) - - // Volume endpoints. - r.Post("/volumes", s.createVolume) - r.Put("/volumes/{volId}", s.updateVolume) - r.Delete("/volumes/{volId}", s.deleteVolume) - r.Post("/volumes/{volId}/upload", s.uploadToVolume) - }) - }) - // Stacks (docker-compose). - r.With(deprecated("/api/workloads?kind=plugin&source_kind=compose")).Get("/stacks", s.listStacks) - r.Route("/stacks/{id}", func(r chi.Router) { - r.Get("/", s.getStack) - r.Get("/revisions", s.listStackRevisions) - r.Get("/revisions/{revId}", s.getStackRevision) - r.Get("/services", s.getStackServices) - r.Get("/logs", s.getStackLogs) - - r.Group(func(r chi.Router) { - r.Use(auth.AdminOnly) - r.Put("/", s.updateStack) - r.Delete("/", s.deleteStack) - r.Post("/revisions", s.createStackRevision) - r.Post("/rollback/{revId}", s.rollbackStack) - r.Post("/stop", s.stopStack) - r.Post("/start", s.startStack) - }) - }) - r.With(auth.AdminOnly).Post("/stacks", s.createStack) - - // Static sites. - r.With(deprecated("/api/workloads?kind=plugin&source_kind=static")).Get("/sites", s.listStaticSites) - r.Route("/sites/{id}", func(r chi.Router) { - r.Get("/", s.getStaticSite) - r.Get("/secrets", s.listStaticSiteSecrets) - r.Get("/storage", s.getStaticSiteStorage) - r.Get("/logs", s.streamStaticSiteLogs) - r.Get("/stats", s.getStaticSiteStats) - r.Get("/stats/history", s.getStaticSiteStatsHistory) - - // Admin-only mutations. - r.Group(func(r chi.Router) { - r.Use(auth.AdminOnly) - r.Put("/", s.updateStaticSite) - r.Delete("/", s.deleteStaticSite) - r.Post("/deploy", s.deployStaticSite) - r.Post("/stop", s.stopStaticSite) - r.Post("/start", s.startStaticSite) - r.Get("/webhook", s.getStaticSiteWebhook) - r.Post("/webhook/regenerate", s.regenerateStaticSiteWebhook) - r.Post("/webhook/signing-secret/regenerate", s.regenerateStaticSiteSigningSecret) - r.Delete("/webhook/signing-secret", s.disableStaticSiteSigningSecret) - r.Put("/webhook/require-signature", s.updateStaticSiteSigningRequirement) - r.Get("/webhook/deliveries", s.listStaticSiteWebhookDeliveries) - - // Per-site outgoing-webhook signing & test. - r.Get("/notification-secret", s.getStaticSiteNotificationSecret) - r.Post("/notification-secret/regenerate", s.regenerateStaticSiteNotificationSecret) - r.Post("/notification-secret/disable", s.disableStaticSiteNotificationSigning) - r.Post("/notification-test", s.staticSiteNotificationTest) - r.Post("/secrets", s.createStaticSiteSecret) - r.Put("/secrets/{sid}", s.updateStaticSiteSecret) - r.Delete("/secrets/{sid}", s.deleteStaticSiteSecret) - }) - }) - - r.Get("/deploys", s.listDeploys) - r.Get("/deploys/{id}/logs", s.streamDeployLogs) r.Get("/events", s.streamEvents) r.Get("/events/log", s.listEventLog) r.Get("/events/log/stats", s.getEventLogStats) @@ -388,12 +251,9 @@ func (s *Server) Router() chi.Router { // Stale container endpoints (read). r.Get("/containers/stale", s.listStaleContainers) - // Workload-shaped endpoints (the unifying layer over project / - // stack / site). Read endpoints are open to any authenticated - // user; create / update / deploy mutate state and are admin-gated. - // Plugin-native workloads (source_kind + trigger_kind set) are - // created here; legacy project / stack / site mutations remain at - // their dedicated endpoints during the cutover. + // Workload-shaped endpoints — the canonical surface after the + // hard cutover. Reads open to any authenticated user; mutations + // admin-gated. r.Get("/workloads", s.listWorkloads) r.With(auth.AdminOnly).Post("/workloads", s.createPluginWorkload) r.Route("/workloads/{id}", func(r chi.Router) { @@ -405,22 +265,17 @@ func (s *Server) Router() chi.Router { r.With(auth.AdminOnly).Post("/deploy", s.deployPluginWorkload) r.With(auth.AdminOnly).Delete("/", s.deletePluginWorkload) - // Per-workload env vars (analog of legacy stage_env). - // Listing is open to authenticated readers; mutations are - // admin-gated. Encrypted values are write-only after store. + // Per-workload env vars. Listing open to authenticated readers; + // mutations admin-gated. Encrypted values are write-only after store. r.Get("/env", s.listWorkloadEnv) r.With(auth.AdminOnly).Put("/env", s.setWorkloadEnv) r.With(auth.AdminOnly).Delete("/env/{envID}", s.deleteWorkloadEnv) - // Per-workload inbound webhook URL: rotate the secret + fetch - // the canonical URL. Mirrors the project / site webhook UX. - r.With(auth.AdminOnly).Get("/webhook", s.getWorkloadWebhook) - r.With(auth.AdminOnly).Post("/webhook/regenerate", s.regenerateWorkloadWebhook) + // Per-workload inbound webhook URL handlers were dropped in + // the hard legacy cutover; inbound webhooks are now first- + // class Triggers reachable via /api/triggers/{id}/webhook. - // Per-workload volume mounts (analog of legacy project volumes). - // Reads are open to authenticated users; mutations admin-gated. - // Source/target paths are validated for traversal safety here; - // host-path allow-listing happens at deploy time. + // Per-workload volume mounts. r.Get("/volumes", s.listWorkloadVolumes) r.With(auth.AdminOnly).Put("/volumes", s.setWorkloadVolume) r.With(auth.AdminOnly).Delete("/volumes/{volID}", s.deleteWorkloadVolume) @@ -432,8 +287,7 @@ func (s *Server) Router() chi.Router { r.With(auth.AdminOnly).Post("/promote-from/{sourceID}", s.promoteFromWorkload) // Trigger bindings on this workload — the symmetric view - // of /triggers/{id}/bindings keyed on the workload side - // so the workload detail page is one round-trip. + // of /triggers/{id}/bindings keyed on the workload side. r.Get("/triggers", s.listBindingsForWorkload) r.With(auth.AdminOnly).Post("/triggers", s.bindTriggerToWorkload) }) @@ -453,10 +307,7 @@ func (s *Server) Router() chi.Router { }) // First-class Triggers (redeploy signal sources). One trigger - // (registry / git / webhook / manual / schedule / log_scan) // fans out to many workloads via workload_trigger_bindings. - // Reads are open to authenticated users; mutations + secret - // rotation are admin-gated. r.Get("/triggers", s.listTriggers) r.Get("/triggers/{id}", s.getTrigger) r.Get("/triggers/{id}/bindings", s.listBindingsForTrigger) @@ -472,10 +323,7 @@ func (s *Server) Router() chi.Router { r.Delete("/bindings/{bid}", s.deleteBinding) }) - // Event triggers: filter+action rules over the event_log - // stream. Read endpoints are available to any authenticated - // user; mutations + test-dispatch are admin-gated since they - // can fire arbitrary outbound webhooks. + // Event triggers: filter+action rules over the event_log stream. r.Get("/event-triggers", s.listEventTriggers) r.Get("/event-triggers/{id}", s.getEventTrigger) r.Group(func(r chi.Router) { @@ -486,11 +334,7 @@ func (s *Server) Router() chi.Router { r.Post("/event-triggers/{id}/test", s.testEventTrigger) }) - // Log-scan rules: regex patterns the scanner manager - // applies to container log lines. Read endpoints are - // available to any authenticated user; mutations are - // admin-gated since they can change global observability - // behavior across every workload. + // Log-scan rules. r.Get("/log-scan-rules", s.listLogScanRules) r.Get("/log-scan-rules/stats", s.getLogScanStats) r.Get("/log-scan-rules/{id}", s.getLogScanRule) @@ -512,7 +356,7 @@ func (s *Server) Router() chi.Router { r.Group(func(r chi.Router) { r.Use(auth.AdminOnly) - // Config export (reveals project/infra details). + // Config export (reveals registry/global details). r.Get("/config/export", s.exportConfig) // Event log management. @@ -528,21 +372,6 @@ func (s *Server) Router() chi.Router { r.Put("/auth/users/{uid}/password", s.changePassword) r.Delete("/auth/users/{uid}", s.deleteUser) - // Project creation. - r.Post("/projects", s.createProject) - - // Static site creation and tools. - r.Post("/sites", s.createStaticSite) - r.Post("/sites/test-connection", s.testStaticSiteConnection) - r.Post("/sites/branches", s.listStaticSiteBranches) - r.Post("/sites/tree", s.listStaticSiteTree) - r.Post("/sites/detect-provider", s.detectStaticSiteProvider) - r.Post("/sites/repos", s.listStaticSiteRepos) - - // Quick deploy endpoints. - r.Post("/deploy/inspect", s.inspectImage) - r.Post("/deploy/quick", s.quickDeploy) - // Registry creation. r.Post("/registries", s.createRegistry) diff --git a/internal/api/secrets.go b/internal/api/secrets.go new file mode 100644 index 0000000..526daca --- /dev/null +++ b/internal/api/secrets.go @@ -0,0 +1,43 @@ +package api + +import ( + "strconv" + + "github.com/alexei/tinyforge/internal/store" +) + +// generateWebhookSecret is a one-line bridge to store.GenerateWebhookSecret +// so the api handlers and the store CRUD share one secret-generation +// path — no panic-vs-UUID-fallback divergence. +func generateWebhookSecret() string { return store.GenerateWebhookSecret() } + +// webhookURLResponse is the common payload returned by every webhook +// endpoint. Clients never see raw secrets except at issue/rotate time via +// these fields; the URL shape is "/api/webhook/..." so callers can prepend +// their own origin. +type webhookURLResponse struct { + WebhookURL string `json:"webhook_url"` + WebhookSecret string `json:"webhook_secret"` + HasSigningSecret bool `json:"has_signing_secret"` + WebhookRequireSignature bool `json:"webhook_require_signature"` +} + +// signingSecretResponse is returned when a signing secret is issued or rotated. +type signingSecretResponse struct { + SigningSecret string `json:"signing_secret"` +} + +// parseLimit clamps a query-string limit to [1, max], falling back to def. +func parseLimit(raw string, def, max int) int { + if raw == "" { + return def + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return def + } + if n > max { + return max + } + return n +} diff --git a/internal/api/sse.go b/internal/api/sse.go index affe9a0..8d84d45 100644 --- a/internal/api/sse.go +++ b/internal/api/sse.go @@ -2,147 +2,14 @@ package api import ( "encoding/json" - "errors" "fmt" "log/slog" "net/http" - "strings" "time" - "github.com/go-chi/chi/v5" - "github.com/alexei/tinyforge/internal/events" - "github.com/alexei/tinyforge/internal/store" ) -// streamDeployLogs handles GET /api/deploys/{id}/logs. -// It supports both SSE streaming and JSON fallback based on the Accept header. -// -// SSE mode (Accept: text/event-stream): -// -// Streams deploy log events in real-time. Existing logs are sent first, -// then new logs are pushed as they arrive via the event bus. -// -// JSON mode (default): -// -// Returns all existing deploy logs as a JSON array. -func (s *Server) streamDeployLogs(w http.ResponseWriter, r *http.Request) { - deployID := chi.URLParam(r, "id") - - // Verify deploy exists. - deploy, err := s.store.GetDeployByID(deployID) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "deploy") - return - } - slog.Error("failed to get deploy", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - // JSON fallback: return existing logs as array. - accept := r.Header.Get("Accept") - if !strings.Contains(accept, "text/event-stream") { - logs, err := s.store.GetDeployLogs(deployID) - if err != nil { - slog.Error("failed to get deploy logs", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - respondJSON(w, http.StatusOK, logs) - return - } - - // SSE mode. - release, ok := acquireSSESlot(w, s.sseGate) - if !ok { - return - } - defer release() - - flusher, ok := w.(http.Flusher) - if !ok { - respondError(w, http.StatusInternalServerError, "streaming not supported") - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("X-Accel-Buffering", "no") - w.WriteHeader(http.StatusOK) - flusher.Flush() - - // Send existing logs first. - existingLogs, err := s.store.GetDeployLogs(deployID) - if err != nil { - slog.Error("get existing deploy logs", "error", err) - } else { - for _, entry := range existingLogs { - writeSSE(w, flusher, events.Event{ - Type: events.EventDeployLog, - Payload: events.DeployLogPayload{ - DeployID: deployID, - Message: entry.Message, - Level: entry.Level, - }, - }) - } - } - - // If deploy is already finished, send completion and close. - if isTerminalStatus(deploy.Status) { - writeSSE(w, flusher, events.Event{ - Type: events.EventDeployStatus, - Payload: events.DeployStatusPayload{ - DeployID: deployID, - ProjectID: deploy.ProjectID, - StageID: deploy.StageID, - ImageTag: deploy.ImageTag, - Status: deploy.Status, - Error: deploy.Error, - }, - }) - return - } - - // Subscribe to new deploy log events for this deploy. - sub := s.eventBus.Subscribe(func(evt events.Event) bool { - switch payload := evt.Payload.(type) { - case events.DeployLogPayload: - return payload.DeployID == deployID - case events.DeployStatusPayload: - return payload.DeployID == deployID - default: - return false - } - }) - defer s.eventBus.Unsubscribe(sub) - - ctx := r.Context() - for { - select { - case <-ctx.Done(): - return - case evt, ok := <-sub: - if !ok { - return - } - writeSSE(w, flusher, evt) - - // Close stream when deploy reaches terminal status. - if evt.Type == events.EventDeployStatus { - if payload, ok := evt.Payload.(events.DeployStatusPayload); ok { - if isTerminalStatus(payload.Status) { - return - } - } - } - } - } -} - // streamEvents handles GET /api/events. // It streams instance status changes and deploy status changes via SSE. func (s *Server) streamEvents(w http.ResponseWriter, r *http.Request) { @@ -203,8 +70,3 @@ func writeSSE(w http.ResponseWriter, flusher http.Flusher, evt events.Event) { fmt.Fprintf(w, "data: %s\n\n", data) flusher.Flush() } - -// isTerminalStatus returns true if the deploy status is final. -func isTerminalStatus(status string) bool { - return store.IsTerminalDeployStatus(status) -} diff --git a/internal/api/stacks.go b/internal/api/stacks.go deleted file mode 100644 index 6725705..0000000 --- a/internal/api/stacks.go +++ /dev/null @@ -1,285 +0,0 @@ -package api - -import ( - "context" - "errors" - "io" - "net/http" - "strconv" - "time" - - "github.com/go-chi/chi/v5" - - "github.com/alexei/tinyforge/internal/auth" - "github.com/alexei/tinyforge/internal/store" -) - -// ── List / Get ───────────────────────────────────────────────────────── - -func (s *Server) listStacks(w http.ResponseWriter, r *http.Request) { - stacks, err := s.store.GetAllStacks() - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to list stacks") - return - } - respondJSON(w, http.StatusOK, stacks) -} - -func (s *Server) getStack(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - st, err := s.store.GetStackByID(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "stack") - return - } - respondError(w, http.StatusInternalServerError, "failed to get stack") - return - } - respondJSON(w, http.StatusOK, st) -} - -// ── Create ────────────────────────────────────────────────────────── - -type createStackRequest struct { - Name string `json:"name"` - Description string `json:"description"` - YAML string `json:"yaml"` - Deploy bool `json:"deploy"` // if true, deploy immediately after create -} - -func (s *Server) createStack(w http.ResponseWriter, r *http.Request) { - if s.stackManager == nil { - respondError(w, http.StatusServiceUnavailable, "stack manager not available (docker compose missing?)") - return - } - var req createStackRequest - if !decodeJSON(w, r, &req) { - return - } - if req.Name == "" || req.YAML == "" { - respondError(w, http.StatusBadRequest, "name and yaml are required") - return - } - - author := authorFromRequest(r) - ctx := r.Context() - st, rev, err := s.stackManager.Create(ctx, req.Name, req.Description, req.YAML, author) - if err != nil { - respondError(w, http.StatusBadRequest, err.Error()) - return - } - - if req.Deploy { - // Deploy asynchronously so the client gets a fast response. - go func(stackID, revID string) { - bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) - defer cancel() - _ = s.stackManager.Deploy(bgCtx, stackID, revID) - }(st.ID, rev.ID) - } - - respondJSON(w, http.StatusCreated, map[string]any{ - "stack": st, - "revision": rev, - }) -} - -// ── Update (metadata only) ───────────────────────────────────────── - -type updateStackRequest struct { - Name string `json:"name"` - Description string `json:"description"` -} - -func (s *Server) updateStack(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - existing, err := s.store.GetStackByID(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "stack") - return - } - respondError(w, http.StatusInternalServerError, "failed to get stack") - return - } - var req updateStackRequest - if !decodeJSON(w, r, &req) { - return - } - if req.Name != "" { - existing.Name = req.Name - } - existing.Description = req.Description - if err := s.store.UpdateStack(existing); err != nil { - respondError(w, http.StatusInternalServerError, "failed to update stack") - return - } - respondJSON(w, http.StatusOK, existing) -} - -// ── Delete ────────────────────────────────────────────────────────── - -func (s *Server) deleteStack(w http.ResponseWriter, r *http.Request) { - if s.stackManager == nil { - respondError(w, http.StatusServiceUnavailable, "stack manager not available") - return - } - id := chi.URLParam(r, "id") - removeVolumes := r.URL.Query().Get("remove_volumes") == "true" - if err := s.stackManager.Delete(r.Context(), id, removeVolumes); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "stack") - return - } - respondError(w, http.StatusInternalServerError, "failed to delete stack: "+err.Error()) - return - } - respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) -} - -// ── Revisions ────────────────────────────────────────────────────── - -func (s *Server) listStackRevisions(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - revs, err := s.store.GetStackRevisionsByStackID(id) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to list revisions") - return - } - respondJSON(w, http.StatusOK, revs) -} - -func (s *Server) getStackRevision(w http.ResponseWriter, r *http.Request) { - revID := chi.URLParam(r, "revId") - rev, err := s.store.GetStackRevisionByID(revID) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "revision") - return - } - respondError(w, http.StatusInternalServerError, "failed to get revision") - return - } - respondJSON(w, http.StatusOK, rev) -} - -type newRevisionRequest struct { - YAML string `json:"yaml"` -} - -func (s *Server) createStackRevision(w http.ResponseWriter, r *http.Request) { - if s.stackManager == nil { - respondError(w, http.StatusServiceUnavailable, "stack manager not available") - return - } - id := chi.URLParam(r, "id") - var req newRevisionRequest - if !decodeJSON(w, r, &req) { - return - } - if req.YAML == "" { - respondError(w, http.StatusBadRequest, "yaml is required") - return - } - author := authorFromRequest(r) - - // Deploy asynchronously; return the revision immediately. - ctx := r.Context() - rev, err := s.stackManager.NewRevisionAndDeployAsync(ctx, id, req.YAML, author) - if err != nil { - respondError(w, http.StatusBadRequest, err.Error()) - return - } - respondJSON(w, http.StatusAccepted, rev) -} - -func (s *Server) rollbackStack(w http.ResponseWriter, r *http.Request) { - if s.stackManager == nil { - respondError(w, http.StatusServiceUnavailable, "stack manager not available") - return - } - id := chi.URLParam(r, "id") - revID := chi.URLParam(r, "revId") - author := authorFromRequest(r) - - rev, err := s.stackManager.RollbackAsync(r.Context(), id, revID, author) - if err != nil { - respondError(w, http.StatusBadRequest, err.Error()) - return - } - respondJSON(w, http.StatusAccepted, rev) -} - -// ── Control ────────────────────────────────────────────────────── - -func (s *Server) stopStack(w http.ResponseWriter, r *http.Request) { - if s.stackManager == nil { - respondError(w, http.StatusServiceUnavailable, "stack manager not available") - return - } - id := chi.URLParam(r, "id") - if err := s.stackManager.Stop(r.Context(), id); err != nil { - respondError(w, http.StatusInternalServerError, err.Error()) - return - } - respondJSON(w, http.StatusOK, map[string]string{"status": "stopped"}) -} - -func (s *Server) startStack(w http.ResponseWriter, r *http.Request) { - if s.stackManager == nil { - respondError(w, http.StatusServiceUnavailable, "stack manager not available") - return - } - id := chi.URLParam(r, "id") - if err := s.stackManager.Start(r.Context(), id); err != nil { - respondError(w, http.StatusInternalServerError, err.Error()) - return - } - respondJSON(w, http.StatusOK, map[string]string{"status": "running"}) -} - -func (s *Server) getStackServices(w http.ResponseWriter, r *http.Request) { - if s.stackManager == nil { - respondError(w, http.StatusServiceUnavailable, "stack manager not available") - return - } - id := chi.URLParam(r, "id") - services, err := s.stackManager.Services(r.Context(), id) - if err != nil { - respondError(w, http.StatusInternalServerError, err.Error()) - return - } - respondJSON(w, http.StatusOK, services) -} - -func (s *Server) getStackLogs(w http.ResponseWriter, r *http.Request) { - if s.stackManager == nil { - respondError(w, http.StatusServiceUnavailable, "stack manager not available") - return - } - id := chi.URLParam(r, "id") - service := r.URL.Query().Get("service") - tail := 200 - if t := r.URL.Query().Get("tail"); t != "" { - if n, err := strconv.Atoi(t); err == nil && n > 0 { - tail = n - } - } - logs, err := s.stackManager.Logs(r.Context(), id, service, tail) - if err != nil { - respondError(w, http.StatusInternalServerError, err.Error()) - return - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - _, _ = io.WriteString(w, logs) -} - -// authorFromRequest best-effort returns the username of the acting user. -// Falls back to "system" if no auth context is present. -func authorFromRequest(r *http.Request) string { - if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.Username != "" { - return claims.Username - } - return "system" -} diff --git a/internal/api/stage_env.go b/internal/api/stage_env.go deleted file mode 100644 index 079b0ce..0000000 --- a/internal/api/stage_env.go +++ /dev/null @@ -1,186 +0,0 @@ -package api - -import ( - "errors" - "log/slog" - "net/http" - - "github.com/go-chi/chi/v5" - - "github.com/alexei/tinyforge/internal/crypto" - "github.com/alexei/tinyforge/internal/store" -) - -// stageEnvRequest is the expected JSON body for creating/updating a stage env override. -type stageEnvRequest struct { - Key string `json:"key"` - Value string `json:"value"` - Encrypted *bool `json:"encrypted"` -} - -// listStageEnv handles GET /api/projects/{id}/stages/{stage}/env. -func (s *Server) listStageEnv(w http.ResponseWriter, r *http.Request) { - stageID := chi.URLParam(r, "stage") - - // Verify stage exists. - if _, err := s.store.GetStageByID(stageID); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "stage") - return - } - slog.Error("failed to get stage", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - envs, err := s.store.GetStageEnvByStageID(stageID) - if err != nil { - slog.Error("failed to list stage env", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - // Mask encrypted values in the response. - masked := make([]store.StageEnv, len(envs)) - for i, env := range envs { - masked[i] = env - if env.Encrypted { - masked[i].Value = "••••••••" - } - } - - respondJSON(w, http.StatusOK, masked) -} - -// createStageEnv handles POST /api/projects/{id}/stages/{stage}/env. -func (s *Server) createStageEnv(w http.ResponseWriter, r *http.Request) { - stageID := chi.URLParam(r, "stage") - - // Verify stage exists. - if _, err := s.store.GetStageByID(stageID); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "stage") - return - } - slog.Error("failed to get stage", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - var req stageEnvRequest - if !decodeJSON(w, r, &req) { - return - } - - if req.Key == "" { - respondError(w, http.StatusBadRequest, "key is required") - return - } - - encrypted := false - if req.Encrypted != nil { - encrypted = *req.Encrypted - } - - value := req.Value - if encrypted && value != "" { - enc, err := crypto.Encrypt(s.encKey, value) - if err != nil { - slog.Error("failed to encrypt value", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - value = enc - } - - env, err := s.store.CreateStageEnv(store.StageEnv{ - StageID: stageID, - Key: req.Key, - Value: value, - Encrypted: encrypted, - }) - if err != nil { - slog.Error("failed to create stage env", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - // Mask encrypted value in the response. - if env.Encrypted { - env.Value = "••••••••" - } - - respondJSON(w, http.StatusCreated, env) -} - -// updateStageEnv handles PUT /api/projects/{id}/stages/{stage}/env/{envId}. -func (s *Server) updateStageEnv(w http.ResponseWriter, r *http.Request) { - envID := chi.URLParam(r, "envId") - - existing, err := s.store.GetStageEnvByID(envID) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "stage env") - return - } - slog.Error("failed to get stage env", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - var req stageEnvRequest - if !decodeJSON(w, r, &req) { - return - } - - updated := existing - if req.Key != "" { - updated.Key = req.Key - } - if req.Encrypted != nil { - updated.Encrypted = *req.Encrypted - } - - // Only update value if provided (allows updating key/encrypted without changing the value). - if req.Value != "" { - value := req.Value - if updated.Encrypted { - enc, err := crypto.Encrypt(s.encKey, value) - if err != nil { - slog.Error("failed to encrypt value", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - value = enc - } - updated.Value = value - } - - if err := s.store.UpdateStageEnv(updated); err != nil { - slog.Error("failed to update stage env", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - // Mask encrypted value in the response. - if updated.Encrypted { - updated.Value = "••••••••" - } - - respondJSON(w, http.StatusOK, updated) -} - -// deleteStageEnv handles DELETE /api/projects/{id}/stages/{stage}/env/{envId}. -func (s *Server) deleteStageEnv(w http.ResponseWriter, r *http.Request) { - envID := chi.URLParam(r, "envId") - if err := s.store.DeleteStageEnv(envID); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "stage env") - return - } - slog.Error("failed to delete stage env", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - respondJSON(w, http.StatusOK, map[string]string{"deleted": envID}) -} diff --git a/internal/api/stages.go b/internal/api/stages.go deleted file mode 100644 index 8534de2..0000000 --- a/internal/api/stages.go +++ /dev/null @@ -1,202 +0,0 @@ -package api - -import ( - "errors" - "log/slog" - "net/http" - - "github.com/go-chi/chi/v5" - - "github.com/alexei/tinyforge/internal/events" - "github.com/alexei/tinyforge/internal/store" -) - -// stageRequest is the expected JSON body for creating/updating a stage. -type stageRequest struct { - Name string `json:"name"` - TagPattern string `json:"tag_pattern"` - AutoDeploy *bool `json:"auto_deploy"` - EnableProxy *bool `json:"enable_proxy"` - MaxInstances *int `json:"max_instances"` - Confirm *bool `json:"confirm"` - PromoteFrom string `json:"promote_from"` - Subdomain string `json:"subdomain"` - NotificationURL string `json:"notification_url"` - CpuLimit *float64 `json:"cpu_limit"` - MemoryLimit *int `json:"memory_limit"` -} - -// createStage handles POST /api/projects/{id}/stages. -func (s *Server) createStage(w http.ResponseWriter, r *http.Request) { - projectID := chi.URLParam(r, "id") - - // Verify project exists. - if _, err := s.store.GetProjectByID(projectID); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - slog.Error("failed to get project", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - var req stageRequest - if !decodeJSON(w, r, &req) { - return - } - - if req.Name == "" { - respondError(w, http.StatusBadRequest, "name is required") - return - } - if req.TagPattern == "" { - req.TagPattern = "*" - } - - autoDeploy := false - if req.AutoDeploy != nil { - autoDeploy = *req.AutoDeploy - } - maxInstances := 1 - if req.MaxInstances != nil { - maxInstances = *req.MaxInstances - } - confirm := false - if req.Confirm != nil { - confirm = *req.Confirm - } - enableProxy := true - if req.EnableProxy != nil { - enableProxy = *req.EnableProxy - } - - var cpuLimit float64 - if req.CpuLimit != nil { - cpuLimit = *req.CpuLimit - } - var memoryLimit int - if req.MemoryLimit != nil { - memoryLimit = *req.MemoryLimit - } - - stage, err := s.store.CreateStage(store.Stage{ - ProjectID: projectID, - Name: req.Name, - TagPattern: req.TagPattern, - AutoDeploy: autoDeploy, - EnableProxy: enableProxy, - MaxInstances: maxInstances, - Confirm: confirm, - PromoteFrom: req.PromoteFrom, - Subdomain: req.Subdomain, - NotificationURL: req.NotificationURL, - CpuLimit: cpuLimit, - MemoryLimit: memoryLimit, - }) - if err != nil { - slog.Error("failed to create stage", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - s.eventBus.Publish(events.Event{ - Type: events.EventLog, - Payload: events.EventLogPayload{ - Source: "admin", - Severity: "info", - Message: "stage created: " + stage.Name, - }, - }) - - respondJSON(w, http.StatusCreated, stage) -} - -// updateStage handles PUT /api/projects/{id}/stages/{stage}. -func (s *Server) updateStage(w http.ResponseWriter, r *http.Request) { - stageID := chi.URLParam(r, "stage") - - existing, err := s.store.GetStageByID(stageID) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "stage") - return - } - slog.Error("failed to get stage", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - var req stageRequest - if !decodeJSON(w, r, &req) { - return - } - - updated := existing - if req.Name != "" { - updated.Name = req.Name - } - if req.TagPattern != "" { - updated.TagPattern = req.TagPattern - } - if req.AutoDeploy != nil { - updated.AutoDeploy = *req.AutoDeploy - } - if req.EnableProxy != nil { - updated.EnableProxy = *req.EnableProxy - } - if req.MaxInstances != nil { - updated.MaxInstances = *req.MaxInstances - } - if req.Confirm != nil { - updated.Confirm = *req.Confirm - } - updated.PromoteFrom = req.PromoteFrom - updated.Subdomain = req.Subdomain - updated.NotificationURL = req.NotificationURL - if req.CpuLimit != nil { - updated.CpuLimit = *req.CpuLimit - } - if req.MemoryLimit != nil { - updated.MemoryLimit = *req.MemoryLimit - } - - if err := s.store.UpdateStage(updated); err != nil { - slog.Error("failed to update stage", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - s.eventBus.Publish(events.Event{ - Type: events.EventLog, - Payload: events.EventLogPayload{ - Source: "admin", - Severity: "info", - Message: "stage updated: " + updated.Name, - }, - }) - - respondJSON(w, http.StatusOK, updated) -} - -// deleteStage handles DELETE /api/projects/{id}/stages/{stage}. -func (s *Server) deleteStage(w http.ResponseWriter, r *http.Request) { - stageID := chi.URLParam(r, "stage") - if err := s.store.DeleteStage(stageID); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "stage") - return - } - slog.Error("failed to delete stage", "error", err) - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - s.eventBus.Publish(events.Event{ - Type: events.EventLog, - Payload: events.EventLogPayload{ - Source: "admin", - Severity: "info", - Message: "stage deleted: " + stageID, - }, - }) - - respondJSON(w, http.StatusOK, map[string]string{"deleted": stageID}) -} diff --git a/internal/api/static_sites.go b/internal/api/static_sites.go deleted file mode 100644 index 6ecfda5..0000000 --- a/internal/api/static_sites.go +++ /dev/null @@ -1,662 +0,0 @@ -package api - -import ( - "context" - "errors" - "net/http" - "strings" - - "github.com/go-chi/chi/v5" - - "github.com/alexei/tinyforge/internal/crypto" - "github.com/alexei/tinyforge/internal/store" -) - -// ── List / Get ───────────────────────────────────────────────────────── - -func (s *Server) listStaticSites(w http.ResponseWriter, r *http.Request) { - sites, err := s.store.GetAllStaticSites() - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to list static sites") - return - } - - // Mask access tokens in response. - for i := range sites { - sites[i].AccessToken = maskToken(sites[i].AccessToken) - } - - respondJSON(w, http.StatusOK, sites) -} - -func (s *Server) getStaticSite(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - site, err := s.store.GetStaticSiteByID(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "static site") - return - } - respondError(w, http.StatusInternalServerError, "failed to get static site") - return - } - - site.AccessToken = maskToken(site.AccessToken) - respondJSON(w, http.StatusOK, site) -} - -// ── Create ────────────────────────────────────────────────────────── - -type createStaticSiteRequest struct { - Name string `json:"name"` - Provider string `json:"provider"` - GiteaURL string `json:"gitea_url"` - RepoOwner string `json:"repo_owner"` - RepoName string `json:"repo_name"` - Branch string `json:"branch"` - FolderPath string `json:"folder_path"` - AccessToken string `json:"access_token"` - Domain string `json:"domain"` - Mode string `json:"mode"` - RenderMarkdown bool `json:"render_markdown"` - SyncTrigger string `json:"sync_trigger"` - TagPattern string `json:"tag_pattern"` - StorageEnabled bool `json:"storage_enabled"` - StorageLimitMB int `json:"storage_limit_mb"` - NotificationURL *string `json:"notification_url,omitempty"` -} - -func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) { - var req createStaticSiteRequest - if !decodeJSON(w, r, &req) { - return - } - - if req.Name == "" || req.GiteaURL == "" || req.RepoOwner == "" || req.RepoName == "" { - respondError(w, http.StatusBadRequest, "name, gitea_url, repo_owner, and repo_name are required") - return - } - - if req.Branch == "" { - req.Branch = "main" - } - if req.Mode == "" { - req.Mode = "static" - } - if req.SyncTrigger == "" { - req.SyncTrigger = "manual" - } - - // Encrypt access token if provided. - encryptedToken := "" - if req.AccessToken != "" { - encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to encrypt access token") - return - } - encryptedToken = encrypted - } - - site := store.StaticSite{ - Name: req.Name, - Provider: req.Provider, - GiteaURL: strings.TrimRight(req.GiteaURL, "/"), - RepoOwner: req.RepoOwner, - RepoName: req.RepoName, - Branch: req.Branch, - FolderPath: req.FolderPath, - AccessToken: encryptedToken, - Domain: req.Domain, - Mode: req.Mode, - RenderMarkdown: req.RenderMarkdown, - SyncTrigger: req.SyncTrigger, - TagPattern: req.TagPattern, - StorageEnabled: req.StorageEnabled, - StorageLimitMB: req.StorageLimitMB, - Status: "idle", - } - if req.NotificationURL != nil { - site.NotificationURL = *req.NotificationURL - } - - created, err := s.store.CreateStaticSite(site) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to create static site: "+err.Error()) - return - } - - created.AccessToken = maskToken(created.AccessToken) - respondJSON(w, http.StatusCreated, created) -} - -// ── Update ────────────────────────────────────────────────────────── - -func (s *Server) updateStaticSite(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - existing, err := s.store.GetStaticSiteByID(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "static site") - return - } - respondError(w, http.StatusInternalServerError, "failed to get static site") - return - } - - var req createStaticSiteRequest - if !decodeJSON(w, r, &req) { - return - } - - // Update fields. - if req.Name != "" { - existing.Name = req.Name - } - if req.Provider != "" { - existing.Provider = req.Provider - } - if req.GiteaURL != "" { - existing.GiteaURL = strings.TrimRight(req.GiteaURL, "/") - } - if req.RepoOwner != "" { - existing.RepoOwner = req.RepoOwner - } - if req.RepoName != "" { - existing.RepoName = req.RepoName - } - if req.Branch != "" { - existing.Branch = req.Branch - } - if req.FolderPath != "" { - existing.FolderPath = req.FolderPath - } - if req.Domain != "" { - existing.Domain = req.Domain - } - if req.Mode != "" { - existing.Mode = req.Mode - } - if req.SyncTrigger != "" { - existing.SyncTrigger = req.SyncTrigger - } - existing.RenderMarkdown = req.RenderMarkdown - existing.TagPattern = req.TagPattern - existing.StorageEnabled = req.StorageEnabled - existing.StorageLimitMB = req.StorageLimitMB - if req.NotificationURL != nil { - existing.NotificationURL = *req.NotificationURL - } - - // Update access token only if a new one is provided. - if req.AccessToken != "" { - encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to encrypt access token") - return - } - existing.AccessToken = encrypted - } - - if err := s.store.UpdateStaticSite(existing); err != nil { - respondError(w, http.StatusInternalServerError, "failed to update static site: "+err.Error()) - return - } - - existing.AccessToken = maskToken(existing.AccessToken) - respondJSON(w, http.StatusOK, existing) -} - -// ── Delete ────────────────────────────────────────────────────────── - -func (s *Server) deleteStaticSite(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - // Remove container and proxy route first. - if s.staticSiteManager != nil { - if err := s.staticSiteManager.Remove(r.Context(), id); err != nil { - // Log but don't fail — still delete the DB record. - respondError(w, http.StatusInternalServerError, "failed to remove site resources: "+err.Error()) - return - } - } - - if err := s.store.DeleteStaticSite(id); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "static site") - return - } - respondError(w, http.StatusInternalServerError, "failed to delete static site") - return - } - - respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) -} - -// ── Deploy ────────────────────────────────────────────────────────── - -func (s *Server) deployStaticSite(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - if s.staticSiteManager == nil { - respondError(w, http.StatusInternalServerError, "static site manager not initialized") - return - } - - // Trigger deploy asynchronously with a detached context - // (the HTTP request context is canceled when the response is sent). - // Manual deploys always force a full rebuild + proxy regeneration. - go func() { - ctx := context.Background() - if err := s.staticSiteManager.Deploy(ctx, id, true); err != nil { - // Error is already stored in the site status. - return - } - }() - - respondJSON(w, http.StatusAccepted, map[string]string{"status": "deploying"}) -} - -// ── Stop / Start ──────────────────────────────────────────────────── - -func (s *Server) stopStaticSite(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - if s.staticSiteManager == nil { - respondError(w, http.StatusInternalServerError, "static site manager not initialized") - return - } - - go func() { - ctx := context.Background() - _ = s.staticSiteManager.Stop(ctx, id) - }() - - respondJSON(w, http.StatusAccepted, map[string]string{"status": "stopping"}) -} - -func (s *Server) startStaticSite(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - if s.staticSiteManager == nil { - respondError(w, http.StatusInternalServerError, "static site manager not initialized") - return - } - - go func() { - ctx := context.Background() - _ = s.staticSiteManager.Start(ctx, id) - }() - - respondJSON(w, http.StatusAccepted, map[string]string{"status": "starting"}) -} - -// ── Test Connection ───────────────────────────────────────────────── - -type testConnectionRequest struct { - Provider string `json:"provider"` - GiteaURL string `json:"gitea_url"` - AccessToken string `json:"access_token"` - RepoOwner string `json:"repo_owner"` - RepoName string `json:"repo_name"` -} - -func (s *Server) testStaticSiteConnection(w http.ResponseWriter, r *http.Request) { - var req testConnectionRequest - if !decodeJSON(w, r, &req) { - return - } - - if req.GiteaURL == "" || req.RepoOwner == "" || req.RepoName == "" { - respondError(w, http.StatusBadRequest, "gitea_url, repo_owner, and repo_name are required") - return - } - - // Encrypt token for the manager to decrypt (consistent handling). - encToken := "" - if req.AccessToken != "" { - encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to process token") - return - } - encToken = encrypted - } - - if s.staticSiteManager == nil { - respondError(w, http.StatusInternalServerError, "static site manager not initialized") - return - } - - if err := s.staticSiteManager.TestConnection(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName); err != nil { - respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) - return - } - - respondJSON(w, http.StatusOK, map[string]string{"status": "connected"}) -} - -// ── Branches ──────────────────────────────────────────────────────── - -type listBranchesRequest struct { - Provider string `json:"provider"` - GiteaURL string `json:"gitea_url"` - AccessToken string `json:"access_token"` - RepoOwner string `json:"repo_owner"` - RepoName string `json:"repo_name"` -} - -func (s *Server) listStaticSiteBranches(w http.ResponseWriter, r *http.Request) { - var req listBranchesRequest - if !decodeJSON(w, r, &req) { - return - } - - encToken := "" - if req.AccessToken != "" { - encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to process token") - return - } - encToken = encrypted - } - - if s.staticSiteManager == nil { - respondError(w, http.StatusInternalServerError, "static site manager not initialized") - return - } - - branches, err := s.staticSiteManager.ListBranches(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName) - if err != nil { - respondError(w, http.StatusBadRequest, "failed to list branches: "+err.Error()) - return - } - - respondJSON(w, http.StatusOK, branches) -} - -// ── Tree ──────────────────────────────────────────────────────────── - -type listTreeRequest struct { - Provider string `json:"provider"` - GiteaURL string `json:"gitea_url"` - AccessToken string `json:"access_token"` - RepoOwner string `json:"repo_owner"` - RepoName string `json:"repo_name"` - Branch string `json:"branch"` -} - -func (s *Server) listStaticSiteTree(w http.ResponseWriter, r *http.Request) { - var req listTreeRequest - if !decodeJSON(w, r, &req) { - return - } - - encToken := "" - if req.AccessToken != "" { - encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to process token") - return - } - encToken = encrypted - } - - if s.staticSiteManager == nil { - respondError(w, http.StatusInternalServerError, "static site manager not initialized") - return - } - - entries, err := s.staticSiteManager.ListTree(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName, req.Branch) - if err != nil { - respondError(w, http.StatusBadRequest, "failed to list tree: "+err.Error()) - return - } - - respondJSON(w, http.StatusOK, entries) -} - -// ── Secrets ───────────────────────────────────────────────────────── - -func (s *Server) listStaticSiteSecrets(w http.ResponseWriter, r *http.Request) { - siteID := chi.URLParam(r, "id") - secrets, err := s.store.GetStaticSiteSecretsBySiteID(siteID) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to list secrets") - return - } - - // Mask encrypted values. - for i := range secrets { - if secrets[i].Encrypted { - secrets[i].Value = "••••••••" - } - } - - respondJSON(w, http.StatusOK, secrets) -} - -type createSecretRequest struct { - Key string `json:"key"` - Value string `json:"value"` - Encrypted bool `json:"encrypted"` -} - -func (s *Server) createStaticSiteSecret(w http.ResponseWriter, r *http.Request) { - siteID := chi.URLParam(r, "id") - - var req createSecretRequest - if !decodeJSON(w, r, &req) { - return - } - - if req.Key == "" { - respondError(w, http.StatusBadRequest, "key is required") - return - } - - value := req.Value - if req.Encrypted && value != "" { - encrypted, err := crypto.Encrypt(s.encKey, value) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to encrypt secret") - return - } - value = encrypted - } - - secret := store.StaticSiteSecret{ - SiteID: siteID, - Key: req.Key, - Value: value, - Encrypted: req.Encrypted, - } - - created, err := s.store.CreateStaticSiteSecret(secret) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to create secret: "+err.Error()) - return - } - - if created.Encrypted { - created.Value = "••••••••" - } - respondJSON(w, http.StatusCreated, created) -} - -func (s *Server) updateStaticSiteSecret(w http.ResponseWriter, r *http.Request) { - secretID := chi.URLParam(r, "sid") - - existing, err := s.store.GetStaticSiteSecretByID(secretID) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "secret") - return - } - respondError(w, http.StatusInternalServerError, "failed to get secret") - return - } - - var req createSecretRequest - if !decodeJSON(w, r, &req) { - return - } - - if req.Key != "" { - existing.Key = req.Key - } - existing.Encrypted = req.Encrypted - - if req.Value != "" { - value := req.Value - if req.Encrypted { - encrypted, err := crypto.Encrypt(s.encKey, value) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to encrypt secret") - return - } - value = encrypted - } - existing.Value = value - } - - if err := s.store.UpdateStaticSiteSecret(existing); err != nil { - respondError(w, http.StatusInternalServerError, "failed to update secret: "+err.Error()) - return - } - - if existing.Encrypted { - existing.Value = "••••••••" - } - respondJSON(w, http.StatusOK, existing) -} - -func (s *Server) deleteStaticSiteSecret(w http.ResponseWriter, r *http.Request) { - secretID := chi.URLParam(r, "sid") - - if err := s.store.DeleteStaticSiteSecret(secretID); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "secret") - return - } - respondError(w, http.StatusInternalServerError, "failed to delete secret") - return - } - - respondJSON(w, http.StatusOK, map[string]string{"deleted": secretID}) -} - -// ── List Repos ────────────────────────────────────────────────────── - -type listReposRequest struct { - Provider string `json:"provider"` - GiteaURL string `json:"gitea_url"` - AccessToken string `json:"access_token"` - Query string `json:"query"` -} - -func (s *Server) listStaticSiteRepos(w http.ResponseWriter, r *http.Request) { - var req listReposRequest - if !decodeJSON(w, r, &req) { - return - } - - if req.GiteaURL == "" { - respondError(w, http.StatusBadRequest, "gitea_url is required") - return - } - - encToken := "" - if req.AccessToken != "" { - encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to process token") - return - } - encToken = encrypted - } - - if s.staticSiteManager == nil { - respondError(w, http.StatusInternalServerError, "static site manager not initialized") - return - } - - repos, err := s.staticSiteManager.ListRepos(r.Context(), req.Provider, req.GiteaURL, encToken, req.Query) - if err != nil { - respondError(w, http.StatusBadRequest, "failed to list repos: "+err.Error()) - return - } - - respondJSON(w, http.StatusOK, repos) -} - -// ── Detect Provider ───────────────────────────────────────────────── - -type detectProviderRequest struct { - URL string `json:"url"` -} - -func (s *Server) detectStaticSiteProvider(w http.ResponseWriter, r *http.Request) { - var req detectProviderRequest - if !decodeJSON(w, r, &req) { - return - } - - if req.URL == "" { - respondError(w, http.StatusBadRequest, "url is required") - return - } - - if s.staticSiteManager == nil { - respondError(w, http.StatusInternalServerError, "static site manager not initialized") - return - } - - provider := s.staticSiteManager.DetectProvider(r.Context(), req.URL) - respondJSON(w, http.StatusOK, map[string]string{"provider": provider}) -} - -// ── Storage Usage ────────────────────────────────────────────────── - -func (s *Server) getStaticSiteStorage(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - site, err := s.store.GetStaticSiteByID(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "static site") - return - } - respondError(w, http.StatusInternalServerError, "failed to get static site") - return - } - - if !site.StorageEnabled { - respondJSON(w, http.StatusOK, map[string]interface{}{ - "enabled": false, - "used_bytes": 0, - "limit_mb": 0, - }) - return - } - - usage, err := s.docker.InspectSiteStorageUsage(r.Context(), site.ContainerID) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to inspect storage usage") - return - } - - respondJSON(w, http.StatusOK, map[string]interface{}{ - "enabled": true, - "used_bytes": usage.UsedBytes, - "limit_mb": site.StorageLimitMB, - }) -} - -// maskToken returns a masked version of a token string for API responses. -func maskToken(token string) string { - if token == "" { - return "" - } - return "••••••••" -} diff --git a/internal/api/stats_history.go b/internal/api/stats_history.go index 60418d7..14f68ee 100644 --- a/internal/api/stats_history.go +++ b/internal/api/stats_history.go @@ -1,28 +1,23 @@ 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/stats" "github.com/alexei/tinyforge/internal/store" ) -// topConsumerWindow is how recent a container sample must be to count toward +// topConsumerMinWindow is how recent a container sample must be to count toward // the "top consumers" list. Scaled with the collector interval (read from // settings) so it stays meaningful even when sampling is sparse. const topConsumerMinWindow = 2 * time.Minute // TopContainerSample augments a stats sample with the human-readable owner -// name so the UI can show "project/stage" or the static-site name without an -// extra round-trip per row. +// name so the UI can show "workload/role" without an extra round-trip per row. type TopContainerSample struct { store.ContainerStatsSample OwnerName string `json:"owner_name"` @@ -90,107 +85,6 @@ func (s *Server) getSystemStatsHistory(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, samples) } -// getInstanceStatsHistory handles GET /api/projects/{id}/stages/{stage}/instances/{iid}/stats/history. -// {iid} is the container row ID (same UUID as the legacy instance ID). -func (s *Server) getInstanceStatsHistory(w http.ResponseWriter, r *http.Request) { - instanceID := chi.URLParam(r, "iid") - if _, err := s.store.GetContainerByID(instanceID); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "container") - return - } - slog.Error("failed to get container", "id", instanceID, "error", err) - respondError(w, http.StatusInternalServerError, "failed to get container") - return - } - samples, err := s.store.ListContainerStatsSamples(stats.OwnerTypeInstance, instanceID, sinceTimestamp(parseWindow(r))) - if err != nil { - slog.Error("failed to list instance stats samples", "instance_id", instanceID, "error", err) - respondError(w, http.StatusInternalServerError, "failed to list samples") - return - } - if samples == nil { - samples = []store.ContainerStatsSample{} - } - respondJSON(w, http.StatusOK, samples) -} - -// getStaticSiteStats handles GET /api/sites/{id}/stats — current snapshot. -func (s *Server) getStaticSiteStats(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - site, err := s.store.GetStaticSiteByID(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "site") - return - } - slog.Error("failed to get site", "site_id", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to get site") - return - } - if site.ContainerID == "" { - respondError(w, http.StatusConflict, "site has no container") - return - } - if s.docker == nil { - respondError(w, http.StatusServiceUnavailable, "Docker is not available") - return - } - cs, err := s.docker.GetContainerStats(r.Context(), site.ContainerID) - if err != nil { - slog.Error("failed to get site stats", "site_id", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to get site stats") - return - } - respondJSON(w, http.StatusOK, cs) -} - -// getStaticSiteStatsHistory handles GET /api/sites/{id}/stats/history. -func (s *Server) getStaticSiteStatsHistory(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if _, err := s.store.GetStaticSiteByID(id); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "site") - return - } - slog.Error("failed to get site", "site_id", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to get site") - return - } - samples, err := s.store.ListContainerStatsSamples(stats.OwnerTypeSite, id, sinceTimestamp(parseWindow(r))) - if err != nil { - slog.Error("failed to list site stats samples", "site_id", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to list samples") - return - } - if samples == nil { - samples = []store.ContainerStatsSample{} - } - respondJSON(w, http.StatusOK, samples) -} - -// streamStaticSiteLogs handles GET /api/sites/{id}/logs?tail=200&follow=true. -// Reuses the shared container log streamer so the SSE + multiplex handling -// matches /api/projects/.../instances/.../logs exactly. -func (s *Server) streamStaticSiteLogs(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - site, err := s.store.GetStaticSiteByID(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "site") - return - } - slog.Error("failed to get site", "site_id", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to get site") - return - } - if site.ContainerID == "" { - respondError(w, http.StatusConflict, "site has no container") - return - } - s.streamLogsForContainer(w, r, site.ContainerID) -} - // 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 @@ -246,8 +140,6 @@ func (s *Server) listTopContainers(w http.ResponseWriter, r *http.Request) { top = top[:limit] } - // Resolve owner names so the UI can show "project/stage" or the site name - // without a per-row round trip. enriched := s.enrichWithOwnerNames(top) // Scrub container IDs for non-admins. The owner name is the actionable @@ -264,18 +156,13 @@ func (s *Server) listTopContainers(w http.ResponseWriter, r *http.Request) { } // enrichWithOwnerNames attaches a human-readable owner name to each sample. -// Looks up instances and sites in batch so the cost is independent of the -// number of samples (which is at most 'limit'). +// Names are resolved through the containers index → workloads, which after +// the cutover is the only available lookup path. func (s *Server) enrichWithOwnerNames(samples []store.ContainerStatsSample) []TopContainerSample { out := make([]TopContainerSample, len(samples)) for i, sm := range samples { out[i] = TopContainerSample{ContainerStatsSample: sm} - switch sm.OwnerType { - case stats.OwnerTypeInstance: - out[i].OwnerName = s.lookupInstanceName(sm.OwnerID) - case stats.OwnerTypeSite: - out[i].OwnerName = s.lookupSiteName(sm.OwnerID) - } + out[i].OwnerName = s.lookupInstanceName(sm.OwnerID) } return out } @@ -300,11 +187,3 @@ func (s *Server) lookupInstanceName(instanceID string) string { return w.Name } -// lookupSiteName returns the site's display name or empty on lookup error. -func (s *Server) lookupSiteName(siteID string) string { - site, err := s.store.GetStaticSiteByID(siteID) - if err != nil { - return "" - } - return site.Name -} diff --git a/internal/api/volume_browser.go b/internal/api/volume_browser.go deleted file mode 100644 index 17f48ab..0000000 --- a/internal/api/volume_browser.go +++ /dev/null @@ -1,209 +0,0 @@ -package api - -import ( - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "path/filepath" - "strings" - - "github.com/go-chi/chi/v5" - - "github.com/alexei/tinyforge/internal/store" - "github.com/alexei/tinyforge/internal/volume" -) - -// sanitizeFilename removes characters unsafe for Content-Disposition headers. -func sanitizeFilename(name string) string { - return strings.Map(func(r rune) rune { - if r == '"' || r == '\\' || r == '\n' || r == '\r' { - return '_' - } - return r - }, name) -} - -const maxUploadSize = 100 * 1024 * 1024 // 100MB - -// resolveVolumeRoot looks up a volume and resolves its host path. -func (s *Server) resolveVolumeRoot(w http.ResponseWriter, r *http.Request) (string, bool) { - projectID := chi.URLParam(r, "id") - volID := chi.URLParam(r, "volId") - - proj, err := s.store.GetProjectByID(projectID) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return "", false - } - slog.Error("failed to get project", "project_id", projectID, "error", err) - respondError(w, http.StatusInternalServerError, "failed to get project") - return "", false - } - - vol, err := s.store.GetVolumeByID(volID) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "volume") - return "", false - } - slog.Error("failed to get volume", "volume_id", volID, "error", err) - respondError(w, http.StatusInternalServerError, "failed to get volume") - return "", false - } - - // Verify volume belongs to this project. - if vol.ProjectID != projectID { - respondNotFound(w, "volume") - return "", false - } - - if vol.Scope == "ephemeral" { - respondError(w, http.StatusBadRequest, "ephemeral volumes have no host path to browse") - return "", false - } - - settings, err := s.store.GetSettings() - if err != nil { - slog.Error("failed to get settings", "error", err) - respondError(w, http.StatusInternalServerError, "failed to get settings") - return "", false - } - - q := r.URL.Query() - params := volume.ResolveParams{ - BasePath: settings.BaseVolumePath, - ProjectName: proj.Name, - StageName: q.Get("stage"), - ImageTag: q.Get("tag"), - AllowedVolumePaths: settings.AllowedVolumePaths, - } - - rootPath, err := volume.ResolvePath(vol, params) - if err != nil { - respondError(w, http.StatusBadRequest, err.Error()) - return "", false - } - - return rootPath, true -} - -// browseVolume handles GET /api/projects/{id}/volumes/{volId}/browse?path=&stage=&tag= -func (s *Server) browseVolume(w http.ResponseWriter, r *http.Request) { - rootPath, ok := s.resolveVolumeRoot(w, r) - if !ok { - return - } - - relPath := r.URL.Query().Get("path") - entries, err := volume.ListDir(rootPath, relPath) - if err != nil { - slog.Error("failed to list directory", "root", rootPath, "path", relPath, "error", err) - respondError(w, http.StatusInternalServerError, "failed to list directory") - return - } - - respondJSON(w, http.StatusOK, map[string]any{ - "path": relPath, - "entries": entries, - }) -} - -// downloadVolume handles GET /api/projects/{id}/volumes/{volId}/download?path=&stage=&tag= -// Downloads a single file directly, or a directory/root as a zip archive. -func (s *Server) downloadVolume(w http.ResponseWriter, r *http.Request) { - rootPath, ok := s.resolveVolumeRoot(w, r) - if !ok { - return - } - - relPath := r.URL.Query().Get("path") - - // If path is empty or points to a directory, serve as zip. - if relPath == "" { - s.serveZip(w, rootPath, "", "volume") - return - } - - // Check if it's a file or directory. - f, info, err := volume.OpenFile(rootPath, relPath) - if err != nil { - // Might be a directory — try zip. - entries, listErr := volume.ListDir(rootPath, relPath) - if listErr == nil && entries != nil { - name := filepath.Base(relPath) - s.serveZip(w, rootPath, relPath, name) - return - } - slog.Error("failed to open file", "root", rootPath, "path", relPath, "error", err) - respondError(w, http.StatusNotFound, "file not found") - return - } - defer f.Close() - - // Serve single file with forced download. - safeName := sanitizeFilename(filepath.Base(relPath)) - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safeName)) - w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size())) - w.WriteHeader(http.StatusOK) - io.Copy(w, f) -} - -func (s *Server) serveZip(w http.ResponseWriter, rootPath, relPath, name string) { - safeName := sanitizeFilename(name) - w.Header().Set("Content-Type", "application/zip") - w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, safeName)) - w.WriteHeader(http.StatusOK) - if err := volume.WriteZip(rootPath, relPath, w); err != nil { - slog.Error("failed to write zip", "root", rootPath, "path", relPath, "error", err) - } -} - -// uploadToVolume handles POST /api/projects/{id}/volumes/{volId}/upload?path=&stage=&tag= -// Accepts multipart form uploads. Overrides the global body limit for large files. -func (s *Server) uploadToVolume(w http.ResponseWriter, r *http.Request) { - // Override the global 1MB body limit for uploads. - r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) - - rootPath, ok := s.resolveVolumeRoot(w, r) - if !ok { - return - } - - if err := r.ParseMultipartForm(maxUploadSize); err != nil { - respondError(w, http.StatusBadRequest, "upload too large (max 100MB)") - return - } - - relPath := r.URL.Query().Get("path") - uploaded := []string{} - - for _, fileHeaders := range r.MultipartForm.File { - for _, fh := range fileHeaders { - f, err := fh.Open() - if err != nil { - slog.Error("failed to open upload", "filename", fh.Filename, "error", err) - continue - } - - // Strip directory components from filename to prevent directory creation attacks. - targetRel := filepath.Join(relPath, filepath.Base(fh.Filename)) - if err := volume.SaveFile(rootPath, targetRel, f); err != nil { - f.Close() - slog.Error("failed to save upload", "filename", fh.Filename, "error", err) - respondError(w, http.StatusInternalServerError, "failed to save file: "+fh.Filename) - return - } - f.Close() - uploaded = append(uploaded, fh.Filename) - } - } - - respondJSON(w, http.StatusOK, map[string]any{ - "uploaded": uploaded, - "count": len(uploaded), - }) -} diff --git a/internal/api/volumes.go b/internal/api/volumes.go deleted file mode 100644 index 7debf29..0000000 --- a/internal/api/volumes.go +++ /dev/null @@ -1,327 +0,0 @@ -package api - -import ( - "errors" - "log/slog" - "net/http" - "path/filepath" - "regexp" - "strings" - - "github.com/go-chi/chi/v5" - - "github.com/alexei/tinyforge/internal/store" - "github.com/alexei/tinyforge/internal/volume" -) - -// safeNamePattern restricts volume names to alphanumeric, dash, underscore, and dot. -var safeNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`) - -// validateVolumePath checks that the source path does not contain path traversal. -func validateVolumePath(source string) bool { - cleaned := filepath.Clean(source) - return !strings.Contains(cleaned, "..") -} - -// volumeRequest is the expected JSON body for creating/updating a volume. -type volumeRequest struct { - Source string `json:"source"` - Target string `json:"target"` - Mode string `json:"mode,omitempty"` // legacy — ignored if scope is set - Scope string `json:"scope"` - Name string `json:"name"` -} - -// validScopes lists all accepted scope values. -var validScopes = map[string]bool{ - "instance": true, "stage": true, "project": true, - "project_named": true, "named": true, "ephemeral": true, - "absolute": true, -} - -// validateVolumeScope validates the scope, name, and source combination. -func validateVolumeScope(scope, name, source, allowedPathsJSON string) string { - if !validScopes[scope] { - return "scope must be one of: instance, stage, project, project_named, named, ephemeral, absolute" - } - if (scope == "project_named" || scope == "named") && strings.TrimSpace(name) == "" { - return "name is required for " + scope + " scope" - } - if name != "" && !safeNamePattern.MatchString(name) { - return "name must start with a letter or digit and contain only letters, digits, dashes, underscores, or dots" - } - if scope == "absolute" { - if source == "" { - return "source path is required for absolute scope" - } - if !filepath.IsAbs(source) { - return "absolute scope requires an absolute source path" - } - // Validate against allowlist. - allowed, err := volume.ParseAllowedPaths(allowedPathsJSON) - if err != nil { - return "failed to parse allowed volume paths" - } - if len(allowed) == 0 { - return "absolute volume paths are disabled — configure allowed paths in settings first" - } - matched := false - cleanSource := filepath.Clean(source) - for _, prefix := range allowed { - cleanPrefix := filepath.Clean(prefix) - if strings.HasPrefix(cleanSource, cleanPrefix+string(filepath.Separator)) || cleanSource == cleanPrefix { - matched = true - break - } - } - if !matched { - return "source path is not under any allowed volume path" - } - } - return "" -} - -// scopeDescriptions returns metadata about each scope for the UI. -type scopeInfo struct { - Scope string `json:"scope"` - Description string `json:"description"` - NeedsName bool `json:"needs_name"` - PathExample string `json:"path_example"` -} - -// listVolumeScopes handles GET /api/volumes/scopes. -// Returns all available scopes with descriptions for UI hints. -func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) { - scopes := []scopeInfo{ - { - Scope: "instance", - Description: "Each deploy gets its own isolated directory. Data is not shared between deploys.", - NeedsName: false, - PathExample: "{base}/{project}/{stage}-{tag}/{source}", - }, - { - Scope: "stage", - Description: "All deploys within the same stage share this volume. Data persists across blue-green deployments.", - NeedsName: false, - PathExample: "{base}/{project}/{stage}/{source}", - }, - { - Scope: "project", - Description: "Shared across all stages of the project. Good for common config or shared assets.", - NeedsName: false, - PathExample: "{base}/{project}/{source}", - }, - { - Scope: "project_named", - Description: "A named volume within the project. Multiple stages can reference the same name to share data selectively.", - NeedsName: true, - PathExample: "{base}/{project}/_named/{name}/{source}", - }, - { - Scope: "named", - Description: "A globally named volume shared across projects. Use for cross-project resources like shared databases.", - NeedsName: true, - PathExample: "{base}/_named/{name}/{source}", - }, - { - Scope: "ephemeral", - Description: "In-memory tmpfs mount. Fast but data is lost when the container stops. Good for temp files and caches.", - NeedsName: false, - PathExample: "(tmpfs — no host path)", - }, - { - Scope: "absolute", - Description: "Direct host path. Must be under an allowed path configured in settings. Use for external mounts like NFS or pre-existing directories.", - NeedsName: false, - PathExample: "/mnt/nfs/data (must match allowed paths)", - }, - } - respondJSON(w, http.StatusOK, scopes) -} - -// listVolumes handles GET /api/projects/{id}/volumes. -func (s *Server) listVolumes(w http.ResponseWriter, r *http.Request) { - projectID := chi.URLParam(r, "id") - - if _, err := s.store.GetProjectByID(projectID); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - slog.Error("failed to get project", "project_id", projectID, "error", err) - respondError(w, http.StatusInternalServerError, "failed to get project") - return - } - - vols, err := s.store.GetVolumesByProjectID(projectID) - if err != nil { - slog.Error("failed to list volumes", "project_id", projectID, "error", err) - respondError(w, http.StatusInternalServerError, "failed to list volumes") - return - } - - respondJSON(w, http.StatusOK, vols) -} - -// createVolume handles POST /api/projects/{id}/volumes. -func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) { - projectID := chi.URLParam(r, "id") - - if _, err := s.store.GetProjectByID(projectID); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - slog.Error("failed to get project", "project_id", projectID, "error", err) - respondError(w, http.StatusInternalServerError, "failed to get project") - return - } - - var req volumeRequest - if !decodeJSON(w, r, &req) { - return - } - - // Ephemeral volumes don't need a source path. - if req.Scope != "ephemeral" { - if req.Source == "" { - respondError(w, http.StatusBadRequest, "source is required") - return - } - if !validateVolumePath(req.Source) { - respondError(w, http.StatusBadRequest, "source path must not contain '..'") - return - } - } - if req.Target == "" { - respondError(w, http.StatusBadRequest, "target is required") - return - } - if !validateVolumePath(req.Target) { - respondError(w, http.StatusBadRequest, "target path must not contain '..'") - return - } - - // Resolve scope — support legacy mode field. - scope := req.Scope - if scope == "" { - switch req.Mode { - case "isolated": - scope = "instance" - case "shared": - scope = "project" - default: - scope = "project" - } - } - - // Fetch settings for absolute path allowlist validation. - settings, err := s.store.GetSettings() - if err != nil { - slog.Error("failed to get settings for volume validation", "error", err) - respondError(w, http.StatusInternalServerError, "failed to validate volume") - return - } - - if errMsg := validateVolumeScope(scope, req.Name, req.Source, settings.AllowedVolumePaths); errMsg != "" { - respondError(w, http.StatusBadRequest, errMsg) - return - } - - vol, err := s.store.CreateVolume(store.Volume{ - ProjectID: projectID, - Source: req.Source, - Target: req.Target, - Scope: scope, - Name: strings.TrimSpace(req.Name), - }) - if err != nil { - slog.Error("failed to create volume", "project_id", projectID, "error", err) - respondError(w, http.StatusInternalServerError, "failed to create volume") - return - } - respondJSON(w, http.StatusCreated, vol) -} - -// updateVolume handles PUT /api/projects/{id}/volumes/{volId}. -func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) { - volID := chi.URLParam(r, "volId") - - existing, err := s.store.GetVolumeByID(volID) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "volume") - return - } - slog.Error("failed to get volume", "volume_id", volID, "error", err) - respondError(w, http.StatusInternalServerError, "failed to get volume") - return - } - - var req volumeRequest - if !decodeJSON(w, r, &req) { - return - } - - updated := existing - if req.Source != "" { - if !validateVolumePath(req.Source) { - respondError(w, http.StatusBadRequest, "source path must not contain '..'") - return - } - updated.Source = req.Source - } - if req.Target != "" { - if !validateVolumePath(req.Target) { - respondError(w, http.StatusBadRequest, "target path must not contain '..'") - return - } - updated.Target = req.Target - } - if req.Scope != "" { - settings, err := s.store.GetSettings() - if err != nil { - slog.Error("failed to get settings for volume validation", "error", err) - respondError(w, http.StatusInternalServerError, "failed to validate volume") - return - } - source := updated.Source - if req.Source != "" { - source = req.Source - } - if errMsg := validateVolumeScope(req.Scope, req.Name, source, settings.AllowedVolumePaths); errMsg != "" { - respondError(w, http.StatusBadRequest, errMsg) - return - } - updated.Scope = req.Scope - updated.Name = strings.TrimSpace(req.Name) - } - - // Non-ephemeral scopes require a source path. - if updated.Scope != "ephemeral" && updated.Source == "" { - respondError(w, http.StatusBadRequest, "source is required for non-ephemeral scopes") - return - } - - if err := s.store.UpdateVolume(updated); err != nil { - slog.Error("failed to update volume", "volume_id", volID, "error", err) - respondError(w, http.StatusInternalServerError, "failed to update volume") - return - } - respondJSON(w, http.StatusOK, updated) -} - -// deleteVolume handles DELETE /api/projects/{id}/volumes/{volId}. -func (s *Server) deleteVolume(w http.ResponseWriter, r *http.Request) { - volID := chi.URLParam(r, "volId") - if err := s.store.DeleteVolume(volID); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "volume") - return - } - slog.Error("failed to delete volume", "volume_id", volID, "error", err) - respondError(w, http.StatusInternalServerError, "failed to delete volume") - return - } - respondJSON(w, http.StatusOK, map[string]string{"deleted": volID}) -} diff --git a/internal/api/webhooks.go b/internal/api/webhooks.go deleted file mode 100644 index e45287f..0000000 --- a/internal/api/webhooks.go +++ /dev/null @@ -1,370 +0,0 @@ -package api - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "log/slog" - "net/http" - "strconv" - - "github.com/go-chi/chi/v5" - - "github.com/alexei/tinyforge/internal/store" -) - -// generateWebhookSecret returns a 256-bit hex-encoded random token. Mirrors -// the helper in internal/store; kept here to avoid an import cycle and so the -// rotation handlers don't pretend to use uuid for what is really a secret. -func generateWebhookSecret() string { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - panic("crypto/rand failed: " + err.Error()) - } - return hex.EncodeToString(b) -} - -// webhookURLResponse is the common payload returned by every webhook endpoint. -// Clients never see raw secrets except at issue/rotate time via these fields; -// the URL shape is "/api/webhook/..." so callers can prepend their own origin. -type webhookURLResponse struct { - WebhookURL string `json:"webhook_url"` - WebhookSecret string `json:"webhook_secret"` - HasSigningSecret bool `json:"has_signing_secret"` - WebhookRequireSignature bool `json:"webhook_require_signature"` -} - -// signingSecretResponse is returned when a signing secret is issued or rotated. -type signingSecretResponse struct { - SigningSecret string `json:"signing_secret"` -} - -// signingToggleRequest is the body of the require-signature toggle endpoint. -type signingToggleRequest struct { - RequireSignature bool `json:"require_signature"` -} - -// getProjectWebhook handles GET /api/projects/{id}/webhook. -// Returns the project's webhook URL + secret, generating one lazily if the -// project predates the per-project webhook migration. -func (s *Server) getProjectWebhook(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - secret, err := s.store.EnsureProjectWebhookSecret(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - slog.Error("get project webhook: ensure secret", "project", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to get webhook secret") - return - } - - project, err := s.store.GetProjectByID(id) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to get project") - return - } - - respondJSON(w, http.StatusOK, webhookURLResponse{ - WebhookURL: "/api/webhook/" + secret, - WebhookSecret: secret, - HasSigningSecret: project.WebhookSigningSecret != "", - WebhookRequireSignature: project.WebhookRequireSignature, - }) -} - -// regenerateProjectSigningSecret handles POST /api/projects/{id}/webhook/signing-secret/regenerate. -// Issues a fresh HMAC signing secret for inbound webhook verification. The -// secret is returned exactly once — the UI is responsible for letting the -// user copy it into their CI configuration. -func (s *Server) regenerateProjectSigningSecret(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if _, err := s.store.GetProjectByID(id); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - respondError(w, http.StatusInternalServerError, "failed to get project") - return - } - secret := generateWebhookSecret() - if err := s.store.SetProjectWebhookSigningSecret(id, secret); err != nil { - slog.Error("rotate project signing secret", "project", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to rotate signing secret") - return - } - slog.Info("project webhook signing secret rotated", "project", id) - respondJSON(w, http.StatusOK, signingSecretResponse{SigningSecret: secret}) -} - -// disableProjectSigningSecret handles DELETE /api/projects/{id}/webhook/signing-secret. -// Clears the HMAC signing secret and disables enforcement. -func (s *Server) disableProjectSigningSecret(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if err := s.store.SetProjectWebhookSigningSecret(id, ""); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - respondError(w, http.StatusInternalServerError, "failed to clear signing secret") - return - } - if err := s.store.SetProjectWebhookRequireSignature(id, false); err != nil { - slog.Warn("disable project require_signature", "project", id, "error", err) - } - respondJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -// updateProjectSigningRequirement handles PUT /api/projects/{id}/webhook/require-signature. -// Toggles whether unsigned/invalidly-signed inbound webhook requests are -// rejected with 401. -func (s *Server) updateProjectSigningRequirement(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - var req signingToggleRequest - if !decodeJSON(w, r, &req) { - return - } - if req.RequireSignature { - project, err := s.store.GetProjectByID(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - respondError(w, http.StatusInternalServerError, "failed to get project") - return - } - if project.WebhookSigningSecret == "" { - respondError(w, http.StatusBadRequest, "issue a signing secret before enabling enforcement") - return - } - } - if err := s.store.SetProjectWebhookRequireSignature(id, req.RequireSignature); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - respondError(w, http.StatusInternalServerError, "failed to update setting") - return - } - respondJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -// regenerateProjectWebhook handles POST /api/projects/{id}/webhook/regenerate. -// Rotates the project's webhook secret, invalidating the old URL. -func (s *Server) regenerateProjectWebhook(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - // Verify project exists before rotating. - if _, err := s.store.GetProjectByID(id); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - slog.Error("regenerate project webhook: lookup", "project", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to get project") - return - } - - secret := generateWebhookSecret() - if err := s.store.SetProjectWebhookSecret(id, secret); err != nil { - slog.Error("regenerate project webhook: set secret", "project", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret") - return - } - - slog.Info("project webhook secret rotated", "project", id) - respondJSON(w, http.StatusOK, webhookURLResponse{ - WebhookURL: "/api/webhook/" + secret, - WebhookSecret: secret, - }) -} - -// getStaticSiteWebhook handles GET /api/sites/{id}/webhook. -func (s *Server) getStaticSiteWebhook(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - secret, err := s.store.EnsureStaticSiteWebhookSecret(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "static site") - return - } - slog.Error("get site webhook: ensure secret", "site", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to get webhook secret") - return - } - - site, err := s.store.GetStaticSiteByID(id) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to get static site") - return - } - - respondJSON(w, http.StatusOK, webhookURLResponse{ - WebhookURL: "/api/webhook/sites/" + secret, - WebhookSecret: secret, - HasSigningSecret: site.WebhookSigningSecret != "", - WebhookRequireSignature: site.WebhookRequireSignature, - }) -} - -// regenerateStaticSiteSigningSecret handles POST /api/sites/{id}/webhook/signing-secret/regenerate. -func (s *Server) regenerateStaticSiteSigningSecret(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if _, err := s.store.GetStaticSiteByID(id); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "static site") - return - } - respondError(w, http.StatusInternalServerError, "failed to get static site") - return - } - secret := generateWebhookSecret() - if err := s.store.SetStaticSiteWebhookSigningSecret(id, secret); err != nil { - slog.Error("rotate site signing secret", "site", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to rotate signing secret") - return - } - slog.Info("static site webhook signing secret rotated", "site", id) - respondJSON(w, http.StatusOK, signingSecretResponse{SigningSecret: secret}) -} - -// disableStaticSiteSigningSecret handles DELETE /api/sites/{id}/webhook/signing-secret. -func (s *Server) disableStaticSiteSigningSecret(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if err := s.store.SetStaticSiteWebhookSigningSecret(id, ""); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "static site") - return - } - respondError(w, http.StatusInternalServerError, "failed to clear signing secret") - return - } - if err := s.store.SetStaticSiteWebhookRequireSignature(id, false); err != nil { - slog.Warn("disable site require_signature", "site", id, "error", err) - } - respondJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -// listProjectWebhookDeliveries handles GET /api/projects/{id}/webhook/deliveries. -// Returns the most recent webhook deliveries for the project so users can -// debug "why didn't my deploy fire?" without grepping daemon logs. -func (s *Server) listProjectWebhookDeliveries(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if _, err := s.store.GetProjectByID(id); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "project") - return - } - respondError(w, http.StatusInternalServerError, "failed to get project") - return - } - limit := parseLimit(r.URL.Query().Get("limit"), 50, 200) - deliveries, err := s.store.ListWebhookDeliveriesByTarget("project", id, limit) - if err != nil { - slog.Error("list project webhook deliveries", "project", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to list deliveries") - return - } - respondJSON(w, http.StatusOK, deliveries) -} - -// listStaticSiteWebhookDeliveries handles GET /api/sites/{id}/webhook/deliveries. -func (s *Server) listStaticSiteWebhookDeliveries(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if _, err := s.store.GetStaticSiteByID(id); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "static site") - return - } - respondError(w, http.StatusInternalServerError, "failed to get static site") - return - } - limit := parseLimit(r.URL.Query().Get("limit"), 50, 200) - deliveries, err := s.store.ListWebhookDeliveriesByTarget("site", id, limit) - if err != nil { - slog.Error("list site webhook deliveries", "site", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to list deliveries") - return - } - respondJSON(w, http.StatusOK, deliveries) -} - -// parseLimit clamps a query-string limit to [1, max], falling back to def. -func parseLimit(raw string, def, max int) int { - if raw == "" { - return def - } - n, err := strconv.Atoi(raw) - if err != nil || n <= 0 { - return def - } - if n > max { - return max - } - return n -} - -// updateStaticSiteSigningRequirement handles PUT /api/sites/{id}/webhook/require-signature. -func (s *Server) updateStaticSiteSigningRequirement(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - var req signingToggleRequest - if !decodeJSON(w, r, &req) { - return - } - if req.RequireSignature { - site, err := s.store.GetStaticSiteByID(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "static site") - return - } - respondError(w, http.StatusInternalServerError, "failed to get static site") - return - } - if site.WebhookSigningSecret == "" { - respondError(w, http.StatusBadRequest, "issue a signing secret before enabling enforcement") - return - } - } - if err := s.store.SetStaticSiteWebhookRequireSignature(id, req.RequireSignature); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "static site") - return - } - respondError(w, http.StatusInternalServerError, "failed to update setting") - return - } - respondJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -// regenerateStaticSiteWebhook handles POST /api/sites/{id}/webhook/regenerate. -func (s *Server) regenerateStaticSiteWebhook(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - - if _, err := s.store.GetStaticSiteByID(id); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "static site") - return - } - slog.Error("regenerate site webhook: lookup", "site", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to get static site") - return - } - - secret := generateWebhookSecret() - if err := s.store.SetStaticSiteWebhookSecret(id, secret); err != nil { - slog.Error("regenerate site webhook: set secret", "site", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret") - return - } - - slog.Info("static site webhook secret rotated", "site", id) - respondJSON(w, http.StatusOK, webhookURLResponse{ - WebhookURL: "/api/webhook/sites/" + secret, - WebhookSecret: secret, - }) -} diff --git a/internal/api/workload_env.go b/internal/api/workload_env.go index ef6ed67..bec4a0e 100644 --- a/internal/api/workload_env.go +++ b/internal/api/workload_env.go @@ -136,61 +136,12 @@ func (s *Server) deleteWorkloadEnv(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]string{"deleted": envID}) } -// getWorkloadWebhook handles GET /api/workloads/{id}/webhook. Returns -// the canonical URL + secret + signature-state flags. Lazily generates -// a secret if the workload row predates the column. -func (s *Server) getWorkloadWebhook(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - secret, err := s.store.EnsureWorkloadWebhookSecret(id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "workload") - return - } - slog.Error("ensure workload webhook secret", "workload", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to get webhook secret") - return - } - row, err := s.store.GetWorkloadByID(id) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to get workload") - return - } - respondJSON(w, http.StatusOK, webhookURLResponse{ - WebhookURL: "/api/webhook/workloads/" + secret, - WebhookSecret: secret, - HasSigningSecret: row.WebhookSigningSecret != "", - WebhookRequireSignature: row.WebhookRequireSignature, - }) -} - -// regenerateWorkloadWebhook handles POST /api/workloads/{id}/webhook/regenerate. -// Rotates the URL secret. The old secret is invalidated immediately — -// any external system still hitting the old URL gets a 404 on next call. -func (s *Server) regenerateWorkloadWebhook(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if _, err := s.store.GetWorkloadByID(id); err != nil { - if errors.Is(err, store.ErrNotFound) { - respondNotFound(w, "workload") - return - } - respondError(w, http.StatusInternalServerError, "failed to get workload") - return - } - secret := generateWebhookSecret() - if err := s.store.SetWorkloadWebhookSecret(id, secret); err != nil { - slog.Error("rotate workload webhook secret", "workload", id, "error", err) - respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret") - return - } - row, _ := s.store.GetWorkloadByID(id) - respondJSON(w, http.StatusOK, webhookURLResponse{ - WebhookURL: "/api/webhook/workloads/" + secret, - WebhookSecret: secret, - HasSigningSecret: row.WebhookSigningSecret != "", - WebhookRequireSignature: row.WebhookRequireSignature, - }) -} +// Workload-level webhook URL handlers were dropped in the hard legacy +// cutover: the old `/api/webhook/workloads/{secret}` route is gone, so +// minting a workload secret would hand operators a URL that 404s. The +// inbound webhook surface is now exclusively first-class triggers +// (`/api/webhook/triggers/{secret}`); use the trigger CRUD + bindings +// endpoints to wire a workload to inbound deploys. // validEnvKey accepts POSIX-style env names. Rejects anything that would // confuse Docker's env parser (=, spaces, control chars). diff --git a/internal/api/workload_volumes.go b/internal/api/workload_volumes.go index efb8b7a..7782d4e 100644 --- a/internal/api/workload_volumes.go +++ b/internal/api/workload_volumes.go @@ -20,6 +20,66 @@ type workloadVolumeRequest struct { Name string `json:"name"` } +// scopeInfo carries one volume scope plus its operator-facing description. +// The UI uses NeedsName to decide whether to show the name input. +type scopeInfo struct { + Scope string `json:"scope"` + Description string `json:"description"` + NeedsName bool `json:"needs_name"` + PathExample string `json:"path_example"` +} + +// listVolumeScopes handles GET /api/volumes/scopes. Returns the catalogue +// of supported volume scopes so the workload-volume editor can render +// scope-specific help text without baking the list into the frontend. +func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) { + scopes := []scopeInfo{ + { + Scope: "instance", + Description: "Each deploy gets its own isolated directory keyed by image tag.", + NeedsName: false, + PathExample: "{base}/{workload}/instance-{tag}/{source}", + }, + { + Scope: "stage", + Description: "Shared across all instances of this workload (alias of project scope).", + NeedsName: false, + PathExample: "{base}/{workload}/{source}", + }, + { + Scope: "project", + Description: "Shared across all instances of this workload.", + NeedsName: false, + PathExample: "{base}/{workload}/{source}", + }, + { + Scope: "project_named", + Description: "A named volume within the workload — multiple mounts can share the name.", + NeedsName: true, + PathExample: "{base}/{workload}/_named/{name}/{source}", + }, + { + Scope: "named", + Description: "Globally named volume shared across workloads (e.g. shared databases).", + NeedsName: true, + PathExample: "{base}/_named/{name}/{source}", + }, + { + Scope: "ephemeral", + Description: "In-memory tmpfs mount. Fast but data is lost when the container stops.", + NeedsName: false, + PathExample: "(tmpfs — no host path)", + }, + { + Scope: "absolute", + Description: "Direct host path. Must be under an allowed path configured in settings.", + NeedsName: false, + PathExample: "/mnt/nfs/data (must match allowed paths)", + }, + } + respondJSON(w, http.StatusOK, scopes) +} + func (s *Server) listWorkloadVolumes(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if _, err := s.store.GetWorkloadByID(id); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index e66327b..473659c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,11 +7,12 @@ import ( "gopkg.in/yaml.v3" ) -// SeedConfig represents the top-level YAML seed configuration. +// SeedConfig represents the top-level YAML seed configuration. After the +// hard cutover only global settings + registries are supported; workloads +// are created through the API. type SeedConfig struct { - Global GlobalConfig `yaml:"global"` - Registries map[string]RegistryDef `yaml:"registries"` - Projects map[string]ProjectDef `yaml:"projects"` + Global GlobalConfig `yaml:"global"` + Registries map[string]RegistryDef `yaml:"registries"` } // GlobalConfig holds domain-wide settings from the seed file. @@ -38,27 +39,6 @@ type RegistryDef struct { Token string `yaml:"token"` } -// ProjectDef defines a project from the seed file. -type ProjectDef struct { - Registry string `yaml:"registry"` - Image string `yaml:"image"` - Port int `yaml:"port"` - Healthcheck string `yaml:"healthcheck"` - Env map[string]string `yaml:"env"` - Volumes map[string]string `yaml:"volumes"` - Stages map[string]StageDef `yaml:"stages"` -} - -// StageDef defines a deployment stage from the seed file. -type StageDef struct { - TagPattern string `yaml:"tag_pattern"` - AutoDeploy bool `yaml:"auto_deploy"` - MaxInstances int `yaml:"max_instances"` - Confirm bool `yaml:"confirm"` - PromoteFrom string `yaml:"promote_from"` - Subdomain string `yaml:"subdomain"` -} - // LoadSeedFile reads and parses the YAML seed config from the given path. func LoadSeedFile(path string) (SeedConfig, error) { data, err := os.ReadFile(path) @@ -88,25 +68,5 @@ func validate(cfg SeedConfig) error { if cfg.Global.Domain == "" { return fmt.Errorf("global.domain is required") } - - for name, proj := range cfg.Projects { - if proj.Image == "" { - return fmt.Errorf("project %q: image is required", name) - } - if proj.Registry != "" { - if _, ok := cfg.Registries[proj.Registry]; !ok { - return fmt.Errorf("project %q: references unknown registry %q", name, proj.Registry) - } - } - for stageName, stage := range proj.Stages { - if stage.TagPattern == "" { - return fmt.Errorf("project %q stage %q: tag_pattern is required", name, stageName) - } - if stage.MaxInstances < 0 { - return fmt.Errorf("project %q stage %q: max_instances must be >= 0", name, stageName) - } - } - } - return nil } diff --git a/internal/config/export.go b/internal/config/export.go index cbc6ac3..78b6197 100644 --- a/internal/config/export.go +++ b/internal/config/export.go @@ -1,7 +1,6 @@ package config import ( - "encoding/json" "fmt" "github.com/alexei/tinyforge/internal/store" @@ -9,8 +8,10 @@ import ( ) // ExportConfig reads the current database state and produces a SeedConfig YAML -// representation. Credential fields (tokens, passwords) are exported as placeholder -// strings since they are encrypted in the database. +// representation. Credential fields (tokens, passwords) are exported as +// placeholder strings since they are encrypted in the database. After the hard +// cutover, only global settings + registries are exported — workloads and +// triggers are created through the API, not via seed files. func ExportConfig(db *store.Store) ([]byte, error) { cfg, err := buildSeedConfig(db) if err != nil { @@ -25,7 +26,6 @@ func ExportConfig(db *store.Store) ([]byte, error) { return data, nil } -// buildSeedConfig constructs a SeedConfig from the current database state. func buildSeedConfig(db *store.Store) (SeedConfig, error) { settings, err := db.GetSettings() if err != nil { @@ -37,11 +37,6 @@ func buildSeedConfig(db *store.Store) (SeedConfig, error) { return SeedConfig{}, fmt.Errorf("get registries: %w", err) } - projects, err := db.GetAllProjects() - if err != nil { - return SeedConfig{}, fmt.Errorf("get projects: %w", err) - } - cfg := SeedConfig{ Global: GlobalConfig{ Domain: settings.Domain, @@ -56,7 +51,6 @@ func buildSeedConfig(db *store.Store) (SeedConfig, error) { }, }, Registries: make(map[string]RegistryDef), - Projects: make(map[string]ProjectDef), } for _, reg := range registries { @@ -67,52 +61,5 @@ func buildSeedConfig(db *store.Store) (SeedConfig, error) { } } - for _, proj := range projects { - stages, err := db.GetStagesByProjectID(proj.ID) - if err != nil { - return SeedConfig{}, fmt.Errorf("get stages for project %s: %w", proj.Name, err) - } - - stageDefs := make(map[string]StageDef) - for _, st := range stages { - stageDefs[st.Name] = StageDef{ - TagPattern: st.TagPattern, - AutoDeploy: st.AutoDeploy, - MaxInstances: st.MaxInstances, - Confirm: st.Confirm, - PromoteFrom: st.PromoteFrom, - Subdomain: st.Subdomain, - } - } - - envMap := parseJSONMap(proj.Env) - volMap := parseJSONMap(proj.Volumes) - - cfg.Projects[proj.Name] = ProjectDef{ - Registry: proj.Registry, - Image: proj.Image, - Port: proj.Port, - Healthcheck: proj.Healthcheck, - Env: envMap, - Volumes: volMap, - Stages: stageDefs, - } - } - return cfg, nil } - -// parseJSONMap safely parses a JSON-encoded map string. Returns nil on failure. -func parseJSONMap(jsonStr string) map[string]string { - if jsonStr == "" || jsonStr == "{}" { - return nil - } - var m map[string]string - if err := json.Unmarshal([]byte(jsonStr), &m); err != nil { - return nil - } - if len(m) == 0 { - return nil - } - return m -} diff --git a/internal/config/seed.go b/internal/config/seed.go index 4e5eb68..68bd620 100644 --- a/internal/config/seed.go +++ b/internal/config/seed.go @@ -1,7 +1,11 @@ +// Package config loads and exports seed configuration. After the hard +// cutover the seed shape covers only what survives the workload-first +// refactor: global settings and registries. Project / stage / volume +// seeding is gone; the new way to bootstrap a workload is the plugin +// pipeline (POST /api/workloads). package config import ( - "encoding/json" "fmt" "log/slog" "os" @@ -12,7 +16,7 @@ import ( ) // ImportSeed loads the seed YAML file and imports its contents into the store. -// Import is idempotent: it is skipped if any projects or registries already exist. +// Import is idempotent: it is skipped if any registries already exist. // Credential fields (registry tokens, NPM password) are encrypted before storage. func ImportSeed(db *store.Store, seedPath string) error { if _, err := os.Stat(seedPath); os.IsNotExist(err) { @@ -47,16 +51,10 @@ func ImportSeed(db *store.Store, seedPath string) error { return nil } -// isPopulated returns true if the store already contains projects or registries. +// isPopulated returns true if the store already contains any registries. +// Workloads / apps are intentionally not consulted — they get created +// through the API, not seeded. func isPopulated(db *store.Store) (bool, error) { - projects, err := db.GetAllProjects() - if err != nil { - return false, fmt.Errorf("get projects: %w", err) - } - if len(projects) > 0 { - return true, nil - } - registries, err := db.GetAllRegistries() if err != nil { return false, fmt.Errorf("get registries: %w", err) @@ -64,8 +62,7 @@ func isPopulated(db *store.Store) (bool, error) { return len(registries) > 0, nil } -// importAll runs the full seed import inside a database transaction. -// Uses raw SQL within the transaction so all inserts are atomic. +// importAll runs the seed import inside a database transaction. func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error { tx, err := db.DB().Begin() if err != nil { @@ -75,7 +72,6 @@ func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error { timestamp := store.Now() - // Import registries first — projects reference them by name. for name, regDef := range cfg.Registries { encToken, err := crypto.EncryptIfNotEmpty(encKey, regDef.Token) if err != nil { @@ -93,50 +89,6 @@ func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error { } } - // Import projects and their stages. - for name, projDef := range cfg.Projects { - envJSON, err := mapToJSON(projDef.Env) - if err != nil { - return fmt.Errorf("encode env for project %q: %w", name, err) - } - volJSON, err := mapToJSON(projDef.Volumes) - if err != nil { - return fmt.Errorf("encode volumes for project %q: %w", name, err) - } - - projectID := uuid.New().String() - _, err = tx.Exec( - `INSERT INTO projects (id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - projectID, name, projDef.Registry, projDef.Image, projDef.Port, - projDef.Healthcheck, envJSON, volJSON, timestamp, timestamp, - ) - if err != nil { - return fmt.Errorf("insert project %q: %w", name, err) - } - - for stageName, stageDef := range projDef.Stages { - maxInstances := stageDef.MaxInstances - if maxInstances == 0 { - maxInstances = 1 - } - - stageID := uuid.New().String() - _, err = tx.Exec( - `INSERT INTO stages (id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - stageID, projectID, stageName, stageDef.TagPattern, - store.BoolToInt(stageDef.AutoDeploy), maxInstances, - store.BoolToInt(stageDef.Confirm), stageDef.PromoteFrom, - stageDef.Subdomain, timestamp, timestamp, - ) - if err != nil { - return fmt.Errorf("insert stage %q for project %q: %w", stageName, name, err) - } - } - } - - // Import global settings — encrypt NPM password. encNpmPassword, err := crypto.EncryptIfNotEmpty(encKey, cfg.Global.Npm.Password) if err != nil { return fmt.Errorf("encrypt npm password: %w", err) @@ -166,15 +118,3 @@ func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error { return nil } - -// mapToJSON encodes a string map to JSON. Returns "{}" for nil maps. -func mapToJSON(m map[string]string) (string, error) { - if m == nil { - return "{}", nil - } - b, err := json.Marshal(m) - if err != nil { - return "", err - } - return string(b), nil -} diff --git a/internal/deployer/bluegreen.go b/internal/deployer/bluegreen.go deleted file mode 100644 index d72e899..0000000 --- a/internal/deployer/bluegreen.go +++ /dev/null @@ -1,210 +0,0 @@ -package deployer - -import ( - "context" - "fmt" - "log/slog" - - "github.com/alexei/tinyforge/internal/docker" - "github.com/alexei/tinyforge/internal/store" - "github.com/google/uuid" -) - -// blueGreenDeploy performs a zero-downtime deployment: -// 1. Start new container (green) -// 2. Health check green -// 3. Swap NPM proxy to point to green -// 4. Stop old container (blue) -// -// If the new container fails health check, it is removed and the old one stays. -func (d *Deployer) blueGreenDeploy( - ctx context.Context, - project store.Project, - stage store.Stage, - settings store.Settings, - deployID string, - imageTag string, -) (string, string, string, error) { - // Find existing running container for this stage (the "blue" container). - existing, err := d.store.ListContainersByStageID(stage.ID) - if err != nil { - return "", "", "", fmt.Errorf("get existing containers: %w", err) - } - - var blueContainer *store.Container - for _, c := range existing { - if c.State == "running" { - cCopy := c - blueContainer = &cCopy - break - } - } - - // Step 1: Pull image. - if err := d.store.UpdateDeployStatus(deployID, "pulling", ""); err != nil { - slog.Warn("update deploy status", "error", err) - } - d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "pulling", "") - d.logDeploy(deployID, fmt.Sprintf("Blue-green: pulling image %s:%s", project.Image, imageTag), "info") - - authConfig, err := d.buildRegistryAuth(project) - if err != nil { - return "", "", "", fmt.Errorf("build registry auth: %w", err) - } - - if err := d.docker.PullImage(ctx, project.Image, imageTag, authConfig); err != nil { - return "", "", "", fmt.Errorf("pull image: %w", err) - } - d.logDeploy(deployID, "Image pulled successfully", "info") - - // Step 2: Ensure network. - networkID, err := d.docker.EnsureNetwork(ctx, settings.Network) - if err != nil { - return "", "", "", fmt.Errorf("ensure network: %w", err) - } - - // Step 3: Create and start green container. - if err := d.store.UpdateDeployStatus(deployID, "starting", ""); err != nil { - slog.Warn("update deploy status", "error", err) - } - d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "starting", "") - - instanceID := uuid.New().String() - subdomain := d.buildSubdomain(project, stage, settings, imageTag) - workloadID := d.resolveProjectWorkloadID(project.ID) - containerName := docker.ContainerName(project.Name, stage.Name, imageTag) - portStr := fmt.Sprintf("%d/tcp", project.Port) - envVars := d.mergeEnvVars(project, stage.ID) - mounts := d.computeVolumeMounts(project.ID, project.Name, stage.Name, imageTag, settings.BaseVolumePath) - - containerCfg := docker.ContainerConfig{ - Name: containerName, - Image: project.Image + ":" + imageTag, - Env: envVars, - ExposedPorts: []string{portStr}, - NetworkName: settings.Network, - NetworkID: networkID, - WorkloadID: workloadID, - WorkloadKind: string(store.WorkloadKindProject), - Role: stage.Name, - Mounts: mounts, - CpuLimit: stage.CpuLimit, - MemoryLimit: stage.MemoryLimit, - } - - // Set proxy labels for providers that use Docker labels (e.g., Traefik). - if stage.EnableProxy { - fqdn := subdomain + "." + settings.Domain - if proxyLabels := d.proxy.ContainerLabels(fqdn, project.Port); proxyLabels != nil { - if containerCfg.Labels == nil { - containerCfg.Labels = make(map[string]string) - } - for k, v := range proxyLabels { - containerCfg.Labels[k] = v - } - } - } - - d.logDeploy(deployID, fmt.Sprintf("Blue-green: creating green container %s", containerName), "info") - containerID, err := d.docker.CreateContainer(ctx, containerCfg) - if err != nil { - return "", "", instanceID, fmt.Errorf("create container: %w", err) - } - - // Create container row. - row, err := d.store.CreateContainer(store.Container{ - ID: instanceID, - WorkloadID: workloadID, - WorkloadKind: string(store.WorkloadKindProject), - Role: stage.Name, - StageID: stage.ID, - ContainerID: containerID, - ImageRef: project.Image + ":" + imageTag, - ImageTag: imageTag, - Host: "local", - State: "stopped", - Port: project.Port, - Subdomain: subdomain, - }) - if err != nil { - return containerID, "", instanceID, fmt.Errorf("create container row: %w", err) - } - instanceID = row.ID - - if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil { - slog.Warn("link deploy to container", "error", err) - } - - d.logDeploy(deployID, fmt.Sprintf("Blue-green: starting green container %s", containerName), "info") - if err := d.docker.StartContainer(ctx, containerID); err != nil { - return containerID, "", instanceID, fmt.Errorf("start container: %w", err) - } - - if err := d.store.UpdateContainerState(instanceID, "running"); err != nil { - slog.Warn("update container state", "error", err) - } - row.State = "running" - d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running") - - // Step 4: Health check the green container. - if project.Healthcheck != "" { - if err := d.store.UpdateDeployStatus(deployID, "health_checking", ""); err != nil { - slog.Warn("update deploy status", "error", err) - } - d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "health_checking", "") - - healthURL := fmt.Sprintf("http://%s:%d%s", containerName, project.Port, project.Healthcheck) - d.logDeploy(deployID, fmt.Sprintf("Blue-green: health checking green at %s", healthURL), "info") - - if err := d.health.Check(ctx, healthURL); err != nil { - return containerID, "", instanceID, fmt.Errorf("health check green: %w", err) - } - d.logDeploy(deployID, "Blue-green: green health check passed", "info") - } - - // Step 5: Swap proxy to green. - var proxyRouteID string - if stage.EnableProxy { - if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil { - slog.Warn("update deploy status", "error", err) - } - d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "") - - accessListID := settings.NpmAccessListID - if project.NpmAccessListID > 0 { - accessListID = project.NpmAccessListID - } - - proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerID, containerName, project.Port, subdomain, accessListID) - if err != nil { - return containerID, "", instanceID, fmt.Errorf("configure proxy: %w", err) - } - - row.ProxyRouteID = proxyRouteID - d.logDeploy(deployID, "Blue-green: proxy swapped to green container", "info") - - // Create/update DNS record for the green container. - fqdn := subdomain + "." + settings.Domain - d.ensureDNS(ctx, fqdn, "instance", instanceID, deployID) - } else { - d.logDeploy(deployID, "Blue-green: proxy skipped (disabled for this stage)", "info") - } - - row.Subdomain = subdomain - if err := d.store.UpdateContainer(row); err != nil { - slog.Warn("update container with proxy ID", "error", err) - } - - // Step 6: Stop the blue container. - if blueContainer != nil { - d.logDeploy(deployID, fmt.Sprintf("Blue-green: stopping blue container %s (tag: %s)", blueContainer.ID, blueContainer.ImageTag), "info") - if err := d.removeContainer(ctx, *blueContainer, settings); err != nil { - // Non-fatal: log but continue. Green is already serving traffic. - d.logDeploy(deployID, fmt.Sprintf("Blue-green: warning: failed to remove blue container: %v", err), "warn") - } else { - d.logDeploy(deployID, "Blue-green: blue container removed", "info") - } - } - - return containerID, proxyRouteID, instanceID, nil -} diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 61eb4ac..1383056 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -1,16 +1,15 @@ +// Package deployer dispatches plugin-native Source deploys. The legacy +// project-pipeline lived here until the hard cutover; what remains is a +// thin holder for the Deployer's shared dependencies that `dispatch.go` +// hands to every Source via PluginDeps(). package deployer import ( - "context" - "encoding/json" - "errors" "fmt" "log/slog" - "sort" "sync" "sync/atomic" - "github.com/alexei/tinyforge/internal/crypto" "github.com/alexei/tinyforge/internal/dns" "github.com/alexei/tinyforge/internal/docker" "github.com/alexei/tinyforge/internal/events" @@ -18,14 +17,11 @@ import ( "github.com/alexei/tinyforge/internal/notify" "github.com/alexei/tinyforge/internal/proxy" "github.com/alexei/tinyforge/internal/store" - "github.com/alexei/tinyforge/internal/volume" - "github.com/moby/moby/api/types/mount" - "github.com/google/uuid" ) -// Deployer orchestrates the full deployment flow: pull image, create container, -// start, configure proxy, health check, and handle rollback on failure. -// It implements both webhook.DeployTriggerer and registry.DeployTriggerer. +// Deployer owns the dependency bundle each Source plugin needs at deploy +// time. The plugin pipeline reaches in via PluginDeps(); see dispatch.go +// for the dispatch surface itself. type Deployer struct { docker *docker.Client proxy proxy.Provider @@ -88,21 +84,20 @@ func (d *Deployer) SetPreDeployBackuper(b PreDeployBackuper) { d.backuper = b } -// maybeBackupBeforeDeploy creates a "pre-deploy" Tinyforge DB snapshot when +// MaybeBackupBeforeDeploy creates a "pre-deploy" Tinyforge DB snapshot when // the setting is enabled. Failures are logged but do not abort the deploy: -// missing a backup is preferable to refusing to ship a fix. -func (d *Deployer) maybeBackupBeforeDeploy(deployID string, settings store.Settings) { +// missing a backup is preferable to refusing to ship a fix. Exposed so +// Source plugins can opt into the same behaviour. +func (d *Deployer) MaybeBackupBeforeDeploy(deployID string, settings store.Settings) { if !settings.AutoBackupBeforeDeploy || d.backuper == nil { return } backup, err := d.backuper.CreateBackup("pre-deploy") if err != nil { slog.Warn("pre-deploy backup failed", "deploy_id", deployID, "error", err) - d.logDeploy(deployID, fmt.Sprintf("Pre-deploy backup failed: %v", err), "warn") return } slog.Info("pre-deploy backup created", "deploy_id", deployID, "backup_id", backup.ID, "filename", backup.Filename) - d.logDeploy(deployID, fmt.Sprintf("Pre-deploy backup created: %s", backup.Filename), "info") } // SetDNSProvider sets the DNS provider for managing DNS records during deployments. @@ -113,796 +108,24 @@ func (d *Deployer) SetDNSProvider(provider dns.Provider) { d.dns = provider } -// getDNS returns the current DNS provider under read lock. -func (d *Deployer) getDNS() dns.Provider { - d.dnsMu.RLock() - defer d.dnsMu.RUnlock() - return d.dns -} - // Drain waits for all in-progress deploys to complete. Call this during graceful shutdown. func (d *Deployer) Drain() { - d.shuttingDown.Store(true) + if !d.shuttingDown.CompareAndSwap(false, true) { + // Already draining. + } slog.Info("deployer: draining in-progress deploys") d.activeWg.Wait() slog.Info("deployer: all deploys drained") } -// AsyncTriggerDeploy creates a deploy record and returns the deploy ID immediately, -// then runs the full deploy pipeline in a background goroutine. Use this from HTTP handlers -// to avoid blocking the request. Progress is streamed via SSE. -func (d *Deployer) AsyncTriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) (string, error) { - if d.shuttingDown.Load() { - return "", fmt.Errorf("deployer is shutting down, rejecting new deploy") - } +// ShuttingDown reports whether Drain() has been called. +func (d *Deployer) ShuttingDown() bool { return d.shuttingDown.Load() } - // Validate inputs synchronously so the caller gets immediate feedback. - project, err := d.store.GetProjectByID(projectID) - if err != nil { - return "", fmt.Errorf("get project: %w", err) - } - stage, err := d.store.GetStageByID(stageID) - if err != nil { - return "", fmt.Errorf("get stage: %w", err) - } - if err := d.validatePromoteFrom(stage, imageTag); err != nil { - return "", fmt.Errorf("promote validation: %w", err) - } - - // Create deploy record synchronously so caller gets the ID. - deploy, err := d.store.CreateDeploy(store.Deploy{ - ProjectID: projectID, - StageID: stageID, - ImageTag: imageTag, - Status: "pending", - }) - if err != nil { - return "", fmt.Errorf("create deploy record: %w", err) - } - - // Run the actual deploy in the background. - d.activeWg.Add(1) - go func() { - defer d.activeWg.Done() - // Use a detached context so client disconnect doesn't abort the deploy. - bgCtx := context.Background() - if err := d.runDeploy(bgCtx, project, stage, deploy.ID, imageTag); err != nil { - slog.Error("async deploy failed", "deploy_id", deploy.ID, "error", err) - } - }() - - return deploy.ID, nil -} - -// runDeploy is the internal deploy pipeline used by AsyncTriggerDeploy. -// It assumes the deploy record already exists and project/stage are validated. -func (d *Deployer) runDeploy(ctx context.Context, project store.Project, stage store.Stage, deployID string, imageTag string) error { - settings, err := d.store.GetSettings() - if err != nil { - if updateErr := d.store.UpdateDeployStatus(deployID, "failed", err.Error()); updateErr != nil { - slog.Warn("update deploy status", "error", updateErr) - } - return fmt.Errorf("get settings: %w", err) - } - - slog.Info("starting deploy", - "deploy_id", deployID, - "project", project.Name, - "stage", stage.Name, - "tag", imageTag, - ) - d.logDeploy(deployID, fmt.Sprintf("Starting deploy of %s:%s for project %s, stage %s", project.Image, imageTag, project.Name, stage.Name), "info") - - // Take a pre-deploy DB snapshot if the operator opted in. Runs before - // any state-mutating work so a corrupted deploy is recoverable. - d.maybeBackupBeforeDeploy(deployID, settings) - - // Enforce max_instances before deploying. - if err := d.enforceMaxInstances(ctx, stage, deployID, settings); err != nil { - d.logDeploy(deployID, fmt.Sprintf("Failed to enforce max instances: %v", err), "error") - } - - var containerID string - var proxyRouteID string - var instanceID string - var deployErr error - - if stage.MaxInstances == 1 { - containerID, proxyRouteID, instanceID, deployErr = d.blueGreenDeploy(ctx, project, stage, settings, deployID, imageTag) - } else { - containerID, proxyRouteID, instanceID, deployErr = d.executeDeploy(ctx, project, stage, settings, deployID, imageTag) - } - - if deployErr != nil { - d.logDeploy(deployID, fmt.Sprintf("Deploy failed: %v", deployErr), "error") - d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "failed", deployErr.Error()) - d.rollback(ctx, deployID, containerID, proxyRouteID, instanceID) - - url, secret, tier := resolveDeployTarget(stage, project, settings) - d.notifier.SendSigned(url, secret, tier, notify.Event{ - Type: "deploy_failure", - Project: project.Name, - Stage: stage.Name, - ImageTag: imageTag, - Error: deployErr.Error(), - }) - - return fmt.Errorf("deploy failed: %w", deployErr) - } - - if err := d.store.UpdateDeployStatus(deployID, "success", ""); err != nil { - slog.Warn("update deploy status to success", "error", err) - } - d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "success", "") - - subdomain := d.buildSubdomain(project, stage, settings, imageTag) - fullURL := fmt.Sprintf("https://%s.%s", subdomain, settings.Domain) - - d.logDeploy(deployID, fmt.Sprintf("Deploy successful: %s", fullURL), "info") - - url, secret, tier := resolveDeployTarget(stage, project, settings) - d.notifier.SendSigned(url, secret, tier, notify.Event{ - Type: "deploy_success", - Project: project.Name, - Stage: stage.Name, - ImageTag: imageTag, - Subdomain: subdomain, - URL: fullURL, - }) - - return nil -} - -// resolveDeployTarget picks the most-specific (URL, secret, tier) for a -// deploy notification: stage > project > global. An empty URL at a tier -// means "fall through to the next" — never "send unsigned to nowhere". The -// secret is always paired with the URL that sourced it, so a stage can sign -// even when project and global are unsigned (and vice versa). -func resolveDeployTarget(stage store.Stage, project store.Project, settings store.Settings) (string, string, notify.Tier) { - if stage.NotificationURL != "" { - return stage.NotificationURL, stage.NotificationSecret, notify.TierStage - } - if project.NotificationURL != "" { - return project.NotificationURL, project.NotificationSecret, notify.TierProject - } - return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings -} - -// TriggerDeploy is the synchronous entry point for deployments (used by poller and webhook). -// It validates inputs, creates a deploy record, and delegates to runDeploy. -func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error { +// rejectIfDraining is exposed in case any plugin wants the same hard-stop +// behaviour the legacy pipeline used. +func (d *Deployer) rejectIfDraining() error { if d.shuttingDown.Load() { return fmt.Errorf("deployer is shutting down, rejecting new deploy") } - - d.activeWg.Add(1) - defer d.activeWg.Done() - - project, err := d.store.GetProjectByID(projectID) - if err != nil { - return fmt.Errorf("get project: %w", err) - } - - stage, err := d.store.GetStageByID(stageID) - if err != nil { - return fmt.Errorf("get stage: %w", err) - } - - if err := d.validatePromoteFrom(stage, imageTag); err != nil { - return fmt.Errorf("promote validation: %w", err) - } - - deploy, err := d.store.CreateDeploy(store.Deploy{ - ProjectID: projectID, - StageID: stageID, - ImageTag: imageTag, - Status: "pending", - }) - if err != nil { - return fmt.Errorf("create deploy record: %w", err) - } - - if err := d.runDeploy(ctx, project, stage, deploy.ID, imageTag); err != nil { - return err - } return nil } - -// executeDeploy runs the deploy pipeline steps and returns rollback-relevant state. -// It returns (containerID, proxyRouteID, instanceID, error). -func (d *Deployer) executeDeploy( - ctx context.Context, - project store.Project, - stage store.Stage, - settings store.Settings, - deployID string, - imageTag string, -) (string, string, string, error) { - var containerID string - var proxyRouteID string - var instanceID string - - // Step 1: Pull image. - if err := d.store.UpdateDeployStatus(deployID, "pulling", ""); err != nil { - slog.Warn("update deploy status", "error", err) - } - d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "pulling", "") - d.logDeploy(deployID, fmt.Sprintf("Pulling image %s:%s", project.Image, imageTag), "info") - - authConfig, err := d.buildRegistryAuth(project) - if err != nil { - return containerID, proxyRouteID, instanceID, fmt.Errorf("build registry auth: %w", err) - } - - if err := d.docker.PullImage(ctx, project.Image, imageTag, authConfig); err != nil { - return containerID, proxyRouteID, instanceID, fmt.Errorf("pull image: %w", err) - } - d.logDeploy(deployID, "Image pulled successfully", "info") - - // Step 2: Ensure network exists. - if settings.Network == "" { - return containerID, proxyRouteID, instanceID, fmt.Errorf("docker network not configured in settings") - } - networkID, err := d.docker.EnsureNetwork(ctx, settings.Network) - if err != nil { - return containerID, proxyRouteID, instanceID, fmt.Errorf("ensure network: %w", err) - } - d.logDeploy(deployID, fmt.Sprintf("Network %s ready (ID: %s)", settings.Network, truncateID(networkID)), "info") - - // Step 3: Create and start container. - if err := d.store.UpdateDeployStatus(deployID, "starting", ""); err != nil { - slog.Warn("update deploy status", "error", err) - } - d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "starting", "") - - // Pre-generate instance ID so it can be set as a container label. - instanceID = uuid.New().String() - subdomain := d.buildSubdomain(project, stage, settings, imageTag) - workloadID := d.resolveProjectWorkloadID(project.ID) - - containerName := docker.ContainerName(project.Name, stage.Name, imageTag) - - // Remove any stale container with the same name (e.g., from a previous failed deploy). - _ = d.docker.RemoveContainer(ctx, containerName, true) - - portStr := fmt.Sprintf("%d/tcp", project.Port) - envVars := d.mergeEnvVars(project, stage.ID) - mounts := d.computeVolumeMounts(project.ID, project.Name, stage.Name, imageTag, settings.BaseVolumePath) - - containerCfg := docker.ContainerConfig{ - Name: containerName, - Image: project.Image + ":" + imageTag, - Env: envVars, - ExposedPorts: []string{portStr}, - NetworkName: settings.Network, - NetworkID: networkID, - WorkloadID: workloadID, - WorkloadKind: string(store.WorkloadKindProject), - Role: stage.Name, - Mounts: mounts, - CpuLimit: stage.CpuLimit, - MemoryLimit: stage.MemoryLimit, - } - - // Set proxy labels for providers that use Docker labels (e.g., Traefik). - if stage.EnableProxy { - fqdn := subdomain + "." + settings.Domain - if proxyLabels := d.proxy.ContainerLabels(fqdn, project.Port); proxyLabels != nil { - if containerCfg.Labels == nil { - containerCfg.Labels = make(map[string]string) - } - for k, v := range proxyLabels { - containerCfg.Labels[k] = v - } - } - } - - d.logDeploy(deployID, fmt.Sprintf("Creating container %s", containerName), "info") - containerID, err = d.docker.CreateContainer(ctx, containerCfg) - if err != nil { - return containerID, proxyRouteID, instanceID, fmt.Errorf("create container: %w", err) - } - d.logDeploy(deployID, fmt.Sprintf("Container created (ID: %s)", truncateID(containerID)), "info") - - // Create container row with the pre-generated ID. The deployer is the - // authoritative writer until the next reconciler tick — it's important - // the row exists before StartContainer so a fast tick doesn't see an - // orphan and mark it missing. - row, err := d.store.CreateContainer(store.Container{ - ID: instanceID, - WorkloadID: workloadID, - WorkloadKind: string(store.WorkloadKindProject), - Role: stage.Name, - StageID: stage.ID, - ContainerID: containerID, - ImageRef: project.Image + ":" + imageTag, - ImageTag: imageTag, - Host: "local", - State: "stopped", - Port: project.Port, - Subdomain: subdomain, - }) - if err != nil { - return containerID, proxyRouteID, instanceID, fmt.Errorf("create container row: %w", err) - } - instanceID = row.ID - - // Link deploy to container row (the existing Deploy.InstanceID column - // stores the row ID — same value as before, just a renamed concept). - if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil { - slog.Warn("link deploy to container", "error", err) - } - - d.logDeploy(deployID, fmt.Sprintf("Starting container %s", containerName), "info") - if err := d.docker.StartContainer(ctx, containerID); err != nil { - return containerID, proxyRouteID, instanceID, fmt.Errorf("start container: %w", err) - } - - if err := d.store.UpdateContainerState(instanceID, "running"); err != nil { - slog.Warn("update container state to running", "error", err) - } - row.State = "running" - row.LastSeenAt = store.Now() - d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running") - d.logDeploy(deployID, "Container started", "info") - - // Step 4: Configure NPM proxy (optional per stage). - if stage.EnableProxy { - if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil { - slog.Warn("update deploy status", "error", err) - } - d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "") - - accessListID := settings.NpmAccessListID - if project.NpmAccessListID > 0 { - accessListID = project.NpmAccessListID - } - - proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerID, containerName, project.Port, subdomain, accessListID) - if err != nil { - return containerID, proxyRouteID, instanceID, fmt.Errorf("configure proxy: %w", err) - } - - // Update container row with proxy route ID. - row.ProxyRouteID = proxyRouteID - row.Subdomain = subdomain - if err := d.store.UpdateContainer(row); err != nil { - slog.Warn("update container with proxy ID", "error", err) - } - - // Create DNS record for this container. - fqdn := subdomain + "." + settings.Domain - d.ensureDNS(ctx, fqdn, "instance", instanceID, deployID) - } else { - d.logDeploy(deployID, "Proxy creation skipped (disabled for this stage)", "info") - row.Subdomain = subdomain - if err := d.store.UpdateContainer(row); err != nil { - slog.Warn("update container", "error", err) - } - } - - // Step 5: Health check. - if project.Healthcheck != "" { - if err := d.store.UpdateDeployStatus(deployID, "health_checking", ""); err != nil { - slog.Warn("update deploy status", "error", err) - } - d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "health_checking", "") - - healthURL := fmt.Sprintf("http://%s:%d%s", containerName, project.Port, project.Healthcheck) - d.logDeploy(deployID, fmt.Sprintf("Running health check: %s", healthURL), "info") - - if err := d.health.Check(ctx, healthURL); err != nil { - return containerID, proxyRouteID, instanceID, fmt.Errorf("health check: %w", err) - } - d.logDeploy(deployID, "Health check passed", "info") - } else { - d.logDeploy(deployID, "No health check configured, skipping", "info") - } - - return containerID, proxyRouteID, instanceID, nil -} - -// configureProxy creates or updates a proxy route for the deployed container. -// Uses the configured proxy.Provider (NPM, Traefik, or None). -// In NPM remote mode, uses server_ip + published host port instead of container name. -// Returns the proxy route ID string. -func (d *Deployer) configureProxy( - ctx context.Context, - deployID string, - settings store.Settings, - containerID string, - containerName string, - containerPort int, - subdomain string, - accessListID int, -) (string, error) { - fqdn := subdomain + "." + settings.Domain - - forwardHost := containerName - forwardPort := containerPort - - // In NPM remote mode, use server_ip and the published host port. - if settings.NpmRemote && settings.ProxyProvider == "npm" { - if settings.ServerIP == "" { - return "", fmt.Errorf("NPM remote mode requires Server IP to be configured in settings") - } - forwardHost = settings.ServerIP - - hostPort, err := d.docker.InspectContainerPort(ctx, containerID, fmt.Sprintf("%d/tcp", containerPort)) - if err != nil { - return "", fmt.Errorf("look up host port for remote NPM: %w", err) - } - forwardPort = int(hostPort) - d.logDeploy(deployID, fmt.Sprintf("NPM remote mode: using %s:%d (host port)", forwardHost, forwardPort), "info") - } - - d.logDeploy(deployID, fmt.Sprintf("Configuring proxy (%s): %s -> %s:%d", d.proxy.Name(), fqdn, forwardHost, forwardPort), "info") - - routeID, err := d.proxy.ConfigureRoute(ctx, fqdn, forwardHost, forwardPort, proxy.RouteOptions{ - SSLCertificateID: settings.SSLCertificateID, - AccessListID: accessListID, - }) - if err != nil { - return "", fmt.Errorf("configure proxy route: %w", err) - } - - if routeID != "" { - d.logDeploy(deployID, fmt.Sprintf("Proxy route configured (ID: %s)", routeID), "info") - } - return routeID, nil -} - -// enforceMaxInstances removes the oldest container rows when the stage has -// reached its instance limit, making room for the new deploy. -func (d *Deployer) enforceMaxInstances(ctx context.Context, stage store.Stage, deployID string, settings store.Settings) error { - if stage.MaxInstances <= 0 { - return nil - } - - containers, err := d.store.ListContainersByStageID(stage.ID) - if err != nil { - return fmt.Errorf("get containers for stage: %w", err) - } - - // Filter to running/stopped containers (not already failed/removing). - var active []store.Container - for _, c := range containers { - if c.State == "running" || c.State == "stopped" { - active = append(active, c) - } - } - - // We need room for one more container, so remove the oldest when at limit. - removeCount := len(active) - stage.MaxInstances + 1 - if removeCount <= 0 { - return nil - } - - // Sort by created_at ascending (oldest first). - sort.Slice(active, func(i, j int) bool { - return active[i].CreatedAt < active[j].CreatedAt - }) - - for i := 0; i < removeCount && i < len(active); i++ { - c := active[i] - d.logDeploy(deployID, fmt.Sprintf("Removing oldest container %s (tag: %s) to enforce max_instances=%d", c.ID, c.ImageTag, stage.MaxInstances), "info") - - if err := d.removeContainer(ctx, c, settings); err != nil { - d.logDeploy(deployID, fmt.Sprintf("Failed to remove container %s: %v", c.ID, err), "warn") - continue - } - d.logDeploy(deployID, fmt.Sprintf("Removed container %s", c.ID), "info") - } - - return nil -} - -// removeContainer stops + removes the Docker container, deletes its proxy -// route, drops the DNS record, and removes the container row from the store. -func (d *Deployer) removeContainer(ctx context.Context, c store.Container, settings store.Settings) error { - // Mark as removing. - if err := d.store.UpdateContainerState(c.ID, "removing"); err != nil { - slog.Warn("update container state to removing", "id", c.ID, "error", err) - } - - // Remove Docker container. - if c.ContainerID != "" { - if err := d.docker.RemoveContainer(ctx, c.ContainerID, true); err != nil { - slog.Warn("remove docker container", "container_id", c.ContainerID, "error", err) - } - } - - // Delete proxy route. - if c.ProxyRouteID != "" { - if err := d.proxy.DeleteRoute(ctx, c.ProxyRouteID); err != nil { - slog.Warn("delete proxy route", "route_id", c.ProxyRouteID, "error", err) - } - - // Remove DNS record. - if c.Subdomain != "" && settings.Domain != "" { - fqdn := c.Subdomain + "." + settings.Domain - d.removeDNS(ctx, fqdn, "") - } - } - - // Drop the container row. - if err := d.store.DeleteContainer(c.ID); err != nil && !errors.Is(err, store.ErrNotFound) { - return fmt.Errorf("delete container row: %w", err) - } - - return nil -} - -// buildSubdomain generates the subdomain for an instance based on settings and stage config. -func (d *Deployer) buildSubdomain(project store.Project, stage store.Stage, settings store.Settings, imageTag string) string { - return GenerateTaggedSubdomain(settings.SubdomainPattern, project.Name, stage.Name, imageTag, stage.Subdomain) -} - -// buildRegistryAuth constructs the Docker registry auth string for pulling images. -// If the project has a registry configured, it looks up the registry token. -func (d *Deployer) buildRegistryAuth(project store.Project) (string, error) { - if project.Registry == "" { - return "", nil - } - - reg, err := d.store.GetRegistryByName(project.Registry) - if err != nil { - return "", fmt.Errorf("get registry %s: %w", project.Registry, err) - } - - if reg.Token != "" { - decrypted, err := crypto.Decrypt(d.encKey, reg.Token) - if err != nil { - return "", fmt.Errorf("decrypt registry token: %w", err) - } - return docker.EncodeRegistryAuth(decrypted, decrypted, reg.URL) - } - return "", nil -} - -// mergeEnvVars builds the final environment variable list for a container: -// 1. Parse project-level env JSON -// 2. Overlay with stage-level env overrides (stage wins on key conflict) -// 3. Decrypt any encrypted (secret) values -// Returns a []string of KEY=VALUE pairs. -func (d *Deployer) mergeEnvVars(project store.Project, stageID string) []string { - // Step 1: Parse project-level env. - envMap := make(map[string]string) - if project.Env != "" && project.Env != "{}" { - var projectEnv map[string]string - if err := json.Unmarshal([]byte(project.Env), &projectEnv); err != nil { - slog.Warn("parse project env vars", "error", err) - } else { - for k, v := range projectEnv { - envMap[k] = v - } - } - } - - // Step 2: Overlay with stage-level overrides. - stageEnvs, err := d.store.GetStageEnvByStageID(stageID) - if err != nil { - slog.Warn("get stage env overrides", "stage_id", stageID, "error", err) - } else { - for _, se := range stageEnvs { - value := se.Value - if se.Encrypted { - // Step 3: Decrypt secret values. - decrypted, err := crypto.Decrypt(d.encKey, se.Value) - if err != nil { - slog.Warn("decrypt stage env value", "key", se.Key, "error", err) - continue - } - value = decrypted - } - envMap[se.Key] = value - } - } - - vars := make([]string, 0, len(envMap)) - for k, v := range envMap { - vars = append(vars, k+"="+v) - } - return vars -} - -// computeVolumeMounts builds Docker mount specifications from the project's volume config. -// Uses the shared volume.ResolvePath for path resolution. -func (d *Deployer) computeVolumeMounts(projectID, projectName, stageName, imageTag, basePath string) []mount.Mount { - vols, err := d.store.GetVolumesByProjectID(projectID) - if err != nil { - slog.Warn("get project volumes", "project_id", projectID, "error", err) - return nil - } - - if len(vols) == 0 { - return nil - } - - params := volume.ResolveParams{ - BasePath: basePath, - ProjectName: projectName, - StageName: stageName, - ImageTag: imageTag, - } - - mounts := make([]mount.Mount, 0, len(vols)) - for _, vol := range vols { - scope := vol.Scope - if scope == "" { - switch vol.Mode { - case "isolated": - scope = "instance" - default: - scope = "project" - } - } - - // Ephemeral volumes use tmpfs — no host path. - if scope == "ephemeral" { - mounts = append(mounts, mount.Mount{ - Type: mount.TypeTmpfs, - Target: vol.Target, - }) - continue - } - - source, err := volume.ResolvePath(vol, params) - if err != nil { - slog.Warn("resolve volume path", "volume_id", vol.ID, "error", err) - continue - } - - mounts = append(mounts, mount.Mount{ - Type: mount.TypeBind, - Source: source, - Target: vol.Target, - }) - } - return mounts -} - -// logDeploy appends a log entry for a deploy and publishes it on the event bus. -// Errors are logged to stderr but not propagated. -func (d *Deployer) logDeploy(deployID, message, level string) { - if err := d.store.AppendDeployLog(deployID, message, level); err != nil { - slog.Warn("append deploy log", "error", err) - } - if d.eventBus != nil { - d.eventBus.Publish(events.Event{ - Type: events.EventDeployLog, - Payload: events.DeployLogPayload{ - DeployID: deployID, - Message: message, - Level: level, - }, - }) - } -} - -// publishDeployStatus publishes a deploy status change event on the bus. -func (d *Deployer) publishDeployStatus(deployID, projectID, stageID, imageTag, status, deployErr string) { - if d.eventBus != nil { - d.eventBus.Publish(events.Event{ - Type: events.EventDeployStatus, - Payload: events.DeployStatusPayload{ - DeployID: deployID, - ProjectID: projectID, - StageID: stageID, - ImageTag: imageTag, - Status: status, - Error: deployErr, - }, - }) - } -} - -// publishInstanceStatus publishes an instance status change event on the bus. -func (d *Deployer) publishInstanceStatus(instanceID, projectID, stageID, status string) { - if d.eventBus != nil { - d.eventBus.Publish(events.Event{ - Type: events.EventInstanceStatus, - Payload: events.InstanceStatusPayload{ - InstanceID: instanceID, - ProjectID: projectID, - StageID: stageID, - Status: status, - }, - }) - } -} - -// ensureDNS creates or updates a DNS record for the given FQDN. Best-effort: logs warnings on failure. -func (d *Deployer) ensureDNS(ctx context.Context, fqdn, consumerType, consumerID, deployID string) { - dnsProvider := d.getDNS() - if dnsProvider == nil { - return - } - settings, err := d.store.GetSettings() - if err != nil { - slog.Warn("dns: get settings for server IP", "error", err) - return - } - if settings.ServerIP == "" { - slog.Warn("dns: server IP not configured, skipping DNS record creation", "fqdn", fqdn) - return - } - - recordID, err := dnsProvider.EnsureRecord(ctx, fqdn, settings.ServerIP) - if err != nil { - msg := fmt.Sprintf("DNS: failed to create/update record for %s: %v", fqdn, err) - slog.Warn(msg) - if deployID != "" { - d.logDeploy(deployID, msg, "warn") - } - return - } - - // Track the record locally. - if _, err := d.store.CreateDNSRecord(store.DNSRecord{ - FQDN: fqdn, - ProviderRecordID: recordID, - ConsumerType: consumerType, - ConsumerID: consumerID, - }); err != nil { - // May already exist — try updating. - if updateErr := d.store.UpdateDNSRecordProviderID(fqdn, recordID); updateErr != nil { - slog.Warn("dns: failed to track record", "fqdn", fqdn, "error", updateErr) - } - } - - logMsg := fmt.Sprintf("DNS: record ensured for %s", fqdn) - slog.Info(logMsg) - if deployID != "" { - d.logDeploy(deployID, logMsg, "info") - } -} - -// removeDNS deletes a DNS record for the given FQDN. Best-effort: logs warnings on failure. -func (d *Deployer) removeDNS(ctx context.Context, fqdn, deployID string) { - dnsProvider := d.getDNS() - if dnsProvider == nil { - return - } - - if err := dnsProvider.DeleteRecord(ctx, fqdn); err != nil { - msg := fmt.Sprintf("DNS: failed to delete record for %s: %v", fqdn, err) - slog.Warn(msg) - if deployID != "" { - d.logDeploy(deployID, msg, "warn") - } - return - } - - // Remove local tracking. - if err := d.store.DeleteDNSRecord(fqdn); err != nil { - slog.Warn("dns: failed to remove tracking record", "fqdn", fqdn, "error", err) - } - - logMsg := fmt.Sprintf("DNS: record deleted for %s", fqdn) - slog.Info(logMsg) - if deployID != "" { - d.logDeploy(deployID, logMsg, "info") - } -} - -// truncateID safely truncates a Docker ID to 12 characters for display. -func truncateID(id string) string { - if len(id) > 12 { - return id[:12] - } - return id -} - -// resolveProjectWorkloadID returns the workload ID paired with a project. -// Backfill-on-boot guarantees the row exists, so this is essentially a lookup. -// On miss (defensive), it logs and returns empty so the caller can decide. -func (d *Deployer) resolveProjectWorkloadID(projectID string) string { - w, err := d.store.GetWorkloadByRef(store.WorkloadKindProject, projectID) - if err != nil { - slog.Warn("resolve project workload", "project_id", projectID, "error", err) - return "" - } - return w.ID -} - diff --git a/internal/deployer/promote.go b/internal/deployer/promote.go deleted file mode 100644 index 7d53c7f..0000000 --- a/internal/deployer/promote.go +++ /dev/null @@ -1,49 +0,0 @@ -package deployer - -import ( - "fmt" - - "github.com/alexei/tinyforge/internal/store" -) - -// validatePromoteFrom checks that a tag is running in the promote_from stage -// before allowing it to be deployed to the target stage. -// Returns nil if no promote_from is configured or if the tag is eligible. -func (d *Deployer) validatePromoteFrom(stage store.Stage, imageTag string) error { - if stage.PromoteFrom == "" { - return nil - } - - // Look up the source stage by name within the same project. - stages, err := d.store.GetStagesByProjectID(stage.ProjectID) - if err != nil { - return fmt.Errorf("get stages for project: %w", err) - } - - var sourceStage *store.Stage - for _, s := range stages { - if s.Name == stage.PromoteFrom { - sCopy := s - sourceStage = &sCopy - break - } - } - - if sourceStage == nil { - return fmt.Errorf("promote_from stage %q not found in project", stage.PromoteFrom) - } - - // Check if the tag is running in the source stage. - containers, err := d.store.ListContainersByStageID(sourceStage.ID) - if err != nil { - return fmt.Errorf("get containers for source stage: %w", err) - } - - for _, c := range containers { - if c.ImageTag == imageTag && (c.State == "running" || c.State == "stopped") { - return nil // Tag found in source stage, promotion is allowed. - } - } - - return fmt.Errorf("tag %q is not running in stage %q; promotion denied", imageTag, stage.PromoteFrom) -} diff --git a/internal/deployer/resolver_test.go b/internal/deployer/resolver_test.go deleted file mode 100644 index 54a52df..0000000 --- a/internal/deployer/resolver_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package deployer - -import ( - "testing" - - "github.com/alexei/tinyforge/internal/notify" - "github.com/alexei/tinyforge/internal/store" -) - -// TestResolveDeployTarget locks the stage→project→global precedence. The -// most-specific tier with a non-empty URL wins, and the secret travels -// with the URL that sourced it (so a stage can sign even when project and -// global are unsigned). A regression here misroutes notifications and -// silently leaks events to the wrong receiver — worth catching. -func TestResolveDeployTarget(t *testing.T) { - cases := []struct { - name string - stage store.Stage - project store.Project - settings store.Settings - wantURL string - wantSec string - wantTier notify.Tier - }{ - { - name: "stage wins when set", - stage: store.Stage{NotificationURL: "https://stage.example/wh", NotificationSecret: "stage-key"}, - project: store.Project{NotificationURL: "https://project.example/wh", NotificationSecret: "project-key"}, - settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"}, - wantURL: "https://stage.example/wh", - wantSec: "stage-key", - wantTier: notify.TierStage, - }, - { - name: "stage URL empty → project wins", - stage: store.Stage{NotificationURL: "", NotificationSecret: "stage-key"}, // secret without URL ignored - project: store.Project{NotificationURL: "https://project.example/wh", NotificationSecret: "project-key"}, - settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"}, - wantURL: "https://project.example/wh", - wantSec: "project-key", - wantTier: notify.TierProject, - }, - { - name: "stage and project empty → global wins", - stage: store.Stage{}, - project: store.Project{}, - settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"}, - wantURL: "https://global.example/wh", - wantSec: "global-key", - wantTier: notify.TierSettings, - }, - { - name: "all empty → returns settings tier with empty URL (caller skips)", - stage: store.Stage{}, - project: store.Project{}, - settings: store.Settings{}, - wantURL: "", - wantSec: "", - wantTier: notify.TierSettings, - }, - { - name: "stage signs even when global is unsigned", - stage: store.Stage{ - NotificationURL: "https://stage.example/wh", - NotificationSecret: "stage-only-key", - }, - project: store.Project{}, - settings: store.Settings{NotificationURL: "https://global.example/wh"}, - wantURL: "https://stage.example/wh", - wantSec: "stage-only-key", - wantTier: notify.TierStage, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - gotURL, gotSec, gotTier := resolveDeployTarget(tc.stage, tc.project, tc.settings) - if gotURL != tc.wantURL { - t.Errorf("url = %q, want %q", gotURL, tc.wantURL) - } - if gotSec != tc.wantSec { - t.Errorf("secret = %q, want %q", gotSec, tc.wantSec) - } - if gotTier != tc.wantTier { - t.Errorf("tier = %q, want %q", gotTier, tc.wantTier) - } - }) - } -} diff --git a/internal/deployer/rollback.go b/internal/deployer/rollback.go deleted file mode 100644 index f983dc8..0000000 --- a/internal/deployer/rollback.go +++ /dev/null @@ -1,63 +0,0 @@ -package deployer - -import ( - "context" - "fmt" - "log/slog" -) - -// rollback cleans up a failed deployment by removing the container, -// deleting the proxy route, and updating the instance status. -// Errors during rollback are logged but do not prevent other cleanup steps. -func (d *Deployer) rollback(ctx context.Context, deployID string, containerID string, proxyRouteID string, instanceID string) { - d.logDeploy(deployID, "Rolling back failed deployment", "warn") - - // Remove the container if it was created. - if containerID != "" { - if err := d.docker.RemoveContainer(ctx, containerID, true); err != nil { - slog.Warn("rollback: remove container", "container_id", containerID, "error", err) - d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to remove container: %v", err), "error") - } else { - d.logDeploy(deployID, "Rollback: container removed", "info") - } - } - - // Delete the proxy route if it was created. - if proxyRouteID != "" { - if err := d.proxy.DeleteRoute(ctx, proxyRouteID); err != nil { - slog.Warn("rollback: delete proxy route", "route_id", proxyRouteID, "error", err) - d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to delete proxy route: %v", err), "error") - } else { - d.logDeploy(deployID, "Rollback: proxy route deleted", "info") - } - } - - // Clean up DNS record if the container had a subdomain. instanceID is - // the container row ID (same UUID either way) — read from containers. - if instanceID != "" { - c, err := d.store.GetContainerByID(instanceID) - if err == nil && c.Subdomain != "" { - settings, settingsErr := d.store.GetSettings() - if settingsErr != nil { - slog.Warn("rollback: failed to get settings for DNS cleanup", "error", settingsErr) - } else if settings.Domain != "" { - fqdn := c.Subdomain + "." + settings.Domain - d.removeDNS(ctx, fqdn, deployID) - } - } - } - - // Mark the container row as failed if it was created. - if instanceID != "" { - if err := d.store.UpdateContainerState(instanceID, "failed"); err != nil { - slog.Warn("rollback: update container state", "id", instanceID, "error", err) - } - } - - // Mark deploy as rolled back. - if err := d.store.UpdateDeployStatus(deployID, "rolled_back", "deployment failed, rolled back"); err != nil { - slog.Warn("rollback: update deploy status", "deploy_id", deployID, "error", err) - } - - d.logDeploy(deployID, "Rollback complete", "info") -} diff --git a/internal/deployer/subdomain.go b/internal/deployer/subdomain.go deleted file mode 100644 index 646b58b..0000000 --- a/internal/deployer/subdomain.go +++ /dev/null @@ -1,84 +0,0 @@ -package deployer - -import ( - "regexp" - "strings" -) - -// maxSubdomainLen is the maximum length of a single DNS label (RFC 1035). -const maxSubdomainLen = 63 - -// invalidDNSChars matches characters not allowed in a DNS label. -var invalidDNSChars = regexp.MustCompile(`[^a-z0-9-]`) - -// GenerateSubdomain builds a subdomain string from the given pattern and parameters. -// The pattern may contain {stage}, {project}, and {tag} placeholders. -// If the stage has a custom subdomain override, that value is used instead of the pattern. -func GenerateSubdomain(pattern, project, stage, tag, stageSubdomain string) string { - if stageSubdomain != "" { - return SanitizeDNSLabel(stageSubdomain) - } - - result := pattern - result = strings.ReplaceAll(result, "{stage}", stage) - result = strings.ReplaceAll(result, "{project}", project) - result = strings.ReplaceAll(result, "{tag}", tag) - - return SanitizeDNSLabel(result) -} - -// GenerateTaggedSubdomain builds a subdomain that includes the tag for multi-instance support. -// It appends "-{sanitized_tag}" to the base subdomain. -func GenerateTaggedSubdomain(pattern, project, stage, tag, stageSubdomain string) string { - base := GenerateSubdomain(pattern, project, stage, "", stageSubdomain) - sanitizedTag := SanitizeDNSLabel(tag) - - if sanitizedTag == "" { - return base - } - - combined := base + "-" + sanitizedTag - return truncateDNSLabel(combined) -} - -// SanitizeDNSLabel converts an arbitrary string into a valid DNS label. -// It lowercases, replaces dots and invalid characters with hyphens, -// collapses consecutive hyphens, trims leading/trailing hyphens, and truncates. -func SanitizeDNSLabel(s string) string { - s = strings.ToLower(s) - s = strings.ReplaceAll(s, ".", "-") - s = invalidDNSChars.ReplaceAllString(s, "-") - s = collapseHyphens(s) - s = strings.Trim(s, "-") - return truncateDNSLabel(s) -} - -// collapseHyphens replaces consecutive hyphens with a single hyphen. -func collapseHyphens(s string) string { - prev := false - var b strings.Builder - b.Grow(len(s)) - - for _, r := range s { - if r == '-' { - if !prev { - b.WriteRune(r) - } - prev = true - } else { - b.WriteRune(r) - prev = false - } - } - return b.String() -} - -// truncateDNSLabel truncates a label to maxSubdomainLen characters, -// ensuring it does not end with a hyphen after truncation. -func truncateDNSLabel(s string) string { - if len(s) <= maxSubdomainLen { - return s - } - s = s[:maxSubdomainLen] - return strings.TrimRight(s, "-") -} diff --git a/internal/reconciler/reconciler.go b/internal/reconciler/reconciler.go index 5b3dbaa..22d9f1e 100644 --- a/internal/reconciler/reconciler.go +++ b/internal/reconciler/reconciler.go @@ -1,21 +1,16 @@ // Package reconciler keeps the normalized containers index in sync with the // Docker daemon. It runs on a tick (and one-shot at boot) — for every -// Tinyforge-managed container in `docker ps`, it dispatches to a workload by -// labels and writes a Container row through ReconcileContainer (which only -// touches Docker-derived fields on conflict, never deployer-owned columns -// like subdomain / proxy_route_id / npm_proxy_id / image_tag / stage_id). -// Rows whose Docker container ID is no longer present are flipped to -// state='missing'. +// Tinyforge-managed container in `docker ps`, it resolves a workload by the +// canonical workload-id label and writes a Container row through +// ReconcileContainer (which only touches Docker-derived fields on conflict, +// never deployer-owned columns like subdomain / proxy_route_id / +// npm_proxy_id / image_tag / stage_id). Rows whose Docker container ID is no +// longer present are flipped to state='missing'. // -// Dispatch precedence (a container with multiple matching labels is dispatched -// by the first match in this order): -// 1. tinyforge.workload.id label (canonical, new) -// 2. tinyforge.static-site label (legacy site — joins via static_sites) -// 3. com.docker.compose.project (stack — joins via Stack.ComposeProjectName) -// -// The legacy tinyforge.instance-id path was removed when the deployer was -// rewritten to use Container natively — every Tinyforge-managed project -// container now carries the workload labels at create time. +// Only the tinyforge.workload.id label is honored after the hard cutover — +// every Source plugin labels its containers with the workload identity at +// create time. The legacy tinyforge.static-site / compose-project paths +// were dropped along with the static_sites / stacks tables. package reconciler import ( @@ -23,7 +18,6 @@ import ( "encoding/json" "errors" "log/slog" - "strings" "sync" "time" @@ -118,12 +112,8 @@ func (r *Reconciler) ReconcileOnce(ctx context.Context) error { } seen := make(map[string]struct{}, len(items)) // container row IDs we touched - // Build a per-pass cache of compose project name → stack ID so we don't - // hit the DB once per compose container. - stackByCompose := map[string]store.Stack{} - for _, item := range items { - rowID := r.upsertFromItem(item, stackByCompose) + rowID := r.upsertFromItem(item) if rowID != "" { seen[rowID] = struct{}{} } @@ -221,16 +211,13 @@ func (r *Reconciler) loop(ctx context.Context) { // upsertFromItem dispatches one container to its workload and writes the // Container row. Returns the row ID on success or "" if no dispatch matched. -func (r *Reconciler) upsertFromItem(item docker.ReconcileItem, stackCache map[string]store.Stack) string { +// After the hard cutover only the canonical tinyforge.workload.id label +// path is honored — every Source plugin labels its containers with the +// workload identity at create time. +func (r *Reconciler) upsertFromItem(item docker.ReconcileItem) string { if id := item.Labels[docker.LabelWorkloadID]; id != "" { return r.upsertByWorkloadLabel(item, id) } - if siteID := item.Labels["tinyforge.static-site"]; siteID != "" { - return r.upsertBySiteLabel(item, siteID) - } - if cp := item.Labels["com.docker.compose.project"]; cp != "" && strings.HasPrefix(cp, "tinyforge-") { - return r.upsertByComposeProject(item, cp, stackCache) - } return "" } @@ -300,9 +287,11 @@ func (r *Reconciler) upsertByWorkloadLabel(item docker.ReconcileItem, workloadID return "" } - // Site/stack reach this branch only when their kind-specific dispatcher - // hasn't run yet (e.g. boot tick before site row is registered). The - // site/stack dispatchers below own their own deterministic IDs. + // Site/stack reach this branch only when their plugin hasn't yet + // upserted the row (e.g. a boot tick that races the first deploy). + // The deterministic ID computed here matches what the static and + // compose plugins write in their state-save paths, so a subsequent + // plugin write upserts in place rather than creating a sibling row. rowID := workloadIDRow(workloadID, kind, role, item.ID) port := 0 if len(item.Ports) > 0 { @@ -326,79 +315,6 @@ func (r *Reconciler) upsertByWorkloadLabel(item docker.ReconcileItem, workloadID return rowID } -func (r *Reconciler) upsertBySiteLabel(item docker.ReconcileItem, siteID string) string { - w, err := r.store.GetWorkloadByRef(store.WorkloadKindSite, siteID) - if err != nil { - return "" - } - rowID := w.ID + ":site" - port := 0 - if len(item.Ports) > 0 { - port = int(item.Ports[0]) - } - if err := r.store.ReconcileContainer(store.Container{ - ID: rowID, - WorkloadID: w.ID, - WorkloadKind: string(store.WorkloadKindSite), - Role: "", - ContainerID: item.ID, - ImageRef: item.Image, - Host: "local", - State: normalizeState(item.State), - Port: port, - LastSeenAt: store.Now(), - }); err != nil { - slog.Warn("reconciler: reconcile by site label", "container_id", item.ID, "error", err) - return "" - } - return rowID -} - -func (r *Reconciler) upsertByComposeProject(item docker.ReconcileItem, composeProject string, cache map[string]store.Stack) string { - stack, ok := cache[composeProject] - if !ok { - st, err := r.store.GetStackByComposeProjectName(composeProject) - if err != nil { - cache[composeProject] = store.Stack{} // negative cache for the rest of the pass - return "" - } - stack = st - cache[composeProject] = st - } - if stack.ID == "" { - return "" - } - w, err := r.store.GetWorkloadByRef(store.WorkloadKindStack, stack.ID) - if err != nil { - return "" - } - role := item.Labels["com.docker.compose.service"] - if role == "" { - role = item.Name - } - rowID := w.ID + ":" + role - port := 0 - if len(item.Ports) > 0 { - port = int(item.Ports[0]) - } - if err := r.store.ReconcileContainer(store.Container{ - ID: rowID, - WorkloadID: w.ID, - WorkloadKind: string(store.WorkloadKindStack), - Role: role, - ContainerID: item.ID, - ImageRef: item.Image, - Host: "local", - State: normalizeState(item.State), - Port: port, - LastSeenAt: store.Now(), - }); err != nil { - slog.Warn("reconciler: reconcile by compose project", "container_id", item.ID, "error", err) - return "" - } - return rowID -} - // markMissingRows flips state to 'missing' for any container row whose Docker // container ID was not seen in this pass. Uses ListMissingSweepRows to scan // only rows that are bound to a real container and not already missing. @@ -419,9 +335,11 @@ func (r *Reconciler) markMissingRows(seen map[string]struct{}) { } // workloadIDRow picks the row ID for a non-project workload-labelled -// container that has no existing row. Stack rows use workloadID:role; sites -// use workloadID:site. Project rows are never invented here — see -// upsertByWorkloadLabel for the rationale. +// container that has no existing row. Sites use `:site` +// (matches the static plugin's `containerRowID` helper). Stack +// services use `:` (matches the compose +// plugin). Project rows are never invented here — the deployer +// pre-creates per-instance UUID rows so the reconciler must wait. func workloadIDRow(workloadID, kind, role, containerID string) string { if kind == string(store.WorkloadKindSite) { return workloadID + ":site" diff --git a/internal/reconciler/reconciler_test.go b/internal/reconciler/reconciler_test.go index fb8f1ec..53e324e 100644 --- a/internal/reconciler/reconciler_test.go +++ b/internal/reconciler/reconciler_test.go @@ -28,17 +28,23 @@ func newTestStore(t *testing.T) *store.Store { return s } +// makeWorkload inserts a workload row with the given kind so reconciler +// dispatch can resolve it by ID. +func makeWorkload(t *testing.T, st *store.Store, name, kind string) store.Workload { + t.Helper() + w, err := st.CreateWorkload(store.Workload{ + Kind: kind, RefID: name + "-ref", Name: name, + }) + if err != nil { + t.Fatalf("CreateWorkload: %v", err) + } + return w +} + func TestReconcileWorkloadLabelledStackContainer(t *testing.T) { st := newTestStore(t) - // Set up a stack workload (no project/site interaction). - stack, err := st.CreateStack(store.Stack{ - Name: "wf-stack", ComposeProjectName: "tinyforge-wf-stack", - }) - if err != nil { - t.Fatalf("CreateStack: %v", err) - } - w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID) + w := makeWorkload(t, st, "wf-stack", "stack") // One container with the canonical workload labels stamped. fake := &fakeDocker{items: []docker.ReconcileItem{{ @@ -76,51 +82,10 @@ func TestReconcileWorkloadLabelledStackContainer(t *testing.T) { } } -func TestReconcileComposeOnlyStackContainer(t *testing.T) { - st := newTestStore(t) - - stack, _ := st.CreateStack(store.Stack{ - Name: "compose-stack", ComposeProjectName: "tinyforge-compose-stack", - }) - w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID) - - // Pre-existing compose container — only carries compose's own labels, - // no tinyforge.* labels at all. - fake := &fakeDocker{items: []docker.ReconcileItem{{ - ID: "docker-xyz", - Name: "tinyforge-compose-stack-worker-1", - Image: "redis:7", - State: "running", - Labels: map[string]string{ - "com.docker.compose.project": "tinyforge-compose-stack", - "com.docker.compose.service": "worker", - }, - }}} - - r := New(st, fake, 0) - if err := r.ReconcileOnce(context.Background()); err != nil { - t.Fatalf("ReconcileOnce: %v", err) - } - - rows, _ := st.ListContainersByWorkload(w.ID) - if len(rows) != 1 { - t.Fatalf("expected 1 row, got %d", len(rows)) - } - if rows[0].Role != "worker" { - t.Fatalf("role from compose label wrong: %q", rows[0].Role) - } - if rows[0].ContainerID != "docker-xyz" { - t.Fatalf("container_id not bound: %q", rows[0].ContainerID) - } -} - func TestReconcileMarksMissingRows(t *testing.T) { st := newTestStore(t) - stack, _ := st.CreateStack(store.Stack{ - Name: "missing-stack", ComposeProjectName: "tinyforge-missing-stack", - }) - w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID) + w := makeWorkload(t, st, "missing-stack", "stack") // Pre-existing row with a real container_id that no longer exists. if err := st.UpsertContainer(store.Container{ @@ -145,10 +110,7 @@ func TestReconcileMarksMissingRows(t *testing.T) { func TestReconcileSkipsRowsAwaitingDocker(t *testing.T) { st := newTestStore(t) - stack, _ := st.CreateStack(store.Stack{ - Name: "pending", ComposeProjectName: "tinyforge-pending", - }) - w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID) + w := makeWorkload(t, st, "pending", "stack") // A row with empty container_id (deployer placeholder, awaiting docker // create). Reconciler must not mark this as missing. @@ -171,9 +133,8 @@ func TestReconcileSkipsRowsAwaitingDocker(t *testing.T) { } func TestReconcileIgnoresUnmanagedContainers(t *testing.T) { - // A container without any tinyforge or compose labels would not even be - // returned by ListAllForReconciler in production; but the dispatch must - // be a no-op even if a stray item slips through. + // A container without the canonical workload label is ignored even if + // it carries other labels — only tinyforge.workload.id is honored. st := newTestStore(t) fake := &fakeDocker{items: []docker.ReconcileItem{{ ID: "docker-foreign", Labels: map[string]string{"app": "other"}, @@ -197,11 +158,7 @@ func TestReconcileDoesNotClobberDeployerFields(t *testing.T) { // Project workload — exercises the path most affected by the regression // (proxies, blue-green slots, image-tag-based stale detection). - project, err := st.CreateProject(store.Project{Name: "p", Image: "nginx"}) - if err != nil { - t.Fatalf("CreateProject: %v", err) - } - w, _ := st.GetWorkloadByRef(store.WorkloadKindProject, project.ID) + w := makeWorkload(t, st, "p", "project") // Deployer wrote the row with proxy / subdomain / image_tag / stage_id. deployerRow := store.Container{ @@ -277,11 +234,7 @@ func TestReconcileRejectsForgedWorkloadLabel(t *testing.T) { // authoritative writer and inventing rows races with MaxInstances > 1 deploys. func TestReconcileSkipsProjectInsertWithoutDeployerRow(t *testing.T) { st := newTestStore(t) - project, err := st.CreateProject(store.Project{Name: "p2", Image: "nginx"}) - if err != nil { - t.Fatalf("CreateProject: %v", err) - } - w, _ := st.GetWorkloadByRef(store.WorkloadKindProject, project.ID) + w := makeWorkload(t, st, "p2", "project") // Reconciler sees a real container with project labels but no deployer // row exists yet (race during deploy). @@ -306,10 +259,7 @@ func TestReconcileSkipsProjectInsertWithoutDeployerRow(t *testing.T) { func TestReconcileNormalizesState(t *testing.T) { st := newTestStore(t) - stack, _ := st.CreateStack(store.Stack{ - Name: "norm", ComposeProjectName: "tinyforge-norm", - }) - w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID) + w := makeWorkload(t, st, "norm", "stack") fake := &fakeDocker{items: []docker.ReconcileItem{{ ID: "docker-1", diff --git a/internal/registry/poller.go b/internal/registry/poller.go deleted file mode 100644 index 1b340ac..0000000 --- a/internal/registry/poller.go +++ /dev/null @@ -1,189 +0,0 @@ -package registry - -import ( - "context" - "fmt" - "log/slog" - "sync" - "time" - - "github.com/alexei/tinyforge/internal/crypto" - "github.com/alexei/tinyforge/internal/store" - "github.com/robfig/cron/v3" -) - -// Poller periodically checks registries for new image tags and triggers -// deployments for stages with auto_deploy enabled. -type Poller struct { - store *store.Store - deployer DeployTriggerer - encKey [32]byte - cron *cron.Cron - mu sync.Mutex - entryID cron.EntryID - running bool -} - -// NewPoller creates a new Poller instance. -func NewPoller(st *store.Store, deployer DeployTriggerer, encKey [32]byte) *Poller { - return &Poller{ - store: st, - deployer: deployer, - encKey: encKey, - cron: cron.New(), - } -} - -// Start begins the polling scheduler with the given interval string (e.g., "5m", "1h"). -// If the poller is already running, it stops and restarts with the new interval. -func (p *Poller) Start(interval string) error { - p.mu.Lock() - defer p.mu.Unlock() - - duration, err := time.ParseDuration(interval) - if err != nil { - return fmt.Errorf("parse polling interval %q: %w", interval, err) - } - - // Stop existing schedule if running. - if p.running { - p.cron.Remove(p.entryID) - } - - // Convert duration to a cron schedule: @every . - spec := fmt.Sprintf("@every %s", duration.String()) - entryID, err := p.cron.AddFunc(spec, func() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - if pollErr := p.poll(ctx); pollErr != nil { - slog.Warn("poller: poll error", "error", pollErr) - } - }) - if err != nil { - return fmt.Errorf("schedule poller: %w", err) - } - - p.entryID = entryID - if !p.running { - p.cron.Start() - } - p.running = true - - slog.Info("poller started", "interval", duration.String()) - return nil -} - -// Stop gracefully shuts down the poller. -func (p *Poller) Stop() { - p.mu.Lock() - defer p.mu.Unlock() - - if p.running { - ctx := p.cron.Stop() - <-ctx.Done() - p.running = false - slog.Info("poller stopped") - } -} - -// poll performs a single polling cycle: iterates over all projects and their -// stages, checks for new tags, and triggers deploys where appropriate. -func (p *Poller) poll(ctx context.Context) error { - projects, err := p.store.GetAllProjects() - if err != nil { - return fmt.Errorf("get projects: %w", err) - } - - for _, project := range projects { - if err := p.pollProject(ctx, project); err != nil { - slog.Warn("poller: project error", "project", project.Name, "id", project.ID, "error", err) - } - } - return nil -} - -// pollProject checks all stages of a single project for new tags. -func (p *Poller) pollProject(ctx context.Context, project store.Project) error { - if project.Registry == "" { - return nil - } - - reg, err := p.store.GetRegistryByName(project.Registry) - if err != nil { - return fmt.Errorf("get registry %s: %w", project.Registry, err) - } - - token, err := crypto.Decrypt(p.encKey, reg.Token) - if err != nil { - token = reg.Token - } - - client, err := NewClient(reg.Type, reg.URL, token) - if err != nil { - return fmt.Errorf("create registry client: %w", err) - } - - tags, err := client.ListTags(ctx, project.Image) - if err != nil { - return fmt.Errorf("list tags for %s: %w", project.Image, err) - } - - stages, err := p.store.GetStagesByProjectID(project.ID) - if err != nil { - return fmt.Errorf("get stages for project %s: %w", project.ID, err) - } - - for _, stage := range stages { - if err := p.pollStage(ctx, project, stage, tags); err != nil { - slog.Warn("poller: stage error", "project", project.Name, "stage", stage.Name, "error", err) - } - } - return nil -} - -// pollStage checks a single stage for new tags and triggers deploy if needed. -func (p *Poller) pollStage(ctx context.Context, project store.Project, stage store.Stage, allTags []string) error { - latest, err := LatestTag(allTags, stage.TagPattern) - if err != nil { - return fmt.Errorf("match tags for stage %s: %w", stage.Name, err) - } - if latest == "" { - return nil - } - - state, err := p.store.GetPollState(stage.ID) - if err != nil { - return p.store.UpsertPollState(store.PollState{ - StageID: stage.ID, - LastTag: latest, - LastPolled: store.Now(), - }) - } - - defer func() { - if err := p.store.UpsertPollState(store.PollState{ - StageID: stage.ID, - LastTag: latest, - LastPolled: store.Now(), - }); err != nil { - slog.Warn("poller: failed to update poll state", "stage_id", stage.ID, "error", err) - } - }() - - if state.LastTag == latest { - return nil - } - - slog.Info("poller: new tag detected", "tag", latest, "project", project.Name, "stage", stage.Name, "previous", state.LastTag) - - if !stage.AutoDeploy { - slog.Info("poller: auto_deploy disabled, skipping", "stage", stage.Name) - return nil - } - - if err := p.deployer.TriggerDeploy(ctx, project.ID, stage.ID, latest); err != nil { - return fmt.Errorf("trigger deploy for tag %s: %w", latest, err) - } - - return nil -} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 4471b3b..2b13da8 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -29,13 +29,6 @@ type Client interface { ListImages(ctx context.Context, owner string) ([]RegistryImage, error) } -// DeployTriggerer is called by the poller when a new tag is detected for a -// stage with auto_deploy enabled. This decouples the registry package from the -// deployer implementation. -type DeployTriggerer interface { - TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error -} - // MatchTags filters a list of tags, returning only those that match the given // glob pattern. Pattern matching uses path.Match semantics (*, ?, []). // Returns an error if the pattern is malformed. diff --git a/internal/stack/manager.go b/internal/stack/manager.go deleted file mode 100644 index dd70cd1..0000000 --- a/internal/stack/manager.go +++ /dev/null @@ -1,405 +0,0 @@ -package stack - -import ( - "context" - "fmt" - "log/slog" - "os" - "path/filepath" - "regexp" - "strings" - "time" - - "github.com/alexei/tinyforge/internal/events" - "github.com/alexei/tinyforge/internal/store" -) - -// Manager orchestrates the stack deployment pipeline: validate YAML, persist -// a revision, write YAML to disk, run `docker compose up`, update status. -type Manager struct { - store *store.Store - compose *Compose - eventBus *events.Bus - workDir string // where per-stack YAML files are written -} - -// NewManager constructs a stack Manager. workDir is the directory where -// per-stack YAML files are written; it is created if missing. -func NewManager(st *store.Store, compose *Compose, eventBus *events.Bus, workDir string) (*Manager, error) { - if workDir == "" { - workDir = filepath.Join(os.TempDir(), "tinyforge-stacks") - } - if err := os.MkdirAll(workDir, 0o755); err != nil { - return nil, fmt.Errorf("create stack workdir: %w", err) - } - return &Manager{ - store: st, - compose: compose, - eventBus: eventBus, - workDir: workDir, - }, nil -} - -// Available reports whether the underlying `docker compose` CLI is usable. -func (m *Manager) Available(ctx context.Context) error { - return m.compose.Available(ctx) -} - -// Create inserts a new stack + its initial revision. Does NOT deploy. -func (m *Manager) Create(ctx context.Context, name, description, yamlText, author string) (store.Stack, store.StackRevision, error) { - if strings.TrimSpace(name) == "" { - return store.Stack{}, store.StackRevision{}, fmt.Errorf("name is required") - } - spec, err := Parse(yamlText) - if err != nil { - return store.Stack{}, store.StackRevision{}, err - } - if err := Validate(spec); err != nil { - return store.Stack{}, store.StackRevision{}, err - } - - st := store.Stack{ - Name: name, - Description: description, - ComposeProjectName: composeProjectName(name), - Status: "stopped", - } - st, err = m.store.CreateStack(st) - if err != nil { - return store.Stack{}, store.StackRevision{}, err - } - - rev, err := m.store.CreateStackRevision(store.StackRevision{ - StackID: st.ID, - YAML: yamlText, - Author: author, - }) - if err != nil { - // Best-effort cleanup of the stack row. - _ = m.store.DeleteStack(st.ID) - return store.Stack{}, store.StackRevision{}, err - } - return st, rev, nil -} - -// Deploy brings up the stack for the given revision. Updates stack + revision -// status transitions: deploying → running | failed. Blocking. -func (m *Manager) Deploy(ctx context.Context, stackID, revisionID string) error { - st, err := m.store.GetStackByID(stackID) - if err != nil { - return err - } - rev, err := m.store.GetStackRevisionByID(revisionID) - if err != nil { - return err - } - if rev.StackID != stackID { - return fmt.Errorf("revision %s does not belong to stack %s", revisionID, stackID) - } - - deploy, err := m.store.CreateStackDeploy(store.StackDeploy{ - StackID: stackID, - RevisionID: revisionID, - Status: "deploying", - }) - if err != nil { - return err - } - _ = m.store.UpdateStackRevisionStatus(rev.ID, "deploying", deploy.ID) - m.setStatus(st, "deploying", "") - - yamlPath, err := m.writeYAML(st.ID, rev.Revision, rev.YAML) - if err != nil { - m.failDeploy(st, deploy, rev, fmt.Sprintf("write yaml: %v", err)) - return err - } - - out, upErr := m.compose.Up(ctx, st.ComposeProjectName, yamlPath) - if upErr != nil { - m.failDeploy(st, deploy, rev, fmt.Sprintf("compose up: %v\n%s", upErr, out)) - return upErr - } - - // Success. - deploy.Status = "success" - deploy.Log = out - deploy.FinishedAt = store.Now() - _ = m.store.UpdateStackDeploy(deploy) - _ = m.store.UpdateStackRevisionStatus(rev.ID, "success", deploy.ID) - _ = m.store.SetStackCurrentRevision(st.ID, rev.ID) - m.setStatus(st, "running", "") - m.syncContainerRows(ctx, st, yamlPath) - return nil -} - -// NewRevisionAndDeploy appends a new revision (validating YAML first) and deploys it. -func (m *Manager) NewRevisionAndDeploy(ctx context.Context, stackID, yamlText, author string) (store.StackRevision, error) { - spec, err := Parse(yamlText) - if err != nil { - return store.StackRevision{}, err - } - if err := Validate(spec); err != nil { - return store.StackRevision{}, err - } - rev, err := m.store.CreateStackRevision(store.StackRevision{ - StackID: stackID, - YAML: yamlText, - Author: author, - }) - if err != nil { - return store.StackRevision{}, err - } - if err := m.Deploy(ctx, stackID, rev.ID); err != nil { - return rev, err - } - return rev, nil -} - -// NewRevisionAndDeployAsync creates a revision and triggers deploy in a goroutine. -// Returns the created revision immediately. -func (m *Manager) NewRevisionAndDeployAsync(ctx context.Context, stackID, yamlText, author string) (store.StackRevision, error) { - spec, err := Parse(yamlText) - if err != nil { - return store.StackRevision{}, err - } - if err := Validate(spec); err != nil { - return store.StackRevision{}, err - } - rev, err := m.store.CreateStackRevision(store.StackRevision{ - StackID: stackID, - YAML: yamlText, - Author: author, - }) - if err != nil { - return store.StackRevision{}, err - } - go func(stackID, revID string) { - bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) - defer cancel() - if err := m.Deploy(bgCtx, stackID, revID); err != nil { - slog.Warn("stack: async deploy failed", "stack", stackID, "revision", revID, "error", err) - } - }(stackID, rev.ID) - return rev, nil -} - -// RollbackAsync creates a copy-revision from a target and deploys asynchronously. -func (m *Manager) RollbackAsync(ctx context.Context, stackID, targetRevisionID, author string) (store.StackRevision, error) { - target, err := m.store.GetStackRevisionByID(targetRevisionID) - if err != nil { - return store.StackRevision{}, err - } - if target.StackID != stackID { - return store.StackRevision{}, fmt.Errorf("revision %s does not belong to stack %s", targetRevisionID, stackID) - } - return m.NewRevisionAndDeployAsync(ctx, stackID, target.YAML, author+" (rollback to rev "+itoa(target.Revision)+")") -} - -// Rollback creates a new revision whose YAML is copied from the given prior -// revision, then deploys it. Keeps history append-only. -func (m *Manager) Rollback(ctx context.Context, stackID, targetRevisionID, author string) (store.StackRevision, error) { - target, err := m.store.GetStackRevisionByID(targetRevisionID) - if err != nil { - return store.StackRevision{}, err - } - if target.StackID != stackID { - return store.StackRevision{}, fmt.Errorf("revision %s does not belong to stack %s", targetRevisionID, stackID) - } - return m.NewRevisionAndDeploy(ctx, stackID, target.YAML, author+" (rollback to rev "+itoa(target.Revision)+")") -} - -// Stop runs `docker compose stop` without removing containers. -func (m *Manager) Stop(ctx context.Context, stackID string) error { - st, err := m.store.GetStackByID(stackID) - if err != nil { - return err - } - if _, err := m.compose.Stop(ctx, st.ComposeProjectName); err != nil { - return err - } - m.setStatus(st, "stopped", "") - m.markStackContainersState(stackID, "stopped") - return nil -} - -// Start runs `docker compose start` on existing containers. -func (m *Manager) Start(ctx context.Context, stackID string) error { - st, err := m.store.GetStackByID(stackID) - if err != nil { - return err - } - if _, err := m.compose.Start(ctx, st.ComposeProjectName); err != nil { - return err - } - m.setStatus(st, "running", "") - m.markStackContainersState(stackID, "running") - return nil -} - -// Delete tears down the stack and removes the DB row. If removeVolumes is -// true, named volumes are also deleted (`compose down -v`). Destructive. -func (m *Manager) Delete(ctx context.Context, stackID string, removeVolumes bool) error { - st, err := m.store.GetStackByID(stackID) - if err != nil { - return err - } - if _, err := m.compose.Down(ctx, st.ComposeProjectName, removeVolumes); err != nil { - // Log but continue — DB row must not be orphaned. - slog.Warn("stack: compose down failed", "stack", st.Name, "error", err) - } - // Best-effort YAML cleanup. - _ = os.RemoveAll(filepath.Join(m.workDir, st.ID)) - return m.store.DeleteStack(stackID) -} - -// Services returns current service state for a stack. -func (m *Manager) Services(ctx context.Context, stackID string) ([]Service, error) { - st, err := m.store.GetStackByID(stackID) - if err != nil { - return nil, err - } - yamlPath := "" - if st.CurrentRevisionID != "" { - if rev, err := m.store.GetStackRevisionByID(st.CurrentRevisionID); err == nil { - yamlPath, _ = m.writeYAML(st.ID, rev.Revision, rev.YAML) - } - } - ctx, cancel := context.WithTimeout(ctx, 15*time.Second) - defer cancel() - return m.compose.Ps(ctx, st.ComposeProjectName, yamlPath) -} - -// Logs returns the last `tail` log lines for a service (or all services if empty). -func (m *Manager) Logs(ctx context.Context, stackID, service string, tail int) (string, error) { - st, err := m.store.GetStackByID(stackID) - if err != nil { - return "", err - } - if tail <= 0 { - tail = 200 - } - return m.compose.Logs(ctx, st.ComposeProjectName, service, tail) -} - -// --- internals --- - -// syncContainerRows upserts one Container row per compose service for this -// stack so the global container index stays in sync after every deploy. The -// Docker container ID is left empty here — the reconciler resolves it from -// `docker ps` via the `com.docker.compose.project` label. Best-effort: a -// failure here is logged but does not affect deploy outcome. -func (m *Manager) syncContainerRows(ctx context.Context, st store.Stack, yamlPath string) { - w, err := m.store.GetWorkloadByRef(store.WorkloadKindStack, st.ID) - if err != nil { - slog.Warn("stack: resolve workload", "stack", st.ID, "error", err) - return - } - psCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - services, err := m.compose.Ps(psCtx, st.ComposeProjectName, yamlPath) - if err != nil { - slog.Warn("stack: compose ps for container sync", "stack", st.ID, "error", err) - return - } - for _, svc := range services { - state := svc.State - if state == "" { - state = svc.Status - } - m.upsertStackContainer(w.ID, svc, state) - } -} - -// upsertStackContainer writes a Container row for one compose service. The -// row ID is deterministic — `:` — so re-deploys update -// the same row instead of accumulating rows. -func (m *Manager) upsertStackContainer(workloadID string, svc Service, state string) { - role := svc.Service - if role == "" { - role = svc.Name - } - if err := m.store.UpsertContainer(store.Container{ - ID: workloadID + ":" + role, - WorkloadID: workloadID, - WorkloadKind: string(store.WorkloadKindStack), - Role: role, - ContainerID: "", // reconciler fills in from docker ps - Host: "local", - State: state, - LastSeenAt: store.Now(), - }); err != nil { - slog.Warn("stack: upsert container row", "workload_id", workloadID, "service", role, "error", err) - } -} - -// markStackContainersState bulk-updates the state of every container row for -// this stack (used by Stop/Start which don't go through compose ps). -func (m *Manager) markStackContainersState(stackID, state string) { - w, err := m.store.GetWorkloadByRef(store.WorkloadKindStack, stackID) - if err != nil { - return - } - rows, err := m.store.ListContainersByWorkload(w.ID) - if err != nil { - slog.Warn("stack: list containers for state update", "workload_id", w.ID, "error", err) - return - } - for _, r := range rows { - if err := m.store.UpdateContainerState(r.ID, state); err != nil { - slog.Warn("stack: update container state", "container_row", r.ID, "error", err) - } - } -} - -func (m *Manager) setStatus(st store.Stack, status, errMsg string) { - _ = m.store.UpdateStackStatus(st.ID, status, errMsg) - if m.eventBus != nil { - m.eventBus.Publish(events.Event{ - Type: events.EventStackStatus, - Payload: events.StackStatusPayload{ - StackID: st.ID, - Name: st.Name, - Status: status, - Error: errMsg, - }, - }) - } -} - -func (m *Manager) failDeploy(st store.Stack, d store.StackDeploy, rev store.StackRevision, errMsg string) { - d.Status = "failed" - d.Error = errMsg - d.FinishedAt = store.Now() - _ = m.store.UpdateStackDeploy(d) - _ = m.store.UpdateStackRevisionStatus(rev.ID, "failed", d.ID) - m.setStatus(st, "failed", errMsg) -} - -// writeYAML writes yaml to //rev-.yml and returns the path. -func (m *Manager) writeYAML(stackID string, revision int, yamlText string) (string, error) { - dir := filepath.Join(m.workDir, stackID) - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", err - } - path := filepath.Join(dir, fmt.Sprintf("rev-%d.yml", revision)) - if err := os.WriteFile(path, []byte(yamlText), 0o644); err != nil { - return "", err - } - return path, nil -} - -// composeProjectName sanitises a user-provided stack name into something -// `docker compose -p` will accept: lowercase, digits, dashes only. -func composeProjectName(name string) string { - name = strings.ToLower(name) - name = nonProjectChars.ReplaceAllString(name, "-") - name = strings.Trim(name, "-") - if name == "" { - name = "stack" - } - return "tinyforge-" + name -} - -var nonProjectChars = regexp.MustCompile(`[^a-z0-9-]+`) - -func itoa(n int) string { return fmt.Sprintf("%d", n) } diff --git a/internal/staticsite/healthcheck.go b/internal/staticsite/healthcheck.go deleted file mode 100644 index eef9c95..0000000 --- a/internal/staticsite/healthcheck.go +++ /dev/null @@ -1,111 +0,0 @@ -package staticsite - -import ( - "context" - "fmt" - "log/slog" - "sync" - "time" - - "github.com/alexei/tinyforge/internal/docker" - "github.com/alexei/tinyforge/internal/store" - "github.com/robfig/cron/v3" -) - -// HealthChecker periodically checks that deployed static site containers -// are still running. If a container has crashed, it updates the site status -// to "failed" and optionally triggers a redeploy. -type HealthChecker struct { - store *store.Store - docker *docker.Client - manager *Manager - - cron *cron.Cron - mu sync.Mutex - entryID cron.EntryID - running bool -} - -// NewHealthChecker creates a new static site health checker. -func NewHealthChecker(st *store.Store, dockerClient *docker.Client, mgr *Manager) *HealthChecker { - return &HealthChecker{ - store: st, - docker: dockerClient, - manager: mgr, - cron: cron.New(), - } -} - -// Start begins the periodic health check with the given interval (e.g., "5m", "1m"). -func (h *HealthChecker) Start(interval string) error { - h.mu.Lock() - defer h.mu.Unlock() - - duration, err := time.ParseDuration(interval) - if err != nil { - return fmt.Errorf("parse interval %q: %w", interval, err) - } - - if h.running { - h.cron.Remove(h.entryID) - } - - spec := fmt.Sprintf("@every %s", duration) - id, err := h.cron.AddFunc(spec, h.check) - if err != nil { - return fmt.Errorf("schedule health check: %w", err) - } - - h.entryID = id - h.running = true - h.cron.Start() - - slog.Info("static site health checker started", "interval", interval) - return nil -} - -// Stop stops the periodic health checker. -func (h *HealthChecker) Stop() { - h.mu.Lock() - defer h.mu.Unlock() - - if h.running { - h.cron.Stop() - h.running = false - slog.Info("static site health checker stopped") - } -} - -// check runs a single health check pass over all deployed static sites. -func (h *HealthChecker) check() { - sites, err := h.store.GetAllStaticSites() - if err != nil { - slog.Error("static site health check: failed to list sites", "error", err) - return - } - - ctx := context.Background() - - for _, site := range sites { - if site.Status != "deployed" || site.ContainerID == "" { - continue - } - - running, err := h.docker.IsContainerRunning(ctx, site.ContainerID) - if err != nil { - // Container might have been removed externally. - slog.Warn("static site health check: container inspect failed", - "site", site.Name, "container", site.ContainerID[:12], "error", err) - h.manager.updateStatus(site.ID, "failed", site.LastCommitSHA, "container not found") - h.manager.publishEvent(site.ID, site.Name, "failed: container not found") - continue - } - - if !running { - slog.Warn("static site health check: container not running", - "site", site.Name, "container", site.ContainerID[:12]) - h.manager.updateStatus(site.ID, "failed", site.LastCommitSHA, "container stopped unexpectedly") - h.manager.publishEvent(site.ID, site.Name, "failed: container stopped unexpectedly") - } - } -} diff --git a/internal/staticsite/manager.go b/internal/staticsite/manager.go deleted file mode 100644 index c59de6f..0000000 --- a/internal/staticsite/manager.go +++ /dev/null @@ -1,834 +0,0 @@ -package staticsite - -import ( - "context" - "fmt" - "io" - "log/slog" - "os" - "path/filepath" - "strconv" - "time" - - "github.com/moby/moby/api/types/mount" - - "github.com/alexei/tinyforge/internal/crypto" - "github.com/alexei/tinyforge/internal/docker" - "github.com/alexei/tinyforge/internal/events" - "github.com/alexei/tinyforge/internal/notify" - "github.com/alexei/tinyforge/internal/proxy" - "github.com/alexei/tinyforge/internal/staticsite/deno" - "github.com/alexei/tinyforge/internal/store" -) - -// Manager orchestrates the static site deployment pipeline. -type Manager struct { - store *store.Store - docker *docker.Client - proxyProvider proxy.Provider - eventBus *events.Bus - notifier *notify.Notifier - encKey [32]byte -} - -// NewManager creates a new static site manager. -func NewManager( - st *store.Store, - dockerClient *docker.Client, - proxyProvider proxy.Provider, - eventBus *events.Bus, - notifier *notify.Notifier, - encKey [32]byte, -) *Manager { - return &Manager{ - store: st, - docker: dockerClient, - proxyProvider: proxyProvider, - eventBus: eventBus, - notifier: notifier, - encKey: encKey, - } -} - -// SetProxyProvider updates the proxy provider at runtime. -func (m *Manager) SetProxyProvider(provider proxy.Provider) { - m.proxyProvider = provider -} - -// resolveSiteWorkloadID returns the workload ID paired with a static site. -// Boot-time backfill guarantees the row exists; on lookup miss this returns -// empty so the caller can decide (the deployer continues without the label). -func (m *Manager) resolveSiteWorkloadID(siteID string) string { - w, err := m.store.GetWorkloadByRef(store.WorkloadKindSite, siteID) - if err != nil { - slog.Warn("static site: resolve workload", "site_id", siteID, "error", err) - return "" - } - return w.ID -} - -// upsertSiteContainer keeps the global container index in sync with the -// site's current container. Row ID is deterministic (workloadID + ":site") -// so re-deploys update in place. Best-effort. -func (m *Manager) upsertSiteContainer(site store.StaticSite, containerID, state string) { - workloadID := m.resolveSiteWorkloadID(site.ID) - if workloadID == "" { - return - } - if err := m.store.UpsertContainer(store.Container{ - ID: workloadID + ":site", - WorkloadID: workloadID, - WorkloadKind: string(store.WorkloadKindSite), - Role: "", - ContainerID: containerID, - Host: "local", - State: state, - Subdomain: site.Domain, - ProxyRouteID: site.ProxyRouteID, - LastSeenAt: store.Now(), - }); err != nil { - slog.Warn("static site: upsert container row", "site_id", site.ID, "error", err) - } -} - -// markSiteContainerState bulk-updates state for the site's container row. -// Used by Stop/Start which only flip state. -func (m *Manager) markSiteContainerState(siteID, state string) { - workloadID := m.resolveSiteWorkloadID(siteID) - if workloadID == "" { - return - } - rowID := workloadID + ":site" - if err := m.store.UpdateContainerState(rowID, state); err != nil { - // NotFound is fine — the site may have never deployed. - slog.Debug("static site: update container state", "row", rowID, "error", err) - } -} - -// Deploy fetches content from Gitea and deploys a static site container. -// If force is true, skips the "no changes" check and always rebuilds/redeploys. -func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error { - site, err := m.store.GetStaticSiteByID(siteID) - if err != nil { - return fmt.Errorf("get site: %w", err) - } - - // Decrypt access token if present. - token := "" - if site.AccessToken != "" { - decrypted, err := crypto.Decrypt(m.encKey, site.AccessToken) - if err != nil { - slog.Warn("static site: failed to decrypt access token", "site", site.Name, "error", err) - } else { - token = decrypted - } - } - - provider, err := NewGitProvider(ProviderType(site.Provider), site.GiteaURL, token) - if err != nil { - m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("create provider: %v", err)) - return fmt.Errorf("create provider: %w", err) - } - - // Check if there's a new commit. - latestSHA, err := provider.GetLatestCommitSHA(ctx, site.RepoOwner, site.RepoName, site.Branch) - if err != nil { - m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("fetch commit SHA: %v", err)) - return fmt.Errorf("get latest commit: %w", err) - } - - // Skip redeploy only if SHA matches, status is deployed, container is running, - // proxy route exists, AND force is false. Manual deploys always force a full rebuild. - if !force && latestSHA == site.LastCommitSHA && site.Status == "deployed" && site.ContainerID != "" { - running, _ := m.docker.IsContainerRunning(ctx, site.ContainerID) - if !running { - slog.Info("static site: container not running, forcing redeploy", "site", site.Name) - } else if site.Domain != "" { - // Also verify the proxy route still exists (it may have been deleted externally). - proxyOK, err := m.proxyProvider.RouteExists(ctx, site.Domain) - if err != nil { - slog.Warn("static site: proxy check failed, forcing redeploy", "site", site.Name, "error", err) - } else if !proxyOK { - slog.Info("static site: proxy route missing, forcing redeploy", "site", site.Name) - } else { - slog.Info("static site: no changes", "site", site.Name, "sha", latestSHA) - return nil - } - } else { - slog.Info("static site: no changes", "site", site.Name, "sha", latestSHA) - return nil - } - } - - // Update status to syncing. - m.updateStatus(site.ID, "syncing", site.LastCommitSHA, "") - m.publishEvent(site.ID, site.Name, "syncing") - - // Create temp directory for the build context. - buildDir, err := os.MkdirTemp("", "dw-site-"+site.Name+"-*") - if err != nil { - m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("create temp dir: %v", err)) - return fmt.Errorf("create temp dir: %w", err) - } - defer os.RemoveAll(buildDir) - - // Download folder contents. - if err := provider.DownloadFolder(ctx, site.RepoOwner, site.RepoName, site.Branch, site.FolderPath, buildDir); err != nil { - m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("download folder: %v", err)) - return fmt.Errorf("download folder: %w", err) - } - - // Render markdown if enabled. - if site.RenderMarkdown { - if err := RenderMarkdownFiles(buildDir); err != nil { - slog.Warn("static site: markdown rendering failed", "site", site.Name, "error", err) - } - } - - // Determine mode: check for api/ subdirectory. - mode := site.Mode - apiDir := filepath.Join(buildDir, "api") - hasAPI := false - if info, err := os.Stat(apiDir); err == nil && info.IsDir() { - hasAPI = true - } - if mode == "deno" && !hasAPI { - // Fallback to static if no api/ folder found. - mode = "static" - slog.Info("static site: no api/ folder found, falling back to static mode", "site", site.Name) - } - - // Prepare build context based on mode. - imageTag := fmt.Sprintf("dw-site-%s:latest", site.Name) - contextDir, err := os.MkdirTemp("", "dw-site-build-*") - if err != nil { - m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("create build context: %v", err)) - return fmt.Errorf("create build context dir: %w", err) - } - defer os.RemoveAll(contextDir) - - if mode == "deno" { - if err := m.prepareDenoBuild(buildDir, contextDir); err != nil { - m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("prepare deno build: %v", err)) - return fmt.Errorf("prepare deno build: %w", err) - } - } else { - if err := m.prepareStaticBuild(buildDir, contextDir); err != nil { - m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("prepare static build: %v", err)) - return fmt.Errorf("prepare static build: %w", err) - } - } - - // Build Docker image. - if err := m.docker.BuildImage(ctx, contextDir, imageTag); err != nil { - m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("build image: %v", err)) - return fmt.Errorf("build image: %w", err) - } - - // Prepare environment variables (secrets). - env, err := m.buildEnvVars(site.ID) - if err != nil { - m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("build env vars: %v", err)) - return fmt.Errorf("build env vars: %w", err) - } - - // Determine container port. - containerPort := "80" - if mode == "deno" { - containerPort = "8000" - } - - // Get network settings. - settings, err := m.store.GetSettings() - if err != nil { - m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("get settings: %v", err)) - return fmt.Errorf("get settings: %w", err) - } - - networkName := settings.Network - networkID, err := m.docker.EnsureNetwork(ctx, networkName) - if err != nil { - m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("ensure network: %v", err)) - return fmt.Errorf("ensure network: %w", err) - } - - containerName := fmt.Sprintf("dw-site-%s", site.Name) - - // Prepare volume mounts for persistent storage. - var mounts []mount.Mount - if site.StorageEnabled && mode == "deno" { - volName, volErr := m.docker.EnsureSiteVolume(ctx, site.Name) - if volErr != nil { - slog.Warn("static site: failed to ensure storage volume", "site", site.Name, "error", volErr) - } else { - mounts = append(mounts, mount.Mount{ - Type: mount.TypeVolume, - Source: volName, - Target: "/app/data", - }) - slog.Info("static site: storage volume attached", "site", site.Name, "volume", volName) - } - } - - // Create and start new container. - containerID, err := m.docker.CreateContainer(ctx, docker.ContainerConfig{ - Name: containerName, - Image: imageTag, - Env: env, - ExposedPorts: []string{containerPort + "/tcp"}, - NetworkName: networkName, - NetworkID: networkID, - Mounts: mounts, - Labels: map[string]string{ - "tinyforge.static-site": site.ID, - "tinyforge.static-site-name": site.Name, - }, - WorkloadID: m.resolveSiteWorkloadID(site.ID), - WorkloadKind: string(store.WorkloadKindSite), - Role: "", - }) - if err != nil { - // Container might already exist — try to remove and recreate. - if site.ContainerID != "" { - m.docker.StopContainer(ctx, site.ContainerID, 10) - m.docker.RemoveContainer(ctx, site.ContainerID, true) - } - // Also try by name. - m.removeContainerByName(ctx, containerName) - - containerID, err = m.docker.CreateContainer(ctx, docker.ContainerConfig{ - Name: containerName, - Image: imageTag, - Env: env, - ExposedPorts: []string{containerPort + "/tcp"}, - NetworkName: networkName, - NetworkID: networkID, - Mounts: mounts, - Labels: map[string]string{ - "tinyforge.static-site": site.ID, - "tinyforge.static-site-name": site.Name, - }, - WorkloadID: m.resolveSiteWorkloadID(site.ID), - WorkloadKind: string(store.WorkloadKindSite), - Role: "", - }) - if err != nil { - m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("create container: %v", err)) - return fmt.Errorf("create container: %w", err) - } - } - - if err := m.docker.StartContainer(ctx, containerID); err != nil { - m.docker.RemoveContainer(ctx, containerID, true) - m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("start container: %v", err)) - return fmt.Errorf("start container: %w", err) - } - - // Brief health check: wait 3 seconds and verify container is still running. - time.Sleep(3 * time.Second) - running, err := m.docker.IsContainerRunning(ctx, containerID) - if err != nil || !running { - // Grab container logs for the error message. - logMsg := "container exited immediately after start" - if logs, logErr := m.docker.ContainerLogs(ctx, containerID, false, "20"); logErr == nil { - buf, _ := io.ReadAll(logs) - logs.Close() - if len(buf) > 0 { - logMsg = string(buf) - // Truncate to reasonable length. - if len(logMsg) > 500 { - logMsg = logMsg[:500] + "..." - } - } - } - m.docker.RemoveContainer(ctx, containerID, true) - m.updateStatus(site.ID, "failed", latestSHA, logMsg) - return fmt.Errorf("container not running: %s", logMsg) - } - - // Determine proxy target: container name + internal port (default), - // or server IP + host port for NPM remote mode. - internalPort, _ := strconv.Atoi(containerPort) - forwardHost := containerName - forwardPort := internalPort - - if settings.NpmRemote && settings.ProxyProvider == "npm" { - if settings.ServerIP != "" { - hostPort, err := m.docker.InspectContainerPort(ctx, containerID, containerPort+"/tcp") - if err != nil { - slog.Warn("static site: could not get host port for remote NPM", "site", site.Name, "error", err) - } else { - forwardHost = settings.ServerIP - forwardPort = int(hostPort) - } - } - } - - // Configure proxy if domain is set. - proxyRouteID := site.ProxyRouteID - if site.Domain != "" { - // Remove old proxy route if exists. - if site.ProxyRouteID != "" { - m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID) - } - - routeID, err := m.proxyProvider.ConfigureRoute(ctx, site.Domain, forwardHost, forwardPort, proxy.RouteOptions{ - SSLCertificateID: settings.SSLCertificateID, - }) - if err != nil { - slog.Warn("static site: failed to configure proxy", "site", site.Name, "domain", site.Domain, "target", fmt.Sprintf("%s:%d", forwardHost, forwardPort), "error", err) - } else { - proxyRouteID = routeID - slog.Info("static site: proxy configured", "site", site.Name, "domain", site.Domain, "target", fmt.Sprintf("%s:%d", forwardHost, forwardPort), "routeID", routeID) - } - } - - // Remove old container if different. - if site.ContainerID != "" && site.ContainerID != containerID { - m.docker.StopContainer(ctx, site.ContainerID, 10) - m.docker.RemoveContainer(ctx, site.ContainerID, true) - } - - // Update site status. - if err := m.store.UpdateStaticSiteContainer(site.ID, containerID, proxyRouteID); err != nil { - slog.Error("static site: failed to update container info", "site", site.Name, "error", err) - } - site.ContainerID = containerID - site.ProxyRouteID = proxyRouteID - m.upsertSiteContainer(site, containerID, "running") - m.updateStatus(site.ID, "deployed", latestSHA, "") - m.publishEvent(site.ID, site.Name, "deployed") - - slog.Info("static site deployed", "site", site.Name, "sha", latestSHA[:8], "mode", mode) - return nil -} - -// Remove stops and removes a static site's container and proxy route. -func (m *Manager) Remove(ctx context.Context, siteID string) error { - site, err := m.store.GetStaticSiteByID(siteID) - if err != nil { - return fmt.Errorf("get site: %w", err) - } - - // Remove proxy route (best effort). - if site.ProxyRouteID != "" { - if err := m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID); err != nil { - slog.Warn("static site: failed to remove proxy route", "site", site.Name, "error", err) - } - } - - // Stop and remove container (best effort). - if site.ContainerID != "" { - m.docker.StopContainer(ctx, site.ContainerID, 10) - if err := m.docker.RemoveContainer(ctx, site.ContainerID, true); err != nil { - slog.Warn("static site: failed to remove container", "site", site.Name, "error", err) - } - } - - // Remove storage volume if it was enabled (best effort). - if site.StorageEnabled { - if err := m.docker.RemoveSiteVolume(ctx, site.Name); err != nil { - slog.Warn("static site: failed to remove storage volume", "site", site.Name, "error", err) - } - } - - return nil -} - -// Stop stops a running static site container and removes its proxy route. -// The container is kept (not removed) so Start can bring it back without a full rebuild. -func (m *Manager) Stop(ctx context.Context, siteID string) error { - site, err := m.store.GetStaticSiteByID(siteID) - if err != nil { - return fmt.Errorf("get site: %w", err) - } - - // Remove proxy route first (best effort). - if site.ProxyRouteID != "" { - if err := m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID); err != nil { - slog.Warn("static site: failed to remove proxy route", "site", site.Name, "error", err) - } - } - - // Stop container. - if site.ContainerID != "" { - if err := m.docker.StopContainer(ctx, site.ContainerID, 10); err != nil { - slog.Warn("static site: failed to stop container", "site", site.Name, "error", err) - } - } - - // Clear proxy route ID; keep container ID. - if err := m.store.UpdateStaticSiteContainer(site.ID, site.ContainerID, ""); err != nil { - slog.Error("static site: failed to clear proxy route", "site", site.Name, "error", err) - } - m.markSiteContainerState(site.ID, "stopped") - m.updateStatus(site.ID, "stopped", site.LastCommitSHA, "") - m.publishEvent(site.ID, site.Name, "stopped") - - slog.Info("static site stopped", "site", site.Name) - return nil -} - -// Start starts a previously stopped static site container and reconfigures the proxy. -// If the container no longer exists, it triggers a full redeploy. -func (m *Manager) Start(ctx context.Context, siteID string) error { - site, err := m.store.GetStaticSiteByID(siteID) - if err != nil { - return fmt.Errorf("get site: %w", err) - } - - // If no container exists, do a full deploy. - if site.ContainerID == "" { - return m.Deploy(ctx, siteID, true) - } - - // Try to start the existing container. - if err := m.docker.StartContainer(ctx, site.ContainerID); err != nil { - slog.Warn("static site: failed to start container, falling back to redeploy", "site", site.Name, "error", err) - return m.Deploy(ctx, siteID, true) - } - - // Verify it's running after a brief wait. - time.Sleep(2 * time.Second) - running, _ := m.docker.IsContainerRunning(ctx, site.ContainerID) - if !running { - return m.Deploy(ctx, siteID, true) - } - - // Reconfigure proxy if domain is set. - settings, err := m.store.GetSettings() - if err == nil && site.Domain != "" { - containerPort := "80" - if site.Mode == "deno" { - containerPort = "8000" - } - internalPort, _ := strconv.Atoi(containerPort) - containerName := fmt.Sprintf("dw-site-%s", site.Name) - forwardHost := containerName - forwardPort := internalPort - - if settings.NpmRemote && settings.ProxyProvider == "npm" && settings.ServerIP != "" { - if hp, err := m.docker.InspectContainerPort(ctx, site.ContainerID, containerPort+"/tcp"); err == nil { - forwardHost = settings.ServerIP - forwardPort = int(hp) - } - } - - routeID, err := m.proxyProvider.ConfigureRoute(ctx, site.Domain, forwardHost, forwardPort, proxy.RouteOptions{ - SSLCertificateID: settings.SSLCertificateID, - }) - if err != nil { - slog.Warn("static site: failed to reconfigure proxy on start", "site", site.Name, "error", err) - } else { - m.store.UpdateStaticSiteContainer(site.ID, site.ContainerID, routeID) - } - } - - m.markSiteContainerState(site.ID, "running") - m.updateStatus(site.ID, "deployed", site.LastCommitSHA, "") - m.publishEvent(site.ID, site.Name, "deployed") - - slog.Info("static site started", "site", site.Name) - return nil -} - -// TestConnection tests connectivity to a Git repository. -func (m *Manager) TestConnection(ctx context.Context, providerType, baseURL, accessToken, owner, repo string) error { - provider, err := m.createProvider(providerType, baseURL, accessToken) - if err != nil { - return err - } - return provider.TestConnection(ctx, owner, repo) -} - -// ListBranches returns branches for a Git repository. -func (m *Manager) ListBranches(ctx context.Context, providerType, baseURL, accessToken, owner, repo string) ([]string, error) { - provider, err := m.createProvider(providerType, baseURL, accessToken) - if err != nil { - return nil, err - } - return provider.ListBranches(ctx, owner, repo) -} - -// ListTree returns the repository tree for the folder picker. -func (m *Manager) ListTree(ctx context.Context, providerType, baseURL, accessToken, owner, repo, branch string) ([]FolderEntry, error) { - provider, err := m.createProvider(providerType, baseURL, accessToken) - if err != nil { - return nil, err - } - return provider.ListTree(ctx, owner, repo, branch) -} - -// ListRepos returns repositories from a Git server. -func (m *Manager) ListRepos(ctx context.Context, providerType, baseURL, accessToken, query string) ([]RepoInfo, error) { - provider, err := m.createProvider(providerType, baseURL, accessToken) - if err != nil { - return nil, err - } - return provider.ListRepos(ctx, query) -} - -// DetectProvider autodetects the Git provider from a URL, with API probing. -func (m *Manager) DetectProvider(ctx context.Context, baseURL string) string { - return string(DetectProviderWithProbe(ctx, baseURL)) -} - -// createProvider builds a GitProvider from encrypted credentials. -func (m *Manager) createProvider(providerType, baseURL, accessToken string) (GitProvider, error) { - token := "" - if accessToken != "" { - decrypted, err := crypto.Decrypt(m.encKey, accessToken) - if err != nil { - token = accessToken // might be plaintext - } else { - token = decrypted - } - } - return NewGitProvider(ProviderType(providerType), baseURL, token) -} - -// prepareDenoBuild sets up the build context for a Deno container. -func (m *Manager) prepareDenoBuild(srcDir, contextDir string) error { - // Move api/ to context. - apiSrc := filepath.Join(srcDir, "api") - apiDst := filepath.Join(contextDir, "api") - if err := os.Rename(apiSrc, apiDst); err != nil { - return fmt.Errorf("move api dir: %w", err) - } - - // Move remaining files to public/. - publicDir := filepath.Join(contextDir, "public") - if err := os.Rename(srcDir, publicDir); err != nil { - // If rename fails (cross-device), use copy. - if err := copyDir(srcDir, publicDir); err != nil { - return fmt.Errorf("copy public dir: %w", err) - } - } - - // Scan routes and generate router. - routes, err := deno.ScanRoutes(apiDst) - if err != nil { - return fmt.Errorf("scan routes: %w", err) - } - - routerSrc, err := deno.GenerateRouter(routes) - if err != nil { - return fmt.Errorf("generate router: %w", err) - } - - if err := os.WriteFile(filepath.Join(contextDir, "router.ts"), []byte(routerSrc), 0o644); err != nil { - return fmt.Errorf("write router.ts: %w", err) - } - - // Generate Dockerfile. - dockerfile := deno.GenerateDockerfile() - if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil { - return fmt.Errorf("write Dockerfile: %w", err) - } - - return nil -} - -// prepareStaticBuild sets up the build context for a static nginx container. -func (m *Manager) prepareStaticBuild(srcDir, contextDir string) error { - // Copy all files to context directory. - if err := copyDir(srcDir, contextDir); err != nil { - return fmt.Errorf("copy files: %w", err) - } - - // Generate Dockerfile. - dockerfile := deno.GenerateStaticDockerfile() - if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil { - return fmt.Errorf("write Dockerfile: %w", err) - } - - return nil -} - -// buildEnvVars decrypts secrets and builds environment variable list. -func (m *Manager) buildEnvVars(siteID string) ([]string, error) { - secrets, err := m.store.GetStaticSiteSecretsBySiteID(siteID) - if err != nil { - return nil, fmt.Errorf("get secrets: %w", err) - } - - env := make([]string, 0, len(secrets)) - for _, s := range secrets { - value := s.Value - if s.Encrypted { - decrypted, err := crypto.Decrypt(m.encKey, value) - if err != nil { - return nil, fmt.Errorf("decrypt secret %s: %w", s.Key, err) - } - value = decrypted - } - env = append(env, s.Key+"="+value) - } - - return env, nil -} - -// removeContainerByName removes a container by its name (best effort). -func (m *Manager) removeContainerByName(ctx context.Context, name string) { - containers, err := m.docker.ListContainers(ctx, nil) - if err != nil { - return - } - for _, c := range containers { - if c.Name == name { - m.docker.StopContainer(ctx, c.ID, 10) - m.docker.RemoveContainer(ctx, c.ID, true) - return - } - } -} - -// updateStatus updates the site status in the database. -// On failure, it also publishes an event to the event log. On terminal -// state transitions (deployed / failed), it dispatches an outgoing -// notification using the per-site URL+secret with fall-through to global. -func (m *Manager) updateStatus(id, status, commitSHA, errMsg string) { - if err := m.store.UpdateStaticSiteStatus(id, status, commitSHA, errMsg); err != nil { - slog.Error("static site: failed to update status", "id", id, "status", status, "error", err) - } - - // Persist failures to event log automatically. - if status == "failed" { - site, err := m.store.GetStaticSiteByID(id) - siteName := id - if err == nil { - siteName = site.Name - } - m.publishEvent(id, siteName, "failed: "+errMsg) - } - - if status == "deployed" || status == "failed" { - m.dispatchSiteNotification(id, status, errMsg) - } -} - -// dispatchSiteNotification emits a site_sync_success or site_sync_failure -// event to the configured outgoing webhook. Resolution: per-site URL+secret -// first, falling through to the global settings.notification_url/secret. -// Always best-effort — failures are logged but never block status updates. -func (m *Manager) dispatchSiteNotification(siteID, status, errMsg string) { - if m.notifier == nil { - return - } - site, err := m.store.GetStaticSiteByID(siteID) - if err != nil { - slog.Warn("static site: notify lookup failed", "site", siteID, "error", err) - return - } - settings, err := m.store.GetSettings() - if err != nil { - slog.Warn("static site: notify settings lookup failed", "site", siteID, "error", err) - return - } - - url, secret, tier := resolveSiteTarget(site, settings) - if url == "" { - return - } - - eventType := "site_sync_success" - if status == "failed" { - eventType = "site_sync_failure" - } - siteURL := "" - if site.Domain != "" { - siteURL = "https://" + site.Domain - } - m.notifier.SendSigned(url, secret, tier, notify.Event{ - Type: eventType, - Project: site.Name, - URL: siteURL, - Error: errMsg, - }) -} - -// resolveSiteTarget mirrors resolveDeployTarget for the site path: per-site -// URL beats global, secret travels with the URL that sourced it. -func resolveSiteTarget(site store.StaticSite, settings store.Settings) (string, string, notify.Tier) { - if site.NotificationURL != "" { - return site.NotificationURL, site.NotificationSecret, notify.TierSite - } - return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings -} - -// publishEvent publishes a static site status event on the event bus -// and persists it to the event log for the dashboard. -func (m *Manager) publishEvent(siteID, siteName, status string) { - m.eventBus.Publish(events.Event{ - Type: events.EventStaticSiteStatus, - Payload: events.StaticSiteStatusPayload{ - SiteID: siteID, - Name: siteName, - Status: status, - }, - }) - - // Persist to event log. - severity := "info" - message := fmt.Sprintf("Static site \"%s\": %s", siteName, status) - if status == "failed" { - severity = "error" - } - metadata := fmt.Sprintf(`{"site_id":"%s","site_name":"%s","status":"%s"}`, siteID, siteName, status) - - evt, err := m.store.InsertEvent(store.EventLog{ - Source: "static_site", - Severity: severity, - Message: message, - Metadata: metadata, - }) - if err != nil { - slog.Error("static site: failed to persist event log", "error", err) - return - } - - // Publish the persisted event for SSE clients. - m.eventBus.Publish(events.Event{ - Type: events.EventLog, - Payload: events.EventLogPayload{ - ID: evt.ID, - Source: "static_site", - Severity: severity, - Message: message, - Metadata: metadata, - CreatedAt: evt.CreatedAt, - }, - }) -} - -// copyDir recursively copies a directory. -func copyDir(src, dst string) error { - return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - relPath, err := filepath.Rel(src, path) - if err != nil { - return err - } - - dstPath := filepath.Join(dst, relPath) - - if info.IsDir() { - return os.MkdirAll(dstPath, 0o755) - } - - data, err := os.ReadFile(path) - if err != nil { - return err - } - - return os.WriteFile(dstPath, data, info.Mode()) - }) -} - -// hostPortStr converts a uint16 port to a string for proxy configuration. -func hostPortStr(port uint16) string { - return strconv.FormatUint(uint64(port), 10) -} diff --git a/internal/staticsite/resolver_test.go b/internal/staticsite/resolver_test.go deleted file mode 100644 index e2e57ea..0000000 --- a/internal/staticsite/resolver_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package staticsite - -import ( - "testing" - - "github.com/alexei/tinyforge/internal/notify" - "github.com/alexei/tinyforge/internal/store" -) - -// TestResolveSiteTarget locks the per-site → global precedence for static -// site sync notifications. Distinct from the deploy resolver because there -// is no project tier between site and settings; a regression that swapped -// the order would silently route per-site events to the global receiver. -func TestResolveSiteTarget(t *testing.T) { - cases := []struct { - name string - site store.StaticSite - settings store.Settings - wantURL string - wantSec string - wantTier notify.Tier - }{ - { - name: "site wins when URL set", - site: store.StaticSite{NotificationURL: "https://site.example/wh", NotificationSecret: "site-key"}, - settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"}, - wantURL: "https://site.example/wh", - wantSec: "site-key", - wantTier: notify.TierSite, - }, - { - name: "site URL empty → global wins", - site: store.StaticSite{}, - settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"}, - wantURL: "https://global.example/wh", - wantSec: "global-key", - wantTier: notify.TierSettings, - }, - { - name: "both empty → empty URL with settings tier", - site: store.StaticSite{}, - settings: store.Settings{}, - wantURL: "", - wantSec: "", - wantTier: notify.TierSettings, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - gotURL, gotSec, gotTier := resolveSiteTarget(tc.site, tc.settings) - if gotURL != tc.wantURL { - t.Errorf("url = %q, want %q", gotURL, tc.wantURL) - } - if gotSec != tc.wantSec { - t.Errorf("secret = %q, want %q", gotSec, tc.wantSec) - } - if gotTier != tc.wantTier { - t.Errorf("tier = %q, want %q", gotTier, tc.wantTier) - } - }) - } -} diff --git a/internal/store/containers.go b/internal/store/containers.go index ea891d1..2fd2f0b 100644 --- a/internal/store/containers.go +++ b/internal/store/containers.go @@ -187,23 +187,22 @@ func (s *Store) GetContainerByDockerID(dockerID string) (Container, error) { return c, nil } -// ListProxyRoutes returns proxy-enabled project containers joined with -// project + stage names. Reads from the normalized containers index and -// joins through stage_id so a stage rename does not orphan the row's view. -// -// Source is reported as "instance" for back-compat with the Proxies page -// filter (the frontend keys off the literal string). +// ListProxyRoutes returns proxy-enabled containers joined with their +// owning workload's name. The legacy stages join is gone — Role is used +// as the StageName fallback so the Proxies page still reads naturally +// for project-style workloads. Source is reported as "instance" for +// back-compat with the Proxies page filter (the frontend keys off the +// literal string). func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) { rows, err := s.db.Query(` - SELECT c.id, p.id, p.name, s.id, s.name, + SELECT c.id, w.id, w.name, c.image_tag, c.subdomain, c.container_id, c.port, - c.proxy_route_id, c.npm_proxy_id, c.state, c.created_at + c.proxy_route_id, c.npm_proxy_id, c.state, c.created_at, + c.role, c.stage_id FROM containers c - JOIN workloads w ON w.id = c.workload_id AND w.kind = 'project' - JOIN projects p ON p.id = w.ref_id - JOIN stages s ON s.id = c.stage_id OR (c.stage_id = '' AND s.project_id = p.id AND s.name = c.role) + JOIN workloads w ON w.id = c.workload_id WHERE c.subdomain != '' AND (c.proxy_route_id != '' OR c.npm_proxy_id > 0) - ORDER BY p.name, s.name, c.created_at DESC`, + ORDER BY w.name, c.role, c.created_at DESC`, ) if err != nil { return nil, fmt.Errorf("query proxy routes: %w", err) @@ -213,14 +212,18 @@ func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) { routes := []ProxyRoute{} for rows.Next() { var r ProxyRoute + var role, stageID string if err := rows.Scan( - &r.InstanceID, &r.ProjectID, &r.ProjectName, &r.StageID, &r.StageName, + &r.InstanceID, &r.ProjectID, &r.ProjectName, &r.ImageTag, &r.Subdomain, &r.ContainerID, &r.Port, &r.ProxyRouteID, &r.NpmProxyID, &r.Status, &r.CreatedAt, + &role, &stageID, ); err != nil { return nil, fmt.Errorf("scan proxy route: %w", err) } r.Source = "instance" + r.StageID = stageID + r.StageName = role if domain != "" && r.Subdomain != "" { r.Domain = r.Subdomain + "." + domain } @@ -229,40 +232,6 @@ func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) { return routes, rows.Err() } -// ListContainersByStageID returns project containers for the given stage, -// newest first. Resolves via stage_id with a fallback to the legacy -// (stage.name = container.role) join for rows written before the stage_id -// column was populated. Replaces GetInstancesByStageID. -func (s *Store) ListContainersByStageID(stageID string) ([]Container, error) { - rows, err := s.db.Query(` - SELECT `+prefixCols(containerColumns, "c.")+` - FROM containers c - LEFT JOIN stages s ON s.id = ? - WHERE c.stage_id = ? - OR (c.stage_id = '' AND s.id IS NOT NULL - AND c.role = s.name - AND EXISTS ( - SELECT 1 FROM workloads w - WHERE w.id = c.workload_id - AND w.kind = 'project' - AND w.ref_id = s.project_id)) - ORDER BY c.created_at DESC`, stageID, stageID) - if err != nil { - return nil, fmt.Errorf("query containers by stage: %w", err) - } - defer rows.Close() - - out := []Container{} - for rows.Next() { - c, err := scanContainer(rows) - if err != nil { - return nil, fmt.Errorf("scan container: %w", err) - } - out = append(out, c) - } - return out, rows.Err() -} - // ListContainersByWorkload returns all containers for a given workload, newest first. func (s *Store) ListContainersByWorkload(workloadID string) ([]Container, error) { rows, err := s.db.Query( diff --git a/internal/store/deploys.go b/internal/store/deploys.go deleted file mode 100644 index 9987f9c..0000000 --- a/internal/store/deploys.go +++ /dev/null @@ -1,212 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" - "strings" - - "github.com/google/uuid" -) - -// CreateDeploy inserts a new deploy record. -func (s *Store) CreateDeploy(d Deploy) (Deploy, error) { - d.ID = uuid.New().String() - d.StartedAt = Now() - if d.Status == "" { - d.Status = "pending" - } - - _, err := s.db.Exec( - `INSERT INTO deploys (id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - d.ID, d.ProjectID, d.StageID, d.InstanceID, d.ImageTag, d.Status, d.StartedAt, d.FinishedAt, d.Error, - ) - if err != nil { - return Deploy{}, fmt.Errorf("insert deploy: %w", err) - } - return d, nil -} - -// GetDeployByID returns a single deploy by its ID. -func (s *Store) GetDeployByID(id string) (Deploy, error) { - var d Deploy - err := s.db.QueryRow( - `SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error - FROM deploys WHERE id = ?`, id, - ).Scan(&d.ID, &d.ProjectID, &d.StageID, &d.InstanceID, &d.ImageTag, &d.Status, &d.StartedAt, &d.FinishedAt, &d.Error) - if errors.Is(err, sql.ErrNoRows) { - return Deploy{}, fmt.Errorf("deploy %s: %w", id, ErrNotFound) - } - if err != nil { - return Deploy{}, fmt.Errorf("query deploy: %w", err) - } - return d, nil -} - -// GetDeploysByProjectID returns all deploys for a project, newest first. -func (s *Store) GetDeploysByProjectID(projectID string) ([]Deploy, error) { - rows, err := s.db.Query( - `SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error - FROM deploys WHERE project_id = ? ORDER BY started_at DESC`, projectID, - ) - if err != nil { - return nil, fmt.Errorf("query deploys: %w", err) - } - defer rows.Close() - - return scanDeploys(rows) -} - -// GetRecentDeploys returns the most recent deploys across all projects. -func (s *Store) GetRecentDeploys(limit int) ([]Deploy, error) { - rows, err := s.db.Query( - `SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error - FROM deploys ORDER BY started_at DESC LIMIT ?`, limit, - ) - if err != nil { - return nil, fmt.Errorf("query recent deploys: %w", err) - } - defer rows.Close() - - return scanDeploys(rows) -} - -// UpdateDeployStatus sets the status (and optionally error and finished_at) on a deploy. -func (s *Store) UpdateDeployStatus(id string, status string, deployErr string) error { - ts := Now() - var finishedAt string - if IsTerminalDeployStatus(status) { - finishedAt = ts - } - - result, err := s.db.Exec( - `UPDATE deploys SET status=?, error=?, finished_at=? WHERE id=?`, - status, deployErr, finishedAt, id, - ) - if err != nil { - return fmt.Errorf("update deploy status: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("deploy %s: %w", id, ErrNotFound) - } - return nil -} - -// SetDeployInstanceID links a deploy to the instance it created. -func (s *Store) SetDeployInstanceID(deployID string, instanceID string) error { - result, err := s.db.Exec(`UPDATE deploys SET instance_id=? WHERE id=?`, instanceID, deployID) - if err != nil { - return fmt.Errorf("set deploy instance: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("deploy %s: %w", deployID, ErrNotFound) - } - return nil -} - -// AppendDeployLog adds a log entry for a deploy. -func (s *Store) AppendDeployLog(deployID string, message string, level string) error { - if level == "" { - level = "info" - } - _, err := s.db.Exec( - `INSERT INTO deploy_logs (deploy_id, message, level, created_at) VALUES (?, ?, ?, ?)`, - deployID, message, level, Now(), - ) - if err != nil { - return fmt.Errorf("append deploy log: %w", err) - } - return nil -} - -// GetDeployLogs returns all log entries for a deploy, ordered chronologically. -func (s *Store) GetDeployLogs(deployID string) ([]DeployLog, error) { - rows, err := s.db.Query( - `SELECT id, deploy_id, message, level, created_at - FROM deploy_logs WHERE deploy_id = ? ORDER BY id`, deployID, - ) - if err != nil { - return nil, fmt.Errorf("query deploy logs: %w", err) - } - defer rows.Close() - - logs := []DeployLog{} - for rows.Next() { - var l DeployLog - if err := rows.Scan(&l.ID, &l.DeployID, &l.Message, &l.Level, &l.CreatedAt); err != nil { - return nil, fmt.Errorf("scan deploy log: %w", err) - } - logs = append(logs, l) - } - return logs, rows.Err() -} - -// scanDeploys is a helper that scans deploy rows from a cursor. -func scanDeploys(rows *sql.Rows) ([]Deploy, error) { - deploys := []Deploy{} - for rows.Next() { - var d Deploy - if err := rows.Scan(&d.ID, &d.ProjectID, &d.StageID, &d.InstanceID, &d.ImageTag, &d.Status, &d.StartedAt, &d.FinishedAt, &d.Error); err != nil { - return nil, fmt.Errorf("scan deploy: %w", err) - } - deploys = append(deploys, d) - } - return deploys, rows.Err() -} - -// IsTerminalDeployStatus returns true if the status indicates the deploy is finished. -func IsTerminalDeployStatus(status string) bool { - switch status { - case "success", "failed", "rolled_back": - return true - default: - return false - } -} - -// GetDeploys returns deploys with optional filtering by project and stage, with pagination. -func (s *Store) GetDeploys(projectID, stageID string, limit, offset int) ([]Deploy, error) { - query := `SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error FROM deploys` - var args []any - var conditions []string - - if projectID != "" { - conditions = append(conditions, "project_id = ?") - args = append(args, projectID) - } - if stageID != "" { - conditions = append(conditions, "stage_id = ?") - args = append(args, stageID) - } - - if len(conditions) > 0 { - query += " WHERE " + strings.Join(conditions, " AND ") - } - query += " ORDER BY started_at DESC LIMIT ? OFFSET ?" - args = append(args, limit, offset) - - rows, err := s.db.Query(query, args...) - if err != nil { - return nil, fmt.Errorf("query deploys: %w", err) - } - defer rows.Close() - - return scanDeploys(rows) -} - -// CleanupOldDeploys removes deploy records and their logs older than the given -// number of days. Returns the number of deploys removed. -func (s *Store) CleanupOldDeploys(retentionDays int) (int64, error) { - cutoff := fmt.Sprintf("-%d days", retentionDays) - result, err := s.db.Exec( - `DELETE FROM deploys WHERE started_at < datetime('now', ?)`, cutoff, - ) - if err != nil { - return 0, fmt.Errorf("cleanup old deploys: %w", err) - } - n, _ := result.RowsAffected() - return n, nil -} diff --git a/internal/store/helpers.go b/internal/store/helpers.go new file mode 100644 index 0000000..2135ba5 --- /dev/null +++ b/internal/store/helpers.go @@ -0,0 +1,44 @@ +package store + +import ( + "crypto/rand" + "encoding/hex" + + "github.com/google/uuid" +) + +// rowScanner is the subset of *sql.Row / *sql.Rows used by row scanners +// across this package. Kept package-private — callers should not need to +// implement it themselves. +type rowScanner interface { + Scan(dest ...any) error +} + +// BoolToInt converts a Go bool to the 0/1 INTEGER convention SQLite uses +// for boolean columns across this schema. +func BoolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// GenerateWebhookSecret returns a 256-bit hex-encoded random token. +// Exported so the api layer can share one implementation — keeping +// two copies invited drift (one panicked, one fell back to UUID). +// crypto/rand directly rather than uuid.New() so the intent ("secret +// token, not identifier") is explicit and the entropy is unambiguous. +func GenerateWebhookSecret() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + // crypto/rand is documented to never fail on supported platforms; + // fall back to a UUID rather than panicking. + return uuid.New().String() + } + return hex.EncodeToString(b) +} + +// generateWebhookSecret is the in-package alias kept for the existing +// CRUD call sites that don't reach across packages. New callers in +// other packages should use GenerateWebhookSecret directly. +func generateWebhookSecret() string { return GenerateWebhookSecret() } diff --git a/internal/store/models.go b/internal/store/models.go index 4523fdb..250adba 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -1,45 +1,5 @@ package store -// Project represents a deployable application. -type Project struct { - ID string `json:"id"` - Name string `json:"name"` - Registry string `json:"registry"` - Image string `json:"image"` - Port int `json:"port"` - Healthcheck string `json:"healthcheck"` - Env string `json:"env"` // JSON-encoded map - Volumes string `json:"volumes"` // JSON-encoded map - NpmAccessListID int `json:"npm_access_list_id"` // per-project override, 0 = use global - WebhookSecret string `json:"-"` // per-project webhook secret (URL identifier); never serialized - WebhookSigningSecret string `json:"-"` // HMAC-SHA256 key for inbound webhook signature verification; never serialized - WebhookRequireSignature bool `json:"webhook_require_signature"` // if true, reject unsigned/invalid-sig webhook requests - NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings - NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -// Stage represents a deployment stage within a project (e.g. dev, rel, prod). -type Stage struct { - ID string `json:"id"` - ProjectID string `json:"project_id"` - Name string `json:"name"` - TagPattern string `json:"tag_pattern"` - AutoDeploy bool `json:"auto_deploy"` - MaxInstances int `json:"max_instances"` - Confirm bool `json:"confirm"` - EnableProxy bool `json:"enable_proxy"` - PromoteFrom string `json:"promote_from"` - Subdomain string `json:"subdomain"` - NotificationURL string `json:"notification_url"` - NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly - CpuLimit float64 `json:"cpu_limit"` // CPU cores (e.g., 0.5, 1, 2), 0 = unlimited - MemoryLimit int `json:"memory_limit"` // megabytes, 0 = unlimited - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - // Registry represents a container image registry. type Registry struct { ID string `json:"id"` @@ -142,10 +102,15 @@ type DNSRecord struct { UpdatedAt string `json:"updated_at"` } -// ProxyRoute is a proxy-enabled container row joined with its project + stage -// names, shaped for the Proxies page. Source is "instance" for project -// containers and "static_site" for site rows — the names are historical -// (the table itself was renamed to containers in the workload refactor). +// ProxyRoute shapes one proxy-enabled container row for the Proxies +// page. The legacy field names (ProjectID, ProjectName, StageID, +// StageName, InstanceID) are retained verbatim for the existing +// frontend contract — after the workload-first cutover they map to: +// ProjectID/Name → workload id / workload name +// StageID/Name → containers.stage_id / containers.role +// InstanceID → container row id +// Source → "instance" for image/compose, "static_site" for static +// Renaming would require a coordinated frontend change; deferred. type ProxyRoute struct { Source string `json:"source"` InstanceID string `json:"instance_id"` @@ -164,39 +129,6 @@ type ProxyRoute struct { CreatedAt string `json:"created_at"` } -// Deploy represents a deployment attempt. -type Deploy struct { - ID string `json:"id"` - ProjectID string `json:"project_id"` - StageID string `json:"stage_id"` - InstanceID string `json:"instance_id"` - ImageTag string `json:"image_tag"` - Status string `json:"status"` // pending, pulling, starting, configuring_proxy, health_checking, success, failed, rolled_back - StartedAt string `json:"started_at"` - FinishedAt string `json:"finished_at"` - Error string `json:"error"` -} - -// DeployLog is a single log entry for a deploy. -type DeployLog struct { - ID int64 `json:"id"` - DeployID string `json:"deploy_id"` - Message string `json:"message"` - Level string `json:"level"` // info, warn, error - CreatedAt string `json:"created_at"` -} - -// StageEnv represents a per-stage environment variable override. -type StageEnv struct { - ID string `json:"id"` - StageID string `json:"stage_id"` - Key string `json:"key"` - Value string `json:"value"` - Encrypted bool `json:"encrypted"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - // WorkloadVolume is the plugin-shape equivalent of legacy Volume: a // per-workload mount declaration. The Scope enum matches the existing // VolumeScope contract so the legacy resolver can be reused once its @@ -256,101 +188,6 @@ func IsValidVolumeScope(s string) bool { return false } -// Volume represents a volume mount configuration for a project. -type Volume struct { - ID string `json:"id"` - ProjectID string `json:"project_id"` - Source string `json:"source"` - Target string `json:"target"` - Mode string `json:"mode,omitempty"` // legacy: shared/isolated — kept for DB compat - Scope string `json:"scope"` // instance, stage, project, project_named, named, ephemeral - Name string `json:"name"` // required for project_named and named scopes - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -// StaticSite represents a static site deployed from a Git repository folder. -type StaticSite struct { - ID string `json:"id"` - Name string `json:"name"` - Provider string `json:"provider"` // "gitea", "github", "gitlab"; empty = autodetect - GiteaURL string `json:"gitea_url"` // base URL, e.g. https://git.example.com - RepoOwner string `json:"repo_owner"` - RepoName string `json:"repo_name"` - Branch string `json:"branch"` - FolderPath string `json:"folder_path"` // path within repo, e.g. "Pages" - AccessToken string `json:"access_token"` // encrypted; optional for public repos - Domain string `json:"domain"` // full domain for proxy - Mode string `json:"mode"` // "static" or "deno" - RenderMarkdown bool `json:"render_markdown"` - SyncTrigger string `json:"sync_trigger"` // "push", "tag", "manual" - TagPattern string `json:"tag_pattern"` // glob pattern for tag-based sync - ContainerID string `json:"container_id"` - ProxyRouteID string `json:"proxy_route_id"` - Status string `json:"status"` // idle, syncing, deployed, failed - LastSyncAt string `json:"last_sync_at"` - LastCommitSHA string `json:"last_commit_sha"` - Error string `json:"error"` - StorageEnabled bool `json:"storage_enabled"` - StorageLimitMB int `json:"storage_limit_mb"` // 0 = unlimited - WebhookSecret string `json:"-"` // per-site webhook secret (URL identifier); never serialized - WebhookSigningSecret string `json:"-"` // HMAC-SHA256 key for inbound webhook signature verification; never serialized - WebhookRequireSignature bool `json:"webhook_require_signature"` // if true, reject unsigned/invalid-sig webhook requests - NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings - NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -// StaticSiteSecret represents an encrypted environment variable for a static site's Deno backend. -type StaticSiteSecret struct { - ID string `json:"id"` - SiteID string `json:"site_id"` - Key string `json:"key"` - Value string `json:"value"` - Encrypted bool `json:"encrypted"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -// Stack represents a docker-compose stack managed as a single deployable unit. -type Stack struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - ComposeProjectName string `json:"compose_project_name"` // `-p` arg for docker compose - Status string `json:"status"` // stopped, deploying, running, failed - Error string `json:"error"` - CurrentRevisionID string `json:"current_revision_id"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -// StackRevision is an append-only record of a YAML version for a stack. -// Rollback = insert a new revision whose YAML is copied from an older one. -type StackRevision struct { - ID string `json:"id"` - StackID string `json:"stack_id"` - Revision int `json:"revision"` // monotonic per stack - YAML string `json:"yaml"` - Author string `json:"author"` - DeployID string `json:"deploy_id"` - Status string `json:"status"` // pending, success, failed - CreatedAt string `json:"created_at"` -} - -// StackDeploy records a deployment attempt of a specific revision. -type StackDeploy struct { - ID string `json:"id"` - StackID string `json:"stack_id"` - RevisionID string `json:"revision_id"` - Status string `json:"status"` // pending, deploying, success, failed, rolled_back - Log string `json:"log"` - Error string `json:"error"` - StartedAt string `json:"started_at"` - FinishedAt string `json:"finished_at"` -} - // EventLog represents a persistent event log entry. type EventLog struct { ID int64 `json:"id"` @@ -437,8 +274,10 @@ const ( LogScanSeverityError = "error" ) -// WorkloadKind enumerates the kinds of things that own containers. -// Each kind has a corresponding row in projects/stacks/static_sites referenced via Workload.RefID. +// WorkloadKind enumerates the legacy discriminator values written into +// containers.workload_kind and workloads.kind. After the hard cutover the +// backing project / stack / static_site tables are gone — these constants +// are just strings used to filter the unified containers index in the UI. type WorkloadKind string const ( diff --git a/internal/store/poll_state.go b/internal/store/poll_state.go deleted file mode 100644 index 702208b..0000000 --- a/internal/store/poll_state.go +++ /dev/null @@ -1,75 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" -) - -// PollState tracks the last polled tag for a stage, enabling the poller to -// detect new tags since the previous poll cycle. -type PollState struct { - StageID string `json:"stage_id"` - LastTag string `json:"last_tag"` - LastPolled string `json:"last_polled"` -} - -// GetPollState returns the poll state for a given stage. -func (s *Store) GetPollState(stageID string) (PollState, error) { - var ps PollState - err := s.db.QueryRow( - `SELECT stage_id, last_tag, last_polled FROM poll_states WHERE stage_id = ?`, - stageID, - ).Scan(&ps.StageID, &ps.LastTag, &ps.LastPolled) - if errors.Is(err, sql.ErrNoRows) { - return PollState{}, fmt.Errorf("poll state for stage %s: %w", stageID, ErrNotFound) - } - if err != nil { - return PollState{}, fmt.Errorf("query poll state: %w", err) - } - return ps, nil -} - -// UpsertPollState inserts or updates the poll state for a stage. -func (s *Store) UpsertPollState(ps PollState) error { - _, err := s.db.Exec( - `INSERT INTO poll_states (stage_id, last_tag, last_polled) - VALUES (?, ?, ?) - ON CONFLICT(stage_id) DO UPDATE SET last_tag=excluded.last_tag, last_polled=excluded.last_polled`, - ps.StageID, ps.LastTag, ps.LastPolled, - ) - if err != nil { - return fmt.Errorf("upsert poll state: %w", err) - } - return nil -} - -// DeletePollState removes the poll state for a stage. -func (s *Store) DeletePollState(stageID string) error { - _, err := s.db.Exec(`DELETE FROM poll_states WHERE stage_id = ?`, stageID) - if err != nil { - return fmt.Errorf("delete poll state: %w", err) - } - return nil -} - -// GetAllPollStates returns all poll states, ordered by last_polled descending. -func (s *Store) GetAllPollStates() ([]PollState, error) { - rows, err := s.db.Query( - `SELECT stage_id, last_tag, last_polled FROM poll_states ORDER BY last_polled DESC`, - ) - if err != nil { - return nil, fmt.Errorf("query poll states: %w", err) - } - defer rows.Close() - - states := []PollState{} - for rows.Next() { - var ps PollState - if err := rows.Scan(&ps.StageID, &ps.LastTag, &ps.LastPolled); err != nil { - return nil, fmt.Errorf("scan poll state: %w", err) - } - states = append(states, ps) - } - return states, rows.Err() -} diff --git a/internal/store/projects.go b/internal/store/projects.go deleted file mode 100644 index fb26dd5..0000000 --- a/internal/store/projects.go +++ /dev/null @@ -1,342 +0,0 @@ -package store - -import ( - "crypto/rand" - "database/sql" - "encoding/hex" - "errors" - "fmt" - - "github.com/google/uuid" -) - -// minWebhookSecretLength is the smallest user-supplied webhook secret accepted -// at insert time. Auto-generated secrets are 64 hex chars (256 bits); a -// 32-char floor still leaves > 128 bits of brute-force resistance for hex -// alphabets and rejects obvious typos / placeholder strings. -const minWebhookSecretLength = 32 - -// generateWebhookSecret returns a 256-bit hex-encoded random token. We use -// crypto/rand directly rather than uuid.New() so the intent ("secret token, -// not identifier") is explicit and the entropy is unambiguous. -func generateWebhookSecret() string { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - // crypto/rand is documented to never fail on supported platforms; - // fall back to a UUID rather than panicking. - return uuid.New().String() - } - return hex.EncodeToString(b) -} - -// projectCols is the canonical column list for projects queries. -const projectCols = `id, name, registry, image, port, healthcheck, env, volumes, - npm_access_list_id, webhook_secret, webhook_signing_secret, webhook_require_signature, - notification_url, notification_secret, created_at, updated_at` - -// rowScanner is the subset of *sql.Row / *sql.Rows used by scanProject. -type rowScanner interface { - Scan(dest ...any) error -} - -// scanProject reads one row in projectCols order. webhook_require_signature -// is stored as INTEGER and converted to bool here. -func scanProject(r rowScanner) (Project, error) { - var p Project - var requireSig int - if err := r.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, - &p.NpmAccessListID, &p.WebhookSecret, &p.WebhookSigningSecret, &requireSig, - &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil { - return Project{}, err - } - p.WebhookRequireSignature = requireSig != 0 - return p, nil -} - -// CreateProject inserts a new project and returns it. A webhook secret is -// generated automatically if one is not already set on the input. Project -// row + matching workload row are written in a single transaction. -func (s *Store) CreateProject(p Project) (Project, error) { - p.ID = uuid.New().String() - p.CreatedAt = Now() - p.UpdatedAt = p.CreatedAt - if p.WebhookSecret == "" { - p.WebhookSecret = generateWebhookSecret() - } else if len(p.WebhookSecret) < minWebhookSecretLength { - return Project{}, fmt.Errorf("webhook_secret must be at least %d characters", minWebhookSecretLength) - } - - requireSig := 0 - if p.WebhookRequireSignature { - requireSig = 1 - } - - tx, err := s.db.Begin() - if err != nil { - return Project{}, fmt.Errorf("begin: %w", err) - } - defer tx.Rollback() - - if _, err := tx.Exec( - `INSERT INTO projects (`+projectCols+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - p.ID, p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, - p.NpmAccessListID, p.WebhookSecret, p.WebhookSigningSecret, requireSig, - p.NotificationURL, p.NotificationSecret, p.CreatedAt, p.UpdatedAt, - ); err != nil { - return Project{}, fmt.Errorf("insert project: %w", err) - } - if err := SyncProjectWorkloadTx(tx, p); err != nil { - return Project{}, err - } - if err := tx.Commit(); err != nil { - return Project{}, fmt.Errorf("commit: %w", err) - } - return p, nil -} - -// GetProjectByID returns a single project by its ID. -func (s *Store) GetProjectByID(id string) (Project, error) { - row := s.db.QueryRow(`SELECT `+projectCols+` FROM projects WHERE id = ?`, id) - p, err := scanProject(row) - if errors.Is(err, sql.ErrNoRows) { - return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound) - } - if err != nil { - return Project{}, fmt.Errorf("query project: %w", err) - } - return p, nil -} - -// GetProjectByWebhookSecret looks up a project by its webhook secret. -// Returns ErrNotFound if no project has this secret (including empty). -func (s *Store) GetProjectByWebhookSecret(secret string) (Project, error) { - if secret == "" { - return Project{}, ErrNotFound - } - row := s.db.QueryRow(`SELECT `+projectCols+` FROM projects WHERE webhook_secret = ?`, secret) - p, err := scanProject(row) - if errors.Is(err, sql.ErrNoRows) { - return Project{}, ErrNotFound - } - if err != nil { - return Project{}, fmt.Errorf("query project by webhook secret: %w", err) - } - return p, nil -} - -// GetAllProjects returns every project ordered by name. -func (s *Store) GetAllProjects() ([]Project, error) { - rows, err := s.db.Query( - `SELECT ` + projectCols + ` FROM projects ORDER BY name`, - ) - if err != nil { - return nil, fmt.Errorf("query projects: %w", err) - } - defer rows.Close() - - projects := []Project{} - for rows.Next() { - p, err := scanProject(rows) - if err != nil { - return nil, fmt.Errorf("scan project: %w", err) - } - projects = append(projects, p) - } - return projects, rows.Err() -} - -// GetProjectsByImage returns all projects using the given image, newest first. -func (s *Store) GetProjectsByImage(image string) ([]Project, error) { - rows, err := s.db.Query( - `SELECT `+projectCols+` FROM projects WHERE image = ? ORDER BY created_at DESC`, image, - ) - if err != nil { - return nil, fmt.Errorf("query projects by image: %w", err) - } - defer rows.Close() - - projects := []Project{} - for rows.Next() { - p, err := scanProject(rows) - if err != nil { - return nil, fmt.Errorf("scan project: %w", err) - } - projects = append(projects, p) - } - return projects, rows.Err() -} - -// updateProjectAndSyncWorkloadTx performs the parent UPDATE + workload sync in -// a single transaction. Used by every Set*Secret / UpdateProject path so the -// project row and the workload row never desync after a partial failure. -// updateSQL must be a parameterized UPDATE on `projects` ending with `WHERE id=?`; -// args are the parameter values in order, with the project ID last. -func (s *Store) updateProjectAndSyncWorkloadTx(id string, updateSQL string, args ...any) error { - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("begin: %w", err) - } - defer tx.Rollback() - - result, err := tx.Exec(updateSQL, args...) - if err != nil { - return fmt.Errorf("update project: %w", err) - } - n, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("rows affected: %w", err) - } - if n == 0 { - return fmt.Errorf("project %s: %w", id, ErrNotFound) - } - - // Re-read the row inside the transaction so the workload sync sees the - // canonical values (the caller may have only updated one column). - row := tx.QueryRow(`SELECT `+projectCols+` FROM projects WHERE id = ?`, id) - p, err := scanProject(row) - if err != nil { - return fmt.Errorf("reread project for workload sync: %w", err) - } - if err := SyncProjectWorkloadTx(tx, p); err != nil { - return err - } - return tx.Commit() -} - -// UpdateProject updates an existing project's mutable fields. Webhook secret -// and notification_secret are intentionally not updated here — use the -// dedicated SetProjectWebhookSecret / SetProjectNotificationSecret helpers. -func (s *Store) UpdateProject(p Project) error { - p.UpdatedAt = Now() - return s.updateProjectAndSyncWorkloadTx(p.ID, - `UPDATE projects SET name=?, registry=?, image=?, port=?, healthcheck=?, env=?, volumes=?, - npm_access_list_id=?, notification_url=?, updated_at=? - WHERE id=?`, - p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, - p.NpmAccessListID, p.NotificationURL, p.UpdatedAt, p.ID, - ) -} - -// SetProjectWebhookSecret assigns a webhook secret to a project. -// Pass an empty string to disable webhook access for the project. -func (s *Store) SetProjectWebhookSecret(id, secret string) error { - return s.updateProjectAndSyncWorkloadTx(id, - `UPDATE projects SET webhook_secret=?, updated_at=? WHERE id=?`, - secret, Now(), id, - ) -} - -// SetProjectWebhookSigningSecret assigns the HMAC signing secret used to -// verify inbound webhook payloads. Pass an empty string to clear it (which -// also implicitly disables signature enforcement on the next request). -func (s *Store) SetProjectWebhookSigningSecret(id, secret string) error { - return s.updateProjectAndSyncWorkloadTx(id, - `UPDATE projects SET webhook_signing_secret=?, updated_at=? WHERE id=?`, - secret, Now(), id, - ) -} - -// SetProjectWebhookRequireSignature toggles whether unsigned (or -// invalidly-signed) webhook requests are rejected with 401. -func (s *Store) SetProjectWebhookRequireSignature(id string, require bool) error { - v := 0 - if require { - v = 1 - } - return s.updateProjectAndSyncWorkloadTx(id, - `UPDATE projects SET webhook_require_signature=?, updated_at=? WHERE id=?`, - v, Now(), id, - ) -} - -// EnsureProjectWebhookSecret returns the current webhook secret for a project, -// generating one on the fly if the stored value is empty (lazy backfill for -// projects created before the per-project webhook migration). -func (s *Store) EnsureProjectWebhookSecret(id string) (string, error) { - project, err := s.GetProjectByID(id) - if err != nil { - return "", err - } - if project.WebhookSecret != "" { - return project.WebhookSecret, nil - } - secret := generateWebhookSecret() - if err := s.SetProjectWebhookSecret(id, secret); err != nil { - return "", err - } - return secret, nil -} - -// SetProjectNotificationSecret rotates the project's outgoing-webhook signing -// secret. Empty string disables HMAC signing for this project (notifications -// still send unsigned, falling through to the parent tier's secret if any). -func (s *Store) SetProjectNotificationSecret(id, secret string) error { - return s.updateProjectAndSyncWorkloadTx(id, - `UPDATE projects SET notification_secret=?, updated_at=? WHERE id=?`, - secret, Now(), id, - ) -} - -// EnsureProjectNotificationSecret returns the current outgoing-webhook signing -// secret, generating one lazily if missing. Used when an operator first opens -// the outgoing-webhook panel for a project that predates this feature. -func (s *Store) EnsureProjectNotificationSecret(id string) (string, error) { - project, err := s.GetProjectByID(id) - if err != nil { - return "", err - } - if project.NotificationSecret != "" { - return project.NotificationSecret, nil - } - secret := generateWebhookSecret() - if err := s.SetProjectNotificationSecret(id, secret); err != nil { - return "", err - } - return secret, nil -} - -// DeleteProject removes a project by ID. Cascading deletes handle stages, instances, and deploys. -// Workload row + container index entries are removed too so the global views -// don't show ghost rows after a project is gone. Atomic: the project, its -// container index entries, and its workload row all live or die together. -func (s *Store) DeleteProject(id string) error { - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("begin: %w", err) - } - defer tx.Rollback() - - // Resolve the workload before deleting the project so we have the - // workload ID for the cascade. - var workloadID string - if err := tx.QueryRow( - `SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`, - string(WorkloadKindProject), id, - ).Scan(&workloadID); err != nil && !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("lookup project workload: %w", err) - } - - result, err := tx.Exec(`DELETE FROM projects WHERE id = ?`, id) - if err != nil { - return fmt.Errorf("delete project: %w", err) - } - n, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("rows affected: %w", err) - } - if n == 0 { - return fmt.Errorf("project %s: %w", id, ErrNotFound) - } - - if workloadID != "" { - if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, workloadID); err != nil { - return fmt.Errorf("delete project containers: %w", err) - } - if _, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, workloadID); err != nil { - return fmt.Errorf("delete project workload: %w", err) - } - } - - return tx.Commit() -} diff --git a/internal/store/proxy_routes_test.go b/internal/store/proxy_routes_test.go deleted file mode 100644 index 2cc353b..0000000 --- a/internal/store/proxy_routes_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package store - -import "testing" - -// TestListProxyRoutesJoinShape verifies the new containers-based join produces -// the same ProxyRoute shape the /api/proxies frontend has consumed since this -// query was instances-based. Without this test, a missing column or a wrong -// join condition would silently break the Proxies page. -func TestListProxyRoutesJoinShape(t *testing.T) { - s := newTestStore(t) - - p, err := s.CreateProject(Project{ - Name: "wf", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}", - }) - if err != nil { - t.Fatalf("CreateProject: %v", err) - } - stage, err := s.CreateStage(Stage{ - ProjectID: p.ID, Name: "prod", TagPattern: "*", MaxInstances: 1, EnableProxy: true, - }) - if err != nil { - t.Fatalf("CreateStage: %v", err) - } - - w, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID) - if err != nil { - t.Fatalf("workload: %v", err) - } - - // Container with both subdomain and proxy_route_id populated — the rule - // the WHERE clause filters on. - if _, err := s.CreateContainer(Container{ - WorkloadID: w.ID, - WorkloadKind: "project", - Role: stage.Name, - ContainerID: "docker-abc", - ImageTag: "v1", - State: "running", - Port: 8080, - Subdomain: "wf-prod", - ProxyRouteID: "route-1", - }); err != nil { - t.Fatalf("CreateContainer: %v", err) - } - - // Container without subdomain — must be filtered OUT. - if _, err := s.CreateContainer(Container{ - WorkloadID: w.ID, - WorkloadKind: "project", - Role: stage.Name, - ContainerID: "docker-def", - ImageTag: "v2", - State: "running", - }); err != nil { - t.Fatalf("CreateContainer 2: %v", err) - } - - routes, err := s.ListProxyRoutes("example.test") - if err != nil { - t.Fatalf("ListProxyRoutes: %v", err) - } - - if len(routes) != 1 { - t.Fatalf("expected 1 route, got %d (filter wrong?)", len(routes)) - } - r := routes[0] - if r.Source != "instance" { - t.Errorf("Source: got %q, want 'instance' (back-compat)", r.Source) - } - if r.ProjectID != p.ID { - t.Errorf("ProjectID: got %q, want %q", r.ProjectID, p.ID) - } - if r.ProjectName != "wf" { - t.Errorf("ProjectName: got %q, want 'wf'", r.ProjectName) - } - if r.StageID != stage.ID { - t.Errorf("StageID: got %q, want %q", r.StageID, stage.ID) - } - if r.StageName != "prod" { - t.Errorf("StageName: got %q, want 'prod'", r.StageName) - } - if r.ImageTag != "v1" { - t.Errorf("ImageTag: got %q, want 'v1'", r.ImageTag) - } - if r.Subdomain != "wf-prod" { - t.Errorf("Subdomain: got %q, want 'wf-prod'", r.Subdomain) - } - if r.Domain != "wf-prod.example.test" { - t.Errorf("Domain: got %q, want 'wf-prod.example.test'", r.Domain) - } - if r.ContainerID != "docker-abc" { - t.Errorf("ContainerID: got %q, want 'docker-abc'", r.ContainerID) - } - if r.Port != 8080 { - t.Errorf("Port: got %d, want 8080", r.Port) - } - if r.ProxyRouteID != "route-1" { - t.Errorf("ProxyRouteID: got %q, want 'route-1'", r.ProxyRouteID) - } - if r.Status != "running" { - t.Errorf("Status (state): got %q, want 'running'", r.Status) - } -} - -func TestListProxyRoutesNpmOnly(t *testing.T) { - // NPM-only routes (npm_proxy_id > 0, proxy_route_id == "") must still be - // returned — that's the original WHERE-clause OR branch. - s := newTestStore(t) - - p, _ := s.CreateProject(Project{ - Name: "npm-only", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}", - }) - stage, _ := s.CreateStage(Stage{ - ProjectID: p.ID, Name: "dev", TagPattern: "*", MaxInstances: 1, EnableProxy: true, - }) - w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID) - - if _, err := s.CreateContainer(Container{ - WorkloadID: w.ID, - WorkloadKind: "project", - Role: stage.Name, - ContainerID: "docker-1", - Subdomain: "npm-only-dev", - NpmProxyID: 42, - }); err != nil { - t.Fatalf("CreateContainer: %v", err) - } - - routes, err := s.ListProxyRoutes("") - if err != nil { - t.Fatalf("ListProxyRoutes: %v", err) - } - if len(routes) != 1 { - t.Fatalf("expected 1 npm route, got %d", len(routes)) - } - if routes[0].NpmProxyID != 42 { - t.Errorf("NpmProxyID: got %d, want 42", routes[0].NpmProxyID) - } -} - -func TestListProxyRoutesIgnoresWrongRole(t *testing.T) { - // Belt-and-suspenders: a container whose role doesn't match a stage name - // would orphan the JOIN. Verify the row falls out cleanly (LEFT JOIN - // would expose a real bug here). - s := newTestStore(t) - - p, _ := s.CreateProject(Project{ - Name: "wf", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}", - }) - _, _ = s.CreateStage(Stage{ - ProjectID: p.ID, Name: "prod", TagPattern: "*", MaxInstances: 1, - }) - w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID) - - if _, err := s.CreateContainer(Container{ - WorkloadID: w.ID, - WorkloadKind: "project", - Role: "ghost-stage", // intentionally not a real stage name - ContainerID: "docker-x", - Subdomain: "wf-ghost", - ProxyRouteID: "route-x", - }); err != nil { - t.Fatalf("CreateContainer: %v", err) - } - - routes, err := s.ListProxyRoutes("") - if err != nil { - t.Fatalf("ListProxyRoutes: %v", err) - } - if len(routes) != 0 { - t.Fatalf("orphan-role row leaked into result: got %d", len(routes)) - } -} diff --git a/internal/store/settings.go b/internal/store/settings.go index 2244cab..307b5df 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -101,6 +101,27 @@ func (s *Store) UpdateSettings(st Settings) error { return nil } +// EnsureSettingsNotificationSecret returns the current global notification +// secret, lazily generating + persisting one if none is set. Lets the +// settings UI render a stable secret on first load for any install that +// predates the signing feature. +func (s *Store) EnsureSettingsNotificationSecret() (string, error) { + var secret string + if err := s.db.QueryRow( + `SELECT notification_secret FROM settings WHERE id = 1`, + ).Scan(&secret); err != nil { + return "", fmt.Errorf("get settings notification secret: %w", err) + } + if secret != "" { + return secret, nil + } + secret = generateWebhookSecret() + if err := s.SetSettingsNotificationSecret(secret); err != nil { + return "", err + } + return secret, nil +} + // SetSettingsNotificationSecret rewrites only the global outgoing-webhook // signing secret on the singleton settings row. Pass an empty string to // disable signing globally (notifications still send, just without HMAC). diff --git a/internal/store/stacks.go b/internal/store/stacks.go deleted file mode 100644 index 77a6bc6..0000000 --- a/internal/store/stacks.go +++ /dev/null @@ -1,398 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" - - "github.com/google/uuid" -) - -const stackCols = `id, name, description, compose_project_name, status, error, - current_revision_id, created_at, updated_at` - -// CreateStack inserts a new stack and returns it. Stack row + matching -// workload row are written in a single transaction so a partial failure -// leaves no orphan. -func (s *Store) CreateStack(st Stack) (Stack, error) { - st.ID = uuid.New().String() - st.CreatedAt = Now() - st.UpdatedAt = st.CreatedAt - if st.Status == "" { - st.Status = "stopped" - } - - tx, err := s.db.Begin() - if err != nil { - return Stack{}, fmt.Errorf("begin: %w", err) - } - defer tx.Rollback() - - if _, err := tx.Exec( - `INSERT INTO stacks (`+stackCols+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - st.ID, st.Name, st.Description, st.ComposeProjectName, st.Status, - st.Error, st.CurrentRevisionID, st.CreatedAt, st.UpdatedAt, - ); err != nil { - return Stack{}, fmt.Errorf("insert stack: %w", err) - } - if err := SyncStackWorkloadTx(tx, st); err != nil { - return Stack{}, err - } - if err := tx.Commit(); err != nil { - return Stack{}, fmt.Errorf("commit: %w", err) - } - return st, nil -} - -// GetStackByID returns a single stack by its ID. -func (s *Store) GetStackByID(id string) (Stack, error) { - st, err := scanStackRow(s.db.QueryRow( - `SELECT `+stackCols+` FROM stacks WHERE id = ?`, id, - )) - if errors.Is(err, sql.ErrNoRows) { - return Stack{}, fmt.Errorf("stack %s: %w", id, ErrNotFound) - } - if err != nil { - return Stack{}, fmt.Errorf("query stack: %w", err) - } - return st, nil -} - -// GetStackByComposeProjectName looks up a stack by its compose project name. -// Compose project names are unique per the stacks table schema, so this is an -// O(1) index lookup. Used by the reconciler to resolve compose-managed -// containers without scanning every stack. -func (s *Store) GetStackByComposeProjectName(name string) (Stack, error) { - if name == "" { - return Stack{}, ErrNotFound - } - st, err := scanStackRow(s.db.QueryRow( - `SELECT `+stackCols+` FROM stacks WHERE compose_project_name = ?`, name, - )) - if errors.Is(err, sql.ErrNoRows) { - return Stack{}, ErrNotFound - } - if err != nil { - return Stack{}, fmt.Errorf("query stack by compose project: %w", err) - } - return st, nil -} - -// GetAllStacks returns every stack ordered by name. -func (s *Store) GetAllStacks() ([]Stack, error) { - rows, err := s.db.Query(`SELECT ` + stackCols + ` FROM stacks ORDER BY name`) - if err != nil { - return nil, fmt.Errorf("query stacks: %w", err) - } - defer rows.Close() - - out := []Stack{} - for rows.Next() { - st, err := scanStackRows(rows) - if err != nil { - return nil, err - } - out = append(out, st) - } - return out, rows.Err() -} - -// UpdateStack updates the mutable metadata fields (name, description). -// Atomic: stack row UPDATE and workload row sync share a transaction so the -// workload row's name never lags after a rename. -func (s *Store) UpdateStack(st Stack) error { - st.UpdatedAt = Now() - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("begin: %w", err) - } - defer tx.Rollback() - - result, err := tx.Exec( - `UPDATE stacks SET name=?, description=?, updated_at=? WHERE id=?`, - st.Name, st.Description, st.UpdatedAt, st.ID, - ) - if err != nil { - return fmt.Errorf("update stack: %w", err) - } - n, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("rows affected: %w", err) - } - if n == 0 { - return fmt.Errorf("stack %s: %w", st.ID, ErrNotFound) - } - if err := SyncStackWorkloadTx(tx, st); err != nil { - return err - } - return tx.Commit() -} - -// UpdateStackStatus updates the deployment status + error fields. -func (s *Store) UpdateStackStatus(id, status, errMsg string) error { - now := Now() - result, err := s.db.Exec( - `UPDATE stacks SET status=?, error=?, updated_at=? WHERE id=?`, - status, errMsg, now, id, - ) - if err != nil { - return fmt.Errorf("update stack status: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("stack %s: %w", id, ErrNotFound) - } - return nil -} - -// SetStackCurrentRevision updates the current_revision_id pointer. -func (s *Store) SetStackCurrentRevision(id, revisionID string) error { - now := Now() - result, err := s.db.Exec( - `UPDATE stacks SET current_revision_id=?, updated_at=? WHERE id=?`, - revisionID, now, id, - ) - if err != nil { - return fmt.Errorf("update stack revision pointer: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("stack %s: %w", id, ErrNotFound) - } - return nil -} - -// DeleteStack removes a stack by ID. Cascading deletes handle revisions + deploys. -// Stack + workload + container index rows are dropped atomically. -func (s *Store) DeleteStack(id string) error { - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("begin: %w", err) - } - defer tx.Rollback() - - var workloadID string - if err := tx.QueryRow( - `SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`, - string(WorkloadKindStack), id, - ).Scan(&workloadID); err != nil && !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("lookup stack workload: %w", err) - } - - result, err := tx.Exec(`DELETE FROM stacks WHERE id = ?`, id) - if err != nil { - return fmt.Errorf("delete stack: %w", err) - } - n, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("rows affected: %w", err) - } - if n == 0 { - return fmt.Errorf("stack %s: %w", id, ErrNotFound) - } - - if workloadID != "" { - if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, workloadID); err != nil { - return fmt.Errorf("delete stack containers: %w", err) - } - if _, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, workloadID); err != nil { - return fmt.Errorf("delete stack workload: %w", err) - } - } - return tx.Commit() -} - -func scanStackRow(row *sql.Row) (Stack, error) { - var st Stack - err := row.Scan( - &st.ID, &st.Name, &st.Description, &st.ComposeProjectName, - &st.Status, &st.Error, &st.CurrentRevisionID, &st.CreatedAt, &st.UpdatedAt, - ) - return st, err -} - -func scanStackRows(rows *sql.Rows) (Stack, error) { - var st Stack - err := rows.Scan( - &st.ID, &st.Name, &st.Description, &st.ComposeProjectName, - &st.Status, &st.Error, &st.CurrentRevisionID, &st.CreatedAt, &st.UpdatedAt, - ) - if err != nil { - return Stack{}, fmt.Errorf("scan stack: %w", err) - } - return st, nil -} - -// --- Stack revisions --- - -const stackRevisionCols = `id, stack_id, revision, yaml, author, deploy_id, status, created_at` - -// CreateStackRevision inserts a new revision with the next monotonic revision number. -func (s *Store) CreateStackRevision(r StackRevision) (StackRevision, error) { - r.ID = uuid.New().String() - r.CreatedAt = Now() - if r.Status == "" { - r.Status = "pending" - } - - tx, err := s.db.Begin() - if err != nil { - return StackRevision{}, fmt.Errorf("begin tx: %w", err) - } - defer tx.Rollback() - - var next int - if err := tx.QueryRow( - `SELECT COALESCE(MAX(revision), 0) + 1 FROM stack_revisions WHERE stack_id = ?`, - r.StackID, - ).Scan(&next); err != nil { - return StackRevision{}, fmt.Errorf("next revision: %w", err) - } - r.Revision = next - - if _, err := tx.Exec( - `INSERT INTO stack_revisions (`+stackRevisionCols+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - r.ID, r.StackID, r.Revision, r.YAML, r.Author, r.DeployID, r.Status, r.CreatedAt, - ); err != nil { - return StackRevision{}, fmt.Errorf("insert revision: %w", err) - } - if err := tx.Commit(); err != nil { - return StackRevision{}, fmt.Errorf("commit revision: %w", err) - } - return r, nil -} - -// GetStackRevisionByID returns a single revision by ID. -func (s *Store) GetStackRevisionByID(id string) (StackRevision, error) { - r, err := scanStackRevisionRow(s.db.QueryRow( - `SELECT `+stackRevisionCols+` FROM stack_revisions WHERE id = ?`, id, - )) - if errors.Is(err, sql.ErrNoRows) { - return StackRevision{}, fmt.Errorf("revision %s: %w", id, ErrNotFound) - } - if err != nil { - return StackRevision{}, fmt.Errorf("query revision: %w", err) - } - return r, nil -} - -// GetStackRevisionsByStackID returns revisions newest-first. -func (s *Store) GetStackRevisionsByStackID(stackID string) ([]StackRevision, error) { - rows, err := s.db.Query( - `SELECT `+stackRevisionCols+` FROM stack_revisions WHERE stack_id = ? - ORDER BY revision DESC`, - stackID, - ) - if err != nil { - return nil, fmt.Errorf("query revisions: %w", err) - } - defer rows.Close() - - out := []StackRevision{} - for rows.Next() { - r, err := scanStackRevisionRows(rows) - if err != nil { - return nil, err - } - out = append(out, r) - } - return out, rows.Err() -} - -// UpdateStackRevisionStatus updates status + deploy_id linkage. -func (s *Store) UpdateStackRevisionStatus(id, status, deployID string) error { - result, err := s.db.Exec( - `UPDATE stack_revisions SET status=?, deploy_id=? WHERE id=?`, - status, deployID, id, - ) - if err != nil { - return fmt.Errorf("update revision status: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("revision %s: %w", id, ErrNotFound) - } - return nil -} - -func scanStackRevisionRow(row *sql.Row) (StackRevision, error) { - var r StackRevision - err := row.Scan( - &r.ID, &r.StackID, &r.Revision, &r.YAML, &r.Author, &r.DeployID, &r.Status, &r.CreatedAt, - ) - return r, err -} - -func scanStackRevisionRows(rows *sql.Rows) (StackRevision, error) { - var r StackRevision - err := rows.Scan( - &r.ID, &r.StackID, &r.Revision, &r.YAML, &r.Author, &r.DeployID, &r.Status, &r.CreatedAt, - ) - if err != nil { - return StackRevision{}, fmt.Errorf("scan revision: %w", err) - } - return r, nil -} - -// --- Stack deploys --- - -const stackDeployCols = `id, stack_id, revision_id, status, log, error, started_at, finished_at` - -// CreateStackDeploy inserts a new deploy record. -func (s *Store) CreateStackDeploy(d StackDeploy) (StackDeploy, error) { - d.ID = uuid.New().String() - d.StartedAt = Now() - if d.Status == "" { - d.Status = "pending" - } - - _, err := s.db.Exec( - `INSERT INTO stack_deploys (`+stackDeployCols+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - d.ID, d.StackID, d.RevisionID, d.Status, d.Log, d.Error, d.StartedAt, d.FinishedAt, - ) - if err != nil { - return StackDeploy{}, fmt.Errorf("insert stack deploy: %w", err) - } - return d, nil -} - -// GetStackDeployByID returns a single deploy by ID. -func (s *Store) GetStackDeployByID(id string) (StackDeploy, error) { - d, err := scanStackDeployRow(s.db.QueryRow( - `SELECT `+stackDeployCols+` FROM stack_deploys WHERE id = ?`, id, - )) - if errors.Is(err, sql.ErrNoRows) { - return StackDeploy{}, fmt.Errorf("stack deploy %s: %w", id, ErrNotFound) - } - if err != nil { - return StackDeploy{}, fmt.Errorf("query stack deploy: %w", err) - } - return d, nil -} - -// UpdateStackDeploy updates status, log, error, finished_at. -func (s *Store) UpdateStackDeploy(d StackDeploy) error { - result, err := s.db.Exec( - `UPDATE stack_deploys SET status=?, log=?, error=?, finished_at=? WHERE id=?`, - d.Status, d.Log, d.Error, d.FinishedAt, d.ID, - ) - if err != nil { - return fmt.Errorf("update stack deploy: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("stack deploy %s: %w", d.ID, ErrNotFound) - } - return nil -} - -func scanStackDeployRow(row *sql.Row) (StackDeploy, error) { - var d StackDeploy - err := row.Scan( - &d.ID, &d.StackID, &d.RevisionID, &d.Status, &d.Log, &d.Error, &d.StartedAt, &d.FinishedAt, - ) - return d, err -} diff --git a/internal/store/stage_env.go b/internal/store/stage_env.go deleted file mode 100644 index bd59563..0000000 --- a/internal/store/stage_env.go +++ /dev/null @@ -1,112 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" - - "github.com/google/uuid" -) - -// CreateStageEnv inserts a new stage environment variable override. -func (s *Store) CreateStageEnv(env StageEnv) (StageEnv, error) { - env.ID = uuid.New().String() - env.CreatedAt = Now() - env.UpdatedAt = env.CreatedAt - - _, err := s.db.Exec( - `INSERT INTO stage_env (id, stage_id, key, value, encrypted, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - env.ID, env.StageID, env.Key, env.Value, BoolToInt(env.Encrypted), - env.CreatedAt, env.UpdatedAt, - ) - if err != nil { - return StageEnv{}, fmt.Errorf("insert stage env: %w", err) - } - return env, nil -} - -// GetStageEnvByStageID returns all environment variable overrides for a stage. -func (s *Store) GetStageEnvByStageID(stageID string) ([]StageEnv, error) { - rows, err := s.db.Query( - `SELECT id, stage_id, key, value, encrypted, created_at, updated_at - FROM stage_env WHERE stage_id = ? ORDER BY key`, stageID, - ) - if err != nil { - return nil, fmt.Errorf("query stage env: %w", err) - } - defer rows.Close() - - envs := []StageEnv{} - for rows.Next() { - env, err := scanStageEnv(rows) - if err != nil { - return nil, err - } - envs = append(envs, env) - } - return envs, rows.Err() -} - -// GetStageEnvByID returns a single stage env override by ID. -func (s *Store) GetStageEnvByID(id string) (StageEnv, error) { - var env StageEnv - var encrypted int - err := s.db.QueryRow( - `SELECT id, stage_id, key, value, encrypted, created_at, updated_at - FROM stage_env WHERE id = ?`, id, - ).Scan(&env.ID, &env.StageID, &env.Key, &env.Value, &encrypted, - &env.CreatedAt, &env.UpdatedAt) - if errors.Is(err, sql.ErrNoRows) { - return StageEnv{}, fmt.Errorf("stage env %s: %w", id, ErrNotFound) - } - if err != nil { - return StageEnv{}, fmt.Errorf("query stage env: %w", err) - } - env.Encrypted = encrypted != 0 - return env, nil -} - -// UpdateStageEnv updates an existing stage environment variable override. -func (s *Store) UpdateStageEnv(env StageEnv) error { - env.UpdatedAt = Now() - result, err := s.db.Exec( - `UPDATE stage_env SET key=?, value=?, encrypted=?, updated_at=? - WHERE id=?`, - env.Key, env.Value, BoolToInt(env.Encrypted), env.UpdatedAt, env.ID, - ) - if err != nil { - return fmt.Errorf("update stage env: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("stage env %s: %w", env.ID, ErrNotFound) - } - return nil -} - -// DeleteStageEnv removes a stage env override by ID. -func (s *Store) DeleteStageEnv(id string) error { - result, err := s.db.Exec(`DELETE FROM stage_env WHERE id = ?`, id) - if err != nil { - return fmt.Errorf("delete stage env: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("stage env %s: %w", id, ErrNotFound) - } - return nil -} - -// scanStageEnv scans a stage env row from a *sql.Rows cursor. -func scanStageEnv(rows *sql.Rows) (StageEnv, error) { - var env StageEnv - var encrypted int - err := rows.Scan(&env.ID, &env.StageID, &env.Key, &env.Value, &encrypted, - &env.CreatedAt, &env.UpdatedAt) - if err != nil { - return StageEnv{}, fmt.Errorf("scan stage env: %w", err) - } - env.Encrypted = encrypted != 0 - return env, nil -} diff --git a/internal/store/stages.go b/internal/store/stages.go deleted file mode 100644 index 1605b9e..0000000 --- a/internal/store/stages.go +++ /dev/null @@ -1,168 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" - - "github.com/google/uuid" -) - -const stageColumns = `id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, enable_proxy, promote_from, subdomain, notification_url, notification_secret, cpu_limit, memory_limit, created_at, updated_at` - -// CreateStage inserts a new stage for a project. -func (s *Store) CreateStage(st Stage) (Stage, error) { - st.ID = uuid.New().String() - st.CreatedAt = Now() - st.UpdatedAt = st.CreatedAt - - _, err := s.db.Exec( - `INSERT INTO stages (`+stageColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - st.ID, st.ProjectID, st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances, - BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL, - st.NotificationSecret, - st.CpuLimit, st.MemoryLimit, st.CreatedAt, st.UpdatedAt, - ) - if err != nil { - return Stage{}, fmt.Errorf("insert stage: %w", err) - } - return st, nil -} - -// GetStagesByProjectID returns all stages for a given project. -func (s *Store) GetStagesByProjectID(projectID string) ([]Stage, error) { - rows, err := s.db.Query( - `SELECT `+stageColumns+` FROM stages WHERE project_id = ? ORDER BY name`, projectID, - ) - if err != nil { - return nil, fmt.Errorf("query stages: %w", err) - } - defer rows.Close() - - stages := []Stage{} - for rows.Next() { - st, err := scanStage(rows) - if err != nil { - return nil, err - } - stages = append(stages, st) - } - return stages, rows.Err() -} - -// GetStageByID returns a single stage by its ID. -func (s *Store) GetStageByID(id string) (Stage, error) { - var st Stage - var autoDeploy, confirm, enableProxy int - err := s.db.QueryRow( - `SELECT `+stageColumns+` FROM stages WHERE id = ?`, id, - ).Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances, - &confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL, - &st.NotificationSecret, - &st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt) - if errors.Is(err, sql.ErrNoRows) { - return Stage{}, fmt.Errorf("stage %s: %w", id, ErrNotFound) - } - if err != nil { - return Stage{}, fmt.Errorf("query stage: %w", err) - } - st.AutoDeploy = autoDeploy != 0 - st.Confirm = confirm != 0 - st.EnableProxy = enableProxy != 0 - return st, nil -} - -// UpdateStage updates an existing stage's mutable fields. -func (s *Store) UpdateStage(st Stage) error { - st.UpdatedAt = Now() - result, err := s.db.Exec( - `UPDATE stages SET name=?, tag_pattern=?, auto_deploy=?, max_instances=?, confirm=?, enable_proxy=?, promote_from=?, subdomain=?, notification_url=?, cpu_limit=?, memory_limit=?, updated_at=? - WHERE id=?`, - st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances, - BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL, - st.CpuLimit, st.MemoryLimit, st.UpdatedAt, st.ID, - ) - // notification_secret is intentionally not updated here — use the - // dedicated SetStageNotificationSecret rotation helper. - if err != nil { - return fmt.Errorf("update stage: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("stage %s: %w", st.ID, ErrNotFound) - } - return nil -} - -// DeleteStage removes a stage by ID. Cascading deletes handle child instances. -func (s *Store) DeleteStage(id string) error { - result, err := s.db.Exec(`DELETE FROM stages WHERE id = ?`, id) - if err != nil { - return fmt.Errorf("delete stage: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("stage %s: %w", id, ErrNotFound) - } - return nil -} - -// SetStageNotificationSecret rotates the stage's outgoing-webhook signing -// secret. Empty string disables HMAC signing for this stage (notifications -// still send unsigned, falling through to project/global resolution). -func (s *Store) SetStageNotificationSecret(id, secret string) error { - result, err := s.db.Exec( - `UPDATE stages SET notification_secret=?, updated_at=? WHERE id=?`, - secret, Now(), id, - ) - if err != nil { - return fmt.Errorf("set stage notification secret: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("stage %s: %w", id, ErrNotFound) - } - return nil -} - -// EnsureStageNotificationSecret returns the stage's outgoing-webhook signing -// secret, generating one lazily if missing. -func (s *Store) EnsureStageNotificationSecret(id string) (string, error) { - stage, err := s.GetStageByID(id) - if err != nil { - return "", err - } - if stage.NotificationSecret != "" { - return stage.NotificationSecret, nil - } - secret := generateWebhookSecret() - if err := s.SetStageNotificationSecret(id, secret); err != nil { - return "", err - } - return secret, nil -} - -// BoolToInt converts a bool to an integer for SQLite storage. -func BoolToInt(b bool) int { - if b { - return 1 - } - return 0 -} - -// scanStage scans a stage row from a *sql.Rows cursor. -func scanStage(rows *sql.Rows) (Stage, error) { - var st Stage - var autoDeploy, confirm, enableProxy int - err := rows.Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances, - &confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL, - &st.NotificationSecret, - &st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt) - if err != nil { - return Stage{}, fmt.Errorf("scan stage: %w", err) - } - st.AutoDeploy = autoDeploy != 0 - st.Confirm = confirm != 0 - st.EnableProxy = enableProxy != 0 - return st, nil -} diff --git a/internal/store/static_site_secrets.go b/internal/store/static_site_secrets.go deleted file mode 100644 index 2f659a7..0000000 --- a/internal/store/static_site_secrets.go +++ /dev/null @@ -1,112 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" - - "github.com/google/uuid" -) - -// CreateStaticSiteSecret inserts a new secret for a static site. -func (s *Store) CreateStaticSiteSecret(secret StaticSiteSecret) (StaticSiteSecret, error) { - secret.ID = uuid.New().String() - secret.CreatedAt = Now() - secret.UpdatedAt = secret.CreatedAt - - _, err := s.db.Exec( - `INSERT INTO static_site_secrets (id, site_id, key, value, encrypted, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - secret.ID, secret.SiteID, secret.Key, secret.Value, - BoolToInt(secret.Encrypted), secret.CreatedAt, secret.UpdatedAt, - ) - if err != nil { - return StaticSiteSecret{}, fmt.Errorf("insert static site secret: %w", err) - } - return secret, nil -} - -// GetStaticSiteSecretsBySiteID returns all secrets for a static site. -func (s *Store) GetStaticSiteSecretsBySiteID(siteID string) ([]StaticSiteSecret, error) { - rows, err := s.db.Query( - `SELECT id, site_id, key, value, encrypted, created_at, updated_at - FROM static_site_secrets WHERE site_id = ? ORDER BY key`, siteID, - ) - if err != nil { - return nil, fmt.Errorf("query static site secrets: %w", err) - } - defer rows.Close() - - secrets := []StaticSiteSecret{} - for rows.Next() { - secret, err := scanStaticSiteSecret(rows) - if err != nil { - return nil, err - } - secrets = append(secrets, secret) - } - return secrets, rows.Err() -} - -// GetStaticSiteSecretByID returns a single secret by ID. -func (s *Store) GetStaticSiteSecretByID(id string) (StaticSiteSecret, error) { - var secret StaticSiteSecret - var encrypted int - err := s.db.QueryRow( - `SELECT id, site_id, key, value, encrypted, created_at, updated_at - FROM static_site_secrets WHERE id = ?`, id, - ).Scan(&secret.ID, &secret.SiteID, &secret.Key, &secret.Value, &encrypted, - &secret.CreatedAt, &secret.UpdatedAt) - if errors.Is(err, sql.ErrNoRows) { - return StaticSiteSecret{}, fmt.Errorf("static site secret %s: %w", id, ErrNotFound) - } - if err != nil { - return StaticSiteSecret{}, fmt.Errorf("query static site secret: %w", err) - } - secret.Encrypted = encrypted != 0 - return secret, nil -} - -// UpdateStaticSiteSecret updates an existing secret. -func (s *Store) UpdateStaticSiteSecret(secret StaticSiteSecret) error { - secret.UpdatedAt = Now() - result, err := s.db.Exec( - `UPDATE static_site_secrets SET key=?, value=?, encrypted=?, updated_at=? - WHERE id=?`, - secret.Key, secret.Value, BoolToInt(secret.Encrypted), secret.UpdatedAt, secret.ID, - ) - if err != nil { - return fmt.Errorf("update static site secret: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("static site secret %s: %w", secret.ID, ErrNotFound) - } - return nil -} - -// DeleteStaticSiteSecret removes a secret by ID. -func (s *Store) DeleteStaticSiteSecret(id string) error { - result, err := s.db.Exec(`DELETE FROM static_site_secrets WHERE id = ?`, id) - if err != nil { - return fmt.Errorf("delete static site secret: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("static site secret %s: %w", id, ErrNotFound) - } - return nil -} - -// scanStaticSiteSecret scans a secret row from a *sql.Rows cursor. -func scanStaticSiteSecret(rows *sql.Rows) (StaticSiteSecret, error) { - var secret StaticSiteSecret - var encrypted int - err := rows.Scan(&secret.ID, &secret.SiteID, &secret.Key, &secret.Value, &encrypted, - &secret.CreatedAt, &secret.UpdatedAt) - if err != nil { - return StaticSiteSecret{}, fmt.Errorf("scan static site secret: %w", err) - } - secret.Encrypted = encrypted != 0 - return secret, nil -} diff --git a/internal/store/static_sites.go b/internal/store/static_sites.go deleted file mode 100644 index 17d62e6..0000000 --- a/internal/store/static_sites.go +++ /dev/null @@ -1,502 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" - "strings" - - "github.com/google/uuid" -) - -// staticSiteCols is the column list for static_sites queries. -const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path, - access_token, domain, mode, render_markdown, sync_trigger, tag_pattern, - container_id, proxy_route_id, status, last_sync_at, last_commit_sha, error, - storage_enabled, storage_limit_mb, - webhook_secret, webhook_signing_secret, webhook_require_signature, - notification_url, notification_secret, - created_at, updated_at` - -// UpsertStaticSiteWithID inserts or replaces a static site, keeping the -// caller-supplied ID. Used by the plugin static-source Backend adapter -// to keep a phantom row keyed on the workload ID so staticsite.Manager -// (which reads from this table) can serve plugin-native workloads -// without being refactored. Skips workload-row sync since the caller -// already owns the workload row. -func (s *Store) UpsertStaticSiteWithID(site StaticSite) error { - if site.ID == "" { - return fmt.Errorf("UpsertStaticSiteWithID: id is required") - } - if site.WebhookSecret == "" { - site.WebhookSecret = generateWebhookSecret() - } - if site.SyncTrigger == "" { - site.SyncTrigger = "manual" - } - if site.Mode == "" { - site.Mode = "static" - } - if site.Branch == "" { - site.Branch = "main" - } - if site.Status == "" { - site.Status = "idle" - } - now := Now() - site.UpdatedAt = now - if site.CreatedAt == "" { - site.CreatedAt = now - } - _, err := s.db.Exec( - `INSERT OR REPLACE INTO static_sites (`+staticSiteCols+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName, - site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode, - BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern, - site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt, - site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB, - site.WebhookSecret, site.WebhookSigningSecret, BoolToInt(site.WebhookRequireSignature), - site.NotificationURL, site.NotificationSecret, - site.CreatedAt, site.UpdatedAt, - ) - if err != nil { - return fmt.Errorf("upsert static site: %w", err) - } - return nil -} - -// CreateStaticSite inserts a new static site and returns it. A webhook secret -// is generated automatically if one is not already set on the input. Site row -// + matching workload row are written in a single transaction. -func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) { - site.ID = uuid.New().String() - site.CreatedAt = Now() - site.UpdatedAt = site.CreatedAt - if site.WebhookSecret == "" { - site.WebhookSecret = generateWebhookSecret() - } else if len(site.WebhookSecret) < minWebhookSecretLength { - return StaticSite{}, fmt.Errorf("webhook_secret must be at least %d characters", minWebhookSecretLength) - } - - tx, err := s.db.Begin() - if err != nil { - return StaticSite{}, fmt.Errorf("begin: %w", err) - } - defer tx.Rollback() - - if _, err := tx.Exec( - `INSERT INTO static_sites (`+staticSiteCols+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName, - site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode, - BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern, - site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt, - site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB, - site.WebhookSecret, site.WebhookSigningSecret, BoolToInt(site.WebhookRequireSignature), - site.NotificationURL, site.NotificationSecret, - site.CreatedAt, site.UpdatedAt, - ); err != nil { - return StaticSite{}, fmt.Errorf("insert static site: %w", err) - } - if err := SyncStaticSiteWorkloadTx(tx, site); err != nil { - return StaticSite{}, err - } - if err := tx.Commit(); err != nil { - return StaticSite{}, fmt.Errorf("commit: %w", err) - } - return site, nil -} - -// GetStaticSiteByID returns a single static site by its ID. -func (s *Store) GetStaticSiteByID(id string) (StaticSite, error) { - site, err := scanStaticSiteRow(s.db.QueryRow( - `SELECT `+staticSiteCols+` FROM static_sites WHERE id = ?`, id, - )) - if errors.Is(err, sql.ErrNoRows) { - return StaticSite{}, fmt.Errorf("static site %s: %w", id, ErrNotFound) - } - if err != nil { - return StaticSite{}, fmt.Errorf("query static site: %w", err) - } - return site, nil -} - -// GetAllStaticSites returns every static site ordered by name. -func (s *Store) GetAllStaticSites() ([]StaticSite, error) { - rows, err := s.db.Query( - `SELECT ` + staticSiteCols + ` FROM static_sites ORDER BY name`, - ) - if err != nil { - return nil, fmt.Errorf("query static sites: %w", err) - } - defer rows.Close() - - sites := []StaticSite{} - for rows.Next() { - site, err := scanStaticSiteRows(rows) - if err != nil { - return nil, err - } - sites = append(sites, site) - } - return sites, rows.Err() -} - -// GetStaticSitesByRepo returns all static sites for a given repo owner/name. -func (s *Store) GetStaticSitesByRepo(giteaURL, owner, name string) ([]StaticSite, error) { - rows, err := s.db.Query( - `SELECT `+staticSiteCols+` - FROM static_sites WHERE gitea_url = ? AND repo_owner = ? AND repo_name = ? - ORDER BY name`, - giteaURL, owner, name, - ) - if err != nil { - return nil, fmt.Errorf("query static sites by repo: %w", err) - } - defer rows.Close() - - sites := []StaticSite{} - for rows.Next() { - site, err := scanStaticSiteRows(rows) - if err != nil { - return nil, err - } - sites = append(sites, site) - } - return sites, rows.Err() -} - -// updateStaticSiteAndSyncWorkloadTx wraps a parameterized UPDATE on -// static_sites with the workload sync, all inside a single transaction. -// updateSQL must end with `WHERE id=?`; args end with the site ID. -func (s *Store) updateStaticSiteAndSyncWorkloadTx(id string, updateSQL string, args ...any) error { - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("begin: %w", err) - } - defer tx.Rollback() - - result, err := tx.Exec(updateSQL, args...) - if err != nil { - return fmt.Errorf("update static site: %w", err) - } - n, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("rows affected: %w", err) - } - if n == 0 { - return fmt.Errorf("static site %s: %w", id, ErrNotFound) - } - - row := tx.QueryRow(`SELECT `+staticSiteCols+` FROM static_sites WHERE id = ?`, id) - current, err := scanStaticSiteRowFromQuery(row) - if err != nil { - return fmt.Errorf("reread static site for workload sync: %w", err) - } - if err := SyncStaticSiteWorkloadTx(tx, current); err != nil { - return err - } - return tx.Commit() -} - -// scanStaticSiteRowFromQuery is a thin wrapper around scanStaticSiteRow that -// accepts a *sql.Row from either s.db or a transaction. Kept private so the -// public surface stays narrow. -func scanStaticSiteRowFromQuery(row *sql.Row) (StaticSite, error) { - return scanStaticSiteRow(row) -} - -// UpdateStaticSite updates an existing static site's configuration fields. -// notification_secret is intentionally not updated here — use the dedicated -// SetStaticSiteNotificationSecret rotation helper. -func (s *Store) UpdateStaticSite(site StaticSite) error { - site.UpdatedAt = Now() - return s.updateStaticSiteAndSyncWorkloadTx(site.ID, - `UPDATE static_sites SET name=?, provider=?, gitea_url=?, repo_owner=?, repo_name=?, branch=?, - folder_path=?, access_token=?, domain=?, mode=?, render_markdown=?, - sync_trigger=?, tag_pattern=?, storage_enabled=?, storage_limit_mb=?, - notification_url=?, updated_at=? - WHERE id=?`, - site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName, site.Branch, - site.FolderPath, site.AccessToken, site.Domain, site.Mode, - BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern, - BoolToInt(site.StorageEnabled), site.StorageLimitMB, - site.NotificationURL, site.UpdatedAt, site.ID, - ) -} - -// UpdateStaticSiteStatus updates the deployment status fields. -func (s *Store) UpdateStaticSiteStatus(id, status, commitSHA, errMsg string) error { - now := Now() - result, err := s.db.Exec( - `UPDATE static_sites SET status=?, last_commit_sha=?, last_sync_at=?, error=?, updated_at=? - WHERE id=?`, - status, commitSHA, now, errMsg, now, id, - ) - if err != nil { - return fmt.Errorf("update static site status: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("static site %s: %w", id, ErrNotFound) - } - return nil -} - -// UpdateStaticSiteContainer updates the container and proxy route IDs after deployment. -func (s *Store) UpdateStaticSiteContainer(id, containerID, proxyRouteID string) error { - now := Now() - result, err := s.db.Exec( - `UPDATE static_sites SET container_id=?, proxy_route_id=?, updated_at=? WHERE id=?`, - containerID, proxyRouteID, now, id, - ) - if err != nil { - return fmt.Errorf("update static site container: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("static site %s: %w", id, ErrNotFound) - } - return nil -} - -// ListStaticSiteProxyRoutes returns proxy routes backed by static sites, -// shaped to match the unified ProxyRoute model used by the Proxies page. -// Sites without an active proxy route are skipped. -func (s *Store) ListStaticSiteProxyRoutes(domain string) ([]ProxyRoute, error) { - rows, err := s.db.Query( - `SELECT id, name, mode, provider, domain, container_id, proxy_route_id, status, created_at - FROM static_sites - WHERE proxy_route_id != '' - ORDER BY name`, - ) - if err != nil { - return nil, fmt.Errorf("query static site proxy routes: %w", err) - } - defer rows.Close() - - suffix := "" - if domain != "" { - suffix = "." + strings.ToLower(domain) - } - - routes := []ProxyRoute{} - for rows.Next() { - var r ProxyRoute - var mode, provider, fullDomain string - if err := rows.Scan( - &r.InstanceID, &r.ProjectName, &mode, &provider, &fullDomain, - &r.ContainerID, &r.ProxyRouteID, &r.Status, &r.CreatedAt, - ); err != nil { - return nil, fmt.Errorf("scan static site proxy route: %w", err) - } - r.Source = "static_site" - r.StageName = mode - r.ImageTag = provider - r.Domain = fullDomain - if suffix != "" && strings.HasSuffix(strings.ToLower(fullDomain), suffix) { - r.Subdomain = fullDomain[:len(fullDomain)-len(suffix)] - } else { - r.Subdomain = fullDomain - } - routes = append(routes, r) - } - return routes, rows.Err() -} - -// DeleteStaticSite removes a static site by ID. Cascading deletes handle -// secrets. Site + workload + container index rows are dropped atomically. -func (s *Store) DeleteStaticSite(id string) error { - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("begin: %w", err) - } - defer tx.Rollback() - - var workloadID string - if err := tx.QueryRow( - `SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`, - string(WorkloadKindSite), id, - ).Scan(&workloadID); err != nil && !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("lookup site workload: %w", err) - } - - result, err := tx.Exec(`DELETE FROM static_sites WHERE id = ?`, id) - if err != nil { - return fmt.Errorf("delete static site: %w", err) - } - n, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("rows affected: %w", err) - } - if n == 0 { - return fmt.Errorf("static site %s: %w", id, ErrNotFound) - } - - if workloadID != "" { - if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, workloadID); err != nil { - return fmt.Errorf("delete static site containers: %w", err) - } - if _, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, workloadID); err != nil { - return fmt.Errorf("delete static site workload: %w", err) - } - } - return tx.Commit() -} - -// scanStaticSiteRow scans a static site from a *sql.Row. -func scanStaticSiteRow(row *sql.Row) (StaticSite, error) { - var site StaticSite - var renderMarkdown, storageEnabled, requireSig int - err := row.Scan( - &site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName, - &site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode, - &renderMarkdown, &site.SyncTrigger, &site.TagPattern, - &site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt, - &site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB, - &site.WebhookSecret, &site.WebhookSigningSecret, &requireSig, - &site.NotificationURL, &site.NotificationSecret, - &site.CreatedAt, &site.UpdatedAt, - ) - if err != nil { - return StaticSite{}, err - } - site.RenderMarkdown = renderMarkdown != 0 - site.StorageEnabled = storageEnabled != 0 - site.WebhookRequireSignature = requireSig != 0 - return site, nil -} - -// scanStaticSiteRows scans a static site from a *sql.Rows cursor. -func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) { - var site StaticSite - var renderMarkdown, storageEnabled, requireSig int - err := rows.Scan( - &site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName, - &site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode, - &renderMarkdown, &site.SyncTrigger, &site.TagPattern, - &site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt, - &site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB, - &site.WebhookSecret, &site.WebhookSigningSecret, &requireSig, - &site.NotificationURL, &site.NotificationSecret, - &site.CreatedAt, &site.UpdatedAt, - ) - if err != nil { - return StaticSite{}, fmt.Errorf("scan static site: %w", err) - } - site.RenderMarkdown = renderMarkdown != 0 - site.StorageEnabled = storageEnabled != 0 - site.WebhookRequireSignature = requireSig != 0 - return site, nil -} - -// SetStaticSiteWebhookSigningSecret assigns the inbound HMAC signing secret. -// Pass an empty string to clear it (also implicitly disables enforcement). -func (s *Store) SetStaticSiteWebhookSigningSecret(id, secret string) error { - return s.updateStaticSiteAndSyncWorkloadTx(id, - `UPDATE static_sites SET webhook_signing_secret=?, updated_at=? WHERE id=?`, - secret, Now(), id, - ) -} - -// SetStaticSiteWebhookRequireSignature toggles whether unsigned (or -// invalidly-signed) inbound webhook requests are rejected with 401. -func (s *Store) SetStaticSiteWebhookRequireSignature(id string, require bool) error { - v := 0 - if require { - v = 1 - } - return s.updateStaticSiteAndSyncWorkloadTx(id, - `UPDATE static_sites SET webhook_require_signature=?, updated_at=? WHERE id=?`, - v, Now(), id, - ) -} - -// SetStaticSiteNotificationSecret rotates the static site's outgoing-webhook -// signing secret. Empty string disables HMAC signing for this site -// (notifications still send unsigned, falling through to global resolution). -func (s *Store) SetStaticSiteNotificationSecret(id, secret string) error { - return s.updateStaticSiteAndSyncWorkloadTx(id, - `UPDATE static_sites SET notification_secret=?, updated_at=? WHERE id=?`, - secret, Now(), id, - ) -} - -// EnsureStaticSiteNotificationSecret returns the static site's outgoing-webhook -// signing secret, generating one lazily if missing. -func (s *Store) EnsureStaticSiteNotificationSecret(id string) (string, error) { - site, err := s.GetStaticSiteByID(id) - if err != nil { - return "", err - } - if site.NotificationSecret != "" { - return site.NotificationSecret, nil - } - secret := generateWebhookSecret() - if err := s.SetStaticSiteNotificationSecret(id, secret); err != nil { - return "", err - } - return secret, nil -} - -// EnsureSettingsNotificationSecret returns the global outgoing-webhook signing -// secret, generating one lazily if missing. -func (s *Store) EnsureSettingsNotificationSecret() (string, error) { - st, err := s.GetSettings() - if err != nil { - return "", err - } - if st.NotificationSecret != "" { - return st.NotificationSecret, nil - } - secret := generateWebhookSecret() - if err := s.SetSettingsNotificationSecret(secret); err != nil { - return "", err - } - return secret, nil -} - -// GetStaticSiteByWebhookSecret looks up a static site by its webhook secret. -// Returns ErrNotFound if no site has this secret (including empty). -func (s *Store) GetStaticSiteByWebhookSecret(secret string) (StaticSite, error) { - if secret == "" { - return StaticSite{}, ErrNotFound - } - site, err := scanStaticSiteRow(s.db.QueryRow( - `SELECT `+staticSiteCols+` FROM static_sites WHERE webhook_secret = ?`, secret, - )) - if errors.Is(err, sql.ErrNoRows) { - return StaticSite{}, ErrNotFound - } - if err != nil { - return StaticSite{}, fmt.Errorf("query static site by webhook secret: %w", err) - } - return site, nil -} - -// SetStaticSiteWebhookSecret assigns a webhook secret to a static site. -// Pass an empty string to disable webhook access for the site. -func (s *Store) SetStaticSiteWebhookSecret(id, secret string) error { - return s.updateStaticSiteAndSyncWorkloadTx(id, - `UPDATE static_sites SET webhook_secret=?, updated_at=? WHERE id=?`, - secret, Now(), id, - ) -} - -// EnsureStaticSiteWebhookSecret returns the current webhook secret for a site, -// generating one on the fly if the stored value is empty (lazy backfill). -func (s *Store) EnsureStaticSiteWebhookSecret(id string) (string, error) { - site, err := s.GetStaticSiteByID(id) - if err != nil { - return "", err - } - if site.WebhookSecret != "" { - return site.WebhookSecret, nil - } - secret := generateWebhookSecret() - if err := s.SetStaticSiteWebhookSecret(id, secret); err != nil { - return "", err - } - return secret, nil -} diff --git a/internal/store/store.go b/internal/store/store.go index 07ca893..722bae2 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -97,97 +97,44 @@ func (s *Store) migrate() error { } // runMigrations applies additive schema changes that cannot be expressed -// with CREATE TABLE IF NOT EXISTS. +// with CREATE TABLE IF NOT EXISTS, plus the hard-cutover drops that +// remove every legacy project/stage/stack/static_site/deploy table. func (s *Store) runMigrations() error { migrations := []string{ - // Add owner column to registries (2026-03-28). - `ALTER TABLE registries ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, - // Add base_volume_path to settings (2026-03-28). + // Set default network for existing databases with empty network. + `UPDATE settings SET network = 'tinyforge' WHERE network = ''`, + // Settings column adds that survive the cutover. SQLite is tolerant + // of "duplicate column" errors at the apply step, so re-running on + // a fully-migrated DB is a no-op. `ALTER TABLE settings ADD COLUMN base_volume_path TEXT NOT NULL DEFAULT ''`, - // Add enable_proxy to stages (2026-03-29). Default true for backwards compat. - `ALTER TABLE stages ADD COLUMN enable_proxy INTEGER NOT NULL DEFAULT 1`, - // Add ssl_certificate_id to settings (2026-03-29). `ALTER TABLE settings ADD COLUMN ssl_certificate_id INTEGER NOT NULL DEFAULT 0`, - // Add stale_threshold_days to settings (2026-03-30). `ALTER TABLE settings ADD COLUMN stale_threshold_days INTEGER NOT NULL DEFAULT 7`, - // Add last_alive_at to instances for stale container detection (2026-03-30). - `ALTER TABLE instances ADD COLUMN last_alive_at TEXT NOT NULL DEFAULT ''`, - // Add name column and rename mode→scope for volume scopes redesign (2026-03-31). - `ALTER TABLE volumes ADD COLUMN name TEXT NOT NULL DEFAULT ''`, - `ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`, - // Add allowed_volume_paths to settings for absolute volume scope allowlist (2026-04-01). `ALTER TABLE settings ADD COLUMN allowed_volume_paths TEXT NOT NULL DEFAULT '[]'`, - // Add DNS management fields to settings (2026-04-02). `ALTER TABLE settings ADD COLUMN wildcard_dns INTEGER NOT NULL DEFAULT 1`, `ALTER TABLE settings ADD COLUMN dns_provider TEXT NOT NULL DEFAULT ''`, `ALTER TABLE settings ADD COLUMN cloudflare_api_token TEXT NOT NULL DEFAULT ''`, `ALTER TABLE settings ADD COLUMN cloudflare_zone_id TEXT NOT NULL DEFAULT ''`, - // Add backup management fields to settings (2026-04-02). `ALTER TABLE settings ADD COLUMN backup_enabled INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE settings ADD COLUMN backup_interval_hours INTEGER NOT NULL DEFAULT 24`, `ALTER TABLE settings ADD COLUMN backup_retention_count INTEGER NOT NULL DEFAULT 10`, - `ALTER TABLE stages ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`, - // Add proxy_route_id to instances for provider-agnostic route tracking (2026-04-04). - `ALTER TABLE instances ADD COLUMN proxy_route_id TEXT NOT NULL DEFAULT ''`, - // Add proxy_provider to settings (2026-04-04). Default to npm for backward compat. `ALTER TABLE settings ADD COLUMN proxy_provider TEXT NOT NULL DEFAULT 'npm'`, - // Add Traefik provider settings (2026-04-04). `ALTER TABLE settings ADD COLUMN traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure'`, `ALTER TABLE settings ADD COLUMN traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt'`, `ALTER TABLE settings ADD COLUMN traefik_network TEXT NOT NULL DEFAULT ''`, `ALTER TABLE settings ADD COLUMN traefik_api_url TEXT NOT NULL DEFAULT ''`, - // Set default network for existing databases with empty network. - `UPDATE settings SET network = 'tinyforge' WHERE network = ''`, - // NPM remote mode: forward to server_ip instead of container name. `ALTER TABLE settings ADD COLUMN npm_remote INTEGER NOT NULL DEFAULT 0`, - // Resource limits per stage. - `ALTER TABLE stages ADD COLUMN cpu_limit REAL NOT NULL DEFAULT 0`, - `ALTER TABLE stages ADD COLUMN memory_limit INTEGER NOT NULL DEFAULT 0`, - // NPM access list support (global default + per-project override). `ALTER TABLE settings ADD COLUMN npm_access_list_id INTEGER NOT NULL DEFAULT 0`, - `ALTER TABLE projects ADD COLUMN npm_access_list_id INTEGER NOT NULL DEFAULT 0`, - // Separate public IP for DNS A records. `ALTER TABLE settings ADD COLUMN public_ip TEXT NOT NULL DEFAULT ''`, - // Image prune threshold (MB). Warn on dashboard when exceeded. 0 = disabled. `ALTER TABLE settings ADD COLUMN image_prune_threshold_mb INTEGER NOT NULL DEFAULT 1024`, - // Add provider column to static_sites (2026-04-11). - `ALTER TABLE static_sites ADD COLUMN provider TEXT NOT NULL DEFAULT ''`, - // Add persistent storage columns to static_sites (2026-04-12). - `ALTER TABLE static_sites ADD COLUMN storage_enabled INTEGER NOT NULL DEFAULT 0`, - `ALTER TABLE static_sites ADD COLUMN storage_limit_mb INTEGER NOT NULL DEFAULT 0`, - // Per-project + per-site webhook secrets (2026-04-23). Global - // settings.webhook_secret is deprecated; its column is retained to - // avoid a destructive migration on SQLite. - `ALTER TABLE projects ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`, - `ALTER TABLE static_sites ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`, - // Resource metrics collection (2026-04-24). Interval in seconds, - // retention in hours. 0 in either disables collection. `ALTER TABLE settings ADD COLUMN stats_interval_seconds INTEGER NOT NULL DEFAULT 15`, `ALTER TABLE settings ADD COLUMN stats_retention_hours INTEGER NOT NULL DEFAULT 2`, - // Outgoing-webhook signing secrets per tier (2026-05-07). Plain hex - // tokens (matches the inbound webhook_secret pattern). Empty = no - // signing; existing rows stay unsigned on upgrade for back-compat. `ALTER TABLE settings ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`, - `ALTER TABLE projects ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`, - `ALTER TABLE projects ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`, - `ALTER TABLE stages ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`, - `ALTER TABLE static_sites ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`, - `ALTER TABLE static_sites ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`, - // Auto-backup before deploy (2026-05-07). When enabled, the deployer - // triggers a "pre-deploy" Tinyforge DB backup before any project deploy - // so a corrupted deploy is recoverable without data loss. `ALTER TABLE settings ADD COLUMN auto_backup_before_deploy INTEGER NOT NULL DEFAULT 0`, - // Per-entity inbound HMAC signing (2026-05-07). webhook_signing_secret - // is the HMAC-SHA256 key separate from the URL secret so a leaked URL - // alone is not sufficient to forge a valid request. require_signature - // rejects unsigned requests when set (defense-in-depth opt-in). - `ALTER TABLE projects ADD COLUMN webhook_signing_secret TEXT NOT NULL DEFAULT ''`, - `ALTER TABLE projects ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`, - `ALTER TABLE static_sites ADD COLUMN webhook_signing_secret TEXT NOT NULL DEFAULT ''`, - `ALTER TABLE static_sites ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`, - // Webhook delivery audit log (2026-05-07). Persists every inbound - // webhook request (project or site) with its outcome so users can - // debug "why didn't my deploy fire?" without grepping daemon logs. + // Registries — owner column. + `ALTER TABLE registries ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, + // Webhook delivery audit log persists every inbound webhook + // request so operators can debug "why didn't my deploy fire?" + // without grepping daemon logs. `CREATE TABLE IF NOT EXISTS webhook_deliveries ( id INTEGER PRIMARY KEY AUTOINCREMENT, target_type TEXT NOT NULL, @@ -203,19 +150,36 @@ func (s *Store) runMigrations() error { )`, `CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_target ON webhook_deliveries(target_type, target_id, received_at)`, `CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_received_at ON webhook_deliveries(received_at)`, - // Add stage_id to containers (2026-05-09). Backfill via the deployer - // re-write path; the LEFT JOIN in ListContainersByStageID falls back - // to (project_id, role=stage_name) so legacy rows still resolve. + // Containers — stage_id is now an opaque string set by the source + // plugin (image plugin uses it for the deploy-target tag). No FK + // semantics: the legacy `stages` table this column once joined to + // is gone; the column is just a free-form discriminator the + // proxies / dashboard views read to disambiguate sibling rows. `ALTER TABLE containers ADD COLUMN stage_id TEXT NOT NULL DEFAULT ''`, - // Workload-first refactor columns (2026-05-10). Land additively so - // the legacy kind/ref_id columns continue to serve existing - // project/stack/site rows during cutover. + // Workload-first refactor columns. Land additively so old databases + // (which have a bare workloads table) pick them up on the next boot. `ALTER TABLE workloads ADD COLUMN source_kind TEXT NOT NULL DEFAULT ''`, `ALTER TABLE workloads ADD COLUMN source_config TEXT NOT NULL DEFAULT '{}'`, `ALTER TABLE workloads ADD COLUMN trigger_kind TEXT NOT NULL DEFAULT ''`, `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 ''`, + // Hard cutover: drop every legacy table. Idempotent — DROP TABLE + // IF EXISTS is a no-op once the table is gone. Operators upgrading + // from a pre-cutover build will lose any project / stack / static + // site rows; the upgrade notes call this out explicitly. + `DROP TABLE IF EXISTS deploy_logs`, + `DROP TABLE IF EXISTS deploys`, + `DROP TABLE IF EXISTS stage_env`, + `DROP TABLE IF EXISTS stages`, + `DROP TABLE IF EXISTS poll_states`, + `DROP TABLE IF EXISTS volumes`, + `DROP TABLE IF EXISTS static_site_secrets`, + `DROP TABLE IF EXISTS static_sites`, + `DROP TABLE IF EXISTS stack_deploys`, + `DROP TABLE IF EXISTS stack_revisions`, + `DROP TABLE IF EXISTS stacks`, + `DROP TABLE IF EXISTS projects`, } // Workload refactor tables (2026-05-09). Workload is the unifying primitive @@ -369,46 +333,6 @@ func (s *Store) runMigrations() error { } } - stackTables := []string{ - `CREATE TABLE IF NOT EXISTS stacks ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - description TEXT NOT NULL DEFAULT '', - compose_project_name TEXT NOT NULL UNIQUE, - status TEXT NOT NULL DEFAULT 'stopped', - error TEXT NOT NULL DEFAULT '', - current_revision_id TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - )`, - `CREATE TABLE IF NOT EXISTS stack_revisions ( - id TEXT PRIMARY KEY, - stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE, - revision INTEGER NOT NULL, - yaml TEXT NOT NULL, - author TEXT NOT NULL DEFAULT '', - deploy_id TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(stack_id, revision) - )`, - `CREATE TABLE IF NOT EXISTS stack_deploys ( - id TEXT PRIMARY KEY, - stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE, - revision_id TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending', - log TEXT NOT NULL DEFAULT '', - error TEXT NOT NULL DEFAULT '', - started_at TEXT NOT NULL DEFAULT (datetime('now')), - finished_at TEXT NOT NULL DEFAULT '' - )`, - } - for _, t := range stackTables { - if _, err := s.db.Exec(t); err != nil { - return fmt.Errorf("create stack table: %w", err) - } - } - // Observability: event_triggers — consume EventLog entries off the // bus and dispatch webhook actions. Schema kept flat (comma-list // filters, single optional regex) — see LOGSCAN_AND_TRIGGERS_TODO.md. @@ -469,34 +393,18 @@ func (s *Store) runMigrations() error { } } - // Create indexes on foreign key columns for query performance. + // Create indexes on foreign key columns for query performance. Only + // indexes targeting tables that still exist after the hard cutover. indexes := []string{ - // instances table dropped 2026-05-09 (workload refactor) — no indexes - // needed; containers replaces it with idx_containers_workload below. - `CREATE INDEX IF NOT EXISTS idx_deploys_project_id ON deploys(project_id)`, - `CREATE INDEX IF NOT EXISTS idx_deploys_stage_id ON deploys(stage_id)`, - `CREATE INDEX IF NOT EXISTS idx_deploy_logs_deploy_id ON deploy_logs(deploy_id)`, - `CREATE INDEX IF NOT EXISTS idx_stages_project_id ON stages(project_id)`, - `CREATE INDEX IF NOT EXISTS idx_stage_env_stage_id ON stage_env(stage_id)`, - `CREATE INDEX IF NOT EXISTS idx_volumes_project_id ON volumes(project_id)`, `CREATE INDEX IF NOT EXISTS idx_event_log_severity ON event_log(severity)`, `CREATE INDEX IF NOT EXISTS idx_event_log_source ON event_log(source)`, `CREATE INDEX IF NOT EXISTS idx_event_log_created_at ON event_log(created_at)`, `CREATE INDEX IF NOT EXISTS idx_dns_records_consumer ON dns_records(consumer_type, consumer_id)`, - `CREATE INDEX IF NOT EXISTS idx_static_site_secrets_site_id ON static_site_secrets(site_id)`, - `CREATE INDEX IF NOT EXISTS idx_stack_revisions_stack_id ON stack_revisions(stack_id)`, - `CREATE INDEX IF NOT EXISTS idx_stack_deploys_stack_id ON stack_deploys(stack_id)`, - `CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_webhook_secret ON projects(webhook_secret) WHERE webhook_secret != ''`, - `CREATE UNIQUE INDEX IF NOT EXISTS idx_static_sites_webhook_secret ON static_sites(webhook_secret) WHERE webhook_secret != ''`, `CREATE INDEX IF NOT EXISTS idx_container_stats_owner_ts ON container_stats_samples(owner_type, owner_id, ts)`, `CREATE INDEX IF NOT EXISTS idx_container_stats_container_ts ON container_stats_samples(container_id, ts)`, `CREATE INDEX IF NOT EXISTS idx_container_stats_ts ON container_stats_samples(ts)`, `CREATE INDEX IF NOT EXISTS idx_system_stats_ts ON system_stats_samples(ts)`, - // Drop the legacy instances table — containers is the canonical index - // after the workload refactor (2026-05-09). Idempotent: SQLite's - // DROP TABLE IF EXISTS is a no-op on databases that already shed it. - `DROP TABLE IF EXISTS instances`, - // Workload refactor indexes (2026-05-09). + // Workload refactor indexes. `CREATE INDEX IF NOT EXISTS idx_workloads_kind ON workloads(kind)`, `CREATE INDEX IF NOT EXISTS idx_workloads_app_id ON workloads(app_id) WHERE app_id != ''`, `CREATE INDEX IF NOT EXISTS idx_workloads_ref ON workloads(kind, ref_id)`, @@ -508,7 +416,7 @@ func (s *Store) runMigrations() error { `CREATE INDEX IF NOT EXISTS idx_containers_stage_id ON containers(stage_id) WHERE stage_id != ''`, `CREATE INDEX IF NOT EXISTS idx_workload_env_workload ON workload_env(workload_id)`, `CREATE INDEX IF NOT EXISTS idx_workload_volumes_workload ON workload_volumes(workload_id)`, - // Trigger-split indexes (2026-05-16). + // Trigger-split indexes. `CREATE INDEX IF NOT EXISTS idx_triggers_kind ON triggers(kind)`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_triggers_webhook_secret ON triggers(webhook_secret) WHERE webhook_secret != ''`, `CREATE INDEX IF NOT EXISTS idx_bindings_workload ON workload_trigger_bindings(workload_id)`, @@ -520,19 +428,6 @@ func (s *Store) runMigrations() error { } } - // Data migration: copy mode→scope for volumes that have scope still empty. - // shared→project, isolated→instance. Log errors but don't fail startup. - dataMigrations := []struct{ query, desc string }{ - {`UPDATE volumes SET scope = 'project' WHERE scope = '' AND mode = 'shared'`, "migrate shared→project"}, - {`UPDATE volumes SET scope = 'instance' WHERE scope = '' AND mode = 'isolated'`, "migrate isolated→instance"}, - {`UPDATE volumes SET scope = 'project' WHERE scope = '' AND mode = ''`, "migrate empty→project"}, - } - for _, dm := range dataMigrations { - if _, err := s.db.Exec(dm.query); err != nil { - fmt.Printf("volume scope migration warning (%s): %v\n", dm.desc, err) - } - } - if err := s.backfillTriggersFromWorkloads(); err != nil { slog.Warn("trigger backfill", "error", err) } @@ -658,42 +553,6 @@ func (s *Store) backfillOneTrigger(workloadID, workloadName, kind, config, } const schema = ` -CREATE TABLE IF NOT EXISTS projects ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - registry TEXT NOT NULL DEFAULT '', - image TEXT NOT NULL, - port INTEGER NOT NULL DEFAULT 0, - healthcheck TEXT NOT NULL DEFAULT '', - env TEXT NOT NULL DEFAULT '{}', - volumes TEXT NOT NULL DEFAULT '{}', - npm_access_list_id INTEGER NOT NULL DEFAULT 0, - notification_url TEXT NOT NULL DEFAULT '', - notification_secret TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS stages ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - name TEXT NOT NULL, - tag_pattern TEXT NOT NULL DEFAULT '*', - auto_deploy INTEGER NOT NULL DEFAULT 0, - max_instances INTEGER NOT NULL DEFAULT 1, - confirm INTEGER NOT NULL DEFAULT 0, - enable_proxy INTEGER NOT NULL DEFAULT 1, - promote_from TEXT NOT NULL DEFAULT '', - subdomain TEXT NOT NULL DEFAULT '', - notification_url TEXT NOT NULL DEFAULT '', - notification_secret TEXT NOT NULL DEFAULT '', - cpu_limit REAL NOT NULL DEFAULT 0, - memory_limit INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(project_id, name) -); - CREATE TABLE IF NOT EXISTS registries ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, @@ -730,36 +589,6 @@ CREATE TABLE IF NOT EXISTS settings ( updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); --- The instances table was removed in the workload refactor (2026-05-09). --- Container state lives in the containers table; see runMigrations for the --- current schema. The DROP TABLE migration runs unconditionally on boot. - -CREATE TABLE IF NOT EXISTS deploys ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE, - instance_id TEXT NOT NULL DEFAULT '', - image_tag TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - started_at TEXT NOT NULL DEFAULT (datetime('now')), - finished_at TEXT NOT NULL DEFAULT '', - error TEXT NOT NULL DEFAULT '' -); - -CREATE TABLE IF NOT EXISTS deploy_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - deploy_id TEXT NOT NULL REFERENCES deploys(id) ON DELETE CASCADE, - message TEXT NOT NULL, - level TEXT NOT NULL DEFAULT 'info', - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS poll_states ( - stage_id TEXT PRIMARY KEY REFERENCES stages(id) ON DELETE CASCADE, - last_tag TEXT NOT NULL DEFAULT '', - last_polled TEXT NOT NULL DEFAULT (datetime('now')) -); - CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, @@ -785,27 +614,6 @@ INSERT OR IGNORE INTO settings (id) VALUES (1); -- Seed the auth_settings row if it does not exist. INSERT OR IGNORE INTO auth_settings (id) VALUES (1); -CREATE TABLE IF NOT EXISTS stage_env ( - id TEXT PRIMARY KEY, - stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE, - key TEXT NOT NULL, - value TEXT NOT NULL DEFAULT '', - encrypted INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(stage_id, key) -); - -CREATE TABLE IF NOT EXISTS volumes ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - source TEXT NOT NULL, - target TEXT NOT NULL, - mode TEXT NOT NULL DEFAULT 'shared', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - CREATE TABLE IF NOT EXISTS event_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL DEFAULT '', @@ -845,44 +653,6 @@ CREATE TABLE IF NOT EXISTS backups ( backup_type TEXT NOT NULL DEFAULT 'manual', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); - -CREATE TABLE IF NOT EXISTS static_sites ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - provider TEXT NOT NULL DEFAULT '', - gitea_url TEXT NOT NULL DEFAULT '', - repo_owner TEXT NOT NULL DEFAULT '', - repo_name TEXT NOT NULL DEFAULT '', - branch TEXT NOT NULL DEFAULT 'main', - folder_path TEXT NOT NULL DEFAULT '', - access_token TEXT NOT NULL DEFAULT '', - domain TEXT NOT NULL DEFAULT '', - mode TEXT NOT NULL DEFAULT 'static', - render_markdown INTEGER NOT NULL DEFAULT 0, - sync_trigger TEXT NOT NULL DEFAULT 'manual', - tag_pattern TEXT NOT NULL DEFAULT '', - container_id TEXT NOT NULL DEFAULT '', - proxy_route_id TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'idle', - last_sync_at TEXT NOT NULL DEFAULT '', - last_commit_sha TEXT NOT NULL DEFAULT '', - error TEXT NOT NULL DEFAULT '', - notification_url TEXT NOT NULL DEFAULT '', - notification_secret TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS static_site_secrets ( - id TEXT PRIMARY KEY, - site_id TEXT NOT NULL REFERENCES static_sites(id) ON DELETE CASCADE, - key TEXT NOT NULL, - value TEXT NOT NULL DEFAULT '', - encrypted INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(site_id, key) -); ` // Now returns the current time formatted for SQLite storage. diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 7c0305a..f8e8716 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -14,62 +14,6 @@ func newTestStore(t *testing.T) *Store { return s } -func TestCreateAndGetProject(t *testing.T) { - s := newTestStore(t) - - p, err := s.CreateProject(Project{ - Name: "test-project", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}", - }) - if err != nil { - t.Fatalf("CreateProject: %v", err) - } - if p.ID == "" { - t.Fatal("project ID should be set") - } - - got, err := s.GetProjectByID(p.ID) - if err != nil { - t.Fatalf("GetProjectByID: %v", err) - } - if got.Name != "test-project" { - t.Fatalf("got name %q, want %q", got.Name, "test-project") - } -} - -func TestGetAllProjects(t *testing.T) { - s := newTestStore(t) - - s.CreateProject(Project{Name: "bravo", Image: "img", Env: "{}", Volumes: "{}"}) - s.CreateProject(Project{Name: "alpha", Image: "img", Env: "{}", Volumes: "{}"}) - - projects, err := s.GetAllProjects() - if err != nil { - t.Fatalf("GetAllProjects: %v", err) - } - if len(projects) != 2 { - t.Fatalf("expected 2 projects, got %d", len(projects)) - } - // Should be ordered by name - if projects[0].Name != "alpha" { - t.Fatalf("expected first project 'alpha', got %q", projects[0].Name) - } -} - -func TestDeleteProject(t *testing.T) { - s := newTestStore(t) - - p, _ := s.CreateProject(Project{Name: "del-me", Image: "img", Env: "{}", Volumes: "{}"}) - err := s.DeleteProject(p.ID) - if err != nil { - t.Fatalf("DeleteProject: %v", err) - } - - _, err = s.GetProjectByID(p.ID) - if err == nil { - t.Fatal("expected error getting deleted project") - } -} - func TestCreateAndGetUser(t *testing.T) { s := newTestStore(t) @@ -110,80 +54,6 @@ func TestUserCount(t *testing.T) { } } -func TestCreateStageAndDeploy(t *testing.T) { - s := newTestStore(t) - - p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"}) - stage, err := s.CreateStage(Stage{ - ProjectID: p.ID, Name: "dev", TagPattern: "*", MaxInstances: 2, - }) - if err != nil { - t.Fatalf("CreateStage: %v", err) - } - - d, err := s.CreateDeploy(Deploy{ - ProjectID: p.ID, StageID: stage.ID, ImageTag: "v1.0", - }) - if err != nil { - t.Fatalf("CreateDeploy: %v", err) - } - if d.Status != "pending" { - t.Fatalf("expected pending status, got %q", d.Status) - } - - err = s.UpdateDeployStatus(d.ID, "success", "") - if err != nil { - t.Fatalf("UpdateDeployStatus: %v", err) - } - - got, _ := s.GetDeployByID(d.ID) - if got.Status != "success" { - t.Fatalf("expected success, got %q", got.Status) - } -} - -func TestGetDeploysByProjectID(t *testing.T) { - s := newTestStore(t) - - p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"}) - stage, _ := s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"}) - - for i := 0; i < 5; i++ { - _, err := s.CreateDeploy(Deploy{ProjectID: p.ID, StageID: stage.ID, ImageTag: "v" + string(rune('0'+i))}) - if err != nil { - t.Fatalf("CreateDeploy %d: %v", i, err) - } - } - - deploys, err := s.GetDeploysByProjectID(p.ID) - if err != nil { - t.Fatalf("GetDeploysByProjectID: %v", err) - } - if len(deploys) != 5 { - t.Fatalf("expected 5 deploys, got %d", len(deploys)) - } -} - -func TestGetRecentDeploys(t *testing.T) { - s := newTestStore(t) - - p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"}) - stage, _ := s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"}) - - for i := 0; i < 5; i++ { - s.CreateDeploy(Deploy{ProjectID: p.ID, StageID: stage.ID, ImageTag: "v" + string(rune('0'+i))}) - } - - // Limit to 2 - deploys, err := s.GetRecentDeploys(2) - if err != nil { - t.Fatalf("GetRecentDeploys: %v", err) - } - if len(deploys) != 2 { - t.Fatalf("expected 2 deploys with limit, got %d", len(deploys)) - } -} - func TestDeleteUser(t *testing.T) { s := newTestStore(t) @@ -199,27 +69,6 @@ func TestDeleteUser(t *testing.T) { } } -func TestUpdateProject(t *testing.T) { - s := newTestStore(t) - - p, _ := s.CreateProject(Project{Name: "original", Image: "nginx", Env: "{}", Volumes: "{}"}) - - p.Name = "updated" - p.Image = "alpine" - err := s.UpdateProject(p) - if err != nil { - t.Fatalf("UpdateProject: %v", err) - } - - got, _ := s.GetProjectByID(p.ID) - if got.Name != "updated" { - t.Fatalf("expected name 'updated', got %q", got.Name) - } - if got.Image != "alpine" { - t.Fatalf("expected image 'alpine', got %q", got.Image) - } -} - func TestUpdateUser(t *testing.T) { s := newTestStore(t) @@ -241,88 +90,3 @@ func TestUpdateUser(t *testing.T) { } } -func TestDeployLogs(t *testing.T) { - s := newTestStore(t) - - p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"}) - stage, _ := s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"}) - d, _ := s.CreateDeploy(Deploy{ProjectID: p.ID, StageID: stage.ID, ImageTag: "v1"}) - - err := s.AppendDeployLog(d.ID, "pulling image", "info") - if err != nil { - t.Fatalf("AppendDeployLog: %v", err) - } - err = s.AppendDeployLog(d.ID, "something failed", "error") - if err != nil { - t.Fatalf("AppendDeployLog: %v", err) - } - - logs, err := s.GetDeployLogs(d.ID) - if err != nil { - t.Fatalf("GetDeployLogs: %v", err) - } - if len(logs) != 2 { - t.Fatalf("expected 2 logs, got %d", len(logs)) - } - if logs[0].Message != "pulling image" { - t.Fatalf("expected first log 'pulling image', got %q", logs[0].Message) - } - if logs[1].Level != "error" { - t.Fatalf("expected second log level 'error', got %q", logs[1].Level) - } -} - -func TestGetStagesByProjectID(t *testing.T) { - s := newTestStore(t) - - p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"}) - s.CreateStage(Stage{ProjectID: p.ID, Name: "prod", TagPattern: "v*"}) - s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"}) - - stages, err := s.GetStagesByProjectID(p.ID) - if err != nil { - t.Fatalf("GetStagesByProjectID: %v", err) - } - if len(stages) != 2 { - t.Fatalf("expected 2 stages, got %d", len(stages)) - } - // Ordered by name - if stages[0].Name != "dev" { - t.Fatalf("expected first stage 'dev', got %q", stages[0].Name) - } -} - -func TestIsTerminalDeployStatus(t *testing.T) { - terminals := []string{"success", "failed", "rolled_back"} - for _, s := range terminals { - if !IsTerminalDeployStatus(s) { - t.Fatalf("expected %q to be terminal", s) - } - } - - nonTerminals := []string{"pending", "pulling", "starting", "configuring_proxy", "health_checking"} - for _, s := range nonTerminals { - if IsTerminalDeployStatus(s) { - t.Fatalf("expected %q to be non-terminal", s) - } - } -} - -func TestCascadeDeleteProjectRemovesStagesAndDeploys(t *testing.T) { - s := newTestStore(t) - - p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"}) - stage, _ := s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"}) - s.CreateDeploy(Deploy{ProjectID: p.ID, StageID: stage.ID, ImageTag: "v1"}) - - err := s.DeleteProject(p.ID) - if err != nil { - t.Fatalf("DeleteProject: %v", err) - } - - // Stage should be gone - _, err = s.GetStageByID(stage.ID) - if err == nil { - t.Fatal("expected stage to be deleted by cascade") - } -} diff --git a/internal/store/volumes.go b/internal/store/volumes.go deleted file mode 100644 index be4534d..0000000 --- a/internal/store/volumes.go +++ /dev/null @@ -1,119 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" - - "github.com/google/uuid" -) - -// volumeColumns is the canonical column list for volume queries. -const volumeColumns = `id, project_id, source, target, mode, scope, name, created_at, updated_at` - -// CreateVolume inserts a new volume configuration for a project. -func (s *Store) CreateVolume(vol Volume) (Volume, error) { - vol.ID = uuid.New().String() - vol.CreatedAt = Now() - vol.UpdatedAt = vol.CreatedAt - - // Default scope for backward compatibility. - if vol.Scope == "" { - switch vol.Mode { - case "isolated": - vol.Scope = "instance" - default: - vol.Scope = "project" - } - } - - _, err := s.db.Exec( - `INSERT INTO volumes (`+volumeColumns+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - vol.ID, vol.ProjectID, vol.Source, vol.Target, vol.Mode, - vol.Scope, vol.Name, vol.CreatedAt, vol.UpdatedAt, - ) - if err != nil { - return Volume{}, fmt.Errorf("insert volume: %w", err) - } - return vol, nil -} - -// GetVolumesByProjectID returns all volume configurations for a project. -func (s *Store) GetVolumesByProjectID(projectID string) ([]Volume, error) { - rows, err := s.db.Query( - `SELECT `+volumeColumns+` FROM volumes WHERE project_id = ? ORDER BY target`, projectID, - ) - if err != nil { - return nil, fmt.Errorf("query volumes: %w", err) - } - defer rows.Close() - - vols := []Volume{} - for rows.Next() { - vol, err := scanVolume(rows) - if err != nil { - return nil, err - } - vols = append(vols, vol) - } - return vols, rows.Err() -} - -// GetVolumeByID returns a single volume by its ID. -func (s *Store) GetVolumeByID(id string) (Volume, error) { - var vol Volume - err := s.db.QueryRow( - `SELECT `+volumeColumns+` FROM volumes WHERE id = ?`, id, - ).Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode, - &vol.Scope, &vol.Name, &vol.CreatedAt, &vol.UpdatedAt) - if errors.Is(err, sql.ErrNoRows) { - return Volume{}, fmt.Errorf("volume %s: %w", id, ErrNotFound) - } - if err != nil { - return Volume{}, fmt.Errorf("query volume: %w", err) - } - return vol, nil -} - -// UpdateVolume updates an existing volume configuration. -func (s *Store) UpdateVolume(vol Volume) error { - vol.UpdatedAt = Now() - result, err := s.db.Exec( - `UPDATE volumes SET source=?, target=?, mode=?, scope=?, name=?, updated_at=? - WHERE id=?`, - vol.Source, vol.Target, vol.Mode, vol.Scope, vol.Name, vol.UpdatedAt, vol.ID, - ) - if err != nil { - return fmt.Errorf("update volume: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("volume %s: %w", vol.ID, ErrNotFound) - } - return nil -} - -// DeleteVolume removes a volume configuration by ID. -func (s *Store) DeleteVolume(id string) error { - result, err := s.db.Exec(`DELETE FROM volumes WHERE id = ?`, id) - if err != nil { - return fmt.Errorf("delete volume: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("volume %s: %w", id, ErrNotFound) - } - return nil -} - -// scanVolume scans a volume row from a *sql.Rows cursor. -func scanVolume(rows *sql.Rows) (Volume, error) { - var vol Volume - err := rows.Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode, - &vol.Scope, &vol.Name, &vol.CreatedAt, &vol.UpdatedAt) - if err != nil { - return Volume{}, fmt.Errorf("scan volume: %w", err) - } - return vol, nil -} diff --git a/internal/store/webhook_deliveries.go b/internal/store/webhook_deliveries.go index 7aa7d55..da258a0 100644 --- a/internal/store/webhook_deliveries.go +++ b/internal/store/webhook_deliveries.go @@ -8,7 +8,7 @@ import ( // handler decides what to do so the row reflects the final outcome. type WebhookDelivery struct { ID int64 `json:"id"` - TargetType string `json:"target_type"` // "project" or "site" + TargetType string `json:"target_type"` // "trigger" today; legacy rows may carry "project" or "site" TargetID string `json:"target_id"` TargetName string `json:"target_name"` ReceivedAt string `json:"received_at"` @@ -38,9 +38,9 @@ func (s *Store) InsertWebhookDelivery(d WebhookDelivery) error { return nil } -// ListWebhookDeliveriesByTarget returns the most recent N deliveries for a -// specific target. Used by the per-entity panel on the project / site detail -// pages. +// ListWebhookDeliveriesByTarget returns the most recent N deliveries for +// a specific target. Used by the trigger detail panel after the legacy +// project / site detail pages were removed. func (s *Store) ListWebhookDeliveriesByTarget(targetType, targetID string, limit int) ([]WebhookDelivery, error) { if limit <= 0 || limit > 200 { limit = 50 diff --git a/internal/store/workload_sync.go b/internal/store/workload_sync.go deleted file mode 100644 index c9e3f9a..0000000 --- a/internal/store/workload_sync.go +++ /dev/null @@ -1,150 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" - - "github.com/google/uuid" -) - -// dbExec is the subset of *sql.DB and *sql.Tx used by the sync helpers so -// CRUD callers can pass in either a transaction or the raw DB handle. Keeps -// the sync logic atomic with the parent row when wrapped in a Begin/Commit. -type dbExec interface { - Exec(query string, args ...any) (sql.Result, error) - QueryRow(query string, args ...any) *sql.Row -} - -// syncWorkloadTx is the shared upsert path used by every kind-specific -// sync helper. Caller passes the kind, ref, and the projection of fields -// that map onto the workload row. Idempotent — uses the (kind, ref_id) UNIQUE -// constraint to decide INSERT vs UPDATE. -func syncWorkloadTx(ex dbExec, kind WorkloadKind, refID, name, notifURL, notifSecret, hookSecret, signSecret string, requireSig bool) error { - now := Now() - requireInt := 0 - if requireSig { - requireInt = 1 - } - - var existingID string - err := ex.QueryRow( - `SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`, - string(kind), refID, - ).Scan(&existingID) - - if errors.Is(err, sql.ErrNoRows) { - _, err := ex.Exec( - `INSERT INTO workloads (id, kind, ref_id, name, app_id, - notification_url, notification_secret, - webhook_secret, webhook_signing_secret, webhook_require_signature, - created_at, updated_at) - VALUES (?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?)`, - uuid.New().String(), string(kind), refID, name, - notifURL, notifSecret, hookSecret, signSecret, requireInt, - now, now, - ) - if err != nil { - return fmt.Errorf("insert %s workload: %w", kind, err) - } - return nil - } - if err != nil { - return fmt.Errorf("lookup %s workload: %w", kind, err) - } - - _, err = ex.Exec( - `UPDATE workloads SET name=?, - notification_url=?, notification_secret=?, - webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?, - updated_at=? - WHERE id=?`, - name, notifURL, notifSecret, hookSecret, signSecret, requireInt, now, existingID, - ) - if err != nil { - return fmt.Errorf("update %s workload: %w", kind, err) - } - return nil -} - -// SyncProjectWorkloadTx upserts the workload row paired with a project inside -// the caller's transaction. Used by CreateProject / UpdateProject / -// SetProject*Secret so the parent UPDATE and the workload sync share atomicity. -func SyncProjectWorkloadTx(tx *sql.Tx, p Project) error { - return syncWorkloadTx(tx, WorkloadKindProject, p.ID, p.Name, - p.NotificationURL, p.NotificationSecret, - p.WebhookSecret, p.WebhookSigningSecret, p.WebhookRequireSignature) -} - -// SyncStackWorkloadTx upserts the workload row paired with a stack inside the -// caller's transaction. Stacks don't carry notification or webhook config yet. -func SyncStackWorkloadTx(tx *sql.Tx, st Stack) error { - return syncWorkloadTx(tx, WorkloadKindStack, st.ID, st.Name, "", "", "", "", false) -} - -// SyncStaticSiteWorkloadTx upserts the workload row paired with a static site -// inside the caller's transaction. -func SyncStaticSiteWorkloadTx(tx *sql.Tx, site StaticSite) error { - return syncWorkloadTx(tx, WorkloadKindSite, site.ID, site.Name, - site.NotificationURL, site.NotificationSecret, - site.WebhookSecret, site.WebhookSigningSecret, site.WebhookRequireSignature) -} - -// SyncProjectWorkload is the non-transactional convenience used by -// BackfillWorkloads (a boot-time, single-row, idempotent recovery pass). -// CRUD paths must use SyncProjectWorkloadTx instead, with their parent -// UPDATE inside the same transaction. -func (s *Store) SyncProjectWorkload(p Project) error { - return syncWorkloadTx(s.db, WorkloadKindProject, p.ID, p.Name, - p.NotificationURL, p.NotificationSecret, - p.WebhookSecret, p.WebhookSigningSecret, p.WebhookRequireSignature) -} - -// SyncStackWorkload is the non-transactional convenience used by BackfillWorkloads. -func (s *Store) SyncStackWorkload(st Stack) error { - return syncWorkloadTx(s.db, WorkloadKindStack, st.ID, st.Name, "", "", "", "", false) -} - -// SyncStaticSiteWorkload is the non-transactional convenience used by BackfillWorkloads. -func (s *Store) SyncStaticSiteWorkload(site StaticSite) error { - return syncWorkloadTx(s.db, WorkloadKindSite, site.ID, site.Name, - site.NotificationURL, site.NotificationSecret, - site.WebhookSecret, site.WebhookSigningSecret, site.WebhookRequireSignature) -} - -// BackfillWorkloads scans every project / stack / static_site row and ensures -// each has a matching workload row. Called once at boot before HTTP starts so -// any pre-Workload-refactor data is upgraded transparently. Idempotent. -func (s *Store) BackfillWorkloads() error { - projects, err := s.GetAllProjects() - if err != nil { - return fmt.Errorf("backfill: list projects: %w", err) - } - for _, p := range projects { - if err := s.SyncProjectWorkload(p); err != nil { - return fmt.Errorf("backfill project %s: %w", p.ID, err) - } - } - - stacks, err := s.GetAllStacks() - if err != nil { - return fmt.Errorf("backfill: list stacks: %w", err) - } - for _, st := range stacks { - if err := s.SyncStackWorkload(st); err != nil { - return fmt.Errorf("backfill stack %s: %w", st.ID, err) - } - } - - sites, err := s.GetAllStaticSites() - if err != nil { - return fmt.Errorf("backfill: list static sites: %w", err) - } - for _, site := range sites { - if err := s.SyncStaticSiteWorkload(site); err != nil { - return fmt.Errorf("backfill static site %s: %w", site.ID, err) - } - } - - return nil -} diff --git a/internal/store/workload_sync_test.go b/internal/store/workload_sync_test.go deleted file mode 100644 index dd6b5a3..0000000 --- a/internal/store/workload_sync_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package store - -import ( - "errors" - "testing" -) - -func TestCreateProjectAlsoCreatesWorkload(t *testing.T) { - s := newTestStore(t) - - p, err := s.CreateProject(Project{ - Name: "wf-project", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}", - NotificationURL: "https://example.test/hook", - }) - if err != nil { - t.Fatalf("CreateProject: %v", err) - } - - w, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID) - if err != nil { - t.Fatalf("workload should exist after CreateProject: %v", err) - } - if w.Name != "wf-project" { - t.Fatalf("workload name not synced: got %q", w.Name) - } - if w.WebhookSecret == "" { - t.Fatal("webhook secret should be carried into workload row") - } - if w.NotificationURL != "https://example.test/hook" { - t.Fatalf("notification url not synced: got %q", w.NotificationURL) - } -} - -func TestUpdateProjectSyncsWorkload(t *testing.T) { - s := newTestStore(t) - - p, _ := s.CreateProject(Project{ - Name: "before", Image: "i", Env: "{}", Volumes: "{}", - }) - - p.Name = "after" - p.NotificationURL = "https://new.test/hook" - if err := s.UpdateProject(p); err != nil { - t.Fatalf("UpdateProject: %v", err) - } - - w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID) - if w.Name != "after" { - t.Fatalf("workload name not updated: got %q", w.Name) - } - if w.NotificationURL != "https://new.test/hook" { - t.Fatalf("workload notification url not updated: got %q", w.NotificationURL) - } -} - -func TestDeleteProjectCascadesWorkload(t *testing.T) { - s := newTestStore(t) - - p, _ := s.CreateProject(Project{Name: "doomed", Image: "i", Env: "{}", Volumes: "{}"}) - w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID) - - // Add a container under this workload to verify cascade. - if _, err := s.CreateContainer(Container{ - WorkloadID: w.ID, WorkloadKind: "project", State: "running", - }); err != nil { - t.Fatalf("CreateContainer: %v", err) - } - - if err := s.DeleteProject(p.ID); err != nil { - t.Fatalf("DeleteProject: %v", err) - } - - if _, err := s.GetWorkloadByID(w.ID); !errors.Is(err, ErrNotFound) { - t.Fatalf("workload should be deleted, got %v", err) - } - containers, _ := s.ListContainersByWorkload(w.ID) - if len(containers) != 0 { - t.Fatalf("containers should be deleted, got %d", len(containers)) - } -} - -func TestSetProjectWebhookSecretSyncsWorkload(t *testing.T) { - s := newTestStore(t) - - p, _ := s.CreateProject(Project{Name: "n", Image: "i", Env: "{}", Volumes: "{}"}) - - newSecret := "new-secret-value-with-enough-entropy-1234" - if err := s.SetProjectWebhookSecret(p.ID, newSecret); err != nil { - t.Fatalf("SetProjectWebhookSecret: %v", err) - } - w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID) - if w.WebhookSecret != newSecret { - t.Fatalf("workload webhook secret not synced: got %q", w.WebhookSecret) - } -} - -func TestCreateStackAlsoCreatesWorkload(t *testing.T) { - s := newTestStore(t) - - st, err := s.CreateStack(Stack{Name: "wf-stack", ComposeProjectName: "wf-stack"}) - if err != nil { - t.Fatalf("CreateStack: %v", err) - } - - w, err := s.GetWorkloadByRef(WorkloadKindStack, st.ID) - if err != nil { - t.Fatalf("workload should exist after CreateStack: %v", err) - } - if w.Name != "wf-stack" { - t.Fatalf("workload name not synced: got %q", w.Name) - } -} - -func TestUpdateStackSyncsWorkload(t *testing.T) { - s := newTestStore(t) - - st, _ := s.CreateStack(Stack{Name: "before", ComposeProjectName: "before-cp"}) - st.Name = "after" - if err := s.UpdateStack(st); err != nil { - t.Fatalf("UpdateStack: %v", err) - } - - w, _ := s.GetWorkloadByRef(WorkloadKindStack, st.ID) - if w.Name != "after" { - t.Fatalf("workload name not updated: got %q", w.Name) - } -} - -func TestDeleteStackCascadesWorkload(t *testing.T) { - s := newTestStore(t) - - st, _ := s.CreateStack(Stack{Name: "doomed-stack", ComposeProjectName: "doomed-cp"}) - w, _ := s.GetWorkloadByRef(WorkloadKindStack, st.ID) - - if err := s.DeleteStack(st.ID); err != nil { - t.Fatalf("DeleteStack: %v", err) - } - if _, err := s.GetWorkloadByID(w.ID); !errors.Is(err, ErrNotFound) { - t.Fatalf("workload should be deleted, got %v", err) - } -} - -func TestBackfillWorkloadsIdempotent(t *testing.T) { - s := newTestStore(t) - - // Create rows directly via the store (which already auto-syncs), then run - // the backfill twice — it must be a no-op the second time and not error. - p, _ := s.CreateProject(Project{Name: "p1", Image: "i", Env: "{}", Volumes: "{}"}) - st, _ := s.CreateStack(Stack{Name: "s1", ComposeProjectName: "s1-cp"}) - - if err := s.BackfillWorkloads(); err != nil { - t.Fatalf("first backfill: %v", err) - } - if err := s.BackfillWorkloads(); err != nil { - t.Fatalf("second backfill (should be idempotent): %v", err) - } - - all, _ := s.ListWorkloads("") - // Expect exactly 2: one project workload, one stack workload, no duplicates. - if len(all) != 2 { - t.Fatalf("expected 2 workloads after backfill, got %d", len(all)) - } - - // Confirm both refs are findable. - if _, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID); err != nil { - t.Fatalf("project workload not found: %v", err) - } - if _, err := s.GetWorkloadByRef(WorkloadKindStack, st.ID); err != nil { - t.Fatalf("stack workload not found: %v", err) - } -} - -func TestBackfillRecoversFromMissingWorkloads(t *testing.T) { - s := newTestStore(t) - - p, _ := s.CreateProject(Project{Name: "p1", Image: "i", Env: "{}", Volumes: "{}"}) - - // Simulate the legacy state: a project exists but its workload row is gone - // (e.g. the rollout from before the refactor). Backfill must restore it. - w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID) - _ = s.DeleteWorkload(w.ID) - - if err := s.BackfillWorkloads(); err != nil { - t.Fatalf("backfill: %v", err) - } - - if _, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID); err != nil { - t.Fatalf("workload should be restored: %v", err) - } -} diff --git a/internal/store/workloads.go b/internal/store/workloads.go index cd12765..d3ce9bd 100644 --- a/internal/store/workloads.go +++ b/internal/store/workloads.go @@ -93,24 +93,6 @@ func (s *Store) GetWorkloadByRef(kind WorkloadKind, refID string) (Workload, err return w, nil } -// GetWorkloadByWebhookSecret looks up a workload by its inbound webhook URL secret. -// Returns ErrNotFound when no match — used by the webhook router. -func (s *Store) GetWorkloadByWebhookSecret(secret string) (Workload, error) { - if secret == "" { - return Workload{}, fmt.Errorf("empty secret: %w", ErrNotFound) - } - w, err := scanWorkload(s.db.QueryRow( - `SELECT `+workloadColumns+` FROM workloads WHERE webhook_secret = ?`, secret, - )) - if errors.Is(err, sql.ErrNoRows) { - return Workload{}, ErrNotFound - } - if err != nil { - return Workload{}, fmt.Errorf("query workload by webhook secret: %w", err) - } - return w, nil -} - // ListWorkloads returns all workloads, optionally filtered by kind. Pass // empty string to get every workload regardless of kind. func (s *Store) ListWorkloads(kind WorkloadKind) ([]Workload, error) { @@ -231,40 +213,12 @@ func (s *Store) ListChildrenByParent(parentID string) ([]Workload, error) { return out, rows.Err() } -// SetWorkloadWebhookSecret rotates the inbound webhook URL secret. Pass -// empty to disable inbound webhooks for this workload. -func (s *Store) SetWorkloadWebhookSecret(id, secret string) error { - result, err := s.db.Exec( - `UPDATE workloads SET webhook_secret=?, updated_at=? WHERE id=?`, - secret, Now(), id, - ) - if err != nil { - return fmt.Errorf("update workload webhook_secret: %w", err) - } - n, _ := result.RowsAffected() - if n == 0 { - return fmt.Errorf("workload %s: %w", id, ErrNotFound) - } - return nil -} - -// EnsureWorkloadWebhookSecret returns the current secret, generating one -// lazily for workloads that predate the column. Mirrors the project / -// site equivalents. -func (s *Store) EnsureWorkloadWebhookSecret(id string) (string, error) { - w, err := s.GetWorkloadByID(id) - if err != nil { - return "", err - } - if w.WebhookSecret != "" { - return w.WebhookSecret, nil - } - secret := generateWebhookSecret() - if err := s.SetWorkloadWebhookSecret(id, secret); err != nil { - return "", err - } - return secret, nil -} +// Workload-level webhook secret accessors (Get/Set/Ensure) were dropped +// in the hard legacy cutover: the inbound `/api/webhook/workloads/...` +// route is gone. The trigger-split refactor's boot backfill still reads +// the `workloads.webhook_secret` column directly via SQL to lift any +// pre-cutover embedded secret onto its standalone Trigger row, then the +// column is effectively dead. // DeleteWorkloadByRef removes the workload paired with a given (kind, ref_id). // Idempotent — returns nil if no row exists, since the kind-specific Delete diff --git a/internal/store/workloads_test.go b/internal/store/workloads_test.go index 2a53b13..445af98 100644 --- a/internal/store/workloads_test.go +++ b/internal/store/workloads_test.go @@ -84,28 +84,9 @@ func TestUpdateWorkload(t *testing.T) { } } -func TestGetWorkloadByWebhookSecret(t *testing.T) { - s := newTestStore(t) - - w, _ := s.CreateWorkload(Workload{ - Kind: "project", RefID: "p1", Name: "n", WebhookSecret: "deadbeef", - }) - - got, err := s.GetWorkloadByWebhookSecret("deadbeef") - if err != nil { - t.Fatalf("GetWorkloadByWebhookSecret: %v", err) - } - if got.ID != w.ID { - t.Fatalf("got workload %s, want %s", got.ID, w.ID) - } - - if _, err := s.GetWorkloadByWebhookSecret(""); !errors.Is(err, ErrNotFound) { - t.Fatalf("empty secret should be NotFound, got %v", err) - } - if _, err := s.GetWorkloadByWebhookSecret("nope"); !errors.Is(err, ErrNotFound) { - t.Fatalf("unknown secret should be NotFound, got %v", err) - } -} +// GetWorkloadByWebhookSecret was deleted with the legacy +// `/api/webhook/workloads/{secret}` route in the hard cutover; the +// inbound webhook surface is now first-class Triggers. func TestListWorkloads(t *testing.T) { s := newTestStore(t) diff --git a/internal/volume/resolver.go b/internal/volume/resolver.go index 4751b74..e38fcbd 100644 --- a/internal/volume/resolver.go +++ b/internal/volume/resolver.go @@ -10,58 +10,6 @@ import ( "github.com/alexei/tinyforge/internal/store" ) -// ResolveParams holds the parameters needed to resolve a volume's host path. -type ResolveParams struct { - BasePath string - ProjectName string - StageName string // required for instance and stage scopes - ImageTag string // required for instance scope - AllowedVolumePaths string // JSON array of allowed absolute paths (from settings) -} - -// ResolvePath returns the absolute host path for a volume based on its scope. -// Returns an error for ephemeral volumes (no host path) or missing parameters. -func ResolvePath(vol store.Volume, params ResolveParams) (string, error) { - scope := vol.Scope - if scope == "" { - switch vol.Mode { - case "isolated": - scope = "instance" - default: - scope = "project" - } - } - - if scope == "ephemeral" { - return "", fmt.Errorf("ephemeral volumes have no host path") - } - - if scope == "absolute" { - return resolveAbsolute(vol.Source, params.AllowedVolumePaths) - } - - switch scope { - case "instance": - if params.StageName == "" || params.ImageTag == "" { - return "", fmt.Errorf("instance scope requires stage and tag parameters") - } - return filepath.Join(params.BasePath, params.ProjectName, fmt.Sprintf("%s-%s", params.StageName, params.ImageTag), vol.Source), nil - case "stage": - if params.StageName == "" { - return "", fmt.Errorf("stage scope requires stage parameter") - } - return filepath.Join(params.BasePath, params.ProjectName, params.StageName, vol.Source), nil - case "project": - return filepath.Join(params.BasePath, params.ProjectName, vol.Source), nil - case "project_named": - return filepath.Join(params.BasePath, params.ProjectName, "_named", vol.Name, vol.Source), nil - case "named": - return filepath.Join(params.BasePath, "_named", vol.Name, vol.Source), nil - default: - return filepath.Join(params.BasePath, params.ProjectName, vol.Source), nil - } -} - // resolveAbsolute validates that the source path is under one of the allowed prefixes. func resolveAbsolute(source, allowedPathsJSON string) (string, error) { if source == "" { diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go index e9bfe5e..9d81e82 100644 --- a/internal/webhook/handler.go +++ b/internal/webhook/handler.go @@ -6,13 +6,10 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" - "errors" "fmt" - "io" "log/slog" "net/http" "strings" - "sync" "github.com/go-chi/chi/v5" @@ -125,28 +122,12 @@ func verifyHMAC(signingSecret string, body []byte, headerValue string) (verified return hmac.Equal(provided, expected), true } -// maxSiteConcurrentSyncs caps fan-out of background site syncs triggered by -// webhooks. Above this limit, requests are rejected with 503. -const maxSiteConcurrentSyncs = 4 - // maxWebhookBodyBytes caps the request body size for webhook payloads. The // /api routes already wrap the body with MaxBytesReader, but the webhook // router relies on its own limit so changes to the parent middleware can't // silently increase the cap. const maxWebhookBodyBytes = 256 * 1024 // 256 KiB -// DeployTriggerer is called when a webhook determines a deploy should happen. -// Same interface as registry.DeployTriggerer — kept separate to avoid import cycles. -type DeployTriggerer interface { - TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error -} - -// SiteSyncTriggerer is called when a static-site webhook determines a sync -// should happen. The manager handles the actual git-pull + redeploy. -type SiteSyncTriggerer interface { - Deploy(ctx context.Context, siteID string, force bool) error -} - // PluginDispatcher is what the plugin-workload webhook handler needs from // the deployer: the canonical Source-dispatch entry point plus access to // the same Deps bundle so Trigger.Match can read store / crypto. @@ -155,23 +136,10 @@ type PluginDispatcher interface { PluginDeps() pluginDeps } -// Payload is the expected JSON body for a project webhook request. -type Payload struct { - // Image is the full image reference including tag, e.g. - // "git.dolgolyov-family.by/alexei/web-app-launcher:dev-abc123". - Image string `json:"image"` -} - -// SitePayload is the expected JSON body for a static-site webhook request. -// Callers point Gitea/GitHub/GitLab webhooks at the site URL; only the ref -// matters for branch filtering. Body is optional — an empty body triggers -// a sync using the site's configured branch. -type SitePayload struct { - Ref string `json:"ref"` // e.g. "refs/heads/main"; optional -} - -// ParsedImage holds the components extracted from a full image reference string. -type ParsedImage struct { +// parsedImage holds the components extracted from a full image reference +// string. Package-private — the only callers are buildInboundEvent and the +// vendor parsers in this package. +type parsedImage struct { // Registry is the hostname, e.g. "git.dolgolyov-family.by". Registry string // Owner is the namespace/org, e.g. "alexei". @@ -182,28 +150,28 @@ type ParsedImage struct { Tag string } -// FullName returns "owner/name" (the image path without registry and tag). -func (p ParsedImage) FullName() string { +// fullName returns "owner/name" (the image path without registry and tag). +func (p parsedImage) fullName() string { if p.Owner != "" { return p.Owner + "/" + p.Name } return p.Name } -// ParseImageRef splits a full image reference into its components. +// parseImageRef splits a full image reference into its components. // Accepted formats: // // registry.example.com/owner/name:tag // registry.example.com/owner/name // owner/name:tag // name:tag -func ParseImageRef(ref string) (ParsedImage, error) { +func parseImageRef(ref string) (parsedImage, error) { ref = strings.TrimSpace(ref) if ref == "" { - return ParsedImage{}, fmt.Errorf("empty image reference") + return parsedImage{}, fmt.Errorf("empty image reference") } - var parsed ParsedImage + var parsed parsedImage // Split off tag. if idx := strings.LastIndex(ref, ":"); idx != -1 { @@ -232,81 +200,45 @@ func ParseImageRef(ref string) (ParsedImage, error) { } if parsed.Name == "" { - return ParsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref) + return parsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref) } return parsed, nil } -// Handler is the HTTP handler for webhook requests. +// Handler is the HTTP handler for webhook requests. After the legacy +// project / site webhook routes were dropped, the only inbound path is +// the trigger fan-out — every project / site / stack webhook was lifted +// into a first-class Trigger row by the boot backfill. type Handler struct { - store *store.Store - deployer DeployTriggerer - sites SiteSyncTriggerer - plugins PluginDispatcher // optional; nil disables /workloads/{secret} - - // Site sync coordination — webhooks fire syncs in the background; Drain - // blocks until those goroutines finish, so a graceful shutdown does not - // kill an in-flight git fetch + container rebuild. - siteSyncCtx context.Context - siteSyncCancel context.CancelFunc - siteSyncWG sync.WaitGroup - siteSyncSem chan struct{} + store *store.Store + plugins PluginDispatcher // optional; nil disables /triggers/{secret} } -// NewHandler creates a new webhook Handler. The sites triggerer is optional -// and may be nil (site webhooks will return 404). -func NewHandler(st *store.Store, deployer DeployTriggerer, sites SiteSyncTriggerer) *Handler { - ctx, cancel := context.WithCancel(context.Background()) - return &Handler{ - store: st, - deployer: deployer, - sites: sites, - siteSyncCtx: ctx, - siteSyncCancel: cancel, - siteSyncSem: make(chan struct{}, maxSiteConcurrentSyncs), - } -} - -// SetSiteSyncTriggerer injects the static-site manager after construction. -// The site manager depends on the store + docker client, which are wired up -// in the same startup path as the handler; this setter lets callers defer the -// dependency if needed. -func (h *Handler) SetSiteSyncTriggerer(s SiteSyncTriggerer) { - h.sites = s +// NewHandler creates a new webhook Handler bound to a store. +func NewHandler(st *store.Store) *Handler { + return &Handler{store: st} } // SetPluginDispatcher injects the plugin-pipeline dispatcher. Until this -// is called the /workloads/{secret} route returns 503 — preventing partial +// is called the /triggers/{secret} route returns 503 — preventing partial // initialization from silently dropping deploys. func (h *Handler) SetPluginDispatcher(d PluginDispatcher) { h.plugins = d } -// Drain cancels in-flight site syncs and waits for their goroutines to exit. -// Safe to call from a graceful-shutdown path. -func (h *Handler) Drain() { - h.siteSyncCancel() - h.siteSyncWG.Wait() -} +// Drain is a no-op kept for symmetry with the previous shutdown path. +// The trigger fan-out runs synchronously inside the request goroutine, +// so there is nothing to drain at the handler level. +func (h *Handler) Drain() {} -// Route returns a chi router with the webhook endpoints mounted. -// -// Routes: -// -// POST /{secret} — per-project deploy trigger (legacy) -// POST /sites/{secret} — per-site sync trigger (legacy) -// POST /triggers/{secret} — first-class trigger fan-out to all bound workloads -// -// The legacy POST /workloads/{secret} route was dropped in the -// trigger-split refactor. Existing inbound webhook secrets were lifted -// into trigger rows by the boot backfill, so the same secret value -// works at /triggers/{secret} after the upgrade. +// Route returns a chi router with the single inbound webhook endpoint +// mounted at /triggers/{secret}. Legacy /{secret} and /sites/{secret} +// routes were removed in the hard cutover; their secrets were lifted +// into Trigger rows on boot. func (h *Handler) Route() chi.Router { r := chi.NewRouter() - r.Post("/sites/{secret}", h.handleSiteWebhook) r.Post("/triggers/{secret}", h.handleTriggerWebhook) - r.Post("/{secret}", h.handleWebhook) return r } @@ -322,363 +254,6 @@ func respondWebhookError(w http.ResponseWriter, status int, msg string) { respondWebhookJSON(w, status, map[string]any{"success": false, "error": msg}) } -// handleWebhook processes an incoming project webhook request. -// -// URL: POST /api/webhook/{secret} -// -// The secret identifies exactly one project. Stage routing is delegated to -// the project's configured stages (tag_pattern match). Returns 404 for -// unknown secrets (no information leak). -func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Build the audit record incrementally; record on every return path so - // users can debug "why didn't my deploy fire?" without grepping logs. - delivery := store.WebhookDelivery{ - TargetType: "project", - SourceIP: clientIP(r), - SignatureState: sigStateUnconfigured, - StatusCode: http.StatusOK, - Outcome: outcomeSkip, - } - defer func() { h.recordDelivery(delivery) }() - - secret := chi.URLParam(r, "secret") - if secret == "" { - delivery.StatusCode = http.StatusNotFound - delivery.Outcome = outcomeNotFound - http.NotFound(w, r) - return - } - - // Resolve the secret via the workload row only. The project's own - // webhook_secret column is the source of truth, but lookups go through - // workloads.webhook_secret which is kept in lock-step by the - // transactional sync in the project CRUD path. Reading from workloads - // alone closes the rotation-durability gap: any rotation that didn't - // commit also didn't update the workload row, so an old secret - // surfaces here as 404 rather than being silently accepted. - var ( - project store.Project - err error - ) - wl, wErr := h.store.GetWorkloadByWebhookSecret(secret) - if wErr == nil && wl.Kind == string(store.WorkloadKindProject) { - project, err = h.store.GetProjectByID(wl.RefID) - } else { - err = store.ErrNotFound - } - if err != nil { - if errors.Is(err, store.ErrNotFound) { - delivery.StatusCode = http.StatusNotFound - delivery.Outcome = outcomeNotFound - delivery.Detail = "unknown webhook secret" - http.NotFound(w, r) - return - } - slog.Error("webhook: project lookup failed", "error", err) - delivery.StatusCode = http.StatusNotFound - delivery.Outcome = outcomeError - delivery.Detail = "lookup failed" - http.NotFound(w, r) - return - } - delivery.TargetID = project.ID - delivery.TargetName = project.Name - - // Read body once so we can both verify HMAC and decode JSON. - body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes)) - if err != nil { - delivery.StatusCode = http.StatusBadRequest - delivery.Outcome = outcomeBadRequest - delivery.Detail = "failed to read request body" - respondWebhookError(w, http.StatusBadRequest, "failed to read request body") - return - } - delivery.BodySize = len(body) - - // HMAC enforcement: a configured signing secret + the require_signature - // flag together produce a hard reject on missing/invalid signatures. - // When the flag is off we still verify any submitted signature so a - // CI misconfiguration surfaces as a 401 rather than silent acceptance. - header := r.Header.Get(signatureHeader) - verified, attempted := verifyHMAC(project.WebhookSigningSecret, body, header) - delivery.SignatureState = signatureStateFor(project.WebhookSigningSecret, header, verified, attempted) - if project.WebhookRequireSignature && !verified { - slog.Warn("webhook: signature required but invalid/missing", "project", project.Name) - delivery.StatusCode = http.StatusUnauthorized - delivery.Outcome = outcomeRejected - delivery.Detail = "invalid or missing signature" - respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature") - return - } - if attempted && !verified { - slog.Warn("webhook: bad signature", "project", project.Name) - delivery.StatusCode = http.StatusUnauthorized - delivery.Outcome = outcomeRejected - delivery.Detail = "invalid signature" - respondWebhookError(w, http.StatusUnauthorized, "invalid signature") - return - } - - var payload Payload - if err := json.Unmarshal(body, &payload); err != nil { - delivery.StatusCode = http.StatusBadRequest - delivery.Outcome = outcomeBadRequest - delivery.Detail = "invalid JSON payload" - respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload") - return - } - - if payload.Image == "" { - delivery.StatusCode = http.StatusBadRequest - delivery.Outcome = outcomeBadRequest - delivery.Detail = "missing image field" - respondWebhookError(w, http.StatusBadRequest, "missing image field") - return - } - - parsed, err := ParseImageRef(payload.Image) - if err != nil { - delivery.StatusCode = http.StatusBadRequest - delivery.Outcome = outcomeBadRequest - delivery.Detail = "invalid image reference" - respondWebhookError(w, http.StatusBadRequest, "invalid image reference") - return - } - - if parsed.Tag == "" { - parsed.Tag = "latest" - } - - if project.Image != "" && !imageMatches(project.Image, parsed.FullName()) { - slog.Warn("webhook: image mismatch", - "project", project.Name, "expected", project.Image, "received", parsed.FullName()) - delivery.StatusCode = http.StatusBadRequest - delivery.Outcome = outcomeBadRequest - delivery.Detail = fmt.Sprintf("image %q does not match project image %q", parsed.FullName(), project.Image) - respondWebhookError(w, http.StatusBadRequest, delivery.Detail) - return - } - - slog.Info("webhook: received push", - "project", project.Name, "image", parsed.FullName(), "tag", parsed.Tag) - - stage, found, err := matchStage(h.store, project.ID, parsed.Tag) - if err != nil { - slog.Error("webhook: stage match failed", "project", project.Name, "error", err) - delivery.StatusCode = http.StatusInternalServerError - delivery.Outcome = outcomeError - delivery.Detail = "stage match failed" - respondWebhookError(w, http.StatusInternalServerError, "internal error") - return - } - if !found { - slog.Info("webhook: no stage matches tag", - "project", project.Name, "tag", parsed.Tag) - delivery.Detail = fmt.Sprintf("no stage matches tag %q", parsed.Tag) - respondWebhookJSON(w, http.StatusOK, map[string]any{ - "success": true, "deploy": false, "project": project.Name, - "reason": "no stage pattern matched tag", - }) - return - } - - if !stage.AutoDeploy { - slog.Info("webhook: auto_deploy disabled, skipping", - "project", project.Name, "stage", stage.Name) - delivery.Detail = fmt.Sprintf("stage %q has auto_deploy disabled", stage.Name) - respondWebhookJSON(w, http.StatusOK, map[string]any{ - "success": true, "deploy": false, - "project": project.Name, "stage": stage.Name, - }) - return - } - - if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil { - slog.Error("webhook: deploy trigger failed", "error", err) - delivery.StatusCode = http.StatusInternalServerError - delivery.Outcome = outcomeError - delivery.Detail = "deploy trigger failed: " + err.Error() - respondWebhookError(w, http.StatusInternalServerError, "deploy trigger failed") - return - } - - slog.Info("webhook: triggered deploy", - "project", project.Name, "stage", stage.Name, "tag", parsed.Tag) - delivery.Outcome = outcomeDeploy - delivery.Detail = fmt.Sprintf("stage=%s tag=%s", stage.Name, parsed.Tag) - respondWebhookJSON(w, http.StatusOK, map[string]any{ - "success": true, "deploy": true, - "project": project.Name, "stage": stage.Name, "tag": parsed.Tag, - }) -} - -// handleSiteWebhook processes an incoming static-site webhook request. -// -// URL: POST /api/webhook/sites/{secret} -// -// The secret identifies exactly one static site. If the payload includes a -// ref (Git push event), it must match the site's configured branch (when the -// site's sync_trigger is "push"). For tag-based sync, the ref must match the -// stored tag pattern. Manual-trigger sites ignore webhooks entirely. -func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - delivery := store.WebhookDelivery{ - TargetType: "site", - SourceIP: clientIP(r), - SignatureState: sigStateUnconfigured, - StatusCode: http.StatusOK, - Outcome: outcomeSkip, - } - defer func() { h.recordDelivery(delivery) }() - - if h.sites == nil { - delivery.StatusCode = http.StatusNotFound - delivery.Outcome = outcomeNotFound - delivery.Detail = "static site manager not configured" - http.NotFound(w, r) - return - } - - secret := chi.URLParam(r, "secret") - if secret == "" { - delivery.StatusCode = http.StatusNotFound - delivery.Outcome = outcomeNotFound - http.NotFound(w, r) - return - } - - // Workload-only lookup, mirroring the project handler. Reading from - // workloads.webhook_secret keeps rotation-durability honest — a - // rotation that didn't commit doesn't update the workload row, so the - // stale secret returns 404 instead of being silently accepted. - var ( - site store.StaticSite - err error - ) - wl, wErr := h.store.GetWorkloadByWebhookSecret(secret) - if wErr == nil && wl.Kind == string(store.WorkloadKindSite) { - site, err = h.store.GetStaticSiteByID(wl.RefID) - } else { - err = store.ErrNotFound - } - if err != nil { - if errors.Is(err, store.ErrNotFound) { - delivery.StatusCode = http.StatusNotFound - delivery.Outcome = outcomeNotFound - delivery.Detail = "unknown webhook secret" - http.NotFound(w, r) - return - } - slog.Error("webhook: site lookup failed", "error", err) - delivery.StatusCode = http.StatusNotFound - delivery.Outcome = outcomeError - delivery.Detail = "lookup failed" - http.NotFound(w, r) - return - } - delivery.TargetID = site.ID - delivery.TargetName = site.Name - - if site.SyncTrigger == "manual" { - slog.Info("webhook: site sync_trigger=manual, skipping", - "site", site.Name) - delivery.Detail = "sync_trigger=manual" - respondWebhookJSON(w, http.StatusOK, map[string]any{ - "success": true, "sync": false, "site": site.Name, - "reason": "sync_trigger is manual", - }) - return - } - - var payload SitePayload - body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes)) - if err != nil { - delivery.StatusCode = http.StatusBadRequest - delivery.Outcome = outcomeBadRequest - delivery.Detail = "failed to read request body" - respondWebhookError(w, http.StatusBadRequest, "failed to read request body") - return - } - delivery.BodySize = len(body) - - header := r.Header.Get(signatureHeader) - verified, attempted := verifyHMAC(site.WebhookSigningSecret, body, header) - delivery.SignatureState = signatureStateFor(site.WebhookSigningSecret, header, verified, attempted) - if site.WebhookRequireSignature && !verified { - slog.Warn("webhook: site signature required but invalid/missing", "site", site.Name) - delivery.StatusCode = http.StatusUnauthorized - delivery.Outcome = outcomeRejected - delivery.Detail = "invalid or missing signature" - respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature") - return - } - if attempted && !verified { - slog.Warn("webhook: site bad signature", "site", site.Name) - delivery.StatusCode = http.StatusUnauthorized - delivery.Outcome = outcomeRejected - delivery.Detail = "invalid signature" - respondWebhookError(w, http.StatusUnauthorized, "invalid signature") - return - } - - if len(body) > 0 { - if err := json.Unmarshal(body, &payload); err != nil { - delivery.StatusCode = http.StatusBadRequest - delivery.Outcome = outcomeBadRequest - delivery.Detail = "invalid JSON payload" - respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload") - return - } - } - - if payload.Ref != "" && !siteRefMatches(site, payload.Ref) { - slog.Info("webhook: site ref does not match configured branch/tag", - "site", site.Name, "ref", payload.Ref, - "branch", site.Branch, "tag_pattern", site.TagPattern, - "trigger", site.SyncTrigger) - delivery.Detail = fmt.Sprintf("ref %q does not match", payload.Ref) - respondWebhookJSON(w, http.StatusOK, map[string]any{ - "success": true, "sync": false, "site": site.Name, - "reason": "ref does not match configured branch or tag pattern", - }) - return - } - - select { - case h.siteSyncSem <- struct{}{}: - default: - delivery.StatusCode = http.StatusServiceUnavailable - delivery.Outcome = outcomeError - delivery.Detail = "site sync queue full" - respondWebhookError(w, http.StatusServiceUnavailable, "site sync queue full") - return - } - - h.siteSyncWG.Add(1) - go func(siteID, siteName string) { - defer h.siteSyncWG.Done() - defer func() { <-h.siteSyncSem }() - if err := h.sites.Deploy(h.siteSyncCtx, siteID, false); err != nil { - slog.Error("webhook: site sync failed", "site", siteName, "error", err) - } - }(site.ID, site.Name) - - _ = ctx - slog.Info("webhook: triggered site sync", "site", site.Name, "ref", payload.Ref) - delivery.Outcome = outcomeDeploy - if payload.Ref != "" { - delivery.Detail = fmt.Sprintf("ref=%s", payload.Ref) - } else { - delivery.Detail = "no ref filter" - } - respondWebhookJSON(w, http.StatusOK, map[string]any{ - "success": true, "sync": true, "site": site.Name, - }) -} - // buildInboundEvent normalizes the incoming webhook body into the // plugin.InboundEvent shape. The dispatch order is: // @@ -730,14 +305,14 @@ func buildInboundEvent(body []byte, headers http.Header) (plugin.InboundEvent, e return plugin.InboundEvent{}, fmt.Errorf("invalid JSON payload") } if probe.Image != "" { - parsed, err := ParseImageRef(probe.Image) + parsed, err := parseImageRef(probe.Image) if err != nil { return plugin.InboundEvent{}, fmt.Errorf("invalid image reference") } evt.Kind = "image-push" evt.Image = &plugin.ImagePushEvent{ Registry: parsed.Registry, - Repo: parsed.FullName(), + Repo: parsed.fullName(), Tag: parsed.Tag, } return evt, nil @@ -776,8 +351,8 @@ func toPluginWorkload(w store.Workload) plugin.Workload { TriggerKind: w.TriggerKind, TriggerConfig: json.RawMessage(w.TriggerConfig), PublicFaces: faces, - NotificationURL: w.NotificationURL, - NotificationSecret: w.NotificationSecret, + NotificationURL: w.NotificationURL, + NotificationSecret: w.NotificationSecret, WebhookSecret: w.WebhookSecret, WebhookSigningSecret: w.WebhookSigningSecret, WebhookRequireSignature: w.WebhookRequireSignature, diff --git a/internal/webhook/handler_test.go b/internal/webhook/handler_test.go deleted file mode 100644 index 8193944..0000000 --- a/internal/webhook/handler_test.go +++ /dev/null @@ -1,457 +0,0 @@ -package webhook_test - -import ( - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - - "github.com/go-chi/chi/v5" - - "github.com/alexei/tinyforge/internal/store" - "github.com/alexei/tinyforge/internal/webhook" -) - -// signBody computes the HMAC-SHA256 hex digest used by the X-Hub-Signature-256 header. -func signBody(secret, body string) string { - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write([]byte(body)) - return "sha256=" + hex.EncodeToString(mac.Sum(nil)) -} - -// doJSONSigned mirrors doJSON but adds the X-Hub-Signature-256 header. -func doJSONSigned(t *testing.T, r chi.Router, method, path, body, signingSecret string) (*http.Response, string) { - t.Helper() - req := httptest.NewRequest(method, path, strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - if signingSecret != "" { - req.Header.Set("X-Hub-Signature-256", signBody(signingSecret, body)) - } - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - resp := w.Result() - b, _ := io.ReadAll(resp.Body) - resp.Body.Close() - return resp, string(b) -} - -// fakeDeployer records the last trigger for assertion. -type fakeDeployer struct { - mu sync.Mutex - calls int - lastProj string - lastStg string - lastTag string - err error -} - -func (f *fakeDeployer) TriggerDeploy(_ context.Context, projectID, stageID, tag string) error { - f.mu.Lock() - defer f.mu.Unlock() - f.calls++ - f.lastProj = projectID - f.lastStg = stageID - f.lastTag = tag - return f.err -} - -// fakeSiteTriggerer records Deploy calls. -type fakeSiteTriggerer struct { - mu sync.Mutex - calls int - done chan struct{} -} - -func (f *fakeSiteTriggerer) Deploy(_ context.Context, _ string, _ bool) error { - f.mu.Lock() - f.calls++ - ch := f.done - f.mu.Unlock() - if ch != nil { - select { - case ch <- struct{}{}: - default: - } - } - return nil -} - -func newRouter(t *testing.T, h *webhook.Handler) chi.Router { - t.Helper() - r := chi.NewRouter() - r.Mount("/api/webhook", h.Route()) - return r -} - -func newStore(t *testing.T) *store.Store { - t.Helper() - s, err := store.New(":memory:") - if err != nil { - t.Fatalf("create store: %v", err) - } - t.Cleanup(func() { s.Close() }) - return s -} - -func doJSON(t *testing.T, r chi.Router, method, path, body string) (*http.Response, string) { - t.Helper() - req := httptest.NewRequest(method, path, strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - resp := w.Result() - b, _ := io.ReadAll(resp.Body) - resp.Body.Close() - return resp, string(b) -} - -func TestProjectWebhook_UnknownSecretReturns404(t *testing.T) { - t.Parallel() - st := newStore(t) - h := webhook.NewHandler(st, &fakeDeployer{}, nil) - r := newRouter(t, h) - - resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/bogus-secret", `{"image":"x"}`) - if resp.StatusCode != http.StatusNotFound { - t.Errorf("expected 404, got %d", resp.StatusCode) - } -} - -func TestProjectWebhook_DeploysOnMatchingStage(t *testing.T) { - t.Parallel() - st := newStore(t) - - p, err := st.CreateProject(store.Project{ - Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", - }) - if err != nil { - t.Fatalf("create project: %v", err) - } - stage, err := st.CreateStage(store.Stage{ - ProjectID: p.ID, Name: "dev", TagPattern: "dev-*", AutoDeploy: true, MaxInstances: 1, - }) - if err != nil { - t.Fatalf("create stage: %v", err) - } - - dep := &fakeDeployer{} - h := webhook.NewHandler(st, dep, nil) - r := newRouter(t, h) - - path := "/api/webhook/" + p.WebhookSecret - resp, body := doJSON(t, r, http.MethodPost, path, `{"image":"alexei/app:dev-abc"}`) - if resp.StatusCode != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) - } - if dep.calls != 1 { - t.Fatalf("expected 1 deploy call, got %d", dep.calls) - } - if dep.lastProj != p.ID || dep.lastStg != stage.ID || dep.lastTag != "dev-abc" { - t.Errorf("deploy called with wrong args: proj=%s stage=%s tag=%s", - dep.lastProj, dep.lastStg, dep.lastTag) - } -} - -func TestProjectWebhook_ImageMismatchRejected(t *testing.T) { - t.Parallel() - st := newStore(t) - p, err := st.CreateProject(store.Project{ - Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", - }) - if err != nil { - t.Fatalf("create project: %v", err) - } - if _, err := st.CreateStage(store.Stage{ - ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1, - }); err != nil { - t.Fatalf("create stage: %v", err) - } - - dep := &fakeDeployer{} - h := webhook.NewHandler(st, dep, nil) - r := newRouter(t, h) - - resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, - `{"image":"otheruser/other:dev"}`) - if resp.StatusCode != http.StatusBadRequest { - t.Errorf("expected 400 on image mismatch, got %d", resp.StatusCode) - } - if dep.calls != 0 { - t.Errorf("deploy should not have been triggered on image mismatch") - } -} - -func TestProjectWebhook_NoMatchingStageReturns200NoDeploy(t *testing.T) { - t.Parallel() - st := newStore(t) - p, err := st.CreateProject(store.Project{ - Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", - }) - if err != nil { - t.Fatalf("create project: %v", err) - } - if _, err := st.CreateStage(store.Stage{ - ProjectID: p.ID, Name: "prod", TagPattern: "v*", AutoDeploy: true, MaxInstances: 1, - }); err != nil { - t.Fatalf("create stage: %v", err) - } - - dep := &fakeDeployer{} - h := webhook.NewHandler(st, dep, nil) - r := newRouter(t, h) - - resp, body := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, - `{"image":"alexei/app:dev-abc"}`) - if resp.StatusCode != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) - } - if dep.calls != 0 { - t.Errorf("expected no deploy call, got %d", dep.calls) - } - var parsed map[string]any - if err := json.Unmarshal([]byte(body), &parsed); err != nil { - t.Fatalf("response is not JSON: %v", err) - } - if parsed["deploy"] != false { - t.Errorf("expected deploy=false, got %v", parsed["deploy"]) - } -} - -func TestProjectWebhook_AutoDeployDisabled(t *testing.T) { - t.Parallel() - st := newStore(t) - p, _ := st.CreateProject(store.Project{Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}"}) - _, _ = st.CreateStage(store.Stage{ - ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: false, MaxInstances: 1, - }) - - dep := &fakeDeployer{} - h := webhook.NewHandler(st, dep, nil) - r := newRouter(t, h) - - resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, - `{"image":"alexei/app:dev-1"}`) - if resp.StatusCode != http.StatusOK { - t.Fatalf("expected 200, got %d", resp.StatusCode) - } - if dep.calls != 0 { - t.Errorf("auto_deploy=false should suppress deploy call; got %d", dep.calls) - } -} - -func TestSiteWebhook_UnknownSecretReturns404(t *testing.T) { - t.Parallel() - st := newStore(t) - h := webhook.NewHandler(st, &fakeDeployer{}, &fakeSiteTriggerer{}) - r := newRouter(t, h) - - resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/sites/bogus", "{}") - if resp.StatusCode != http.StatusNotFound { - t.Errorf("expected 404, got %d", resp.StatusCode) - } -} - -func TestSiteWebhook_ManualTriggerShortCircuits(t *testing.T) { - t.Parallel() - st := newStore(t) - site, err := st.CreateStaticSite(store.StaticSite{ - Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y", - Branch: "main", SyncTrigger: "manual", Status: "idle", - }) - if err != nil { - t.Fatalf("create site: %v", err) - } - - ft := &fakeSiteTriggerer{} - h := webhook.NewHandler(st, &fakeDeployer{}, ft) - r := newRouter(t, h) - - resp, _ := doJSON(t, r, http.MethodPost, - "/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/main"}`) - if resp.StatusCode != http.StatusOK { - t.Fatalf("expected 200, got %d", resp.StatusCode) - } - if ft.calls != 0 { - t.Errorf("manual-trigger site must not invoke sync; got %d calls", ft.calls) - } -} - -func TestSiteWebhook_PushTriggersSyncOnBranchMatch(t *testing.T) { - t.Parallel() - st := newStore(t) - site, err := st.CreateStaticSite(store.StaticSite{ - Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y", - Branch: "main", SyncTrigger: "push", Status: "idle", - }) - if err != nil { - t.Fatalf("create site: %v", err) - } - - ft := &fakeSiteTriggerer{done: make(chan struct{}, 1)} - h := webhook.NewHandler(st, &fakeDeployer{}, ft) - r := newRouter(t, h) - - resp, body := doJSON(t, r, http.MethodPost, - "/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/main"}`) - if resp.StatusCode != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) - } - - // Sync runs in a goroutine — wait for the signal. - <-ft.done - ft.mu.Lock() - calls := ft.calls - ft.mu.Unlock() - if calls != 1 { - t.Errorf("expected 1 sync call, got %d", calls) - } -} - -func TestSiteWebhook_PushSkippedForNonMatchingBranch(t *testing.T) { - t.Parallel() - st := newStore(t) - site, _ := st.CreateStaticSite(store.StaticSite{ - Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y", - Branch: "main", SyncTrigger: "push", Status: "idle", - }) - - ft := &fakeSiteTriggerer{} - h := webhook.NewHandler(st, &fakeDeployer{}, ft) - r := newRouter(t, h) - - resp, _ := doJSON(t, r, http.MethodPost, - "/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/feature-x"}`) - if resp.StatusCode != http.StatusOK { - t.Fatalf("expected 200, got %d", resp.StatusCode) - } - if ft.calls != 0 { - t.Errorf("non-matching branch must not trigger sync; got %d calls", ft.calls) - } -} - -// HMAC enforcement scenarios. - -func TestProjectWebhook_HMACRequiredAndValid(t *testing.T) { - t.Parallel() - st := newStore(t) - p, _ := st.CreateProject(store.Project{ - Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", - }) - if _, err := st.CreateStage(store.Stage{ - ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1, - }); err != nil { - t.Fatal(err) - } - const sig = "deadbeef-signing-secret-1234567890abcdef" - if err := st.SetProjectWebhookSigningSecret(p.ID, sig); err != nil { - t.Fatal(err) - } - if err := st.SetProjectWebhookRequireSignature(p.ID, true); err != nil { - t.Fatal(err) - } - - dep := &fakeDeployer{} - h := webhook.NewHandler(st, dep, nil) - r := newRouter(t, h) - - body := `{"image":"alexei/app:dev-abc"}` - resp, msg := doJSONSigned(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, body, sig) - if resp.StatusCode != http.StatusOK { - t.Fatalf("expected 200 with valid sig, got %d: %s", resp.StatusCode, msg) - } - if dep.calls != 1 { - t.Errorf("valid signed deploy should fire once, got %d", dep.calls) - } -} - -func TestProjectWebhook_HMACRequiredButMissing(t *testing.T) { - t.Parallel() - st := newStore(t) - p, _ := st.CreateProject(store.Project{ - Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", - }) - if _, err := st.CreateStage(store.Stage{ - ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1, - }); err != nil { - t.Fatal(err) - } - if err := st.SetProjectWebhookSigningSecret(p.ID, "abc-signing-secret-12345678901234567890"); err != nil { - t.Fatal(err) - } - if err := st.SetProjectWebhookRequireSignature(p.ID, true); err != nil { - t.Fatal(err) - } - - dep := &fakeDeployer{} - h := webhook.NewHandler(st, dep, nil) - r := newRouter(t, h) - - resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, `{"image":"alexei/app:dev-abc"}`) - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("missing signature must return 401 when required, got %d", resp.StatusCode) - } - if dep.calls != 0 { - t.Errorf("deploy must not fire when required signature is missing") - } -} - -func TestProjectWebhook_HMACPresentButWrong(t *testing.T) { - t.Parallel() - st := newStore(t) - p, _ := st.CreateProject(store.Project{ - Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", - }) - if _, err := st.CreateStage(store.Stage{ - ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1, - }); err != nil { - t.Fatal(err) - } - if err := st.SetProjectWebhookSigningSecret(p.ID, "real-signing-secret-1234567890abcdef"); err != nil { - t.Fatal(err) - } - // Note: require_signature stays false — but a wrong sig must still 401. - - dep := &fakeDeployer{} - h := webhook.NewHandler(st, dep, nil) - r := newRouter(t, h) - - resp, _ := doJSONSigned(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, - `{"image":"alexei/app:dev-abc"}`, "wrong-secret-xxxxxxxxxxxxxxxxxxxxxxxxxxxx") - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("wrong signature must 401, got %d", resp.StatusCode) - } - if dep.calls != 0 { - t.Errorf("deploy must not fire on wrong signature") - } -} - -func TestProjectWebhook_HMACOptionalUnsignedAccepted(t *testing.T) { - // require_signature=false AND signing_secret="": unsigned requests pass. - t.Parallel() - st := newStore(t) - p, _ := st.CreateProject(store.Project{ - Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", - }) - if _, err := st.CreateStage(store.Stage{ - ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1, - }); err != nil { - t.Fatal(err) - } - dep := &fakeDeployer{} - h := webhook.NewHandler(st, dep, nil) - r := newRouter(t, h) - resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, `{"image":"alexei/app:dev-x"}`) - if resp.StatusCode != http.StatusOK { - t.Fatalf("unsigned + unconfigured should pass, got %d", resp.StatusCode) - } - if dep.calls != 1 { - t.Errorf("expected 1 deploy, got %d", dep.calls) - } -} diff --git a/internal/webhook/matcher.go b/internal/webhook/matcher.go deleted file mode 100644 index a8c0961..0000000 --- a/internal/webhook/matcher.go +++ /dev/null @@ -1,94 +0,0 @@ -package webhook - -import ( - "fmt" - "log/slog" - "path" - "strings" - - "github.com/alexei/tinyforge/internal/store" -) - -// matchStage finds the first stage of a project whose tag pattern matches the -// given tag. Uses path.Match for glob-style matching (same as the registry poller). -func matchStage(st *store.Store, projectID, tag string) (store.Stage, bool, error) { - stages, err := st.GetStagesByProjectID(projectID) - if err != nil { - return store.Stage{}, false, fmt.Errorf("get stages: %w", err) - } - - for _, stage := range stages { - pattern := stage.TagPattern - if pattern == "" { - pattern = "*" - } - - matched, err := path.Match(pattern, tag) - if err != nil { - slog.Warn("webhook: invalid tag pattern, skipping stage", - "project", projectID, "stage", stage.Name, "pattern", pattern, "error", err) - continue - } - if matched { - return stage, true, nil - } - } - - return store.Stage{}, false, nil -} - -// imageMatches reports whether an incoming image reference matches the -// project's stored image. The registry hostname is matched case-insensitively -// (per RFC: registry hostnames are case-insensitive); the path/owner/name are -// matched exactly. -func imageMatches(projectImage, incomingImage string) bool { - if projectImage == incomingImage { - return true - } - pIdx := strings.IndexByte(projectImage, '/') - iIdx := strings.IndexByte(incomingImage, '/') - if pIdx <= 0 || iIdx <= 0 { - return false - } - pHost, pPath := projectImage[:pIdx], projectImage[pIdx:] - iHost, iPath := incomingImage[:iIdx], incomingImage[iIdx:] - return strings.EqualFold(pHost, iHost) && pPath == iPath -} - -// siteRefMatches reports whether a Git ref (e.g. "refs/heads/main" or -// "refs/tags/v1.2.3") targets the site's configured branch or tag pattern. -// -// For sync_trigger = "push": the ref must be a heads/ ref whose -// branch name equals site.Branch. -// For sync_trigger = "tag": the ref must be a tags/ ref whose tag name -// matches site.TagPattern via glob semantics. -// Unknown triggers return false (caller should have filtered these out). -func siteRefMatches(site store.StaticSite, ref string) bool { - switch site.SyncTrigger { - case "push": - branch, ok := strings.CutPrefix(ref, "refs/heads/") - if !ok { - return false - } - if site.Branch == "" { - return true - } - return branch == site.Branch - case "tag": - tag, ok := strings.CutPrefix(ref, "refs/tags/") - if !ok { - return false - } - pattern := site.TagPattern - if pattern == "" { - pattern = "*" - } - matched, err := path.Match(pattern, tag) - if err != nil { - return false - } - return matched - default: - return false - } -} diff --git a/internal/webhook/matcher_test.go b/internal/webhook/matcher_test.go deleted file mode 100644 index db04ceb..0000000 --- a/internal/webhook/matcher_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package webhook - -import ( - "testing" - - "github.com/alexei/tinyforge/internal/store" -) - -func TestSiteRefMatches_Push(t *testing.T) { - t.Parallel() - site := store.StaticSite{SyncTrigger: "push", Branch: "main"} - cases := []struct { - ref string - want bool - }{ - {"refs/heads/main", true}, - {"refs/heads/develop", false}, - {"refs/tags/v1.0.0", false}, - {"", false}, - {"main", false}, - } - for _, tc := range cases { - if got := siteRefMatches(site, tc.ref); got != tc.want { - t.Errorf("siteRefMatches(push, %q) = %v; want %v", tc.ref, got, tc.want) - } - } -} - -func TestSiteRefMatches_PushEmptyBranchAcceptsAny(t *testing.T) { - t.Parallel() - // When Branch is unset, any heads ref should match — tolerates the sites - // table having blank Branch values from legacy rows. - site := store.StaticSite{SyncTrigger: "push"} - if !siteRefMatches(site, "refs/heads/whatever") { - t.Error("expected empty Branch to accept any heads ref") - } - if siteRefMatches(site, "refs/tags/v1") { - t.Error("empty Branch must still reject tag refs") - } -} - -func TestSiteRefMatches_Tag(t *testing.T) { - t.Parallel() - site := store.StaticSite{SyncTrigger: "tag", TagPattern: "v*"} - cases := []struct { - ref string - want bool - }{ - {"refs/tags/v1.0.0", true}, - {"refs/tags/v2", true}, - {"refs/tags/hotfix", false}, - {"refs/heads/main", false}, - } - for _, tc := range cases { - if got := siteRefMatches(site, tc.ref); got != tc.want { - t.Errorf("siteRefMatches(tag, %q) = %v; want %v", tc.ref, got, tc.want) - } - } -} - -func TestSiteRefMatches_ManualIsIgnored(t *testing.T) { - t.Parallel() - site := store.StaticSite{SyncTrigger: "manual", Branch: "main"} - if siteRefMatches(site, "refs/heads/main") { - t.Error("manual trigger must never match any ref — caller short-circuits") - } -} - -func TestParseImageRef(t *testing.T) { - t.Parallel() - cases := []struct { - in string - wantFull string - wantTag string - }{ - {"registry.example.com/alexei/app:v1", "alexei/app", "v1"}, - {"alexei/app:dev", "alexei/app", "dev"}, - {"app", "app", ""}, - } - for _, tc := range cases { - got, err := ParseImageRef(tc.in) - if err != nil { - t.Errorf("ParseImageRef(%q) unexpected error: %v", tc.in, err) - continue - } - if got.FullName() != tc.wantFull || got.Tag != tc.wantTag { - t.Errorf("ParseImageRef(%q) = %q:%q; want %q:%q", - tc.in, got.FullName(), got.Tag, tc.wantFull, tc.wantTag) - } - } -} - -func TestParseImageRef_Empty(t *testing.T) { - t.Parallel() - if _, err := ParseImageRef(""); err == nil { - t.Error("expected error for empty image ref") - } -} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index fe37ca2..8467ab5 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -8,27 +8,19 @@ import type { SystemStats, SystemStatsSample, TopContainerSample, - Deploy, - DeployLog, DockerHealth, ProxyHealth, EventLogEntry, EventLogStats, InspectResult, - Instance, LocalImage, NpmCertificate, NpmAccessList, ProxyRoute, - Project, - ProjectDetail, Registry, RegistryImage, Settings, StaleContainer, - Stage, - StageEnv, - Volume, VolumeScopeInfo, BrowseResult, DnsZone, @@ -174,125 +166,15 @@ function patch(path: string, body: unknown): Promise { }); } -// ── Projects ──────────────────────────────────────────────────────── - -export function listProjects(signal?: AbortSignal): Promise { - return get('/api/projects', signal); -} - -export function getProject(id: string, signal?: AbortSignal): Promise { - return get(`/api/projects/${id}`, signal); -} - -export function createProject(data: Partial): Promise { - return post('/api/projects', data); -} - -export function updateProject(id: string, data: Partial): Promise { - return put(`/api/projects/${id}`, data); -} - -export function deleteProject(id: string): Promise<{ deleted: string }> { - return del<{ deleted: string }>(`/api/projects/${id}`); -} - -// ── Stages ───────────────────────────────────────────────────────── - -export function createStage(projectId: string, data: Partial): Promise { - return post(`/api/projects/${projectId}/stages`, data); -} - -export function updateStage(projectId: string, stageId: string, data: Partial): Promise { - return put(`/api/projects/${projectId}/stages/${stageId}`, data); -} - -export function deleteStage(projectId: string, stageId: string): Promise { - return del(`/api/projects/${projectId}/stages/${stageId}`); -} - -// ── Instances ─────────────────────────────────────────────────────── - -export function listInstances(projectId: string, stageId: string, signal?: AbortSignal): Promise { - return get(`/api/projects/${projectId}/stages/${stageId}/instances`, signal); -} - -export function deployInstance( - projectId: string, - stageId: string, - imageTag: string -): Promise<{ status: string }> { - return post<{ status: string }>(`/api/projects/${projectId}/stages/${stageId}/instances`, { - image_tag: imageTag - }); -} - -export function removeInstance( - projectId: string, - stageId: string, - instanceId: string -): Promise<{ deleted: string }> { - return del<{ deleted: string }>( - `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}` - ); -} - -export function stopInstance( - projectId: string, - stageId: string, - instanceId: string -): Promise<{ status: string }> { - return post<{ status: string }>( - `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stop` - ); -} - -export function startInstance( - projectId: string, - stageId: string, - instanceId: string -): Promise<{ status: string }> { - return post<{ status: string }>( - `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/start` - ); -} - -export function restartInstance( - projectId: string, - stageId: string, - instanceId: string -): Promise<{ status: string }> { - return post<{ status: string }>( - `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/restart` - ); -} - -// ── Deploys ───────────────────────────────────────────────────────── - -export function listDeploys(limit = 50, signal?: AbortSignal): Promise { - return get(`/api/deploys?limit=${limit}`, signal); -} - -export function getDeployLogs(deployId: string): Promise { - return get(`/api/deploys/${deployId}/logs`); -} +// ── Deploys (inspect only; quick-deploy retired with /deploy page) ──── +// `inspectImage` survives because the new-app wizard can use it to pre-fill +// image port/healthcheck. `quickDeploy` (POST /api/deploy/quick) is gone: +// it created a legacy Project + Stage in the now-dead path. export function inspectImage(image: string): Promise { return post('/api/deploy/inspect', { image }); } -export function quickDeploy(data: { - name?: string; - image: string; - tag?: string; - registry?: string; - port?: number; - force?: boolean; - enable_proxy?: boolean; - auto_deploy?: boolean; -}): Promise<{ project: Project; status: string }> { - return post<{ project: Project; status: string }>('/api/deploy/quick', data); -} - // ── Registries ────────────────────────────────────────────────────── export function listRegistries(): Promise { @@ -335,7 +217,8 @@ export function updateSettings(data: Partial): Promise { return put('/api/settings', data); } -// ── Webhooks ─────────────────────────────────────────────────────── +// ── Webhook envelopes ────────────────────────────────────────────── +// These shapes are reused by the workload + trigger webhook flows. export interface WebhookUrlResponse { webhook_url: string; @@ -348,49 +231,9 @@ export interface SigningSecretResponse { signing_secret: string; } -export function getProjectWebhook(projectId: string): Promise { - return get(`/api/projects/${projectId}/webhook`); -} - -export function regenerateProjectWebhook(projectId: string): Promise { - return post(`/api/projects/${projectId}/webhook/regenerate`); -} - -export function regenerateProjectSigningSecret(projectId: string): Promise { - return post(`/api/projects/${projectId}/webhook/signing-secret/regenerate`); -} - -export async function disableProjectSigningSecret(projectId: string): Promise { - await del(`/api/projects/${projectId}/webhook/signing-secret`); -} - -export async function setProjectRequireSignature(projectId: string, require: boolean): Promise { - await put(`/api/projects/${projectId}/webhook/require-signature`, { require_signature: require }); -} - -export function getStaticSiteWebhook(siteId: string): Promise { - return get(`/api/sites/${siteId}/webhook`); -} - -export function regenerateStaticSiteWebhook(siteId: string): Promise { - return post(`/api/sites/${siteId}/webhook/regenerate`); -} - -export function regenerateStaticSiteSigningSecret(siteId: string): Promise { - return post(`/api/sites/${siteId}/webhook/signing-secret/regenerate`); -} - -export async function disableStaticSiteSigningSecret(siteId: string): Promise { - await del(`/api/sites/${siteId}/webhook/signing-secret`); -} - -export async function setStaticSiteRequireSignature(siteId: string, require: boolean): Promise { - await put(`/api/sites/${siteId}/webhook/require-signature`, { require_signature: require }); -} - export interface WebhookDelivery { id: number; - target_type: 'project' | 'site'; + target_type: 'project' | 'site' | 'workload' | 'trigger'; target_id: string; target_name: string; received_at: string; @@ -402,15 +245,10 @@ export interface WebhookDelivery { body_size: number; } -export function listProjectWebhookDeliveries(projectId: string, signal?: AbortSignal): Promise { - return get(`/api/projects/${projectId}/webhook/deliveries`, signal); -} - -export function listStaticSiteWebhookDeliveries(siteId: string, signal?: AbortSignal): Promise { - return get(`/api/sites/${siteId}/webhook/deliveries`, signal); -} - -// ── Outgoing-webhook signing & test ──────────────────────────────── +// ── Outgoing-webhook signing & test (settings tier only) ─────────── +// Per-project, per-stage, per-site tiers were dropped with the legacy +// endpoints. Per-workload signing is exposed via the workload webhook +// flow below. export interface NotificationSecretResponse { secret: string; @@ -419,7 +257,7 @@ export interface NotificationSecretResponse { export interface NotificationTestResult { url: string; - tier: 'settings' | 'project' | 'stage' | 'site'; + tier: 'settings' | 'project' | 'stage' | 'site' | 'workload' | 'trigger'; status_code: number; latency_ms: number; signature_sent: boolean; @@ -428,7 +266,6 @@ export interface NotificationTestResult { error?: string; } -// Settings (global) tier. export function getSettingsNotificationSecret(): Promise { return get('/api/settings/notification-secret'); } @@ -442,66 +279,14 @@ export function testSettingsNotification(): Promise { return post('/api/settings/notification-test'); } -// Project tier. -export function getProjectNotificationSecret(projectId: string): Promise { - return get(`/api/projects/${projectId}/notification-secret`); -} -export function regenerateProjectNotificationSecret(projectId: string): Promise { - return post(`/api/projects/${projectId}/notification-secret/regenerate`); -} -export function disableProjectNotificationSigning(projectId: string): Promise { - return post(`/api/projects/${projectId}/notification-secret/disable`); -} -export function testProjectNotification(projectId: string): Promise { - return post(`/api/projects/${projectId}/notification-test`); -} - -// Stage tier. -export function getStageNotificationSecret(projectId: string, stageId: string): Promise { - return get(`/api/projects/${projectId}/stages/${stageId}/notification-secret`); -} -export function regenerateStageNotificationSecret(projectId: string, stageId: string): Promise { - return post(`/api/projects/${projectId}/stages/${stageId}/notification-secret/regenerate`); -} -export function disableStageNotificationSigning(projectId: string, stageId: string): Promise { - return post(`/api/projects/${projectId}/stages/${stageId}/notification-secret/disable`); -} -export function testStageNotification(projectId: string, stageId: string): Promise { - return post(`/api/projects/${projectId}/stages/${stageId}/notification-test`); -} - -// Static-site tier. -export function getStaticSiteNotificationSecret(siteId: string): Promise { - return get(`/api/sites/${siteId}/notification-secret`); -} -export function regenerateStaticSiteNotificationSecret(siteId: string): Promise { - return post(`/api/sites/${siteId}/notification-secret/regenerate`); -} -export function disableStaticSiteNotificationSigning(siteId: string): Promise { - return post(`/api/sites/${siteId}/notification-secret/disable`); -} -export function testStaticSiteNotification(siteId: string): Promise { - return post(`/api/sites/${siteId}/notification-test`); -} - // ── Proxy Routes ─────────────────────────────────────────────────── -export function listProxyRoutes(): Promise { - return get('/api/proxies'); +export function listProxyRoutes(signal?: AbortSignal): Promise { + return get('/api/proxies', signal); } // ── Docker Management ────────────────────────────────────────────── -export function fetchContainerLogs( - projectId: string, stageId: string, instanceId: string, tail = 200 -): Promise { - return get(`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/logs?tail=${tail}`); -} - -export function listProjectImages(projectId: string, signal?: AbortSignal): Promise { - return get(`/api/projects/${projectId}/images`, signal); -} - export function getUnusedImageStats(signal?: AbortSignal): Promise<{ total_size_mb: number; count: number; threshold_mb: number; exceeded: boolean; }> { @@ -524,6 +309,15 @@ export function listNpmAccessLists(): Promise { return get('/api/settings/npm-access-lists'); } +// ── Volume scopes (metadata only) ─────────────────────────────────── +// Per-project volume CRUD endpoints died with the legacy routes; the +// workload volume endpoints cover the new path. Scope metadata stays +// because the volume editor for workloads still needs it. + +export function listVolumeScopes(): Promise { + return get('/api/volumes/scopes'); +} + // ── DNS ──────────────────────────────────────────────────────────── export function testDnsConnection(provider: string, token: string, zoneId: string): Promise<{ success: boolean; error?: string }> { @@ -635,107 +429,48 @@ export function exportConfigUrl(): string { return '/api/config/export'; } -// ── Stage Env Overrides ────────────────────────────────────────────── - -export function listStageEnv(projectId: string, stageId: string): Promise { - return get(`/api/projects/${projectId}/stages/${stageId}/env`); -} - -export function createStageEnv( - projectId: string, - stageId: string, - data: { key: string; value: string; encrypted?: boolean } -): Promise { - return post(`/api/projects/${projectId}/stages/${stageId}/env`, data); -} - -export function updateStageEnv( - projectId: string, - stageId: string, - envId: string, - data: { key?: string; value?: string; encrypted?: boolean } -): Promise { - return put(`/api/projects/${projectId}/stages/${stageId}/env/${envId}`, data); -} - -export function deleteStageEnv( - projectId: string, - stageId: string, - envId: string -): Promise<{ deleted: string }> { - return del<{ deleted: string }>(`/api/projects/${projectId}/stages/${stageId}/env/${envId}`); -} - -// ── Volumes ────────────────────────────────────────────────────────── - -export function listVolumes(projectId: string): Promise { - return get(`/api/projects/${projectId}/volumes`); -} - -export function createVolume( - projectId: string, - data: { source: string; target: string; scope: string; name?: string; mode?: string } -): Promise { - return post(`/api/projects/${projectId}/volumes`, data); -} - -export function updateVolume( - projectId: string, - volId: string, - data: { source?: string; target?: string; scope?: string; name?: string; mode?: string } -): Promise { - return put(`/api/projects/${projectId}/volumes/${volId}`, data); -} - -export function listVolumeScopes(): Promise { - return get('/api/volumes/scopes'); -} - -export function deleteVolume( - projectId: string, - volId: string -): Promise<{ deleted: string }> { - return del<{ deleted: string }>(`/api/projects/${projectId}/volumes/${volId}`); -} +// ── Workload volume browse / download / upload ───────────────────── +// The browse/download/upload helpers now target /api/workloads/{id} +// instead of the deleted project-scoped path. Source/path/scope params +// retain the same query keys for compatibility with the volume editor. export function browseVolume( - projectId: string, + workloadId: string, volId: string, - params?: { path?: string; stage?: string; tag?: string } + params?: { path?: string; reference?: string } ): Promise { const query = new URLSearchParams(); if (params?.path) query.set('path', params.path); - if (params?.stage) query.set('stage', params.stage); - if (params?.tag) query.set('tag', params.tag); + if (params?.reference) query.set('reference', params.reference); const qs = query.toString(); - return get(`/api/projects/${projectId}/volumes/${volId}/browse${qs ? `?${qs}` : ''}`); + return get( + `/api/workloads/${workloadId}/volumes/${volId}/browse${qs ? `?${qs}` : ''}` + ); } export function volumeDownloadUrl( - projectId: string, + workloadId: string, volId: string, - params?: { path?: string; stage?: string; tag?: string } + params?: { path?: string; reference?: string } ): string { const query = new URLSearchParams(); if (params?.path) query.set('path', params.path); - if (params?.stage) query.set('stage', params.stage); - if (params?.tag) query.set('tag', params.tag); + if (params?.reference) query.set('reference', params.reference); const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null; if (token) query.set('token', token); const qs = query.toString(); - return `/api/projects/${projectId}/volumes/${volId}/download${qs ? `?${qs}` : ''}`; + return `/api/workloads/${workloadId}/volumes/${volId}/download${qs ? `?${qs}` : ''}`; } export async function uploadToVolume( - projectId: string, + workloadId: string, volId: string, files: FileList, - params?: { path?: string; stage?: string; tag?: string } + params?: { path?: string; reference?: string } ): Promise<{ uploaded: string[]; count: number }> { const query = new URLSearchParams(); if (params?.path) query.set('path', params.path); - if (params?.stage) query.set('stage', params.stage); - if (params?.tag) query.set('tag', params.tag); + if (params?.reference) query.set('reference', params.reference); const qs = query.toString(); const formData = new FormData(); @@ -747,11 +482,14 @@ export async function uploadToVolume( const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; - const res = await fetch(`/api/projects/${projectId}/volumes/${volId}/upload${qs ? `?${qs}` : ''}`, { - method: 'POST', - headers, - body: formData, - }); + const res = await fetch( + `/api/workloads/${workloadId}/volumes/${volId}/upload${qs ? `?${qs}` : ''}`, + { + method: 'POST', + headers, + body: formData + } + ); const envelope = await res.json(); if (!envelope.success) throw new Error(envelope.error ?? 'Upload failed'); @@ -779,8 +517,8 @@ export function fetchEventLog(params?: { return get(`/api/events/log${qs ? `?${qs}` : ''}`); } -export function fetchEventLogStats(): Promise { - return get('/api/events/log/stats'); +export function fetchEventLogStats(signal?: AbortSignal): Promise { + return get('/api/events/log/stats', signal); } export function deleteEvent(id: number): Promise<{ status: string }> { @@ -805,32 +543,7 @@ export function bulkCleanupStaleContainers(): Promise<{ deleted: number }> { return post<{ deleted: number }>('/api/containers/stale/cleanup'); } -// ── Container Stats ──────────────────────────────────────────────── - -export function fetchContainerStats( - projectId: string, - stageId: string, - instanceId: string, - signal?: AbortSignal -): Promise { - return get( - `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stats`, - signal - ); -} - -export function fetchInstanceStatsHistory( - projectId: string, - stageId: string, - instanceId: string, - window = '2h', - signal?: AbortSignal -): Promise { - return get( - `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stats/history?window=${encodeURIComponent(window)}`, - signal - ); -} +// ── System Stats ─────────────────────────────────────────────────── export function fetchSystemStats(signal?: AbortSignal): Promise { return get('/api/system/stats', signal); @@ -851,212 +564,32 @@ export function fetchTopContainers( return get(`/api/system/stats/top?by=${by}&limit=${limit}`, signal); } -export function fetchStaticSiteStats(id: string, signal?: AbortSignal): Promise { - return get(`/api/sites/${id}/stats`, signal); -} +// ── Per-container stats (workload-scoped) ────────────────────────── +// Project / stage / instance + static-site stats endpoints died with +// the legacy routes. Use the workload-scoped endpoint for any per- +// container CPU/memory drill-down. -export function fetchStaticSiteStatsHistory( - id: string, - window = '2h', +export function fetchWorkloadContainerStats( + workloadId: string, + containerRowId: string, signal?: AbortSignal -): Promise { - return get( - `/api/sites/${id}/stats/history?window=${encodeURIComponent(window)}`, +): Promise { + return get( + `/api/workloads/${workloadId}/containers/${containerRowId}/stats`, signal ); } -export async function fetchStaticSiteLogs(id: string, tail = 200): Promise { - const result = await get(`/api/sites/${id}/logs?tail=${tail}`); - return result ?? []; -} - -// ── Static Sites ────────────────────────────────────────────────────── - -import type { StaticSite, StaticSiteSecret, FolderEntry, GitProvider, RepoInfo } from './types'; - -export function listStaticSites(signal?: AbortSignal): Promise { - return get('/api/sites', signal); -} - -export function getStaticSite(id: string): Promise { - return get(`/api/sites/${id}`); -} - -export function createStaticSite(data: Partial): Promise { - return post('/api/sites', data); -} - -export function updateStaticSite(id: string, data: Partial): Promise { - return put(`/api/sites/${id}`, data); -} - -export function deleteStaticSite(id: string): Promise<{ deleted: string }> { - return del<{ deleted: string }>(`/api/sites/${id}`); -} - -export function deployStaticSite(id: string): Promise<{ status: string }> { - return post<{ status: string }>(`/api/sites/${id}/deploy`); -} - -export function stopStaticSite(id: string): Promise<{ status: string }> { - return post<{ status: string }>(`/api/sites/${id}/stop`); -} - -export function startStaticSite(id: string): Promise<{ status: string }> { - return post<{ status: string }>(`/api/sites/${id}/start`); -} - -export function listStaticSiteRepos(data: { - provider?: string; - gitea_url: string; - access_token?: string; - query?: string; -}): Promise { - return post('/api/sites/repos', data); -} - -export function detectStaticSiteProvider(url: string): Promise<{ provider: GitProvider }> { - return post<{ provider: GitProvider }>('/api/sites/detect-provider', { url }); -} - -export function testStaticSiteConnection(data: { - provider?: string; - gitea_url: string; - access_token?: string; - repo_owner: string; - repo_name: string; -}): Promise<{ status: string }> { - return post<{ status: string }>('/api/sites/test-connection', data); -} - -export function listStaticSiteBranches(data: { - provider?: string; - gitea_url: string; - access_token?: string; - repo_owner: string; - repo_name: string; -}): Promise { - return post('/api/sites/branches', data); -} - -export function listStaticSiteTree(data: { - provider?: string; - gitea_url: string; - access_token?: string; - repo_owner: string; - repo_name: string; - branch: string; -}): Promise { - return post('/api/sites/tree', data); -} - -export function listStaticSiteSecrets(siteId: string): Promise { - return get(`/api/sites/${siteId}/secrets`); -} - -export function createStaticSiteSecret( - siteId: string, - data: { key: string; value: string; encrypted?: boolean } -): Promise { - return post(`/api/sites/${siteId}/secrets`, data); -} - -export function updateStaticSiteSecret( - siteId: string, - secretId: string, - data: { key?: string; value?: string; encrypted?: boolean } -): Promise { - return put(`/api/sites/${siteId}/secrets/${secretId}`, data); -} - -export function deleteStaticSiteSecret( - siteId: string, - secretId: string -): Promise<{ deleted: string }> { - return del<{ deleted: string }>(`/api/sites/${siteId}/secrets/${secretId}`); -} - -export function getStaticSiteStorage( - siteId: string -): Promise { - return get(`/api/sites/${siteId}/storage`); -} - -// ── Stacks (docker-compose) ───────────────────────────────────────── - -import type { Stack, StackRevision, StackService } from './types'; - -export function listStacks(signal?: AbortSignal): Promise { - return get('/api/stacks', signal); -} - -export function getStack(id: string, signal?: AbortSignal): Promise { - return get(`/api/stacks/${id}`, signal); -} - -export function createStack(data: { - name: string; - description?: string; - yaml: string; - deploy?: boolean; -}): Promise<{ stack: Stack; revision: StackRevision }> { - return post<{ stack: Stack; revision: StackRevision }>('/api/stacks', data); -} - -export function updateStack(id: string, data: { name?: string; description?: string }): Promise { - return put(`/api/stacks/${id}`, data); -} - -export function deleteStack(id: string, removeVolumes = false): Promise<{ deleted: string }> { - const qs = removeVolumes ? '?remove_volumes=true' : ''; - return del<{ deleted: string }>(`/api/stacks/${id}${qs}`); -} - -export function listStackRevisions(id: string, signal?: AbortSignal): Promise { - return get(`/api/stacks/${id}/revisions`, signal); -} - -export function getStackRevision(id: string, revId: string): Promise { - return get(`/api/stacks/${id}/revisions/${revId}`); -} - -export function createStackRevision(id: string, yaml: string): Promise { - return post(`/api/stacks/${id}/revisions`, { yaml }); -} - -export function rollbackStack(id: string, revId: string): Promise { - return post(`/api/stacks/${id}/rollback/${revId}`); -} - -export function stopStack(id: string): Promise<{ status: string }> { - return post<{ status: string }>(`/api/stacks/${id}/stop`); -} - -export function startStack(id: string): Promise<{ status: string }> { - return post<{ status: string }>(`/api/stacks/${id}/start`); -} - -export function getStackServices(id: string, signal?: AbortSignal): Promise { - return get(`/api/stacks/${id}/services`, signal); -} - -export async function getStackLogs( - id: string, - service?: string, - tail = 200 -): Promise { - const params = new URLSearchParams(); - if (service) params.set('service', service); - params.set('tail', String(tail)); - const token = getAuthToken(); - const headers: Record = {}; - if (token) headers['Authorization'] = `Bearer ${token}`; - const res = await fetch(`/api/stacks/${id}/logs?${params.toString()}`, { headers }); - if (!res.ok) { - throw new ApiError(`Failed to fetch logs: ${res.status}`, res.status); - } - return res.text(); +export function fetchWorkloadContainerStatsHistory( + workloadId: string, + containerRowId: string, + window = '2h', + signal?: AbortSignal +): Promise { + return get( + `/api/workloads/${workloadId}/containers/${containerRowId}/stats/history?window=${encodeURIComponent(window)}`, + signal + ); } // ── Workloads ─────────────────────────────────────────────────────── @@ -1126,20 +659,10 @@ export function deleteWorkloadEnv(id: string, envID: string): Promise<{ deleted: return del<{ deleted: string }>(`/api/workloads/${id}/env/${envID}`); } -export interface WorkloadWebhook { - webhook_url: string; - webhook_secret: string; - has_signing_secret: boolean; - webhook_require_signature: boolean; -} - -export function getWorkloadWebhook(id: string, signal?: AbortSignal): Promise { - return get(`/api/workloads/${id}/webhook`, signal); -} - -export function regenerateWorkloadWebhook(id: string): Promise { - return post(`/api/workloads/${id}/webhook/regenerate`); -} +// Workload-level webhook URL accessors were removed in the hard legacy +// cutover: inbound webhooks are now first-class Triggers. To wire a +// workload to inbound deploys, create or bind a Trigger via the +// /triggers UI (which mints a /api/webhook/triggers/{secret} URL). export function fetchWorkloadContainerLogs( workloadId: string, diff --git a/web/src/lib/components/ContainerLogs.svelte b/web/src/lib/components/ContainerLogs.svelte index 6a83439..1eb8740 100644 --- a/web/src/lib/components/ContainerLogs.svelte +++ b/web/src/lib/components/ContainerLogs.svelte @@ -1,24 +1,22 @@ - -
-
-
-
- - {instance.image_tag} - - -
- - {#if subdomainUrl} - - {instance.subdomain} - - - {/if} - -
- :{instance.port} - {timeSinceCreated} -
-
- - -
- {#if instance.state === 'running'} - - - {:else if instance.state === 'stopped'} - - {/if} - - -
-
- - {#if instance.state === 'running'} - - {/if} - - {#if showLogs} -
- { showLogs = false; }} - /> -
- {/if} - - {#if error} -

{error}

- {/if} -
- - { if (confirmAction) handleAction(confirmAction); }} - oncancel={() => { confirmAction = null; }} -/> diff --git a/web/src/lib/components/ProjectCard.svelte b/web/src/lib/components/ProjectCard.svelte deleted file mode 100644 index 2872213..0000000 --- a/web/src/lib/components/ProjectCard.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - - - -
-
-
-
- -
-

{project.name}

-
-

{project.image}

-
- -
- - -
- {#if totalCount > 0} - - - {runningCount} - - {#if stoppedCount > 0} - - - {stoppedCount} - - {/if} - {#if failedCount > 0} - - - {failedCount} - - {/if} - - {totalCount} {totalCount === 1 ? $t('common.instance') : $t('common.instances')} - - {:else} - {$t('projectDetail.noInstancesRunning')} - {/if} -
- - -
- {#if project.port} - :{project.port} - {/if} - {#if project.healthcheck} - {project.healthcheck} - {/if} -
-
diff --git a/web/src/lib/components/StaleContainerCard.svelte b/web/src/lib/components/StaleContainerCard.svelte deleted file mode 100644 index 2208893..0000000 --- a/web/src/lib/components/StaleContainerCard.svelte +++ /dev/null @@ -1,85 +0,0 @@ - - - -
- -
-
-

- {displayName} -

-
- - {container.workload_name} - - {#if container.role} - - {container.role} - - {/if} -
-
- - - - - {container.days_stale} {$t('stale.daysStale')} - -
- - -
- - - {container.container.image_tag} - - - - {$t('stale.lastAlive')}: {$fmt.shortDate(container.container.last_seen_at)} - - - {container.container.state} - -
- - -
- -
-
diff --git a/web/src/lib/components/StatusBadge.svelte b/web/src/lib/components/StatusBadge.svelte index 6ab97b3..14b8cc4 100644 --- a/web/src/lib/components/StatusBadge.svelte +++ b/web/src/lib/components/StatusBadge.svelte @@ -1,7 +1,9 @@ @@ -62,7 +51,7 @@

{$t('systemHealth.title')}

- +
diff --git a/web/src/lib/components/TagCombobox.svelte b/web/src/lib/components/TagCombobox.svelte index feca417..e946e90 100644 --- a/web/src/lib/components/TagCombobox.svelte +++ b/web/src/lib/components/TagCombobox.svelte @@ -176,7 +176,7 @@ > {item.tag} - {item.source === 'registry' ? $t('projectDetail.registryTag') : $t('projectDetail.localTag')} + {item.source === 'registry' ? $t('tagPicker.registry') : $t('tagPicker.local')} {/each} diff --git a/web/src/lib/components/WebhookDeliveryLog.svelte b/web/src/lib/components/WebhookDeliveryLog.svelte index 22bdd00..9b062a0 100644 --- a/web/src/lib/components/WebhookDeliveryLog.svelte +++ b/web/src/lib/components/WebhookDeliveryLog.svelte @@ -1,9 +1,10 @@ @@ -121,9 +106,9 @@
{#snippet heroToolbar()} - - - {$t('dashboard.quickDeploy')} + + + {$t('dashboard.newApp')} {/snippet} @@ -201,59 +177,11 @@ - - {#if !loading} - {#snippet sitesActions()} - {#if sites.length > 0} - - {$t('dashboard.viewAllSites')} → - - {/if} - {/snippet} - 0 ? String(sites.length) : ''} - actions={sitesActions} - > - {#if sites.length === 0} - - {:else} - - {/if} - - {/if} - - + 0 ? String(projects.length) : ''} + id="dashboard-workloads" + title={$t('dashboard.recentWorkloads')} + badge={!loading && workloads.length > 0 ? String(workloads.length) : ''} > {#if loading}
@@ -272,18 +200,36 @@ {$t('dashboard.retry')}
- {:else if projects.length === 0} + {:else if workloads.length === 0} {:else} -
- {#each projects as project (project.id)} - +
+ {#each recentWorkloads as wl (wl.id)} + {@const state = containerStateFor(wl.id)} + {@const count = containerCountFor(wl.id)} + +
+ {wl.name} + +
+
+ {wl.source_kind || wl.kind} + · + {count} {count === 1 ? $t('common.instance') : $t('common.instances')} +
+ {#if wl.updated_at} +

{$fmt.dateTime(wl.updated_at)}

+ {/if} +
{/each}
{/if} @@ -298,19 +244,4 @@ transition: background 150ms ease; } .stat-link:hover { background: var(--surface-card-hover); } - .stat-link .forge-stat-sub .tag { - display: inline-block; - padding: 0.05rem 0.4rem; - margin-right: 0.25rem; - border-radius: var(--radius-sm); - font-family: var(--forge-mono); - font-size: 0.62rem; - font-weight: 600; - } - .stat-link .tag.ok { background: var(--color-success-light); color: var(--color-success-dark); } - .stat-link .tag.bad { background: var(--color-danger-light); color: var(--color-danger-dark); } - :global([data-theme='dark']) .stat-link .tag.ok { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; } - :global([data-theme='dark']) .stat-link .tag.bad { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; } - - .section { margin-top: 0.5rem; } diff --git a/web/src/routes/apps/[id]/+page.svelte b/web/src/routes/apps/[id]/+page.svelte index 18ed002..9bb2d71 100644 --- a/web/src/routes/apps/[id]/+page.svelte +++ b/web/src/routes/apps/[id]/+page.svelte @@ -328,11 +328,9 @@ let newEnvValue = $state(''); let newEnvEncrypted = $state(true); - // ── Webhook ──────────────────────────────────────────────── - let webhook = $state(null); - let webhookLoading = $state(false); - let webhookError = $state(''); - let regenerating = $state(false); + // Workload-side webhook UI was removed in the hard legacy cutover — + // inbound webhooks are now first-class Triggers. Use the bindings + // panel + the /triggers detail page to manage the webhook URL. // ── Logs viewer ──────────────────────────────────────────── let logContainerRowID = $state(null); @@ -501,18 +499,6 @@ } } - async function loadWebhook() { - webhookLoading = true; - webhookError = ''; - try { - webhook = await api.getWorkloadWebhook(id); - } catch (e) { - webhookError = e instanceof Error ? e.message : 'Failed to load webhook'; - } finally { - webhookLoading = false; - } - } - async function addEnv() { envError = ''; const key = newEnvKey.trim(); @@ -547,18 +533,6 @@ } } - async function regenerateWebhook() { - regenerating = true; - webhookError = ''; - try { - webhook = await api.regenerateWorkloadWebhook(id); - } catch (e) { - webhookError = e instanceof Error ? e.message : 'Failed to rotate secret'; - } finally { - regenerating = false; - } - } - async function deploy() { deploying = true; lastDeployMsg = ''; @@ -2231,57 +2205,9 @@

- -
-
-

Webhook.

- {#if !webhook} - - {/if} -
- {#if webhookError} -
ERR{webhookError}
- {/if} - {#if webhook} -

- Point your registry or CI here. The URL itself is the credential — treat it as a - secret. Rotate any time without disrupting deploys (the next call uses the new URL). -

-
- {webhook.webhook_url} - -
-
- - {webhook.has_signing_secret ? 'HMAC SIGNED' : 'UNSIGNED'} - - - {webhook.webhook_require_signature ? 'SIGNATURE REQUIRED' : 'SIGNATURE OPTIONAL'} - -
-
- -
- {/if} -
+ {/if} diff --git a/web/src/routes/containers/+page.svelte b/web/src/routes/containers/+page.svelte index 6600ff8..6852378 100644 --- a/web/src/routes/containers/+page.svelte +++ b/web/src/routes/containers/+page.svelte @@ -15,7 +15,6 @@ // client-side so the tab counters reflect the whole population, not the // current narrowed view (otherwise picking "Project" would show All=0). let allContainers = $state([]); - let refIDByWorkload = $state>({}); let loading = $state(true); let refreshing = $state(false); let error = $state(''); @@ -40,15 +39,9 @@ try { // Race-safety: keep the latest fetch's result and discard stragglers. const seq = ++loadSeq; - const [containers, workloads] = await Promise.all([ - api.listContainers({}), - api.listWorkloads() - ]); + const containers = await api.listContainers({}); if (seq !== loadSeq) return; allContainers = containers; - const map: Record = {}; - for (const wl of workloads) map[wl.id] = wl.ref_id; - refIDByWorkload = map; } catch (e) { error = e instanceof Error ? e.message : $t('containers.errLoad'); } finally { @@ -127,18 +120,11 @@ } function detailHref(c: ContainerView): string | undefined { - const refID = refIDByWorkload[c.workload_id]; - if (!refID) return undefined; - switch (c.workload_kind) { - case 'project': - return `/projects/${refID}`; - case 'stack': - return `/stacks/${refID}`; - case 'site': - return `/sites/${refID}`; - default: - return undefined; - } + // Legacy project / stack / site detail pages were retired with the + // hard cutover. The workload-first equivalent lives under /apps — + // every workload now belongs to an app, so the row deep-links to + // the app detail page when one is attached, otherwise stays flat. + return c.app_id ? `/apps/${c.app_id}` : undefined; } function tabClass(active: boolean): string { diff --git a/web/src/routes/containers/stale/+page.svelte b/web/src/routes/containers/stale/+page.svelte index 355a621..b7f4083 100644 --- a/web/src/routes/containers/stale/+page.svelte +++ b/web/src/routes/containers/stale/+page.svelte @@ -1,7 +1,6 @@ @@ -124,17 +136,124 @@ /> {:else}
- {#each containers as container (container.container.id)} - + {#each containers as entry (entry.container.id)} + {@const c = entry.container} + {@const cleaning = cleaningIds.has(c.id)} +
+
+
+ {entry.workload_name || c.workload_id || '—'} + {#if entry.role} + / {entry.role} + {/if} +
+ {entry.days_stale}d +
+
+
{$t('common.running')}
{c.state}
+
image
{c.image_ref}{c.image_tag ? ':' + c.image_tag : ''}
+ {#if c.last_seen_at} +
{$t('stale.lastAlive')}
{$fmt.dateTime(c.last_seen_at)}
+ {/if} +
+
+ +
+
{/each}
{/if}
+ + - import { inspectImage, quickDeploy, listProjects, listRegistries, listRegistryImages } from '$lib/api'; - import type { InspectResult, EntityPickerItem, Project } from '$lib/types'; - import FormField from '$lib/components/FormField.svelte'; - import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; - import EntityPicker from '$lib/components/EntityPicker.svelte'; - import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; - import ForgeHero from '$lib/components/ForgeHero.svelte'; - import { toasts } from '$lib/stores/toast'; - import { t } from '$lib/i18n'; - import { goto } from '$app/navigation'; - import { IconSearch, IconDeploy, IconLoader, IconCheck } from '$lib/components/icons'; - - let imageUrl = $state(''); - let inspecting = $state(false); - let deploying = $state(false); - let inspected = $state(false); - let inspectResult: InspectResult | null = $state(null); - - let projectName = $state(''); - let port = $state(''); - let stage = $state('dev'); - let subdomain = $state(''); - let envVars = $state(''); - let enableProxy = $state(true); - let autoDeploy = $state(false); - - let errors = $state>({}); - - // Duplicate detection state - let conflictProjects = $state([]); - let showConflictDialog = $state(false); - - // Image picker state - let showImagePicker = $state(false); - let imagePickerItems = $state([]); - let imagePickerLoading = $state(false); - - async function handleBrowseImages() { - showImagePicker = true; - if (imagePickerItems.length > 0) return; - - imagePickerLoading = true; - try { - const registries = await listRegistries(); - const items: EntityPickerItem[] = []; - for (const reg of registries) { - if (!reg.owner) continue; - try { - const images = await listRegistryImages(reg.id); - for (const img of images) { - items.push({ - value: img.full_ref + ':latest', - label: img.full_ref, - description: reg.name, - group: reg.name - }); - } - } catch { - // Skip registries that fail. - } - } - imagePickerItems = items; - } catch { - toasts.error($t('quickDeploy.imageLoadFailed')); - } finally { - imagePickerLoading = false; - } - } - - function selectPickedImage(value: string) { - imageUrl = value; - showImagePicker = false; - } - - function validateImageUrl(url: string): string { - if (!url.trim()) return $t('validation.required', { field: 'Image URL' }); - if (!/^[a-zA-Z0-9._\-/]+:[a-zA-Z0-9._\-]+$/.test(url.trim())) { - return $t('validation.invalidUrl'); - } - return ''; - } - - function validatePort(value: string | number): string { - const s = String(value ?? ''); - if (!s.trim()) return $t('validation.required', { field: 'Port' }); - const num = parseInt(s, 10); - if (isNaN(num) || num < 1 || num > 65535) return $t('validation.invalidPort'); - return ''; - } - - function validateProjectName(value: string): string { - if (!value.trim()) return $t('validation.required', { field: 'Project name' }); - if (value.trim().length > 1 && !/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(value.trim())) { - return $t('validation.invalidProjectName'); - } - return ''; - } - - function validateAll(): boolean { - const newErrors: Record = {}; - const nameErr = validateProjectName(projectName); - if (nameErr) newErrors.projectName = nameErr; - const portErr = validatePort(port); - if (portErr) newErrors.port = portErr; - errors = newErrors; - return Object.keys(newErrors).length === 0; - } - - function deriveProjectName(image: string): string { - const withoutTag = image.split(':')[0] ?? image; - const segments = withoutTag.split('/'); - return (segments[segments.length - 1] ?? 'unknown').toLowerCase().replace(/[^a-z0-9\-]/g, '-'); - } - - async function handleInspect() { - const urlError = validateImageUrl(imageUrl); - if (urlError) { - errors = { imageUrl: urlError }; - return; - } - errors = {}; - inspecting = true; - try { - const result = await inspectImage(imageUrl.trim()); - inspectResult = result; - projectName = deriveProjectName(result.image); - port = result.port?.toString() ?? ''; - // Healthcheck auto-detected but not shown — user can configure later on project page. - stage = 'dev'; - subdomain = ''; - envVars = ''; - inspected = true; - toasts.success($t('quickDeploy.inspectedSuccess')); - } catch (err) { - const message = err instanceof Error ? err.message : $t('quickDeploy.inspectFailed'); - toasts.error(message); - } finally { - inspecting = false; - } - } - - async function handleDeploy(force = false) { - if (!validateAll()) return; - deploying = true; - try { - const result = await quickDeploy({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10), force, enable_proxy: enableProxy, auto_deploy: autoDeploy }); - toasts.success($t('quickDeploy.deployedSuccess', { name: projectName })); - // Redirect to the new project page. - if (result.project?.id) { - goto(`/projects/${result.project.id}`); - } else { - imageUrl = ''; - inspected = false; - inspectResult = null; - projectName = ''; - port = ''; - stage = 'dev'; - subdomain = ''; - envVars = ''; - } - } catch (err: unknown) { - // Handle 409 Conflict — existing project with same image. - if (err instanceof Error && 'status' in err && (err as any).status === 409) { - try { - // Find existing projects with the same image. - const allProjects = await listProjects(); - const imageBase = imageUrl.trim().split(':')[0]; - const matching = allProjects.filter(p => p.image === imageBase || p.image === imageUrl.trim()); - if (matching.length > 0) { - conflictProjects = matching; - showConflictDialog = true; - return; - } - } catch { /* fall through */ } - toasts.error($t('quickDeploy.imageAlreadyExists')); - } else { - const message = err instanceof Error ? err.message : $t('quickDeploy.deployFailed'); - toasts.error(message); - } - } finally { - deploying = false; - } - } - - async function handleDeployToExisting(project: Project) { - showConflictDialog = false; - conflictProjects = []; - goto(`/projects/${project.id}`); - } - - async function handleForceNewProject() { - showConflictDialog = false; - conflictProjects = []; - await handleDeploy(true); - } - - - - {$t('quickDeploy.title')} - {$t('app.name')} - - -
- - - -
-

{$t('quickDeploy.step1')}

-
-
- -
-
- - -
-
- { showImagePicker = false; }} - /> -
- - - {#if inspected} -
-

{$t('quickDeploy.step2')}

-

{$t('quickDeploy.reviewDesc')}

- -
- - -
- - -

{$t('quickDeploy.stageHelp')}

-
- -
- -
- -
- -
-
- - {$t('projectDetail.enableProxy')} -
-
- - {$t('quickDeploy.autoDeployLabel')} -
-
-
- - -
-

{$t('quickDeploy.step3')}

-

{$t('quickDeploy.deployDesc')}

-
- - -
-
- {/if} -
- - -{#if showConflictDialog} - -
- -
{ if (e.key === 'Escape') showConflictDialog = false; }} - onclick={(e) => e.stopPropagation()} - class="w-full max-w-lg rounded-2xl bg-[var(--surface-card)] p-6 shadow-xl animate-scale-in" - > -

- {$t('quickDeploy.imageAlreadyExists')} -

-

- {$t('quickDeploy.conflictDescription')} -

- -
- {#each conflictProjects as project (project.id)} - - {/each} -
- -
- - -
-
-
-{/if} diff --git a/web/src/routes/projects/+page.svelte b/web/src/routes/projects/+page.svelte deleted file mode 100644 index 95f4f14..0000000 --- a/web/src/routes/projects/+page.svelte +++ /dev/null @@ -1,302 +0,0 @@ - - - - {$t('projects.title')} - {$t('app.name')} - - -
- {#snippet heroToolbar()} - - {/snippet} - - - - {#if showAddForm} -
-

{$t('projects.newProject')}

- - {#if formError} -
-

{formError}

-
- {/if} - -
- -
-
- -
- -
- { showImagePicker = false; }} - /> - - -
- -
- -
-
- {/if} - - - {#if loading} - - {:else if error} -
-

{error}

- -
- {:else if projects.length === 0} - { showAddForm = true; }} - icon="projects" - /> - {:else} - -
- - -
- - {#if filteredProjects.length === 0} -
-

{$t('projects.noMatchingProjects')}

-
- {:else} -
- - - - - - - - - - - - - {#each filteredProjects as project (project.id)} - - - - - - - - - {/each} - -
{$t('projects.name')}{$t('projects.image')}{$t('projects.port')}{$t('projects.registry')}{$t('projects.created')}
- - {project.name} - - - {project.image} - - {project.port || '-'} - - {project.registry || '-'} - - {$fmt.date(project.created_at)} - - - {$t('projects.view')} - -
-
- {/if} - {/if} -
diff --git a/web/src/routes/projects/[id]/+page.svelte b/web/src/routes/projects/[id]/+page.svelte deleted file mode 100644 index e6e7452..0000000 --- a/web/src/routes/projects/[id]/+page.svelte +++ /dev/null @@ -1,915 +0,0 @@ - - - - {project?.name ?? $t('common.project')} - {$t('app.name')} - - -{#if loading} -
-
-
- - - -
-
-
- {#each Array(4) as _} - - {/each} -
-
-{:else if error} -
-

{error}

- -
-{:else if project} - {@const p = project} -
- {#snippet projectToolbar()} - - {/snippet} - - - - - - -
- {#if editing} -
- - - - - -
- -
- - {#if editAccessListId > 0} - - {/if} -
-

{$t('projectDetail.accessListIdHelp')}

-
-
-
- - -
- {:else} -
-
-
-

{$t('projects.port')}

-

{project.port || 'Auto'}

-
-
-

{$t('projects.healthcheck')}

-

{project.healthcheck || 'Auto'}

-
-
-

{$t('projects.registry')}

-

{project.registry || '-'}

-
-
-

{$t('projects.created')}

-

{$fmt.date(project.created_at)}

-
-
- -
- {/if} -
- - -
-
-

{$t('projectDetail.stages')}

- -
- - {#if showAddStage} -
-
- - - - - -
-
- {$t('projectDetail.autoDeployLabel')} - -
-
- {$t('projectDetail.enableProxy')} - -
-
-
-
- -
-
- {/if} - - {#if stages.length === 0 && !showAddStage} -
- -
- {:else} -
- {#each stages as stage (stage.id)} - {@const stageInstances = instancesByStage[stage.id] ?? []} -
- - {#if editingStageId === stage.id} -
-
- - - - - -
-
- {$t('projectDetail.autoDeployLabel')} - -
-
- {$t('projectDetail.enableProxy')} - -
-
-
-
- -
-
- - -
- -
- api.getStageNotificationSecret(projectId, stage.id)} - regenerateSecret={() => api.regenerateStageNotificationSecret(projectId, stage.id)} - disableSigning={() => api.disableStageNotificationSigning(projectId, stage.id)} - sendTest={() => api.testStageNotification(projectId, stage.id)} - /> -
-
- {:else} -
-
-

{stage.name}

- {stage.tag_pattern} - {#if stage.auto_deploy} - {$t('projectDetail.autoDeploy')} - {/if} - {#if stage.confirm} - {$t('projectDetail.requiresConfirm')} - {/if} - {#if !stage.enable_proxy} - {$t('projectDetail.noProxy')} - {/if} -
-
- - {stageInstances.length} / {stage.max_instances} {$t('projectDetail.instances')} - - - - -
-
- {/if} - - - {#if deployStageId === stage.id && deployTag} -
-
- {$t('projectDetail.deployTag')}: - {deployTag} - -
- - -
-
- {#if deployError} -

{deployError}

- {/if} -
- {/if} - - -
- {#if stageInstances.length === 0} -

{$t('projectDetail.noInstancesRunning')}

- {:else} -
- {#each stageInstances as instance (instance.id)} - - {/each} -
- {/if} -
-
- {/each} -
- {/if} -
- - - {#if localImages.length > 0} -
-

{$t('projectDetail.localImages')}

-
- - - - - - - - - - - {#each localImages as img (img.id + img.tag)} - - - - - - - {/each} - -
{$t('projectDetail.imageTag')}{$t('projectDetail.imageId')}{$t('projectDetail.imageSize')}{$t('projectDetail.imageCreated')}
- {img.tag || 'untagged'} - {img.id.substring(7, 19)}{(img.size / (1024 * 1024)).toFixed(1)} MB{$fmt.date(img.created)}
-
-
- {/if} - - - api.getProjectWebhook(projectId)} - regenerateWebhook={() => api.regenerateProjectWebhook(projectId)} - regenerateSigningSecret={() => api.regenerateProjectSigningSecret(projectId)} - disableSigning={() => api.disableProjectSigningSecret(projectId)} - setRequireSignature={(require) => api.setProjectRequireSignature(projectId, require)} - /> - - - api.listProjectWebhookDeliveries(projectId, signal)} /> - - - api.getProjectNotificationSecret(projectId)} - regenerateSecret={() => api.regenerateProjectNotificationSecret(projectId)} - disableSigning={() => api.disableProjectNotificationSigning(projectId)} - sendTest={() => api.testProjectNotification(projectId)} - /> - - -
-

{$t('projectDetail.recentDeploys')}

- - {#if deploys.length === 0} -

{$t('projectDetail.noDeployHistory')}

- {:else} -
- {#each deploys as deploy (deploy.id)} -
- -
-
-
- -
-
- {deploy.image_tag} - -
-
- {#if deploy.started_at} - - - {$fmt.dateTime(deploy.started_at)} - - {/if} - {#if deploy.finished_at} - → {$fmt.dateTime(deploy.finished_at)} - {/if} -
- {#if deploy.error} -

{deploy.error}

- {/if} -
-
- {/each} -
- {/if} -
-
- - { showDeleteConfirm = false; }} - /> - - { - const target = stageDeleteTarget; - stageDeleteTarget = null; - if (target) await handleDeleteStage(target.id, target.name); - }} - oncancel={() => { stageDeleteTarget = null; }} - /> - - { accessListPickerOpen = false; }} - /> - - { tagPickerOpen = false; }} - /> -{/if} diff --git a/web/src/routes/projects/[id]/env/+page.svelte b/web/src/routes/projects/[id]/env/+page.svelte deleted file mode 100644 index fee6978..0000000 --- a/web/src/routes/projects/[id]/env/+page.svelte +++ /dev/null @@ -1,471 +0,0 @@ - - - - {$t('envEditor.title')} - {$t('app.name')} - - -
- - - {#if loading} -
- - -
- {:else if error} -
-

{error}

-
- {:else} - - {#if stages.length === 0} - - {:else} -
-

{$t('envEditor.projectDefaults')}

-
- - - - - - - - - - - {#each Object.entries(projectEnv) as [key, val] (key)} - {#if editingProjectKey === key} - - - - - - - {:else} - - - - - - - {/if} - {/each} - - - - - - - - - -
{$t('envEditor.key')}{$t('envEditor.value')}{$t('envEditor.source')}{$t('envEditor.actions')}
{key} - - -
- - -
-
{key}{val} - {#if isOverridden(key)} - {$t('envEditor.overridden')} - {:else} - {$t('envEditor.inherited')} - {/if} - -
- - -
-
- - - - - -
-
- {#if Object.keys(projectEnv).length === 0} -

{$t('envEditor.noProjectEnv')}

- {/if} -
- - -
-
-

{$t('envEditor.stageOverrides')}

- -
- - {#if envLoading} -
- - {$t('common.loading')} -
- {:else} -
- - - - - - - - - - - - {#each envVars as env (env.id)} - {#if editingId === env.id} - - - - - - - - {:else} - - - - - - - - {/if} - {/each} - - - - - - - - - - -
{$t('envEditor.key')}{$t('envEditor.value')}{$t('envEditor.secret')}{$t('envEditor.source')}{$t('envEditor.actions')}
- - - - - - -
- - -
-
{env.key} - {env.encrypted ? '••••••••' : env.value} - - {#if env.encrypted} - - - {$t('envEditor.secret')} - - {/if} - - {#if env.key in projectEnv} - {$t('envEditor.overridesProject')} - {:else} - {$t('envEditor.stageOnly')} - {/if} - -
- - -
-
- - - - - - - -
-
- {/if} -
- {/if} - {/if} -
- - { - const envId = envDeleteTarget; - envDeleteTarget = null; - if (envId) await handleDelete(envId); - }} - oncancel={() => { envDeleteTarget = null; }} -/> - - { - const key = projectEnvDeleteTarget; - projectEnvDeleteTarget = null; - if (key) await handleDeleteProjectEnv(key); - }} - oncancel={() => { projectEnvDeleteTarget = null; }} -/> diff --git a/web/src/routes/projects/[id]/volumes/+page.svelte b/web/src/routes/projects/[id]/volumes/+page.svelte deleted file mode 100644 index cb998d3..0000000 --- a/web/src/routes/projects/[id]/volumes/+page.svelte +++ /dev/null @@ -1,324 +0,0 @@ - - - - {$t('volumeEditor.title')} - {$t('app.name')} - - -
- - - - {#if scopes.length > 0 && !loading} -
-
- -

{$t('volumeEditor.scopeGuide')}

-
-
- {#each scopes as scope} -
- {scopeLabel(scope.scope)} -
-

{scope.description}

-

{scope.path_example}

-
-
- {/each} -
-
- {/if} - - {#if loading} -
- -
- {:else if error} -
-

{error}

- -
- {:else} -
- - - - - - - - - - - - {#each volumes as vol (vol.id)} - {#if editingId === vol.id} - - - - - - - - {:else} - - - - - - - - {/if} - {/each} - - - - - - - - - - -
{$t('volumeEditor.sourceHost')}{$t('volumeEditor.targetContainer')}{$t('volumeEditor.scope')}{$t('volumeEditor.nameColumn')}{$t('volumeEditor.actions')}
- {#if editScopeIsEphemeral} - {$t('volumeEditor.tmpfs')} - {:else} - - {/if} - - - - - - {#if editScopeNeedsName} - - {:else} - - {/if} - -
- - -
-
- {#if vol.scope === 'ephemeral'} - {$t('volumeEditor.tmpfs')} - {:else} - {vol.source} - {/if} - {vol.target} - {scopeLabel(vol.scope)} - - {vol.name || '—'} - -
- - -
-
- {#if newScopeIsEphemeral} - {$t('volumeEditor.tmpfs')} - {:else} - - {/if} - - - - - - {#if newScopeNeedsName} - - {:else} - - {/if} - - -
-
- - {#if volumes.length === 0} -

{$t('volumeEditor.noVolumes')}

- {/if} - {/if} -
- - { - const volId = volumeDeleteTarget; - volumeDeleteTarget = null; - if (volId) await handleDelete(volId); - }} - oncancel={() => { volumeDeleteTarget = null; }} -/> diff --git a/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte b/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte deleted file mode 100644 index f8e3c7a..0000000 --- a/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte +++ /dev/null @@ -1,233 +0,0 @@ - - - - {$t('volumeBrowser.title')} - {$t('app.name')} - - -
- {#snippet browserToolbar()} - - - {/snippet} - - - - - - {#if loading} - - {:else if error} -
-

{error}

- -
- {:else if entries.length === 0} -
-

{$t('volumeBrowser.empty')}

-
- {:else} -
- - - - - - - - - - {#if currentPath} - { - const parts = currentPath.split('/').filter(Boolean); - parts.pop(); - navigateTo(parts.join('/')); - }}> - - - - - {/if} - {#each entries.sort((a, b) => { - if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; - return a.name.localeCompare(b.name); - }) as entry (entry.name)} - handleEntryClick(entry)} - > - - - - - {/each} - -
{$t('volumeBrowser.name')}{$t('volumeBrowser.size')}{$t('volumeBrowser.modified')}
- 📁.. -
- {fileIcon(entry)} - {#if entry.is_dir} - {entry.name} - {:else} - {entry.name} - {/if} - - {entry.is_dir ? '—' : formatSize(entry.size)} - - {$fmt.compact(entry.mod_time)} -
-
- {/if} -
diff --git a/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.ts b/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.ts deleted file mode 100644 index a3d1578..0000000 --- a/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.ts +++ /dev/null @@ -1 +0,0 @@ -export const ssr = false; diff --git a/web/src/routes/proxies/+page.svelte b/web/src/routes/proxies/+page.svelte index 9909c6f..e6d7829 100644 --- a/web/src/routes/proxies/+page.svelte +++ b/web/src/routes/proxies/+page.svelte @@ -48,8 +48,13 @@ : 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)] dark:text-[var(--color-brand-200)]'; } + // Legacy /projects/{id} and /sites/{id} routes were retired with the + // hard cutover. Proxy rows now point at the workload-first containers + // page filtered by name; the app deep-link is not available because + // proxy_route rows don't carry an app_id today. function targetHref(route: ProxyRoute): string { - return route.source === 'static_site' ? `/sites/${route.instance_id}` : `/projects/${route.project_id}`; + const q = encodeURIComponent(route.project_name ?? ''); + return q ? `/containers?q=${q}` : '/containers'; } async function loadRoutes() { diff --git a/web/src/routes/sites/+page.svelte b/web/src/routes/sites/+page.svelte deleted file mode 100644 index 3dbdb3f..0000000 --- a/web/src/routes/sites/+page.svelte +++ /dev/null @@ -1,271 +0,0 @@ - - - - {$t('sites.title')} - {$t('app.name')} - - -
- {#snippet heroToolbar()} - - - {$t('sites.addSite')} - - {/snippet} - - - {#if loading} - - {:else if error} -
-

{error}

- -
- {:else if sites.length === 0} - { window.location.href = '/sites/new'; }} - /> - {:else} - -
- - -
- - {#if filteredSites.length === 0} -
-

{$t('sites.noMatching')}

-
- {:else} -
- - - - - - - - - - - - - {#each filteredSites as site (site.id)} - {@const status = statusBadge(site.status)} - {@const mode = modeBadge(site.mode)} - - - - - - - - - {/each} - -
{$t('sites.name')}{$t('sites.domain')}{$t('sites.mode')}{$t('sites.status')}{$t('sites.lastSync')}
- - {site.name} - -

{site.repo_owner}/{site.repo_name}

-
- {#if site.domain} - - {site.domain} - - {:else} - - - {/if} - - - {mode.text} - - - - {status.text} - - {#if site.error} -

{site.error}

- {/if} -
- {#if site.last_sync_at} - {$fmt.dateTime(site.last_sync_at)} - {:else} - - - {/if} - -
- - {#if site.status === 'stopped'} - - {:else if site.status === 'deployed'} - - {/if} - -
-
-
- {/if} - {/if} -
- -{#if confirmDelete} - { confirmDelete = null; }} - /> -{/if} diff --git a/web/src/routes/sites/[id]/+page.svelte b/web/src/routes/sites/[id]/+page.svelte deleted file mode 100644 index df02296..0000000 --- a/web/src/routes/sites/[id]/+page.svelte +++ /dev/null @@ -1,484 +0,0 @@ - - - - {site?.name ?? $t('sites.title')} - {$t('app.name')} - - -
- {#if loading} -
- - {$t('common.loading')} -
- {:else if error && !site} -
-

{error}

-
- {:else if site} - {@const s = site} - {#snippet siteToolbar()} - - {#if s.status === 'stopped'} - - {:else if s.status === 'deployed'} - - {/if} - {#if s.domain} - - - {$t('sites.openSite')} - - {/if} - - {/snippet} - - - {#if error} -
-

{error}

-
- {/if} - - -
- -
-

{$t('sites.siteInfo')}

-
- {$t('sites.status')} - - {statusBadge(site.status).text} - - - {$t('sites.mode')} - {site.mode === 'deno' ? 'Deno (Static + API)' : 'Static (Nginx)'} - - {$t('sites.domain')} - {site.domain || '-'} - - {$t('sites.folder')} - {site.folder_path || '/ (root)'} - - {$t('sites.syncTrigger')} - {site.sync_trigger}{site.sync_trigger === 'tag' ? ` (${site.tag_pattern})` : ''} - - {$t('sites.lastSync')} - {site.last_sync_at ? $fmt.dateTime(site.last_sync_at) : '-'} - - {$t('sites.commitSha')} - {site.last_commit_sha ? site.last_commit_sha.slice(0, 8) : '-'} - - {#if site.mode === 'deno' && site.storage_enabled} - {$t('sites.dataPath')} - /app/data - {/if} -
- - {#if site.error} -
-

{site.error}

-
- {/if} -
- - -
-
-

{$t('sites.secrets')}

- -
- - {#if showSecretForm} -
- - -
- - {$t('sites.encryptSecret')} -
- -
- {/if} - - {#if secrets.length === 0} -

{$t('sites.noSecrets')}

- {:else} -
- {#each secrets as secret (secret.id)} -
-
- {#if secret.encrypted} - - {:else} - - {/if} - {secret.key} - {secret.value} -
- -
- {/each} -
- {/if} -
- - - {#if site.container_id} -
-
-

{$t('resources.sectionTitle')}

- -
- -
- - {#if showLogs} - { showLogs = false; }} - /> - {/if} - {/if} - - - api.getStaticSiteWebhook(siteId!)} - regenerateWebhook={() => api.regenerateStaticSiteWebhook(siteId!)} - regenerateSigningSecret={() => api.regenerateStaticSiteSigningSecret(siteId!)} - disableSigning={() => api.disableStaticSiteSigningSecret(siteId!)} - setRequireSignature={(require) => api.setStaticSiteRequireSignature(siteId!, require)} - /> - - - api.listStaticSiteWebhookDeliveries(siteId!, signal)} /> - - -
-

{$t('sites.outgoingUrlTitle')}

-

{$t('sites.outgoingUrlDesc')}

-
-
- -
- -
-
- - - api.getStaticSiteNotificationSecret(siteId!)} - regenerateSecret={() => api.regenerateStaticSiteNotificationSecret(siteId!)} - disableSigning={() => api.disableStaticSiteNotificationSigning(siteId!)} - sendTest={() => api.testStaticSiteNotification(siteId!)} - /> -
- - - {#if site.storage_enabled && site.mode === 'deno'} -
-

{$t('sites.storage')}

-
- {$t('sites.storageVolume')} - tinyforge-site-{site.name}-data - - {$t('sites.storageMountPath')} - /app/data - - {$t('sites.storageLimit')} - {site.storage_limit_mb > 0 ? `${site.storage_limit_mb} MB` : $t('sites.unlimited')} - - {$t('sites.storageUsed')} - - {#if storageUsage} - {storageUsage.used_bytes < 1024 ? `${storageUsage.used_bytes} B` : storageUsage.used_bytes < 1048576 ? `${(storageUsage.used_bytes / 1024).toFixed(1)} KB` : `${(storageUsage.used_bytes / 1048576).toFixed(1)} MB`} - {:else} - - - {/if} - -
- - {#if storageUsage && site.storage_limit_mb > 0} - {@const pct = Math.min(100, (storageUsage.used_bytes / (site.storage_limit_mb * 1048576)) * 100)} -
-
-
-
-

{pct.toFixed(1)}% {$t('sites.storageOfLimit')}

-
- {/if} -
- {/if} - {/if} -
- -{#if confirmDelete} - { confirmDelete = false; }} - /> -{/if} - -{#if confirmDeleteSecretId} - s.id === confirmDeleteSecretId)?.key}"?`} - confirmLabel={$t('common.delete')} - onconfirm={handleDeleteSecret} - oncancel={() => { confirmDeleteSecretId = null; }} - /> -{/if} diff --git a/web/src/routes/sites/new/+page.svelte b/web/src/routes/sites/new/+page.svelte deleted file mode 100644 index 40dd00e..0000000 --- a/web/src/routes/sites/new/+page.svelte +++ /dev/null @@ -1,702 +0,0 @@ - - - - {$t('sites.newSite')} - {$t('app.name')} - - -
- - - -
- {#each Array(totalSteps) as _, i} -
- {/each} -
- -
- - {#if step === 1} -

{$t('sites.step1Title')}

- -
- -
- -
- {#each providerOptions as opt} - - {/each} -
- {#if provider === '' && detectedProvider} -

- {$t('sites.detectedProvider')}: {providerOptions.find(o => o.value === detectedProvider)?.label ?? detectedProvider} -

- {/if} -
- - - { - const val = (e.target as HTMLInputElement).value; - if (val.includes('/') && val.startsWith('http')) { - parseRepoUrl(val); - autoDetectProvider(); - } - }} - /> - - - -
- -
-
- -
- -
-
- { showRepoPicker = false; }} - /> - - - {#if connectionError} -
-

{connectionError}

-
- {/if} - {#if connectionTested} -
- -

{$t('sites.connectionSuccess')}

-
- {/if} -
- -
- - -
- - - {:else if step === 2} -

{$t('sites.step2Title')}

- - {#if branchesLoading} -
- - {$t('sites.loadingBranches')} -
- {:else} -
-

{$t('sites.selectBranch')}

- - { selectedBranch = val; showBranchPicker = false; tree = []; }} - onclose={() => { showBranchPicker = false; }} - /> -
- {/if} - -
- - -
- - - {:else if step === 3} -

{$t('sites.step3Title')}

- - {#if treeLoading} -
- - {$t('sites.loadingTree')} -
- {:else} -

{$t('sites.selectFolder')}

- - - - -
- {#each getTopLevelFolders() as folder (folder.path)} - {@const isSelected = selectedFolder === folder.path} - {@const isExpanded = expandedDirs.has(folder.path)} - {@const children = getChildFolders(folder.path)} -
-
- {#if children.length > 0} - - {:else} - - {/if} - -
- {#if isExpanded} -
- {#each children as child (child.path)} - {@const childSelected = selectedFolder === child.path} - - {/each} -
- {/if} -
- {/each} -
- - {#if selectedFolder} -

{$t('sites.selectedFolder')}: {selectedFolder || '/'}

- {/if} - {/if} - -
- - -
- - - {:else if step === 4} -

{$t('sites.step4Title')}

- -
-
- - -
- - -
- -
- - -
-
- - -
- -
- {#each [ - { value: 'manual', label: $t('sites.triggerManual') }, - { value: 'push', label: $t('sites.triggerPush') }, - { value: 'tag', label: $t('sites.triggerTag') } - ] as opt} - - {/each} -
-
- - {#if syncTrigger === 'tag'} - - {/if} - - -
- - {$t('sites.renderMarkdown')} -
- - - {#if mode === 'deno'} -
- - {$t('sites.enableStorage')} -
- {#if storageEnabled} -
-

{$t('sites.storageHelp')}

- -
- {/if} - {/if} -
- -
- - -
- - - {:else if step === 5} -

{$t('sites.step5Title')}

- -
-
- {$t('sites.provider')} - {providerOptions.find(o => o.value === effectiveProvider)?.label ?? effectiveProvider} - - {$t('sites.repoUrl')} - {giteaUrl}/{repoOwner}/{repoName} - - {$t('sites.branch')} - {selectedBranch} - - {$t('sites.folder')} - {selectedFolder || '/ (root)'} - - {$t('sites.siteName')} - {siteName} - - {$t('sites.domain')} - {domain || '-'} - - {$t('sites.mode')} - {mode === 'deno' ? 'Deno (Static + API)' : 'Static (Nginx)'} - - {$t('sites.syncTrigger')} - {syncTrigger}{syncTrigger === 'tag' ? ` (${tagPattern})` : ''} - - {$t('sites.renderMarkdown')} - {renderMarkdown ? $t('common.yes') : $t('common.no')} - - {#if mode === 'deno'} - {$t('sites.storage')} - {storageEnabled ? (parseInt(storageLimitStr, 10) > 0 ? `${storageLimitStr} MB` : $t('sites.unlimited')) : $t('common.no')} - {/if} - - {$t('sites.accessToken')} - {accessToken ? '••••••••' : $t('sites.noToken')} -
-
- - {#if submitError} -
-

{submitError}

-
- {/if} - -
- - -
- {/if} -
-
diff --git a/web/src/routes/stacks/+page.svelte b/web/src/routes/stacks/+page.svelte deleted file mode 100644 index 11fafe0..0000000 --- a/web/src/routes/stacks/+page.svelte +++ /dev/null @@ -1,535 +0,0 @@ - - -
- {#snippet stacksToolbar()} - - - - {$t('stacks.newStack')} - - {/snippet} - {#snippet stacksStats()} -
{$t('stacks.total').toUpperCase()}
{loading ? '—' : String(stacks.length).padStart(2, '0')}
-
{$t('stacks.running').toUpperCase()}
{loading ? '—' : stacks.filter(s=>s.status==='running').length}
-
{$t('stacks.deploying').toUpperCase()}
{loading ? '—' : stacks.filter(s=>s.status==='deploying').length}
-
{$t('stacks.failed').toUpperCase()}
s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}
- {/snippet} - {#snippet stacksLede()}{@html $t('stacks.lede')}{/snippet} - - - {#if error} -
ERR{error}
- {/if} - - {#if loading} -
- {#each Array(3) as _, i} -
- {/each} -
- {:else if stacks.length === 0} -
-
- -
-

{$t('stacks.empty.title')}

-

{$t('stacks.empty.desc')}

- - {$t('stacks.newStack')} - -
- {:else} -
- {#each stacks as s, i (s.id)} - {@const sm = statusMeta(s.status)} -
- - - - - -
- [{String(i + 1).padStart(2, '0')} / {String(stacks.length).padStart(2, '0')}] - - - {sm.label} - -
- - {s.name} - {#if s.description} -

{s.description}

- {:else} -

{$t('stacks.card.noDescription')}

- {/if} - - {#if s.error} -
{s.error}
- {/if} - -
- {$t('stacks.card.updated')} - {$fmt.dateTime(s.updated_at)} -
- -
- {#if s.status === 'running'} - - {:else} - - {/if} - - {$t('stacks.card.open')} -
-
- {/each} -
- {/if} -
- - { confirmDelete = null; deleteRemoveVolumes = false; }} -/> - - diff --git a/web/src/routes/stacks/[id]/+page.svelte b/web/src/routes/stacks/[id]/+page.svelte deleted file mode 100644 index c4f20d2..0000000 --- a/web/src/routes/stacks/[id]/+page.svelte +++ /dev/null @@ -1,953 +0,0 @@ - - -
- - - - - {$t('stacks.title').toUpperCase()} - - - {#if loading && !stack} -
- - {$t('stacks.detail.loading')} -
- {:else if error && !stack} -
{$t('stacks.detail.err')}{error}
- {:else if stack} - {@const sm = statusMeta(stack.status)} -
-
- - THE FORGE - // - STACK - // - {stack.id.slice(0, 16)} - // - - {sm.label} - -
- -
-
-

{stack.name}

- {#if stack.description} -

{stack.description}

- {:else} -

{$t('stacks.detail.noDescription')}

- {/if} - - {$t('stacks.detail.composeProject')} - {stack.compose_project_name} - -
- -
- - {#if stack.status === 'running'} - - {:else} - - {/if} - -
-
- - {#if stack.error} -
- {$t('stacks.detail.fault')} - {stack.error} -
- {/if} -
- - -
-
- {$t('stacks.detail.stats.services')} - {String(services.length).padStart(2,'0')} - {$t('stacks.detail.stats.servicesSub')} -
-
- {$t('stacks.detail.stats.running')} - - {String(services.filter(s => serviceState(s.State) === 'running').length).padStart(2,'0')} - - {$t('stacks.detail.stats.runningSub')} -
-
- {$t('stacks.detail.stats.revisions')} - {String(revisions.length).padStart(2,'0')} - {$t('stacks.detail.stats.revisionsSub')} -
-
- {$t('stacks.detail.stats.current')} - - R{(revisions.find(r => r.id === stack?.current_revision_id)?.revision ?? 0).toString().padStart(2,'0')} - - {$t('stacks.detail.stats.currentSub')} -
-
- - -
-
-

{$t('stacks.detail.services.title')}.

- {$t('stacks.detail.services.count', { n: String(services.length) })} -
- {#if services.length === 0} -

{$t('stacks.detail.services.empty')}

- {:else} -
    - {#each services as svc (svc.Name)} - {@const st = serviceState(svc.State)} -
  • - -
    -
    {svc.Service}
    -
    {svc.Name}
    -
    -
    - {svc.State} - {svc.Status} -
    -
  • - {/each} -
- {/if} -
- - -
-
- - - -
- - {#if tab === 'yaml'} -
-
- {$t('stacks.detail.yaml.currentRevision')} - {#if !editing} - - {/if} -
- {#if editing} - -
- - -
- {:else if revisions[0]} -
-
- - docker-compose.yml -
-
{revisions[0].yaml}
-
- {/if} -
- {:else if tab === 'revisions'} -
-
    - {#each revisions as rev (rev.id)} -
  1. -
    -
    -
    - R{rev.revision.toString().padStart(2, '0')} - {#if rev.id === stack.current_revision_id} - {$t('stacks.detail.revisions.current')} - {/if} - {rev.status} - {$fmt.dateTime(rev.created_at)} -
    -
    - {$t('stacks.detail.revisions.by')} {rev.author || 'operator'} -
    - {#if rev.id !== stack.current_revision_id} - - {/if} -
    -
  2. - {/each} -
-
- {:else if tab === 'logs'} -
-
- - -
- {#if logsText} -
-
- - - - ~/forge/{stack.name}{logsService ? '/' + logsService : ''}.log -
-
{logsText}
-
- {:else} -

{$t('stacks.detail.logs.empty')}

- {/if} -
- {/if} -
- {/if} -
- - (confirmRollback = null)} -/> - - { confirmDelete = false; deleteRemoveVolumes = false; }} -/> - - diff --git a/web/src/routes/stacks/new/+page.svelte b/web/src/routes/stacks/new/+page.svelte deleted file mode 100644 index 03fd9df..0000000 --- a/web/src/routes/stacks/new/+page.svelte +++ /dev/null @@ -1,595 +0,0 @@ - - -
- - - - - {$t('stacks.new.back').toUpperCase()} - - -
- - - THE FORGE - // - NEW STACK - -

{$t('stacks.new.title')}

-

{@html $t('stacks.new.lede')}

-
- -
- - - - - - {#if error} -
{$t('stacks.detail.err')}{error}
- {/if} - -
- - -

{$t('stacks.new.nameHint')}

-
- -
- - -
- -
-
- 03 - {$t('stacks.new.composeYaml')} - {$t('stacks.new.required')} - - - - -
- - {#if !yaml} - - {/if} - -
-
- - docker-compose.yml -
-
- - -
-
- {$t('stacks.new.lines', { n: String(lineCount) })} - · - {$t('stacks.new.bytes', { n: String(byteCount) })} - · - YAML - -
-
-
- -
- - - {$t('stacks.new.deployImmediate')} - {$t('stacks.new.deployHint')} - -
- -
- {$t('stacks.new.cancel')} - -
-
-
- -