Compare commits

...
120 Commits
Author SHA1 Message Date
Tomáš Dvořák a0952faf25 Merge pull request #31 from Dvorinka/feat/template-catalog
feat(templates): catalog expansion — 5 new managed-DB stacks
2026-10-09 05:30:52 +02:00
Tomas Dvorak 999d719377 Merge remote-tracking branch 'origin/main' into feat/template-catalog 2026-10-09 05:30:27 +02:00
Tomáš Dvořák 8d682583ff Merge pull request #30 from Dvorinka/feat/env-doctor
feat(services): env doctor — empty vars, bad refs, unreadable secrets
2026-10-09 05:30:22 +02:00
Tomas Dvorak c80fbc766d Merge remote-tracking branch 'origin/main' into feat/env-doctor 2026-10-09 05:29:58 +02:00
Tomáš Dvořák 99ee4e79cf Merge pull request #29 from Dvorinka/feat/sleep-mode
feat(services): sleep mode — scale-to-zero on idle, wake on traffic
2026-10-09 05:29:53 +02:00
Tomas Dvorak f729ef2c82 Merge remote-tracking branch 'origin/main' into feat/sleep-mode 2026-10-09 05:29:29 +02:00
Tomáš Dvořák f90fc5f5ad Merge pull request #28 from Dvorinka/feat/templates-v2
feat(templates): v2 — multi-service graph templates, expressions, compose import
2026-10-09 05:29:24 +02:00
Tomas Dvorak f68225a97d Merge remote-tracking branch 'origin/main' into feat/templates-v2 2026-10-09 05:29:01 +02:00
Tomáš Dvořák 7536443610 Merge pull request #27 from Dvorinka/feat/backup-import
feat(databases): backup import — upload .tar.gz as a restore point
2026-10-09 05:28:55 +02:00
Tomas Dvorak 68da4236fa Merge remote-tracking branch 'origin/main' into feat/backup-import
# Conflicts:
#	docs/guides/CLI.md
2026-10-09 05:28:31 +02:00
Tomáš Dvořák b9441f3e11 Merge pull request #26 from Dvorinka/feat/backup-targets
feat(databases): S3-compatible backup targets — offsite archives + remote restore
2026-10-09 05:28:26 +02:00
Tomas Dvorak 605a43455d Merge remote-tracking branch 'origin/main' into feat/backup-targets 2026-10-09 05:28:03 +02:00
Tomáš Dvořák c7256954c2 Merge pull request #25 from Dvorinka/feat/db-public-port
feat: public_port for managed databases — opt-in 0.0.0.0 bind
2026-10-09 05:27:00 +02:00
Tomas Dvorak a50260e857 fix: resolve ROADMAP2 merge markers 2026-10-09 05:21:09 +02:00
Tomas Dvorak 3441acbc37 fix: resolve ROADMAP2 merge markers 2026-10-09 05:21:09 +02:00
Tomas Dvorak c5e0561add fix: resolve ROADMAP2 merge markers 2026-10-09 05:21:08 +02:00
Tomas Dvorak 1ef5ea5f7f fix: resolve ROADMAP2 merge markers 2026-10-09 05:21:08 +02:00
Tomas Dvorak d26452d97e fix: resolve ROADMAP2 merge markers 2026-10-09 05:21:08 +02:00
Tomas Dvorak 164a39c9c6 fix: resolve ROADMAP2 merge markers 2026-10-09 05:21:08 +02:00
Tomas Dvorak 63f65c78cc Merge remote-tracking branch 'origin/main' into feat/template-catalog
# Conflicts:
#	ROADMAP2.md
2026-10-09 05:20:47 +02:00
Tomas Dvorak d5960fdfff Merge remote-tracking branch 'origin/main' into feat/env-doctor
# Conflicts:
#	ROADMAP2.md
2026-10-09 05:20:47 +02:00
Tomas Dvorak 31c04ad51d Merge remote-tracking branch 'origin/main' into feat/sleep-mode
# Conflicts:
#	ROADMAP2.md
2026-10-09 05:20:46 +02:00
Tomas Dvorak 03f6dcc80d Merge remote-tracking branch 'origin/main' into feat/templates-v2
# Conflicts:
#	ROADMAP2.md
2026-10-09 05:20:46 +02:00
Tomas Dvorak 1a4ee99f5b Merge remote-tracking branch 'origin/main' into feat/backup-import
# Conflicts:
#	ROADMAP2.md
2026-10-09 05:20:46 +02:00
Tomas Dvorak 264f619be5 Merge remote-tracking branch 'origin/main' into feat/backup-targets
# Conflicts:
#	ROADMAP2.md
2026-10-09 05:20:46 +02:00
Tomas Dvorak 11525194b0 Merge remote-tracking branch 'origin/main' into feat/db-public-port 2026-10-09 05:20:45 +02:00
Tomáš Dvořák 9e3994e937 Merge pull request #34 from Dvorinka/feat/mobile-app
feat(mobile): Containr companion app (Expo)
2026-10-09 05:20:17 +02:00
Tomáš Dvořák 355eb2c9ab Merge pull request #24 from Dvorinka/feat/external-databases
feat: external databases — register, probe, encrypted credentials
2026-10-09 05:19:31 +02:00
Tomas Dvorak b5a1a8cc21 Merge remote-tracking branch 'origin/main' into feat/template-catalog
# Conflicts:
#	docs/guides/CLI.md
2026-10-09 05:12:38 +02:00
Tomas Dvorak 91f40a652d Merge remote-tracking branch 'origin/main' into feat/env-doctor
# Conflicts:
#	docs/guides/CLI.md
2026-10-09 05:12:38 +02:00
Tomas Dvorak 2be1715015 Merge remote-tracking branch 'origin/main' into feat/mobile-app 2026-10-09 05:12:38 +02:00
Tomas Dvorak b32e63368a Merge remote-tracking branch 'origin/main' into feat/sleep-mode
# Conflicts:
#	docs/guides/CLI.md
2026-10-09 05:12:37 +02:00
Tomas Dvorak f1a0f2189d Merge remote-tracking branch 'origin/main' into feat/templates-v2
# Conflicts:
#	docs/guides/CLI.md
2026-10-09 05:12:37 +02:00
Tomas Dvorak e3676bd89a Merge remote-tracking branch 'origin/main' into feat/backup-import
# Conflicts:
#	docs/guides/CLI.md
2026-10-09 05:12:37 +02:00
Tomas Dvorak 71c7d2b9f0 Merge remote-tracking branch 'origin/main' into feat/backup-targets
# Conflicts:
#	docs/guides/CLI.md
2026-10-09 05:12:19 +02:00
Tomas Dvorak e67a39436c Merge remote-tracking branch 'origin/main' into feat/external-databases 2026-10-09 05:12:01 +02:00
Tomas Dvorak 2c3d9fc123 Merge remote-tracking branch 'origin/main' into feat/db-public-port
# Conflicts:
#	docs/guides/CLI.md
2026-10-09 05:11:56 +02:00
Tomáš Dvořák 4734be0d9d Merge pull request #33 from Dvorinka/feat/release-mcp
ci: MCP release binaries + docs-only path gate
2026-10-09 05:11:14 +02:00
Tomáš Dvořák 4e8d364911 Merge pull request #32 from Dvorinka/feat/shared-vars
feat(vars): shared project variables — ${{shared.KEY}} refs
2026-10-09 05:11:08 +02:00
Tomáš Dvořák f53695be4f Merge pull request #23 from Dvorinka/feat/clone-move-registries
feat: service clone/move + private registry credentials (C4)
2026-10-09 05:10:27 +02:00
Tomas DvorakandDevin 925199633b feat(mobile): Containr companion app — Expo/RN thin client
Phone app v1 (Phase I): PAT auth against any instance, secure-store
credentials, dark UI matching the design system.

- Overview: service status counts, per-project health dots, deploy feed
- Services/Projects: status badges, drill-down detail
- Service detail: start/stop/restart/redeploy/sleep/wake, env-doctor
  issues, deployment history + rollback
- Logs: 300-line tail, 5s poll, stderr highlighted
- Databases: status, start/stop/restart, backup-now
- Settings: server info, disconnect

CI: mobile job (tsc + expo lint) gated on a dedicated app/mobile paths
filter so non-mobile PRs skip it entirely.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 05:08:48 +02:00
Tomas Dvorak 1054679ca2 Merge branch 'feat/shared-vars' into feat/mobile-app 2026-10-09 01:17:57 +02:00
Tomas DvorakandDevin 57f8b4f56e ci: release MCP binaries + path-gate heavyweight jobs
- build-cli.sh also builds containr-mcp-<os>-<arch> per platform; the
  existing containr-* asset glob in the release job picks them up
- paths-filter 'changes' job gates backend/frontend/smoke/docker on PRs —
  docs-only PRs skip ~10min of compute; skipped checks satisfy protection
- docker job requires success() explicitly (skipped parents would pass
  '!= failure'); release requires docker success to never release stale images

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 01:07:18 +02:00
Tomas Dvorak ace68da188 Merge remote-tracking branch 'origin/main' into feat/template-catalog 2026-10-09 01:04:41 +02:00
Tomas Dvorak 2d626f8ee8 Merge remote-tracking branch 'origin/main' into feat/env-doctor 2026-10-09 01:04:41 +02:00
Tomas Dvorak 6d3aff6d58 Merge remote-tracking branch 'origin/main' into feat/sleep-mode 2026-10-09 01:04:41 +02:00
Tomas Dvorak c60c31693b Merge remote-tracking branch 'origin/main' into feat/templates-v2 2026-10-09 01:04:41 +02:00
Tomas Dvorak 9fd34482e3 Merge remote-tracking branch 'origin/main' into feat/backup-import 2026-10-09 01:04:41 +02:00
Tomas Dvorak 541a4719d4 Merge remote-tracking branch 'origin/main' into feat/backup-targets 2026-10-09 01:04:41 +02:00
Tomas Dvorak dd1bbaf5a2 Merge remote-tracking branch 'origin/main' into feat/external-databases 2026-10-09 01:04:41 +02:00
Tomas Dvorak f22a464ad0 Merge remote-tracking branch 'origin/main' into feat/db-public-port 2026-10-09 01:04:41 +02:00
Tomas Dvorak 58424ed0ab Merge remote-tracking branch 'origin/main' into feat/clone-move-registries 2026-10-09 01:04:41 +02:00
Tomas DvorakandDevin 25fa8c47e6 feat(vars): project shared variables — ${{shared.KEY}} refs, CRUD, UI
- project_variables table (secret entries encrypted at rest, masked in API)
- resolveServiceEnv resolves shared.* before service lookup
- GET|PUT /projects/{id}/variables; CLI 'variables shared list|set|unset';
  MCP containr_shared_variables_{list,set}
- Settings tab → Shared Variables editor; env help text mentions shared refs

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 01:04:19 +02:00
Tomáš Dvořák b3da014d26 Merge pull request #22 from Dvorinka/feat/builder-resources
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
C3: builder selection, resource reservations, static builder
2026-10-09 01:03:19 +02:00
Tomas Dvorak f5f1ff34a0 Merge remote-tracking branch 'origin/main' into feat/template-catalog
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:19 +02:00
Tomas Dvorak bac317e0e6 Merge remote-tracking branch 'origin/main' into feat/env-doctor
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:17 +02:00
Tomas Dvorak 26d585ffb7 Merge remote-tracking branch 'origin/main' into feat/sleep-mode
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:16 +02:00
Tomas Dvorak f0df61b52e Merge remote-tracking branch 'origin/main' into feat/templates-v2
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:14 +02:00
Tomas Dvorak ffe66d7b3d Merge remote-tracking branch 'origin/main' into feat/backup-import
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:13 +02:00
Tomas Dvorak 121b33f36a Merge remote-tracking branch 'origin/main' into feat/backup-targets
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:11 +02:00
Tomas Dvorak 8a8b903cdf Merge remote-tracking branch 'origin/main' into feat/db-public-port
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:09 +02:00
Tomas Dvorak f83ad81f73 Merge remote-tracking branch 'origin/main' into feat/external-databases
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:08 +02:00
Tomas Dvorak 2049087ccd Merge remote-tracking branch 'origin/main' into feat/clone-move-registries
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:06 +02:00
Tomas Dvorak 01edb677e5 Merge remote-tracking branch 'origin/main' into feat/builder-resources
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:49:31 +02:00
Tomáš Dvořák b22ac401c8 Merge pull request #21 from Dvorinka/feat/service-domains
C2: multi-domain, DNS preflight, maintenance + basic-auth gates
2026-10-09 00:48:49 +02:00
Tomáš Dvořák ea96929435 Merge pull request #20 from Dvorinka/feat/service-volumes
C1: service volume mounts + admin volume inventory
2026-10-09 00:48:43 +02:00
Tomáš Dvořák 971773f80c Merge pull request #19 from Dvorinka/feat/deploy-queue
feat(deploy): pipeline maturity — queue, cancel, no-cache, secrets at rest
2026-10-09 00:48:37 +02:00
Tomáš Dvořák 7e99e41f8d Merge pull request #18 from Dvorinka/feat/agent-docs
docs: AGENTS.md + llms.txt
2026-10-09 00:48:32 +02:00
Tomáš Dvořák 6c5ff7be41 Merge pull request #17 from Dvorinka/feat/api-ergonomics
feat(api): error codes, idempotency keys, cursor pagination
2026-10-09 00:48:27 +02:00
Tomáš Dvořák 7b93506e2d Merge pull request #16 from Dvorinka/feat/mcp-server
feat(mcp): stdio MCP server exposing the full API as tools
2026-10-09 00:48:22 +02:00
Tomáš Dvořák f34fba165c Merge pull request #15 from Dvorinka/feat/cli-v2
feat(cli): v2 — full API coverage, profiles, --json/--yes, containr up
2026-10-09 00:48:16 +02:00
Tomas Dvorak be83467cc9 Merge branch 'feat/env-doctor' into feat/template-catalog 2026-10-09 00:46:36 +02:00
Tomas Dvorak c99b2df419 Merge branch 'feat/sleep-mode' into feat/env-doctor 2026-10-09 00:46:34 +02:00
Tomas DvorakandDevin a26c103eb4 fix(ui): add sleeping/waking to ServiceStatus union + sleeping badge class
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 00:46:32 +02:00
Tomáš Dvořák 6949c01464 Merge pull request #14 from Dvorinka/feat/personal-access-tokens
feat: personal access tokens with scoped bearer auth
2026-10-09 00:41:09 +02:00
Tomas Dvorak f22ae22375 Merge branch 'feat/service-volumes' into feat/service-domains 2026-10-09 00:40:13 +02:00
Tomas DvorakandDevin a4b6ea4bd2 fix(ui): volumes save — fill type/read_only defaults for generated type
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 00:40:12 +02:00
Tomas Dvorak 9f8eaed386 Merge branch 'feat/env-doctor' into feat/template-catalog 2026-10-09 00:37:10 +02:00
Tomas Dvorak 3360eedc29 Merge branch 'feat/sleep-mode' into feat/env-doctor 2026-10-09 00:37:09 +02:00
Tomas DvorakandDevin 79fb73afc8 fix(ui): sleep form — draft keyed by service id, no setState-in-effect
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 00:37:07 +02:00
Tomas DvorakandDevin 99a25cb10e feat(templates): catalog v2 — 5 new managed-DB stacks + seed validation test
Ghost+MySQL, Gitea+PostgreSQL, Nextcloud+PG+Redis, Paperless+PG+Redis,
Plausible+PG+ClickHouse. TestOfficialTemplatesAllParse parses every
seed, topo-sorts graphs, and dry-resolves with no unresolved refs.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 00:33:27 +02:00
Tomas DvorakandDevin d135e5fd4b feat(services): env doctor — surface empty vars, bad refs, unreadable secrets
GET /services/:id/env-check (read scope) reports, without exposing values:
- unresolved: ${{service.KEY}} refs that don't match a sibling service/env
- empty: variables with blank values
- unreadable: secrets whose ciphertext fails decryption (key rotation)

install.sh now generates SECRETS_KEY explicitly (was falling back to
JWT_SECRET — a rotation would orphan ciphertext). .env.example documents it.

Surfaces: CLI `services env-check`, MCP containr_services_env_check
(90 tools), OpenAPI path, and a warning banner in the service Variables
panel listing each issue.

Verified live: injected a bogus ${{nosuchservice.PORT}} var plus an
unreadable ciphertext row; the endpoint flagged both, ok=false.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 00:27:23 +02:00
Tomas DvorakandDevin dbc87bf34c feat(services): sleep mode — scale-to-zero on idle, wake on traffic
Services gain sleep_enabled + sleep_idle_minutes. A 30s sweeper tracks
container NetworkIO deltas; a flat counter for the configured window
scales the service to zero (status=sleeping, workload containers removed).

While slept, a busybox wake container takes over the service's Traefik
host rule at priority 100: it serves a self-reloading "waking" page and
calls the unauthenticated idempotent GET/POST /api/v1/internal/wake/:id,
which reconciles the service back to running. Wake placeholders carry a
dedicated label (containr.wake-for) so runtime state never counts them;
the sweeper prunes them once the workload is up. The wake URL resolves
the API via its container name when containerized, else the edge
network's IPAM gateway — bare-metal installs need HOST=0.0.0.0 for the
callback.

Surfaces: PUT fields on services, POST /services/:id/sleep|wake,
CLI --sleep/--sleep-idle + services sleep|wake, 2 MCP tools (89 total),
OpenAPI paths/schema, and a Settings > Sleep card with toggle, idle
minutes, and manual sleep/wake plus a Sleeping badge.

Verified live: idle sweep scaled whoami-web to zero; the wake container
served the page on the edge network and its callback woke the service
(container recreated, status running, placeholder pruned).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 00:22:28 +02:00
Tomas DvorakandDevin b4baa853b2 feat(templates): v2 — multi-service graph templates, expressions, compose import
Config v2 adds services[] to TemplateConfig: members declare key, type
(web|worker|cron|database), runtime, environment, volumes and depends_on.
Deploy topo-sorts the graph, creates databases first (managed DBs attached
to the project network under their key as DNS alias), then services with
resolved env, and enqueues a real deployment per service.

Expressions: {{secret}}, {{secret(N)}}, {{random:N}}, {{VAR}},
{{VAR:-default}} and {{service.<key>.<prop>}} with props host, port, url,
user, password, database. Credential-bearing refs (password/url) mark the
consuming variable secret — stored encrypted, masked in plan output.
Single-service template secret vars are now encrypted at rest too.

New endpoints: POST /templates/:id/plan (dry-resolve with missing/
unresolved reporting), POST /templates/import/compose (compose YAML →
v2 config; known db images become managed databases, build: flagged in
warnings), POST /templates/deploy (ad-hoc graph deploy — the compose
import companion).

Surfaces: CLI gains templates plan/import-compose/deploy-graph plus
--name/--var on deploy; 3 new MCP tools (88 total); OpenAPI + generated
frontend types; TemplatesPage shows stack members, deploys graphs without
a name field, renders the created resource list, and ships an
Import Compose modal feeding the template editor.

Seeded: WordPress + MySQL and n8n + PostgreSQL graph templates.

Partial-failure cleanup: resources created by a failed graph deploy are
removed (rows, containers, volumes).

Verified live: wordpress-stack deploy created a managed MySQL attached to
the project network as `db`; an inline pg+redis+nginx graph deployed with
DATABASE_URL=postgresql://app:<gen>@pg:5432/shop resolvable in-container.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 23:42:08 +02:00
Tomas DvorakandDevin 15045b8dd3 feat(databases): backup import — upload .tar.gz as a restore point
D5. POST /databases/:id/backups/import accepts a gzipped tar archive
(multipart `file` field or raw application/gzip body, 2 GiB ceiling),
stages it to a temp file, streams it into the backup volume via the
started holder container, and registers a completed backup row — a
first-class restore point that also ships offsite when a target is
assigned. External databases rejected.

Also fixes `containr databases restore` — it never sent database_id,
which the API requires, so every CLI restore failed validation.

CLI: databases import-backup <id> <file|-> (stdin support via new
client.DoRaw). UI: Import button in the backups row. OpenAPI + docs.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 23:07:27 +02:00
Tomas DvorakandDevin 109f79268f docs: refresh ROADMAP2 — mark shipped phases, add sleep-scale/install/env-doctor/CI-CD/phone-app scope
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 22:59:42 +02:00
Tomas DvorakandDevin 6f43bbf8f6 feat(databases): S3-compatible backup targets — offsite archives + remote restore
Phase D3. Managed databases can ship backup archives to a configured
S3-compatible destination (AWS S3, MinIO, Garage, …) while the local
copy remains intact.

- backup_targets table: owner-scoped endpoint/bucket/region/prefix +
  encrypted access/secret keys (enc:v1:, never returned, has_credentials)
- create/update probe bucket access before storing; empty keys on update
  keep stored credentials; delete refused (409) while referenced
- databases.backup_target_id assigns a target; external DBs rejected
- backup pipeline uploads via a holder container over docker cp streams;
  remote failure keeps the local archive (logged), row gets remote:true
- restore prefers the local archive; on a missing local copy it downloads
  the remote object into the backup volume first
- holder containers must be started before archive ops — docker cp does
  not resolve volume mounts on never-started (Created) containers, and
  removing the source mid-stream stalls the reader without EOF
- CLI: backup-targets list|add|update|remove|test + databases update
  --backup-target; MCP: 5 new tools; Settings UI card + per-database
  target picker + offsite badge on backups; OpenAPI + llms.txt + CLI.md

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 22:54:36 +02:00
Tomas DvorakandDevin b7131965b1 feat: public_port for managed databases — opt-in 0.0.0.0 bind
Managed databases have always published their port on 127.0.0.1 only.
public_port opts into binding on all interfaces so the database is
reachable off-host. Toggling via PUT recreates the container — the
named data volume survives.

- create accepts public_port; PUT /databases/{id} toggles it
- external rows reject the flag (no container exists)
- CLI: databases create --public, databases update --public/--public=false
- MCP: databases_update tool; UI: create checkbox + per-row toggle

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 22:19:29 +02:00
Tomas DvorakandDevin 5dc9b5f115 feat: external databases — register, probe, encrypted credentials
Databases hosted outside Containr can now be registered alongside
managed ones. The connection is probed before storing (protocol-level
for postgres/redis-family via real drivers, TCP dial for the rest),
credentials are encrypted at rest and never returned, and external rows
skip managed lifecycle actions, backups, and Docker metrics.

- POST /databases/register-external — probe + create
- POST /databases/test-connection — ad-hoc probe (ok/latency_ms/error)
- POST /databases/{id}/test-connection — re-probe stored credentials
- provider/external fields on database services; privileged reads get a
  composed connection URL with loopback hosts rewritten to the caller's
- CLI: databases register + test-connection; MCP: 3 new tools
- UI: managed/external toggle in the create modal, external badge and
  host display, per-row test button, backups section hidden
- Migration widens the plan CHECK to admit 'external'

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 22:09:42 +02:00
Tomas DvorakandDevin 3664eaed1a feat(services): clone/move ops + owner-scoped registry credentials (C4)
Service lifecycle: POST /services/:id/clone duplicates config, volumes,
domains, access gates, builder settings, and variables (secret ciphertext
copied verbatim); POST /services/:id/move re-parents to another owned
project. Both enforce ownership, reject name conflicts, and audit.

Registries: CRUD at /registries — host-matched pull credentials for
prebuilt-image deploys, encrypted at rest via secrets pkg, never returned
(has_password flag). Empty password on update keeps the stored credential;
host normalized (scheme/case/trailing slash). Credentials thread through
BuildConfig → BuildRequest → pull auth config, used only for the pull.

Surfaces: CLI services clone|move + registries group, 7 MCP tools,
OpenAPI paths/schemas, frontend Lifecycle card (clone/move/delete) and
Settings registries section, llms.txt + CLI.md.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 17:21:27 +02:00
Tomas DvorakandDevin 912f6f5142 feat(services): builder selection, resource reservations, static builder (C3)
services.builder picks the build strategy per service: auto detects from
repo contents (Dockerfile → railpack → nixpacks), explicit overrides force
a builder, and static builds assets in a node stage then serves them from
an nginx image (static_build_cmd/static_dir, port 80 default).

- cpu_reserve/memory_reserve → Docker CPU shares + MemoryReservation;
  cpu/memory stay hard caps (NanoCPUs/Memory). Empty string clears.
- app_settings.default_cpu/default_memory override service-create
  fallbacks; capacity_policy=block fails deploys whose requested memory
  exceeds node capacity (default warn logs via slog).
- Resource specs are validated — "512mb" fails instead of silently
  dropping the limit.
- Fixes: deployments.go hardcoded nixpacks (auto-detect was dead code);
  DockerfileBuilder passed the Dockerfile *path* as build-context content;
  BuildImage never drained the daemon stream so builds could abort and
  errors went unseen — stream is now consumed and errorDetail surfaced.
- CLI: --builder/--cpu-reserve/--memory-reserve/--static-cmd/--static-dir
  on create+update; MCP args on services create/update; UI Build &
  Resources card; OpenAPI + llms.txt + CLI.md.

Verified live: static build of a real repo → nginx image → deployed;
reserves reach HostConfig (shares=256, MemoryReservation=128Mi);
validation rejects bad specs; builder persists + clears.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 17:08:35 +02:00
Tomas DvorakandDevin e995c98d05 feat(services): multi-domain, DNS check, maintenance + basic-auth gates (C2)
service_domains table holds the full hostname set; services.domain stays
the derived default and is synced on every domain write. Runtime spec
carries all domains into a multi-Host Traefik rule plus access gates.

- GET|POST /services/:id/domains, DELETE + POST /:domain_id/default
- GET /services/:id/domains/check — DNS preflight vs public_ip/base_url
  (ok | wrong-target | pending), stamps last_checked_at
- maintenance_mode → Traefik redirectregex to /api/v1/maintenance
  (base_url/BASE_URL setting; falls back to noop@internal offline)
- basic_auth [{username,password}] → bcrypt htpasswd on the label;
  responses return usernames only
- First domain auto-defaults; domain="" via PUT clears all domains
- CLI: services domains list|add|remove|default|check, update
  --maintenance/--basic-auth; MCP: 5 domain tools + update args
- Settings UI: Domains card (CRUD + DNS check badges) + Access card
- Fixes latent C1 bug: PUT without volumes 500'd — nil []byte sent as
  bytea, not NULL; COALESCE needs untyped nil + ::jsonb cast

Verified live: domain CRUD + default sync, pending/wrong-target checks,
bcrypt htpasswd and both middlewares on the deployed container's labels.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 16:51:03 +02:00
Tomas DvorakandDevin 2f8f595d6e feat(services): volume mounts + admin volume inventory (C1)
services.volumes JSONB [{type: volume|bind, source, target, read_only}]
mapped into every replica's container config at deploy and reconcile
time, hashed into the runtime spec so mount changes trigger
reconciliation. Mounted via existing docker.HostConfig.Mounts.

- RuntimeSpec.Volumes + specHash + mount.Mount mapping in createReplica
- Create/update service accepts volumes; PUT replaces the whole list
- Replica-count reconcile now goes through the deploy queue — the last
  raw `go` reconcile left that could race a build
- GET /admin/volumes inventories node volumes with in_use flags;
  DELETE /admin/volumes/:name removes unused ones (409 on in-use)
- CLI: services update --volume/--bind/--clear-volumes, volumes
  list|delete; MCP: services_update + volumes_list/delete
- Settings UI: Volumes section with mount editor
- Migration normalizes the nullable baseline column to NOT NULL [].

Verified live: deploy applies mounts to the container, docker
auto-creates named volumes, in-use deletion is refused.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 16:38:46 +02:00
Tomas DvorakandDevin 747b714a02 feat(deploy): webhook queue+nocache, restart sweep, secrets at rest
- Webhook-triggered deploys now go through the per-service deploy queue
  and default to no_cache builds (dflow convention — prevents stale
  layer surprises on push).
- Boot sweep marks deployments interrupted by restart as failed instead
  of leaving phantom queued/building rows.
- is_secret environment variables are AES-GCM-encrypted at rest
  (enc:v1: prefix); key from SECRETS_KEY or JWT_SECRET fallback.
  Existing plaintext secrets encrypt on boot; deploy/exec paths decrypt
  transparently. Masked-echo writes preserve stored ciphertext.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 16:27:10 +02:00
Tomas DvorakandDevin 7839dc48a3 feat(deploy): per-service deploy queue, cancellable deployments, no-cache builds
- internal/deployqueue: serialized FIFO per service — concurrent deploys
  of one service no longer race (the container-name-conflict failure
  mode); different services still run in parallel.
- Deploys and rollbacks enqueue through the queue; a busy service
  returns status 'queued' and promotes when the active job finishes.
- POST /deployments/:id/cancel — queued jobs drop immediately
  (200 cancelled); active jobs get ctx cancelled so build/reconcile
  abort (202 cancelling, settles 'cancelled'); finished deployments
  return 409 NOT_CANCELLABLE.
- Redeploy and variables save now route through the same queue so they
  can't overlap a build; PUT /services/:id/variables accepts
  redeploy:true to apply env changes to running containers.
- no_cache on deployment create plumbs BuildRequest -> BuildOptions for
  nixpacks, dockerfile and railpack builders.
- CLI: deployments cancel, deploy --no-cache, variables set/unset
  --redeploy. MCP: containr_deployments_cancel; deploy/variables_set
  document no_cache/redeploy. UI: Cancel button + queued/cancelled
  badges on the deployments table. OpenAPI + types regenerated.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 16:23:28 +02:00
Tomas DvorakandDevin 84d58cf808 docs: AGENTS.md + llms.txt — agent-facing surface documentation
Repo-level AGENTS.md for coding agents (layout, conventions, verify
commands) and llms.txt describing the operator surface: PAT scopes,
CLI/MCP setup, error codes, idempotency, cursor pagination.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 15:40:04 +02:00
Tomas DvorakandDevin 18f78f00a3 feat(api): error codes, idempotency keys, cursor pagination
- Stable machine-readable error codes alongside the error message
  (UNAUTHENTICATED, INVALID_TOKEN, ADMIN_REQUIRED, SCOPE_INSUFFICIENT,
  IDEMPOTENCY_IN_PROGRESS); CLI renders "CODE: message".
- Idempotency middleware on authed/admin groups: POST requests with an
  Idempotency-Key header replay the first response for 24h (Redis-backed,
  caller-scoped); concurrent duplicates get 409. X-Idempotent-Replay
  header marks replays.
- Shared pagination helpers: ?cursor= (opaque, preferred) alongside
  legacy ?page= on projects and deployments lists; pagination meta
  includes next_cursor; invalid cursors fail soft to page 1.
- CLI --idempotency-key flag for safe scripted retries.
- OpenAPI updated: error envelope, cursor params, idempotency contract.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 15:38:36 +02:00
Tomas Dvorak 485e41a817 feat(mcp): stdio MCP server exposing the full API as tools
Add containr-mcp so MCP-compatible agents (Claude, Cursor, Devin)
operate the platform directly — ROADMAP2 phase A3. Reuses the CLI's
thin client and PAT auth; zero duplicated resource logic.

- ~60 containr_* tools across projects, services, deployments, logs,
  exec, variables, databases, cron, templates, nodes, scaling, ha,
  security, gateway, admin, notifications, tokens
- declarative tool specs → {param} path substitution, GET extras become
  query params, remaining args form the request body
- destructive tools require confirm:true and return isError without it
- results are pretty JSON; API error envelopes surface as isError
- configured via CONTAINR_API_URL + CONTAINR_TOKEN env; official
  modelcontextprotocol/go-sdk over StdioTransport

Verified over stdio: initialize + tools/list, auth_status returns the
token owner's identity, projects list, destructive refusal, token
creation through MCP.

Authored By: TDvorak <[email protected]>
2026-10-08 15:25:34 +02:00
Tomas Dvorak e51b20b2d4 feat(cli): v2 — full API coverage, profiles, --json/--yes, containr up
Rewrite the CLI as a thin client over the authenticated API so agents
and scripts reach every resource the UI does (ROADMAP2 phase A2).

- shared Client with bearer auth, error-envelope decoding, and stable
  exit codes: 0 ok / 1 error / 2 auth / 3 not-found / 4 validation /
  5 unreachable
- named profiles in ~/.containr.yaml (current_profile + profiles.*);
  resolution order --flag > env > profile > localhost default;
  CONTAINR_TOKEN / CONTAINR_API_URL / CONTAINR_PROFILE env support
- --json prints raw API responses; --yes skips confirmations;
  destructive commands refuse non-interactively without it
- auth login verifies the token against /user/profile before saving,
  interactive prompt via term.ReadPassword
- commands for every resource: tokens, projects, services, deploy,
  deployments (logs/rollback), logs --follow (SSE), exec, variables
  (set/unset merge with secret masking preserved), databases
  (backup/restore/download), cron, templates, nodes + agent tokens,
  scaling, ha, security, gateway, admin, notifications
- containr up: git remote + branch → create-or-reuse service → deploy

Verified live: login/status/tokens/projects/services, up creating a
service + deployment, non-TTY confirmation refusal, scope-capped admin
rejection (exit 2), revoke.

Authored By: TDvorak <[email protected]>
2026-10-08 15:19:31 +02:00
Tomas Dvorak 17e429b86b docs: ROADMAP2 — dflow successor plan and phased build
Deep analysis of dflow (~43 queue processors, ~30 collections, ~180
server actions) mapped against Containr, sectioned into phases A–G.
Phase A (agent spine) is in progress — this commit adds PATs.

Authored By: TDvorak <[email protected]>
2026-10-08 14:31:39 +02:00
Tomas Dvorak e881747388 feat: personal access tokens with scoped bearer auth
Add cnp_ personal access tokens so the CLI, MCP server, and external
agents authenticate without a browser session — the agent-first spine
from ROADMAP2 phase A. Modeled on agent_auth_tokens: raw token shown
once, only SHA-256 hash stored.

- user_tokens table (goose migration + sqlc), name/prefix/scope/expiry,
  owner-scoped revoke, last_used_at stamping
- middleware: cnp_ bearer resolution, read scope gates mutations with
  403 SCOPE_INSUFFICIENT, non-admin scopes cap is_admin even for admin
  owners — a token never outranks its scope or its owner
- GET/POST/DELETE /api/v1/user/tokens + OpenAPI + generated types
- Settings UI: create with scope/expiry pickers, one-time token display
  with copy, list with last-used/expiry, confirm-revoke
- docs: OPERATIONS.md section, README feature line

Verified end-to-end against live Postgres: create/list/revoke, read
scope blocks PUT (403), write PAT on admin owner blocked from admin
routes (403), admin scope works, revoked token returns 401.

Authored By: TDvorak <[email protected]>
2026-10-08 14:31:29 +02:00
Tomáš Dvořák f9ae5b962c chore: add FUNDING.yml — GitHub Sponsors button
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
2026-10-04 19:50:34 +02:00
Tomáš DvořákandDevin 15b5b0e6e7 fix: HA checks resolve service targets over project network; persist checks (#13)
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
- Health checks for local services dialed 127.0.0.1 from inside the backend
  container — the host's published port is unreachable there. Service-bound
  checks now resolve the service's DNS name on its project network (backend
  joins on demand), falling back to the service's stored port. A resolver
  failure surfaces as TARGET_UNRESOLVED instead of a fake loopback probe.
- Checks lived only in memory: a backend restart silently deleted every
  configured health check. ha_health_checks table + write-through on
  add/update/tick and hydrate-on-boot, matching the scaling-policy pattern.
- sqlc schema.sql gained the runtime columns added by later migrations —
  the stale snapshot already caused one silently-dropped-column bug.
- OpenAPI: runtime response now documents ports and health fields; types
  regenerated.
- Buttons no longer wrap ("+ new project" rendered on two lines).

Verified live: service check probes whoami-web:80 via DNS → healthy;
check survives a backend restart and rehydrates.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-04 09:37:47 +02:00
Tomáš DvořákandDevin 32b52d121f fix: e2e QA hardening — honest runtime state, stable public ports, read-only anon UI (#12)
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
* fix: e2e QA hardening — honest runtime URLs, health, and read-only anon UI

Backend:
- managed DB provisioning: attach DB containers to the platform network and
  probe the container IP, fixing the 127.0.0.1 reachability failure inside
  the backend container; log provisioning failures
- stop fabricating connection URLs when provisioning produced none
- template deploys now carry port + health_check into service runtime fields
- service health probed over the project network (backend joins it) instead
  of an in-container wget that fails on shell-less images
- runtime/public/connection URLs rewrite loopback to the request Host so
  links are usable for LAN/remote clients

Frontend:
- service Networking panel shows real runtime URLs/ports/health instead of
  hardcoded containr.dev + 8080->443
- anonymous views hide all mutation controls (new project, add service,
  group, context menu)
- project cards: remove decorative svcN labels; badge says DOWN only when
  zero services run, IDLE when empty, DEGRADED for partial
- mobile projects header no longer clips (fluid search width)
- unknown routes render a not-found page instead of silently redirecting

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: telemetry scope label, chunk split, remove stale vite artifacts

- /system/host now reports scope=host|docker-desktop-vm and the daemon OS;
  Projects dashboard labels cards VM.* when the daemon is a Desktop VM so
  memory/core numbers aren't mistaken for physical host values
- manualChunks: split @xyflow/react and recharts out of the main bundle;
  removes the >1MB chunk warning
- drop tracked vite.config.js/.d.ts build artifacts that shadowed the real
  vite.config.ts

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: persist service host port; demo-data scope for telemetry

Public URLs were ephemeral: every restart/redeploy rebound a random host
port, so endpoint links rotted. Persist published_port on the service and
prefer it for replica 0 on create. If the port is taken, fall back to
ephemeral and repersist the new assignment. Post-start the binding is
verified — Docker Desktop accepts a conflicting publish silently, which
previously left services running with no endpoint at all.

demo-data.ts gains the required scope field for HostMonitoring (CI fix).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-04 09:01:28 +02:00
Tomáš Dvořák d968eb2315 Merge pull request #11 from Dvorinka/feat/app-demo-polish
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
feat(app): unified demo mode + public-demo build for Vercel
2026-09-23 19:07:14 +02:00
Tomas Dvorak 38e0bc1802 merge: resolve main into feat/app-demo-polish 2026-09-23 19:06:42 +02:00
Tomáš Dvořák fe08f8d299 Merge pull request #10 from Dvorinka/feat/fullscreen-canvas
feat(canvas): fullscreen Railway-style workspace canvas
2026-09-23 19:02:55 +02:00
Tomáš Dvořák ad797a49c9 Merge pull request #9 from Dvorinka/feat/vercel-landing
feat: landing page + public template catalog
2026-09-23 19:02:47 +02:00
Tomas DvorakandDevin 6661357963 feat(canvas): shared 26px grid + collision-free node placement
Nodes snapped to the dot-grid lattice on drop; occupied spots resolve
to the nearest free lattice cell instead of stacking. Same protection
inside groups (sibling-aware), on inspector assign, and on ungroup.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:59:14 +02:00
Tomas DvorakandDevin 7fc6ed1215 feat(canvas): full service cockpit on left-click + real grouping
The inspector is now a proper service panel: address with copy, group
assignment (Ungrouped / any group / "+ New group" wrapping the node),
masked env vars with a deep link into the service's Variables tab, a
config grid deep-linking to Config, inferred connections, and the
lifecycle actions. ServiceDetailPage honours ?section= for the deep
links.

Grouping is first-class: assign via the inspector or drag nodes onto a
frame; members slot beneath existing ones and the frame grows only as
needed instead of clamping or ballooning. Fixed a drag bug where moving
a member inside its own group ejected it.

Context menus everywhere: right-click a node (existing actions), a
group frame (Rename via inline edit, Ungroup keeps children in place),
or empty pane (Add service, Add group here, Tidy layout, Fit view).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:50:38 +02:00
Tomas DvorakandDevin d9bfd8a2fd feat(canvas): fullscreen workspace outside the app shell
Move /projects/:id and its service detail route out of PlatformShell —
the project view now owns the entire viewport like Railway's canvas:
no sidebar, no global nav, just the slim project bar over the graph.
Service detail inherits the same chrome-free context with a breadcrumb
back to the canvas, and back/delete navigation preserves ?demo=1.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:20:05 +02:00
Tomas DvorakandDevin a23fa2a0cc fix(site): blinking title cursor in hero mockup, matching the app
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:12:59 +02:00
Tomas DvorakandDevin 1425618c92 feat(canvas): brand tech icons on services, matching site preview
Resolve a service's tech (postgres, redis, go, node, nginx, kafka,
~50 slugs) from its image/name and render the Simple Icons logo in
the tinted icon tile — canvas nodes, inspector, service detail
header, and database rows — with graceful fallback to type icons.
Demo services get resolvable images, ports, and domains; a Redis
Cache service joins Core Platform so the demo canvas mirrors the
landing preview (5 services, web/api/worker → api → postgres/redis).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:12:15 +02:00
Tomas DvorakandDevin 894192bf9b feat(site): drop live demo, add Railway-style canvas preview
Remove all links to the deleted containr-app deployment and replace
the static hero screenshot with a CSS/HTML product mockup: a project
canvas with five icon-branded service nodes, connected edges (one
animated deploying edge), env/zoom/minimap pills, status dots,
domains, and image tags. Mobile gets a two-column layout with its
own edge paths.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 17:56:43 +02:00
Tomas DvorakandDevin e825eb0d7c feat(site): wire landing to deployed app demo
- All CTAs (nav, hero, install note, gallery, catalog, footer) link to
  https://containr-app.vercel.app - the app frontend deployed as a
  public demo (VITE_PUBLIC_DEMO build).
- templates.html: per-template "Open in demo app" deep links via
  ?template=<id>; dead default API removed so the official snapshot
  renders instantly until an instance is configured with ?api=.
- Hero canvas screenshot updated to the fullscreen canvas build.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 17:41:43 +02:00
Tomas DvorakandDevin 5b0a8e0759 feat(app): unified demo mode + public-demo build for Vercel
- Central demo-mode helper (lib/demo-mode.ts): ?demo=1 query flag plus
  VITE_PUBLIC_DEMO build flag that makes every route demo for the
  public Vercel showcase.
- Single demo fixture source (lib/demo-data.ts): projects, services,
  builds, databases, cron, HA, security, host monitoring, templates.
  Fixes cross-page ID drift that broke project navigation.
- Demo support on Projects, HA, Security, Usage, Templates, Landing,
  Service detail; DemoRestricted gate on People/Settings/Audit/Admin.
- Demo-aware PlatformShell: nav filtering, env:demo, demo account menu,
  ?demo=1 preserved through internal links.
- TemplatesPage: selection moved into ?template= URL param for deep
  links; template fixtures shared via demo-data.
- requestJson returns {} for unparseable 200 bodies (SPA fallback) so
  envelope unwraps degrade instead of dereferencing null.
- Recharts ResponsiveContainer initialDimension kills -1 warnings.
- vercel.json: vite framework + SPA rewrites for direct deep links.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 17:38:02 +02:00
Tomas DvorakandDevin e57f0470eb feat(canvas): fullscreen Railway-style workspace canvas
Rebuild the project canvas as a full-viewport graph instead of a boxed
66vh panel. Slim workspace top bar now holds the view tabs, project meta
and actions; the canvas fills all remaining space.

- Floating glass controls: env filter + group pill (top-left), zoom out /
  live zoom % / zoom in / fit / tidy pill (bottom-left), dark MiniMap
  (bottom-right), hint chip, demo badge.
- Inspector panel: selecting a node opens a right-side glass panel with
  status, image, address, inferred connections incl. variable reasons,
  and deploy/restart/stop/delete actions. Esc or pane click closes.
- Auto-layout: dependency-depth layered layout (consumers left,
  datastores right) for first-visit placement and the Tidy action.
  Unpositioned services park right of existing bounds, not at (0,0).
- Nodes: fixed 240px width (was 210 vs 220-280 CSS mismatch), per-type
  icons/colors incl. cron, spinner icon while deploying, hover lift.
- Edges: lime bezier strokes, animated dashes while either endpoint is
  deploying, hover glow updated from stale purple to accent.
- Groups: off-brand crimson replaced with accent tokens, double-click
  title to rename inline.
- Keyboard: F fit, 0 reset zoom, +/- zoom, Esc deselect. Marquee select
  on Shift-drag. Double-click zoom disabled, dead connection drags off.
- Mobile: icon-only tabs, minimap/hint hidden under md.
- Retake docs/screenshots/canvas.png + site copy (1600x900 demo).

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 17:00:50 +02:00
Tomas DvorakandDevin 117b469fae feat: public template catalog + Vercel git integration
Backend: service_templates.is_public lets owners publish user templates to
the anonymous catalog (GET /api/v1/templates), mirroring Railway's
marketplace publish flow. Migration, sqlc queries, visibility rules, and
openapi.yaml updated; types regenerated.

App: template editor gains a "List in the public catalog" checkbox;
Public badge on catalog cards and detail view.

Site: new templates.html catalog page - search, category filters, Official
vs Community badges, detail modal with variables and copyable import JSON.
Fetches the live catalog from a reachable instance (?api= or localStorage
override) and falls back to templates.json, generated from SeedTemplates
via cmd/dumptemplates. Shared styles extracted to style.css.

Vercel: project connected to GitHub repo with rootDirectory=site;
.vercelignore limits CLI uploads to site/ so `vercel deploy --prod` from
repo root matches git builds.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 16:40:58 +02:00
Tomas DvorakandDevin 2b38406a63 feat(site): standalone landing page for Vercel
Static single-file page in site/ matching the app theme (dark #0c0d0f,
lime #b4e34a, self-hosted Inter + JetBrains Mono). Real product
screenshots, install one-liner with copy button, template marquee,
tabbed gallery. No build step; deployed via vercel deploy --prod.

Live: https://containr-lyart.vercel.app

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 15:33:23 +02:00
Tomáš Dvořák 84b102a2c6 Merge pull request #8 from Dvorinka/ci/auto-release
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
feat(ci): release pipeline on merge to main
2026-09-23 12:08:34 +02:00
195 changed files with 37351 additions and 1603 deletions

No files matched your search

+4
View File
@@ -39,6 +39,10 @@ REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0
# Application Configuration # Application Configuration
# In production this must be a strong value with at least 32 characters. # In production this must be a strong value with at least 32 characters.
JWT_SECRET=SuperSecretJWTKey123456789!@#$%^&*() JWT_SECRET=SuperSecretJWTKey123456789!@#$%^&*()
# Encrypts secret env vars at rest. Defaults to JWT_SECRET when unset;
# setting it explicitly survives a JWT_SECRET rotation.
SECRETS_KEY=
# Shared secret for node-agent registration/heartbeat auth. # Shared secret for node-agent registration/heartbeat auth.
CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
# Optional rotation list (comma-separated). When set, this takes precedence. # Optional rotation list (comma-separated). When set, this takes precedence.
+1
View File
@@ -0,0 +1 @@
github: Dvorinka
+60 -1
View File
@@ -17,6 +17,30 @@ permissions:
contents: read contents: read
jobs: jobs:
# Cheap gate so docs-only/roadmap PRs skip the heavyweight jobs. Skipped
# required checks still satisfy branch protection ("skipped" = success).
changes:
name: Changed paths
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
mobile: ${{ steps.filter.outputs.mobile }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
code:
- 'app/**'
- 'infra/**'
- 'docker-compose.yml'
- 'install.sh'
- '.github/workflows/**'
- 'docs/api/**'
mobile:
- 'app/mobile/**'
infra: infra:
name: Infra (Compose + Scripts) name: Infra (Compose + Scripts)
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -48,6 +72,8 @@ jobs:
backend: backend:
name: Backend (Go) name: Backend (Go)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
defaults: defaults:
run: run:
working-directory: app/backend working-directory: app/backend
@@ -70,6 +96,8 @@ jobs:
frontend: frontend:
name: Frontend (React) name: Frontend (React)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
defaults: defaults:
run: run:
working-directory: app/frontend working-directory: app/frontend
@@ -96,10 +124,40 @@ jobs:
- name: Test - name: Test
run: npm test run: npm test
mobile:
name: Mobile (Expo)
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.mobile == 'true' || github.event_name != 'pull_request'
defaults:
run:
working-directory: app/mobile
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: app/mobile/package-lock.json
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npx tsc --noEmit
- name: Lint
run: npx expo lint
smoke: smoke:
name: Compose boot smoke name: Compose boot smoke
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
needs: changes
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -137,6 +195,7 @@ jobs:
name: Build & publish images name: Build & publish images
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [backend, frontend, smoke] needs: [backend, frontend, smoke]
if: needs.backend.result == 'success' && needs.frontend.result == 'success' && needs.smoke.result == 'success'
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -227,7 +286,7 @@ jobs:
name: Release name: Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [docker] needs: [docker]
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request' && needs.docker.result == 'success'
permissions: permissions:
contents: write contents: write
packages: write packages: write
+1
View File
@@ -123,3 +123,4 @@ bin
ref/ ref/
.playwright-mcp/ .playwright-mcp/
canvas-*.png canvas-*.png
.vercel
+2
View File
@@ -0,0 +1,2 @@
/*
!/site
+37
View File
@@ -0,0 +1,37 @@
# AGENTS.md — Containr
Containr is a self-hosted Docker PaaS (Railway-style). Backend: Go + Gin + PostgreSQL (sqlc) + Redis-compatible cache (Dragonfly) + Docker API. Frontend: React + Vite + TypeScript + React Query. API contract: `docs/api/openapi.yaml` (generated types: `app/frontend/src/generated/api-types.ts`).
This file is for **coding agents working on the repo**. For operating Containr as a deployed product, see `llms.txt`.
## Layout
- `app/backend` — Go monolith. Entrypoints: `cmd/server` (API), `cmd/cli` (`containr` CLI), `cmd/mcp` (`containr-mcp` MCP server).
- `app/backend/internal/api` — Gin handlers + route groups (`routes.go`: public / `read` OptionalAuth / `authed` / `admin`).
- `app/backend/internal/middleware` — `Auth` (JWT + Better Auth session + PAT), `RequireAdmin`, `Idempotency`.
- `app/backend/internal/cli/commands` — all CLI commands; shared `Client`, `Output`, `confirm` helpers live here.
- `app/backend/internal/mcpserver` — declarative MCP tool registry mirroring the API.
- `app/backend/sqlc` — `schema.sql` + `queries/*.sql`; generate via `sqlc generate` into `internal/database/sqlcdb`.
- `app/backend/migrations_goose` — goose migrations (`YYYYMMDDHHMMSS_name.sql`).
- `app/frontend` — React app. Pages under `src/features/`, API calls via `src/lib/api-client.ts`.
## Build / test / verify
```sh
cd app/backend && go build ./... && go vet ./... && go test ./...
cd app/frontend && npx tsc --noEmit && npm run lint && npm run build
cd app/frontend && npm run generate:api # after any openapi.yaml change
```
Dev stack: Postgres on `:15432` (`containr-postgres-1`), Dragonfly on `:16379` (`containr-dragonfly-1`). Server needs `DATABASE_URL`, `REDIS_URL`, `JWT_SECRET`, `PORT=8080`.
## Conventions that matter
- New endpoints go in the right route group; mutations on `authed`/`admin` get idempotency for free.
- Deploy work goes through `deploy_queue` (`internal/deployqueue`) — per-service FIFO; enqueue jobs instead of `go`-ing deployment/reconcile goroutines so they never race a build.
- New list endpoints use `pageWindow`/`paginationMeta` (`internal/api/pagination.go`) — cursor + page, `next_cursor` in the envelope.
- New errors use stable codes via `respondError(c, status, code, msg)` in `internal/api/errors.go`.
- New CLI command → also register an MCP tool in `internal/mcpserver/server.go` and document in OpenAPI. All three surfaces ship together.
- Destructive CLI commands must gate on `--yes`/`confirm` via `commands.confirm`; destructive MCP tools require `confirm: true`.
- sqlc: edit `schema.sql` + `queries/*.sql`, run `sqlc generate`, add the goose migration.
- Don't commit secrets. PATs (`cnp_…`) and agent tokens (`cagt_…`) are sha256-hashed at rest; raw values are shown once.
+2 -1
View File
@@ -56,7 +56,8 @@ dedicated console.
- **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete - **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry - **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
- **HA & scaling** — failover policies, node agents, autoscaling rules - **HA & scaling** — failover policies, node agents, autoscaling rules
- **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vulnerability scans - **Auth & security** — Better Auth sessions (email/password self-hosted), personal access tokens with `read`/`write`/`admin` scopes for CLI and agents, audit logs, vulnerability scans
- **CLI** — `containr` covers the full API: projects, services, deployments, logs, exec, variables, databases, cron, templates, nodes, scaling, HA, security, gateway, admin — with `--json`, `--yes`, named profiles, and `containr up` for current-directory deploys
- **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline - **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel - **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
+432
View File
@@ -0,0 +1,432 @@
# Containr — Roadmap 2: The dflow Convergence
Last verified: **2026-10-08** — deep pass over the full dflow codebase
(`~/Desktop/PROG+HTML/dflow`, community fork of dflow-sh/dflow, frozen for
preservation) against Containr `main` (`15b5b0e`).
Goal: make Containr the superior successor — absorb everything worth having
from dflow, then go past it and past Railway. Same power for humans and
agents: the UI is the friendly face, the CLI and MCP are the machine face,
all three sit on the same API. **Branding stays Containr/Vertice** —
white-labeling is an admin option, not an identity change.
---
## 1. What dflow actually is (verified)
Next.js 15 + PayloadCMS 3 + MongoDB + Redis/BullMQ. Runtime model: every
host is a remote server driven **over SSH** with Dokku auto-installed as the
per-app container manager. Async work = 43 BullMQ queue processors
(`src/queues/**`), live logs via Redis pub/sub → SSE endpoint, persisted to
deployment records. ~30 collections, ~180 server actions, 11 dashboard
routes per service (general, deployments, logs, environment, domains,
volumes, proxy, scaling, backups, settings).
### Full capability matrix — dflow → Containr
Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable (dokku/SSH-specific)
#### Deployment pipeline
| dflow capability | Containr status | Action |
|---|---|---|
| Queued deployments, serialized per server, cancellable while queued | ❌ goroutine fire-and-forget; `CancelDeployment` exists in engine but is **never routed** | Add deploy queue + cancel route |
| Deployment states `queued → building → success/failed`, live SSE logs + persisted record | 🟡 states exist (`building→running`); WS streams builds; no queued state | Extend status + stream |
| Redeploy cache modes (`ps:rebuild` vs fresh pull); webhook pushes force `no-cache` | ❌ always rebuilds | Add `cache` flag on deploy/redeploy |
| Cancel queued deployment from UI | ❌ (engine fn unrouted) | Route it |
| Per-service job ordering | ❌ concurrent deploys of one service can race | Serialize per service |
#### Service configuration
| dflow capability | Containr status | Action |
|---|---|---|
| Multi-domain per service + default flag + cert type | ❌ single `services.domain` | `service_domains` table |
| Cert types: letsencrypt / none / Cloudflare Origin / **CF-for-SaaS custom hostnames** | 🟡 Traefik ACME only | LE default; CF custom hostnames deferred (only needed if hosting tenant domains) |
| DNS preflight check (`checkDNSConfigAction`) | ❌ | `GET /services/:id/domains/check` |
| Volumes per service + dangling-volume browser/attach/delete | ❌ engine supports `VolumeMounts`; no API/UI | `services.volumes` jsonb + Volumes tab |
| Resource **limits and reservations**, per-server defaults | 🟡 `cpu_limit`/`memory_limit` int columns (unclear units) | explicit string units + reserves + instance defaults |
| Pre-deploy capacity check (dokku binary interceptor) | ❌ | Node capacity check pre-deploy/scale |
| Maintenance mode toggle | ❌ | Traefik middleware label |
| Basic-auth gate per service (`toggleHttpAuth`) | ❌ | Traefik basic-auth middleware |
| Raw proxy config override (`setServiceNginxConfig`) | ❌ | Allowlisted `traefik_labels` jsonb |
| Service clone (full config copy) | ❌ | `POST /services/:id/clone` |
| Move service between projects (`SwitchServiceProjectDialog`) | ❌ | `PUT /services/:id {project_id}` |
| Env var save → hot-apply + optional restart (`noRestart`) | ❌ rows rewritten; containers keep stale env until manual redeploy | Apply→redeploy flow |
| Static-site builder; Heroku buildpacks | 🟡 railpack/nixpacks/dockerfile auto-detect, no user override | `services.builder` enum + static builder |
| Docker registry accounts (private registries) | ❌ | `registry_credentials` per service/instance |
#### Databases
| dflow capability | Containr status | Action |
|---|---|---|
| Managed DBs (pg/mysql/mariadb/mongo/redis/clickhouse via dokku plugins) | ✅ plus dragonfly | — |
| **External DB registration** (Neon/Atlas/RDS conn details + test) | ❌ | `provider=external` + test endpoint |
| Internal backups + restore (same-type enforced) | ✅ | — |
| Scheduled backups | ✅ cron field | — |
| **External S3 backups** (auth/schedule/unschedule ops) | ❌ | `backup_targets` + shipper |
| **Cross-server DB migration** (export → sftp hop → import) | ❌ | `POST /databases/:id/migrate` |
| Public DB port expose/unexpose | ❌ loopback only | opt-in TCP expose |
| DB → service env linking (`dokku link`) | ✅ Bind action injects `DATABASE_URL` | — |
#### Servers / infrastructure
| dflow capability | Containr status | Action |
|---|---|---|
| Attach server = paste IP + SSH key; Dokku auto-installs | 🟡 agents exist, manual install | **SSH bootstrap**: one SSH session installs agent, never needed again |
| Tailscale/Headscale, NetBird, ZeroTier enroll + mesh connect | ❌ | Agent mesh-IP detection first; auth-key enroll optional |
| AWS EC2 + Hetzner provisioning w/ cloud-init | ❌ | IaC (ROADMAP §2.2): Terraform Proxmox/AWS + Hetzner module |
| Security groups (AWS SG rules, sync) | ❌ | Under IaC provider modules |
| Server-level global/wildcard domain (`updateServerDomain`) | ❌ | `nodes.default_domain` |
| Scheduled server cleanup (dokku cleanup → docker prune) | ❌ | Agent `prune` command + schedule |
| Queue flush/inspect per server (`flushServerQueues`, `getServerQueues`) | ➖ no queue yet | Ops page shows jobs once queue exists |
| Arbitrary SSH command on server (`executeCommand`) | ➖ exec is container-scoped | Node terminal via agent (PTY) |
| Server onboarding wizard, reset, cleanup-on-delete | 🟡 token issue/revoke only | Onboarding flow + `nodes add` UX |
| Ansible playbooks (stored, runnable per server, exec history) | ➖ | **Defer** — security surface; SSH bootstrap covers provisioning |
| Per-node resource dashboards | 🟡 heartbeat telemetry exists | Node detail page |
#### Platform / org
| dflow capability | Containr status | Action |
|---|---|---|
| Multi-tenant organisations + custom RBAC (per-resource CRUD, create-limits, read-scope) | 🟡 `is_admin` + `project_members` | Defer full RBAC; design PAT scopes role-compatible |
| Team invite links/emails | 🟡 manual user create only | `POST /invites` → link join |
| User API keys → full REST API | ❌ **the big gap** — session/JWT only | PATs (Phase A) |
| Outbound webhooks (collection events, HMAC, custom headers) | ❌ | `webhooks` table + delivery log |
| Banners (global + per-tenant, dismissible) | ❌ | `banners` table + top bar |
| White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` — **Containr remains the default brand** |
| Activity feed (icon/severity/category/metadata, 90-day TTL job) | 🟡 `audit_logs` coarser | Enrich + project feed UI |
| Admin metrics dashboard (users/servers/queued/failed counts) | 🟡 `/admin` exists | Extend |
| **Impersonate user** | ❌ | Admin support tool |
| In-app docs (content-collections markdown) | ✅ `/docs` | — |
| Dynamic client-side filter framework (535-line `filter.utils`) | 🟡 audit page filters only | Reuse pattern for tables |
| Ops "bubble" (queue monitor, terminal, sync) | ❌ | Operations page |
#### Agent surface
| dflow capability | Containr status | Action |
|---|---|---|
| Payload REST/GraphQL auto-API + per-user API keys | ❌ | PATs + OpenAPI (better: typed, documented) |
| CLI | ❌ | none in dflow — **Containr CLI is a genuine leap past it** |
| MCP | ❌ | none — same leap |
| Railway migrator script | ❌ | `containr migrate railway` |
| GitHub App **manifest flow** (instance self-provisions the App) | 🟡 env-configured App | `POST /git/github-app/manifest` flow |
| fork-sync webhook deploy trigger | ❌ | ~20 lines on receiver |
| Azure DevOps provider | ❌ | Optional (dflow does token-clone only) |
---
## 2. The spine: agent-first platform
*Agent and user on the same level.* Concretely:
- Every operation reachable by a **personal access token**, not just
sessions.
- Every operation in the **CLI** with `--json`, stable exit codes, `--yes`.
- Every operation in an **MCP server** (stdio first) so Devin/Claude Code/
Cursor/Codex drive Containr natively.
- `openapi.yaml` stays the single source of truth; UI, CLI, MCP are thin
clients.
### Phase A — Agent surface (do this FIRST; everything after inherits it) ✅ SHIPPED (PRs #14–#18)
- [x] **Personal access tokens.** `user_tokens` table — `id`, `user_id`,
`name`, `key_prefix` (`cnp_…`), `key_hash` (sha256), `scopes`
(`read`/`write`/`admin`, later resource-scoped), `expires_at`,
`last_used_at`. Model on `agent_auth_tokens` (raw shown once, hash stored).
`POST/GET/DELETE /api/v1/user/tokens`; `middleware.Auth` accepts
`Bearer cnp_…`; Settings → Access tokens UI with one-time display +
copyable CLI/MCP config snippets.
- [x] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client:
- `auth login [--token] [--url] [--profile]` (multi-instance profiles).
- Resources: `projects services deploy logs exec variables databases
cron templates nodes scaling ha security gateway notifications
webhooks domains volumes backups admin`.
- `containr up` / `deploy` — cwd→service create-or-update→watch
(the `railway up` equivalent; single most important command).
- `containr link` (bind cwd→service), `containr open`, `containr run`,
`containr shell` (PTY once exec upgrade lands), `completion`.
- Universal `--json`, `-o yaml`, `--watch`, `--yes`; exit codes
0/1/2/3/4 (ok/err/not-found/unauthorized/conflict).
- GoReleaser → GH releases + `install.sh`; `version` command.
- Conventions from docsync/envdiff/cix/repolint.
- [x] **MCP server** (`cmd/mcp`, same module, stdio). Tools mirror the
CLI: `list_projects create_service deploy get_logs set_variable
restart rollback create_database trigger_cron deploy_template exec
get_metrics security_scan …`. Auth: `CONTAINR_URL` + `CONTAINR_TOKEN`
or shared `~/.containr/config`. `containr mcp serve` + install snippets
for Devin/Claude/Cursor.
- [x] **API agent ergonomics.** `Idempotency-Key` on mutating endpoints;
uniform `{error, code, details}` envelope (audit bare-string returns);
cursor pagination on growing lists; name-or-id lookup everywhere.
- [x] **Agent docs.** Root `AGENTS.md`, `/docs` serves `llms.txt` +
`llms-full.txt`, MCP tool list generated from OpenAPI where possible.
**Gate**: an agent holding only a PAT registers a project, deploys from a
git repo, streams logs, sets vars, rolls back — zero UI. Same via CLI
for a human.
---
## 3. Phase B — Deployment pipeline maturity ✅ SHIPPED (PR #19)
dflow's one real architectural edge. Containr deploys are fire-and-forget
goroutines; this fixes correctness *and* unlocks the ops surface.
- [x] **Deploy job queue.** Lightweight internal queue — per-service
FIFO in `internal/deployqueue`; status gains `queued`.
- [x] **Cancel deployments.** `POST /deployments/:id/cancel` — queued
jobs cancel immediately, active jobs cancel context; `cancelled` status.
- [x] **Cache modes.** `no_cache` on deploys; webhook-triggered deploys
default to no-cache (dflow convention — prevents stale-layer surprises).
- [x] **Queued→building→running status + persisted step logs.** `queued`
state lands; streaming over existing log surface.
- [x] **Env var apply → redeploy.** `--redeploy` flag on variable writes;
backend requeues honoring running state.
- [x] **Encrypt variables at rest.** `is_secret` values encrypted
AES-GCM `enc:v1:` (key from `SECRETS_KEY`); masked-echo preserves
ciphertext.
## 4. Phase C — Service configuration parity ✅ SHIPPED (PRs #20–#23)
- [x] **Volumes on services** — `services.volumes` jsonb
`[{volume|host_path, container_path, read_only}]`; Volumes tab +
admin volume inventory.
- [x] **Multi-domain** — `service_domains(service_id, domain, is_default,
cert_type, status, last_checked_at)`. Domains UI: add/remove/
set-default/DNS-status badge. `services.domain` stays derived default.
- [x] **DNS preflight** — `GET /services/:id/domains/check` resolves and
compares vs edge IP → `ok|wrong-target|pending`.
- [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
no proxy code needed.
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with
allowlist validation (middlewares, headers, redirects).
- [x] **Builder override** — `services.builder` enum
`auto|railpack|nixpacks|dockerfile|static`; static builder = build +
nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug.
- [x] **Resource limits + reserves** — `cpu`/`memory` caps +
`cpu_reserve`/`memory_reserve` → NanoCPUs/Memory/MemoryReservation;
instance defaults in `app_settings`.
- [x] **Pre-deploy capacity check** — reservations vs node capacity;
`app_settings.capacity_policy=block` rejects, default warns.
- [x] **Service clone** + **move between projects** — copies volumes,
domains, access gates, vars (ciphertext verbatim); audit-logged.
- [x] **Private registries** — `registry_credentials`
(server/username/secret, encrypted); pull auth resolved at deploy;
per-service registry link.
- [x] **Service detail tabs to match dflow's surface**: Domains,
Volumes, access-controls sections added to the service page.
## 5. Phase D — Data layer maturity
- [x] **External DB registration** — `provider='managed'|'external'`,
conn fields, `POST /databases/test-connection`, same bind flow. (PR #24)
- [x] **Public DB port expose** — `public_port` opt-in `0.0.0.0` host
bind; default stays loopback. (PR #25)
- [x] **Offsite backups** — `backup_targets` (S3-compatible:
endpoint/bucket/keys encrypted); per-DB destination; archives shipped
via holder container + minio-go; restore-from-S3 fallback. (PR #26)
- [ ] **Cross-node DB migration** — `POST /databases/:id/migrate
{target_node_id}`: dump → stream transfer → provision → restore →
re-point bound services. Deferred until Phase F lands real second
nodes — nothing to migrate between today.
- [ ] **Backup import** — upload archive → new restore point (pairs with
migrators).
## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates)
- [ ] **Template = ordered service graph** — `services[]` entries with
`{name, type, source, build, ports, volumes, variables, depends_on}`;
deploy order from dependency refs.
- [ ] **Expressions** — `${{service.KEY}}` (already the runtime ref
syntax — reuse), `{{secret(len,"charset")}}`, `{{random:int}}`,
`{{VAR:-default}}`; `POST /templates/:id/plan` returns the resolved
graph without deploying (agents love this).
- [ ] **Compose import** — `POST /templates/import/compose` (compose-go
parser → graph), `POST /templates/import/github` (compose file from
repo path). Implements `docs/superpowers/specs/2026-04-14-*`.
- [ ] **Graph deploy pipeline** — creates project + services in
dependency order, resolves vars, triggers builds; defined
partial-failure policy.
- [ ] **Visual template builder** — canvas mode: drop git/image/db
nodes, wire refs, set order, save/publish. Reuses existing canvas
primitives (dflow's `templates/compose` is exactly this).
- [ ] **Catalog growth** — convert `templates/*.md` (20 app guides) into
real graph templates; screenshots + badges per the old spec.
**Gate**: paste n8n compose → 3-service project with generated secrets
and wired refs, one click or one CLI call.
## 7. Phase F — Nodes & infrastructure
- [ ] **SSH bootstrap install** — `containr nodes add --ssh user@host` +
UI flow: one SSH session runs agent install (binary + systemd +
enroll token), SSH closed forever. dflow onboarding UX, agent-model
security.
- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/
zt0 IPs at register; backend stores + displays. Optional
`TAILSCALE_AUTH_KEY` enroll-at-provision later.
- [ ] **Node detail page** — placed services, telemetry history, agent
version + self-upgrade, **drain/cordon**, remove.
- [ ] **Node housekeeping** — agent `prune` command (docker system
prune, bounded) + optional schedule (`setServerAutoCleanup` analog —
disk pressure is the #1 small-VPS killer).
- [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists):
pin/spread/affinity, prefer-lowest-usage; deploy engine routes to
`services.node_id` via agent commands (agent already runs containers).
- [ ] **Build-on-node** — agents currently `docker run` prebuilt images
only; extend agent to pull registry images (build stays central) —
private registry creds flow through Phase C.
- [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/
OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module
(dflow's second provider), cloud-init → auto-enroll.
- [ ] **Global/wildcard domain per node** (`nodes.default_domain`) —
auto-domains for services on that node.
## 8. Phase G — Platform polish
- [ ] **Outbound webhooks** — `webhooks(url, secret, events[], headers,
enabled)` + `webhook_deliveries` log; HMAC `X-Containr-Signature`;
events: `deployment.* service.* database.* security.* agent.*`.
- [ ] **Activity feed** — enrich `audit_logs` with `severity category
icon label metadata`; per-project timeline + global feed; TTL job.
- [ ] **Banners** — `banners(title, body, level, scope, active)`; top
bar in UI + surfaced to CLI on `auth status`.
- [ ] **White-label** — `app_settings.branding`: product name, logos,
favicon, accent override, docs/support links. `GET /api/v1/branding`
public + cached. **Containr/Vertice remains default;** this is for
self-hosters who rebrand their instance.
- [ ] **Operations page** — unified view of running/queued/failed jobs
(deploys, builds, backups, cron, scans, agent commands) — dflow's
bubble panel as a real page; flush/stuck-job tools once queue exists.
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`);
`nodes/:id/terminal` via agent (dflow's wetty without the extra
container).
- [ ] **Team invites** — `POST /invites` → signed link → join flow;
replaces manual-only user creation.
- [ ] **Impersonate user** — admin support tool, audit-logged.
- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest`
returns manifest + redirect; instance self-provisions its App
(dflow pattern — removes the env-setup barrier).
- [ ] **fork-sync trigger** — accept repo-sync events on webhook
receiver.
- [ ] **Dynamic filter framework** — port dflow's `filter.utils`
pattern (typed filter configs → URL state → client filtering) for
audit logs, deployments, notifications tables.
- [ ] **Notification producers round-out** — backups, agent offline,
upgrade available, security findings.
- [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) —
`services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper
polls container NetworkIO deltas; idle past threshold → workload
removed, status `sleeping`. Wake-on-traffic via per-service
placeholder container on the edge network carrying the same Traefik
host rule at higher priority — serves a waking page, calls
`/internal/wake/:id`, exits; Traefik falls back once the app is up.
Verified `running → sleeping → waking → running` end to end.
- [x] **Env doctor** — `GET /services/:id/env-check` surfaces
unresolved `${{…}}` refs, empty values, and unreadable
(rotated-key) secrets; template `plan` endpoint reports missing
required vars before deploy (PR #30). Frontend Variables section
shows the banner.
- [x] **Shared project variables** — `project_variables` table,
`GET/PUT /projects/:id/variables`, `${{shared.KEY}}` refs resolved
at deploy/reconcile, secrets `enc:v1:` at rest and masked (PR #32).
- [x] **One-click install** — `install.sh` (curl|sh): detects docker,
writes compose + .env with generated secrets incl. `SECRETS_KEY`,
pulls images, runs migrations, prints admin URL. GHCR images mean
installs never build from source.
- [x] **CI/CD v2** — required checks (Go test/vet, frontend
lint/build/test, compose boot smoke), `dorny/paths-filter` gate so
docs-only PRs skip heavyweight jobs, and release on main/tag:
conventional-commit version bump → tag → GHCR images (backend,
frontend) → cross-platform CLI + MCP binaries → SHA-256 checksums
→ GitHub Release → Trivy scans (PR #33).
- [ ] **Custom roles/RBAC** — defer until demanded; PAT scopes designed
forward-compatible now.
## 9. Phase H — Migration & ecosystem
- [ ] **Railway migrator** — `containr migrate railway --token --project
--dry-run`: Railway GraphQL → services/vars/domains → recreate via
PAT. Landing-page pitch: "off Railway in one command."
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST
export or Mongo dump: servers→nodes, projects, services(+vars/
domains/volumes), databases, templates. Onboards dflow refugees.
- [ ] **`containr import compose`** — Phase-E parser straight to a
project (no template intermediate).
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
fetch/publish; bundled catalog works offline (dflow's self-contained
principle — nothing phones home).
## 9b. Phase I — Companion surfaces
- [x] **Phone app (v1)** — `app/mobile`, Expo/React Native thin client
over the agent-shaped API; dark UI mirroring the design system. PAT
auth against any instance URL, stored in platform secure storage.
- **Dashboard** — service status counts (running/sleeping/failed/
stopped), per-project service health dots, recent deployments feed.
- **Operate** — start/stop/restart/redeploy, sleep/wake, rollback to
a prior deployment, database start/stop/restart/backup-now.
- **Observe** — polling log tail (300 lines, 5s refresh), per-service
deployment history, env-doctor issues surfaced on the service view.
- [ ] **env var edit + apply-redeploy, domain management, replica
scaling, cron execution history** — next iteration.
- [ ] **Push notifications** — zero-infra path = self-hosted
ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional.
- [ ] **Offline resilience** — cached last-known state + unreachable
banner.
- [ ] **Status/offline resilience** — app + CLI degrade gracefully when
the instance is unreachable (cached last-known state, clear banner).
## 10. Carried-over debt (ROADMAP.md items still open)
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit →
`gateway/proxy.go` → metrics. Management API exists; proxy unwired.
- [ ] **Preview environments runtime** — real build+deploy+subdomain+TTL
(bookkeeping only now). Pairs with clone + multi-domain.
- [ ] **Environments** — env switcher, per-env vars/domains, promote.
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`.
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
webhook table tests, Playwright e2e golden path.
- [ ] **First-run wizard** — admin → GitHub App (manifest flow from
Phase G makes this self-serve) → Tunnel → done.
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release.
## 11. Deliberately not imported from dflow
- Dokku-over-SSH runtime — our Docker API + agent model is strictly
better.
- Per-server-named BullMQ queues — our queue stays a single ordered
table.
- Payload/Mongo/Next.js stack, multi-tenant plugin machinery.
- Ansible playbook runner — arbitrary code-on-node is a security
surface; SSH bootstrap + agent commands cover the real needs.
Revisit only with a concrete demand.
- `isAdmin`-only collection access — our owner/admin/public model is
richer already.
- Netdata install flow — our agent telemetry + optional Beszel covers
monitoring; don't ship a third dashboard stack.
## 12. Recommended order
```
A (agent surface) ──► B (deploy pipeline) ──► C/D (service+data depth)
│ │
▼ ▼
CLI/MCP dogfood every feature E (templates v2)
│
F (nodes/infra) ──► H (migrators)
│
G (polish) + debt
```
Why this order: PATs+CLI+MCP first = every later feature ships
user-and-agent-visible at once. The queue (B) is prerequisite for
the ops surface and correct concurrency. Templates v2 needs volumes,
multi-domain, clone. Migrators last — they're launch marketing, not
foundation.
## 13. Design rules
- **API is the contract.** UI, CLI, MCP are thin clients over
`openapi.yaml`. A feature isn't done until all three reach it.
- **Everything scriptable.** JSON in/out, `--json`/`--yes`, uniform
pagination, name-or-id addressing.
- **Same powers, same position.** Nothing user-facing that isn't
API-facing; agent-visible state (tokens, jobs) visible to users too.
- **dflow is a donor, not a dependency.** Port semantics, not code.
- **Containr branding is the default**; white-label is instance config.
- **Self-hosted first** — no required external service, no phone-home.
+1 -4
View File
@@ -15,8 +15,5 @@ var (
func main() { func main() {
cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime)) cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime))
if err := cli.Run(); err != nil { os.Exit(cli.Execute())
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
} }
+24
View File
@@ -0,0 +1,24 @@
// dumptemplates prints the seeded official template catalog as JSON in the same
// shape as GET /api/v1/templates. The landing site (site/templates.json) uses it
// as the offline fallback catalog: `go run ./cmd/dumptemplates > ../../site/templates.json`.
package main
import (
"containr/internal/api"
"encoding/json"
"fmt"
"os"
)
func main() {
out := struct {
Templates []api.ServiceTemplate `json:"templates"`
}{Templates: api.SeedTemplates()}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(out); err != nil {
fmt.Fprintln(os.Stderr, "encode:", err)
os.Exit(1)
}
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"containr/internal/mcpserver"
)
// Injected at build time via -ldflags.
var Version = "dev"
// containr-mcp exposes the whole Containr API as MCP tools over stdio.
// Configure it in an MCP client with:
//
// command: containr-mcp
// env: CONTAINR_API_URL, CONTAINR_TOKEN (a cnp_ personal access token)
func main() {
apiURL := os.Getenv("CONTAINR_API_URL")
if apiURL == "" {
apiURL = "http://localhost:8080/api/v1"
}
apiURL = strings.TrimSuffix(apiURL, "/")
if !strings.HasSuffix(apiURL, "/api/v1") {
apiURL += "/api/v1"
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := mcpserver.Run(ctx, apiURL, os.Getenv("CONTAINR_TOKEN"), Version); err != nil {
fmt.Fprintf(os.Stderr, "containr-mcp: %v\n", err)
os.Exit(1)
}
}
+21 -5
View File
@@ -8,11 +8,13 @@ require (
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/go-playground/validator/v10 v10.30.1 github.com/go-playground/validator/v10 v10.30.1
github.com/go-redis/redis/v8 v8.11.5 github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/miekg/dns v1.1.72 github.com/miekg/dns v1.1.72
github.com/minio/minio-go/v7 v7.0.95
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/pressly/goose/v3 v3.24.1 github.com/pressly/goose/v3 v3.24.1
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/rs/cors v1.10.1 github.com/rs/cors v1.10.1
@@ -21,6 +23,8 @@ require (
github.com/sqlc-dev/pqtype v0.3.0 github.com/sqlc-dev/pqtype v0.3.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
golang.org/x/term v0.46.0
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
@@ -35,22 +39,28 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect github.com/mfridman/interpolate v0.0.2 // indirect
github.com/minio/crc64nvme v1.0.2 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect github.com/moby/term v0.5.2 // indirect
@@ -60,17 +70,23 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel v1.41.0 // indirect
@@ -84,13 +100,13 @@ require (
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/mod v0.41.0 // indirect golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.59.0 // indirect golang.org/x/net v0.59.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.23.0 // indirect golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.11.0 // indirect golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.49.0 // indirect golang.org/x/tools v0.49.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect gotest.tools/v3 v3.5.2 // indirect
modernc.org/sqlite v1.39.0 // indirect modernc.org/sqlite v1.39.0 // indirect
) )
+39 -9
View File
@@ -46,6 +46,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -63,13 +65,15 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -80,9 +84,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -97,6 +104,12 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
@@ -105,6 +118,8 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -126,6 +141,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -140,9 +157,15 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
@@ -174,10 +197,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
@@ -213,16 +240,19 @@ golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
+646
View File
@@ -0,0 +1,646 @@
package api
import (
"archive/tar"
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
"containr/internal/database/sqlcdb"
"containr/internal/docker"
"containr/internal/secrets"
"github.com/docker/docker/api/types/mount"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// BackupTarget is an S3-compatible archive destination. Secret keys are
// stored encrypted and never serialized — has_credentials signals presence.
type BackupTarget struct {
ID string `json:"id"`
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket"`
Region string `json:"region,omitempty"`
Prefix string `json:"prefix,omitempty"`
HasCredentials bool `json:"has_credentials"`
UseTLS bool `json:"use_tls"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
accessKey string `json:"-"`
secretKey string `json:"-"`
}
type backupTargetRequest struct {
Name string `json:"name"`
Endpoint string `json:"endpoint" binding:"required"`
Bucket string `json:"bucket" binding:"required"`
Region string `json:"region"`
Prefix string `json:"prefix"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
UseTLS *bool `json:"use_tls"`
}
func mapBackupTargetRow(row sqlcdb.BackupTarget) BackupTarget {
return BackupTarget{
ID: row.ID,
Name: row.Name,
Endpoint: row.Endpoint,
Bucket: row.Bucket,
Region: row.Region,
Prefix: row.Prefix,
HasCredentials: row.SecretKey != "",
UseTLS: row.UseTls,
CreatedAt: databaseNullTime(row.CreatedAt),
UpdatedAt: databaseNullTime(row.UpdatedAt),
accessKey: secrets.Decrypt(row.AccessKey),
secretKey: secrets.Decrypt(row.SecretKey),
}
}
// s3Client builds a client for the target. Endpoint is a bare host[:port] —
// scheme comes from use_tls, matching MinIO/Garage/AWS conventions.
func (t BackupTarget) s3Client() (*minio.Client, error) {
opts := &minio.Options{Secure: t.UseTLS, Region: t.Region}
if t.accessKey != "" || t.secretKey != "" {
opts.Creds = credentials.NewStaticV4(t.accessKey, t.secretKey, "")
} else {
opts.Creds = credentials.NewIAM("")
}
return minio.New(t.Endpoint, opts)
}
func normalizeBackupTargetRequest(req *backupTargetRequest) error {
req.Name = strings.TrimSpace(req.Name)
req.Endpoint = strings.TrimSpace(req.Endpoint)
req.Endpoint = strings.TrimPrefix(strings.TrimPrefix(req.Endpoint, "https://"), "http://")
req.Endpoint = strings.TrimSuffix(req.Endpoint, "/")
req.Bucket = strings.TrimSpace(req.Bucket)
req.Prefix = strings.Trim(strings.TrimSpace(req.Prefix), "/")
if req.Name == "" {
return errors.New("name is required")
}
if req.Endpoint == "" || req.Bucket == "" {
return errors.New("endpoint and bucket are required")
}
return nil
}
// probeBackupTarget verifies credentials and bucket access.
func probeBackupTarget(ctx context.Context, t BackupTarget) (time.Duration, error) {
client, err := t.s3Client()
if err != nil {
return 0, err
}
start := time.Now()
exists, err := client.BucketExists(ctx, t.Bucket)
if err != nil {
return 0, err
}
if !exists {
return 0, fmt.Errorf("bucket %q not found or not accessible", t.Bucket)
}
return time.Since(start), nil
}
func (h *DatabaseHandler) ListBackupTargets(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
rows, err := h.queries.ListBackupTargetsByUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list backup targets"})
return
}
targets := make([]BackupTarget, 0, len(rows))
for _, row := range rows {
t := mapBackupTargetRow(row)
t.accessKey, t.secretKey = "", ""
targets = append(targets, t)
}
c.JSON(http.StatusOK, gin.H{"backup_targets": targets})
}
func (h *DatabaseHandler) CreateBackupTarget(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
var req backupTargetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
if err := normalizeBackupTargetRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
useTLS := true
if req.UseTLS != nil {
useTLS = *req.UseTLS
}
target := BackupTarget{
ID: "bt_" + strings.ReplaceAll(uuid.NewString(), "-", "")[:12],
Name: req.Name,
Endpoint: req.Endpoint,
Bucket: req.Bucket,
Region: req.Region,
Prefix: req.Prefix,
UseTLS: useTLS,
accessKey: req.AccessKey,
secretKey: req.SecretKey,
}
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
if _, err := probeBackupTarget(probeCtx, target); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Connection check failed: %v", err), "code": "DEPENDENCY_UNAVAILABLE"})
return
}
enc := func(v string) string {
if v == "" {
return ""
}
return secrets.Encrypt(v)
}
now := sql.NullTime{Time: time.Now(), Valid: true}
if err := h.queries.CreateBackupTarget(c.Request.Context(), sqlcdb.CreateBackupTargetParams{
ID: target.ID,
UserID: userID,
Name: target.Name,
Endpoint: target.Endpoint,
Bucket: target.Bucket,
Region: target.Region,
Prefix: target.Prefix,
AccessKey: enc(req.AccessKey),
SecretKey: enc(req.SecretKey),
UseTls: useTLS,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create backup target"})
return
}
LogAudit(userID, "backup_target", target.ID, "create", map[string]interface{}{"name": req.Name, "endpoint": req.Endpoint, "bucket": req.Bucket})
target.accessKey, target.secretKey = "", ""
target.HasCredentials = req.SecretKey != ""
target.CreatedAt, target.UpdatedAt = now.Time, now.Time
c.JSON(http.StatusCreated, gin.H{"backup_target": target})
}
func (h *DatabaseHandler) UpdateBackupTarget(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
targetID := c.Param("id")
row, err := h.queries.GetBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.GetBackupTargetByIDAndUserParams{ID: targetID, UserID: userID})
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup target not found", "code": "NOT_FOUND"})
return
}
var req backupTargetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
if err := normalizeBackupTargetRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
useTLS := row.UseTls
if req.UseTLS != nil {
useTLS = *req.UseTLS
}
// Empty credentials keep the stored pair.
accessKey := row.AccessKey
secretKey := row.SecretKey
if req.AccessKey != "" || req.SecretKey != "" {
accessKey = secrets.Encrypt(req.AccessKey)
secretKey = secrets.Encrypt(req.SecretKey)
}
target := BackupTarget{
Endpoint: req.Endpoint,
Bucket: req.Bucket,
Region: req.Region,
Prefix: req.Prefix,
UseTLS: useTLS,
accessKey: secrets.Decrypt(accessKey),
secretKey: secrets.Decrypt(secretKey),
}
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
if _, err := probeBackupTarget(probeCtx, target); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Connection check failed: %v", err), "code": "DEPENDENCY_UNAVAILABLE"})
return
}
if err := h.queries.UpdateBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.UpdateBackupTargetByIDAndUserParams{
Name: req.Name,
Endpoint: req.Endpoint,
Bucket: req.Bucket,
Region: req.Region,
Prefix: req.Prefix,
AccessKey: accessKey,
SecretKey: secretKey,
UseTls: useTLS,
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: targetID,
UserID: userID,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update backup target"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Backup target updated"})
}
func (h *DatabaseHandler) DeleteBackupTarget(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
targetID := c.Param("id")
count, err := h.queries.CountDatabasesUsingBackupTarget(c.Request.Context(), sql.NullString{String: targetID, Valid: true})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check target usage"})
return
}
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("%d database(s) still back up to this target", count), "code": "CONFLICT"})
return
}
if err := h.queries.DeleteBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.DeleteBackupTargetByIDAndUserParams{ID: targetID, UserID: userID}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete backup target"})
return
}
LogAudit(userID, "backup_target", targetID, "delete", nil)
c.JSON(http.StatusOK, gin.H{"message": "Backup target deleted"})
}
// ImportBackup accepts a `.tar.gz` archive (multipart `file` field or raw
// body) and stores it as a restore point — the counterpart to offsite
// export, and the entry path for migrators.
func (h *DatabaseHandler) ImportBackup(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID, UserID: userID,
})
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
return
}
if row.Provider == "external" {
c.JSON(http.StatusBadRequest, gin.H{"error": "backup import applies to managed databases — external restores are not supported yet", "code": "VALIDATION"})
return
}
// Buffer to a temp file — tar headers need the size up front, and a
// streamed upload can't be rewound for the archive write.
src, _, err := openUploadStream(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
defer src.Close()
tmp, err := os.CreateTemp("", "containr-backup-import-*")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to stage upload"})
return
}
defer func() { tmp.Close(); os.Remove(tmp.Name()) }()
const maxImport = 2 << 30 // 2 GiB ceiling for staged archives
size, err := io.Copy(tmp, http.MaxBytesReader(c.Writer, src, maxImport))
if err != nil {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "archive too large or unreadable", "code": "VALIDATION"})
return
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read staged upload"})
return
}
magic := make([]byte, 2)
if _, err := tmp.Read(magic); err != nil || magic[0] != 0x1f || magic[1] != 0x8b {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive must be gzip-compressed (.tar.gz)", "code": "VALIDATION"})
return
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read staged upload"})
return
}
backupID := generateDatabaseBackupID(databaseID)
archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID))
if _, err := h.dockerClient.CreateVolume(c.Request.Context(), docker.VolumeConfig{Name: managedDatabaseBackupVolume}); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already exists") {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to prepare backup volume"})
return
}
holderID, err := h.createBackupVolumeHolder(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to open backup volume: %v", err)})
return
}
defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) }()
pr, pw := io.Pipe()
tw := tar.NewWriter(pw)
go func() {
err := tw.WriteHeader(&tar.Header{Name: archivePath, Mode: 0o644, Size: size})
if err == nil {
_, err = io.Copy(tw, tmp)
}
if cerr := tw.Close(); err == nil {
err = cerr
}
_ = pw.CloseWithError(err)
}()
if err := h.dockerClient.CopyToContainer(c.Request.Context(), holderID, "/backup", pr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to store archive: %v", err)})
return
}
now := sql.NullTime{Time: time.Now(), Valid: true}
if err := h.queries.CreateDatabaseBackup(c.Request.Context(), sqlcdb.CreateDatabaseBackupParams{
ID: backupID,
DatabaseID: databaseID,
Size: humanReadableBytes(size),
Status: "completed",
BackupPath: sql.NullString{String: archivePath, Valid: true},
CreatedAt: now,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register backup"})
return
}
_ = h.queries.SetDatabaseBackupStatusByID(c.Request.Context(), sqlcdb.SetDatabaseBackupStatusByIDParams{
Status: "completed",
Size: humanReadableBytes(size),
CompletedAt: now,
ID: backupID,
})
// Imported archives ship offsite too when a target is configured —
// the import should be a first-class restore point.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
if key, err := h.uploadBackupToTarget(ctx, row, archivePath, backupID); err != nil {
log.Printf("containr: imported backup %s remote upload failed: %v (local archive kept)", backupID, err)
} else if key != "" {
log.Printf("containr: imported backup %s shipped to %s", backupID, key)
}
}()
LogAudit(userID, "database", databaseID, "backup_import", map[string]interface{}{"backup_id": backupID, "size": size})
c.JSON(http.StatusCreated, gin.H{"backup_id": backupID, "size": humanReadableBytes(size), "status": "completed"})
}
// openUploadStream resolves the request body: multipart `file` field when
// present, otherwise the raw body (e.g. CLI pipe of a .tar.gz).
func openUploadStream(c *gin.Context) (io.ReadCloser, string, error) {
if fh, err := c.FormFile("file"); err == nil {
src, err := fh.Open()
return src, fh.Filename, err
}
if c.Request.Body == nil || c.Request.ContentLength == 0 {
return nil, "", errors.New("no archive uploaded — send multipart field 'file' or a raw .tar.gz body")
}
return c.Request.Body, "archive.tar.gz", nil
}
func (h *DatabaseHandler) TestBackupTarget(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
targetID := c.Param("id")
row, err := h.queries.GetBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.GetBackupTargetByIDAndUserParams{ID: targetID, UserID: userID})
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup target not found", "code": "NOT_FOUND"})
return
}
target := mapBackupTargetRow(row)
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
latency, probeErr := probeBackupTarget(probeCtx, target)
if probeErr != nil {
c.JSON(http.StatusOK, gin.H{"ok": false, "error": probeErr.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
}
// uploadBackupToTarget copies the archive out of the backup volume and puts
// it in the configured S3 target. Returns the object key, or "" when no
// target is configured.
func (h *DatabaseHandler) uploadBackupToTarget(ctx context.Context, db sqlcdb.DatabaseService, archivePath, backupID string) (string, error) {
if !db.BackupTargetID.Valid || db.BackupTargetID.String == "" {
return "", nil
}
targetRow, err := h.queries.GetBackupTargetByID(ctx, db.BackupTargetID.String)
if err != nil {
return "", fmt.Errorf("backup target lookup failed: %w", err)
}
target := mapBackupTargetRow(targetRow)
archive, err := h.readBackupArchive(ctx, archivePath)
if err != nil {
return "", fmt.Errorf("read archive: %w", err)
}
defer func() {
_, _ = io.Copy(io.Discard, archive)
_ = archive.Close()
}()
client, err := target.s3Client()
if err != nil {
return "", err
}
key := target.Prefix
if key != "" {
key += "/"
}
key += "databases/" + db.ID + "/" + sanitizeBackupArchivePath(archivePath)
_, err = client.PutObject(ctx, target.Bucket, key, archive, -1, minio.PutObjectOptions{ContentType: "application/gzip"})
if err != nil {
return "", fmt.Errorf("s3 upload: %w", err)
}
_ = h.queries.SetDatabaseBackupRemoteKeyByID(ctx, sqlcdb.SetDatabaseBackupRemoteKeyByIDParams{
RemoteKey: sql.NullString{String: key, Valid: true},
ID: backupID,
})
return key, nil
}
// readBackupArchive streams an archive out of the shared backup volume via a
// stopped holder container — the volume has no direct host path.
func (h *DatabaseHandler) readBackupArchive(ctx context.Context, archivePath string) (io.ReadCloser, error) {
holderID, err := h.createBackupVolumeHolder(ctx)
if err != nil {
return nil, err
}
reader, err := h.dockerClient.CopyFromContainer(ctx, holderID, "/backup/"+sanitizeBackupArchivePath(archivePath))
if err != nil {
_ = h.dockerClient.RemoveContainer(context.Background(), holderID, true)
return nil, err
}
// The holder must outlive the stream — deleting the source container while
// dockerd serves the archive stalls the read without an error.
return &archiveReader{tar: reader, cleanup: func() {
_ = h.dockerClient.RemoveContainer(context.Background(), holderID, true)
}}, nil
}
// downloadBackupToVolume fetches a remote archive back into the backup volume
// so the normal restore path can run unchanged.
func (h *DatabaseHandler) downloadBackupToVolume(ctx context.Context, db sqlcdb.DatabaseService, remoteKey, archivePath string) error {
targetRow, err := h.queries.GetBackupTargetByID(ctx, db.BackupTargetID.String)
if err != nil {
return fmt.Errorf("backup target lookup failed: %w", err)
}
target := mapBackupTargetRow(targetRow)
client, err := target.s3Client()
if err != nil {
return err
}
obj, err := client.GetObject(ctx, target.Bucket, remoteKey, minio.GetObjectOptions{})
if err != nil {
return err
}
defer obj.Close()
archive := sanitizeBackupArchivePath(archivePath)
if archive == "" {
return errors.New("backup path is required")
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
data, err := io.ReadAll(obj)
if err != nil {
return fmt.Errorf("s3 download: %w", err)
}
if err := tw.WriteHeader(&tar.Header{Name: archive, Mode: 0o644, Size: int64(len(data))}); err != nil {
return err
}
if _, err := tw.Write(data); err != nil {
return err
}
if err := tw.Close(); err != nil {
return err
}
holderID, err := h.createBackupVolumeHolder(ctx)
if err != nil {
return err
}
defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) }()
return h.dockerClient.CopyToContainer(ctx, holderID, "/backup", &buf)
}
// backupArchiveExists reports whether the archive is still in the local
// backup volume — a copy attempt on a holder container is the cheapest check.
func (h *DatabaseHandler) backupArchiveExists(ctx context.Context, archivePath string) (bool, error) {
holderID, err := h.createBackupVolumeHolder(ctx)
if err != nil {
return false, err
}
defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) }()
reader, err := h.dockerClient.CopyFromContainer(ctx, holderID, "/backup/"+sanitizeBackupArchivePath(archivePath))
if err != nil {
return false, nil
}
_ = reader.Close()
return true, nil
}
// createBackupVolumeHolder creates a container mounting the backup volume so
// archives can be copied in and out. It must be started — docker cp/archive
// only resolves volume mounts on containers that have run at least once.
func (h *DatabaseHandler) createBackupVolumeHolder(ctx context.Context) (string, error) {
if err := h.ensureImage(ctx, managedDatabaseBackupImage); err != nil {
return "", err
}
holderID, err := h.dockerClient.CreateContainer(ctx, docker.ContainerConfig{
Image: managedDatabaseBackupImage,
Cmd: []string{"true"},
RestartPolicy: "no",
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: managedDatabaseBackupVolume, Target: "/backup"}},
Labels: map[string]string{"containr.managed": "true", "containr.utility": "backup-io"},
NetworkMode: "none",
})
if err != nil {
return "", err
}
if err := h.dockerClient.StartContainer(ctx, holderID); err != nil {
_ = h.dockerClient.RemoveContainer(context.Background(), holderID, true)
return "", fmt.Errorf("failed to start backup holder: %w", err)
}
return holderID, nil
}
// archiveReader unwraps the tar stream CopyFromContainer returns.
type archiveReader struct {
tar io.ReadCloser
tr *tar.Reader
err error
cleanup func()
}
func (r *archiveReader) Read(p []byte) (int, error) {
if r.err != nil {
return 0, r.err
}
if r.tr == nil {
r.tr = tar.NewReader(r.tar)
// Skip to the first regular file entry.
for {
hdr, err := r.tr.Next()
if err != nil {
r.err = err
return 0, err
}
if hdr.Typeflag == tar.TypeReg {
break
}
}
}
n, err := r.tr.Read(p)
if err != nil {
r.err = err
}
return n, err
}
func (r *archiveReader) Close() error {
err := r.tar.Close()
if r.cleanup != nil {
r.cleanup()
r.cleanup = nil
}
return err
}
@@ -0,0 +1,60 @@
package api
import (
"archive/tar"
"bytes"
"io"
"testing"
)
func TestNormalizeBackupTargetRequest(t *testing.T) {
req := backupTargetRequest{
Name: " garage ",
Endpoint: "https://garage.local:3900/",
Bucket: " backups ",
Prefix: " /prod/dbs/ ",
}
if err := normalizeBackupTargetRequest(&req); err != nil {
t.Fatalf("normalize failed: %v", err)
}
if req.Endpoint != "garage.local:3900" {
t.Fatalf("endpoint not normalized: %q", req.Endpoint)
}
if req.Bucket != "backups" || req.Prefix != "prod/dbs" {
t.Fatalf("bucket/prefix not trimmed: %q %q", req.Bucket, req.Prefix)
}
for i, bad := range []backupTargetRequest{
{Endpoint: "e", Bucket: "b"}, // no name
{Name: "n", Bucket: "b"}, // no endpoint
{Name: "n", Endpoint: "e"}, // no bucket
} {
if err := normalizeBackupTargetRequest(&bad); err == nil {
t.Fatalf("case %d: expected error", i)
}
}
}
func TestArchiveReaderUnwrapsTar(t *testing.T) {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
payload := []byte("backup-bytes")
if err := tw.WriteHeader(&tar.Header{Name: "b.tar.gz", Mode: 0o644, Size: int64(len(payload))}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(payload); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
r := &archiveReader{tar: io.NopCloser(&buf)}
got, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read: %v", err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("payload mismatch: %q", got)
}
}
+276 -56
View File
@@ -3,7 +3,9 @@ package api
import ( import (
"archive/tar" "archive/tar"
"containr/internal/database/sqlcdb" "containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/docker" "containr/internal/docker"
"containr/internal/secrets"
"context" "context"
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
@@ -23,6 +25,7 @@ import (
dockercontainer "github.com/docker/docker/api/types/container" dockercontainer "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry" "github.com/docker/docker/api/types/registry"
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -50,12 +53,32 @@ type DatabaseService struct {
Region string `json:"region" db:"region"` Region string `json:"region" db:"region"`
BackupSchedule string `json:"backup_schedule,omitempty"` BackupSchedule string `json:"backup_schedule,omitempty"`
NextBackupAt *time.Time `json:"next_backup_at,omitempty"` NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
Provider string `json:"provider"` // managed | external
PublicPort bool `json:"public_port" db:"public_port"`
BackupTargetID string `json:"backup_target_id,omitempty"` // S3-compatible archive destination
External *DatabaseExternalRef `json:"external,omitempty"` // connection target for external DBs
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
ConnectionURL string `json:"connection_url"` ConnectionURL string `json:"connection_url"`
Metrics DatabaseMetrics `json:"metrics"` Metrics DatabaseMetrics `json:"metrics"`
Backups DatabaseBackupConfig `json:"backups"` Backups DatabaseBackupConfig `json:"backups"`
Settings DatabaseSettings `json:"settings"` Settings DatabaseSettings `json:"settings"`
// externalPasswordEnc is the stored ciphertext — needed to compose the
// connection URL on privileged reads, never serialized.
externalPasswordEnc string `json:"-"`
}
// DatabaseExternalRef describes a registered external database's connection
// target. The password is stored encrypted and never returned — has_password
// signals presence.
type DatabaseExternalRef struct {
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
HasPassword bool `json:"has_password"`
SSL bool `json:"ssl"`
} }
// DatabaseMetrics represents database performance metrics // DatabaseMetrics represents database performance metrics
@@ -85,6 +108,7 @@ type DatabaseBackup struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Size string `json:"size"` Size string `json:"size"`
Status string `json:"status"` // completed, failed, in_progress Status string `json:"status"` // completed, failed, in_progress
Remote bool `json:"remote"` // archive also exists on the configured backup target
} }
// DatabaseSettings represents database configuration // DatabaseSettings represents database configuration
@@ -101,6 +125,7 @@ type DatabaseCreateRequest struct {
Type string `json:"type" binding:"required"` Type string `json:"type" binding:"required"`
Plan string `json:"plan" binding:"required,oneof=hobby starter standard business"` Plan string `json:"plan" binding:"required,oneof=hobby starter standard business"`
Region string `json:"region" binding:"required"` Region string `json:"region" binding:"required"`
PublicPort bool `json:"public_port"`
} }
// DatabaseUpdateRequest represents a request to update a database // DatabaseUpdateRequest represents a request to update a database
@@ -108,6 +133,8 @@ type DatabaseUpdateRequest struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Plan string `json:"plan,omitempty"` Plan string `json:"plan,omitempty"`
BackupSchedule *string `json:"backup_schedule,omitempty"` BackupSchedule *string `json:"backup_schedule,omitempty"`
PublicPort *bool `json:"public_port,omitempty"`
BackupTargetID *string `json:"backup_target_id,omitempty"` // empty string clears
} }
// DatabaseActionRequest represents a request to perform database actions // DatabaseActionRequest represents a request to perform database actions
@@ -196,7 +223,7 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
} }
db.Settings = h.generateMockSettings() db.Settings = h.generateMockSettings()
db.ConnectionURL = h.resolveConnectionURL(db) db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c))
} else { } else {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
db.ConnectionURL = "" db.ConnectionURL = ""
@@ -235,7 +262,7 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
} }
db.Settings = h.generateMockSettings() db.Settings = h.generateMockSettings()
db.ConnectionURL = h.resolveConnectionURL(db) db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c))
} else { } else {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
db.ConnectionURL = "" db.ConnectionURL = ""
@@ -275,6 +302,7 @@ func (h *DatabaseHandler) CreateDatabase(c *gin.Context) {
Type: req.Type, Type: req.Type,
Plan: req.Plan, Plan: req.Plan,
Region: req.Region, Region: req.Region,
PublicPort: req.PublicPort,
}) })
if err != nil { if err != nil {
switch { switch {
@@ -302,7 +330,13 @@ type managedDatabaseCreateRequest struct {
Type string Type string
Plan string Plan string
Region string Region string
PublicPort bool
RuntimeVariables map[string]string RuntimeVariables map[string]string
// ProjectID + NetworkAlias attach the container to a project network
// under Alias — set by graph-template deploys so sibling services can
// resolve the database by name.
ProjectID string
NetworkAlias string
} }
func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID string, req managedDatabaseCreateRequest) (databaseID string, name string, dbType string, err error) { func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID string, req managedDatabaseCreateRequest) (databaseID string, name string, dbType string, err error) {
@@ -353,6 +387,7 @@ func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID stri
Version: version, Version: version,
Plan: plan, Plan: plan,
Region: region, Region: region,
PublicPort: req.PublicPort,
CreatedAt: sql.NullTime{Time: now, Valid: true}, CreatedAt: sql.NullTime{Time: now, Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true}, UpdatedAt: sql.NullTime{Time: now, Valid: true},
}) })
@@ -369,10 +404,17 @@ func (h *DatabaseHandler) createManagedDatabaseAndProvision(ctx context.Context,
return "", err return "", err
} }
go h.provisionDatabaseWithVariables(databaseID, name, dbType, req.RuntimeVariables) go h.provisionDatabaseWithVariables(databaseID, name, dbType, req.RuntimeVariables, dbNetworkAttach{ProjectID: req.ProjectID, Alias: req.NetworkAlias})
return databaseID, nil return databaseID, nil
} }
// dbNetworkAttach carries the optional project-network membership for a
// managed database provisioned by a graph template.
type dbNetworkAttach struct {
ProjectID string
Alias string
}
// UpdateDatabase updates a database service // UpdateDatabase updates a database service
func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) { func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c) userID, ok := requireAuthenticatedUserID(c)
@@ -391,11 +433,64 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
req.Name = strings.TrimSpace(req.Name) req.Name = strings.TrimSpace(req.Name)
req.Plan = strings.TrimSpace(req.Plan) req.Plan = strings.TrimSpace(req.Plan)
if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil { if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil && req.PublicPort == nil && req.BackupTargetID == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"}) c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
return return
} }
if req.PublicPort != nil || req.BackupTargetID != nil {
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID,
UserID: userID,
})
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
return
}
if req.PublicPort != nil {
if row.Provider == "external" {
c.JSON(http.StatusBadRequest, gin.H{"error": "public_port applies to managed databases — external databases are already reachable", "code": "VALIDATION"})
return
}
if err := h.queries.SetDatabaseServicePublicPortByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseServicePublicPortByIDAndUserParams{
PublicPort: *req.PublicPort,
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: databaseID,
UserID: userID,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update public port"})
return
}
// Port bindings are baked into the container — recreate it so the
// new bind scope takes effect. The data volume survives.
if *req.PublicPort != row.PublicPort {
go h.reprovisionManagedDatabase(databaseID, row.Name, row.Type)
}
}
if req.BackupTargetID != nil {
if row.Provider == "external" {
c.JSON(http.StatusBadRequest, gin.H{"error": "backup targets apply to managed databases — external backups are not supported yet", "code": "VALIDATION"})
return
}
targetID := strings.TrimSpace(*req.BackupTargetID)
if targetID != "" {
if _, err := h.queries.GetBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.GetBackupTargetByIDAndUserParams{ID: targetID, UserID: userID}); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup target not found", "code": "NOT_FOUND"})
return
}
}
if err := h.queries.SetDatabaseBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseBackupTargetByIDAndUserParams{
BackupTargetID: sql.NullString{String: targetID, Valid: targetID != ""},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: databaseID,
UserID: userID,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update backup target"})
return
}
}
}
if req.BackupSchedule != nil { if req.BackupSchedule != nil {
var schedule sql.NullString var schedule sql.NullString
var nextBackup sql.NullTime var nextBackup sql.NullTime
@@ -466,21 +561,20 @@ func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) {
databaseID := c.Param("id") databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID) userID = h.effectiveUserID(c, userID, databaseID)
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{ row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID, ID: databaseID,
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
return return
} }
if !exists { if row.Provider != "external" && h.dockerClient != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
if h.dockerClient != nil {
if err := h.deleteManagedDatabaseRuntime(databaseID); err != nil { if err := h.deleteManagedDatabaseRuntime(databaseID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete runtime database"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete runtime database"})
return return
@@ -514,17 +608,24 @@ func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) {
return return
} }
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{ row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID, ID: databaseID,
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
return return
} }
if !exists { if row.Provider == "external" {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) c.JSON(http.StatusBadRequest, gin.H{
"error": "Lifecycle actions apply to managed databases only — external databases are run elsewhere",
"code": "VALIDATION",
})
return return
} }
@@ -618,6 +719,13 @@ func (h *DatabaseHandler) CreateBackup(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{"error": "Database is still provisioning"}) c.JSON(http.StatusConflict, gin.H{"error": "Database is still provisioning"})
return return
} }
if row.Provider == "external" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Backups run against the managed dump pipeline — external database backups are not supported yet",
"code": "VALIDATION",
})
return
}
backupID := generateDatabaseBackupID(databaseID) backupID := generateDatabaseBackupID(databaseID)
archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID)) archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID))
@@ -658,17 +766,23 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
return return
} }
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{ dbRow, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID, ID: databaseID,
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
return return
} }
if dbRow.Provider == "external" {
if !exists { c.JSON(http.StatusBadRequest, gin.H{
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) "error": "Restores run against the managed dump pipeline — external database restores are not supported yet",
"code": "VALIDATION",
})
return return
} }
@@ -786,7 +900,7 @@ func (h *DatabaseHandler) DownloadBackup(c *gin.Context) {
} }
func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics { func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics {
if h.dockerClient == nil { if h.dockerClient == nil || db.Provider == "external" {
return h.generateMockMetrics() return h.generateMockMetrics()
} }
@@ -917,6 +1031,7 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string
CreatedAt: createdAt, CreatedAt: createdAt,
Size: row.Size, Size: row.Size,
Status: row.Status, Status: row.Status,
Remote: row.RemoteKey.Valid && row.RemoteKey.String != "",
}) })
if lastBackup == nil && row.Status == "completed" { if lastBackup == nil && row.Status == "completed" {
t := createdAt t := createdAt
@@ -942,37 +1057,55 @@ func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
} }
} }
// resolveConnectionURL returns the stored connection URL for privileged
// viewers, or "" when the runtime has not produced one (not provisioned yet).
// External databases compose it from the registered fields on demand.
func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string { func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string {
if strings.TrimSpace(db.ConnectionURL) != "" { if db.Provider == "external" && db.External != nil {
return db.ConnectionURL return externalConnectionURL(db.Type, db.External, secrets.Decrypt(db.externalPasswordEnc))
} }
return h.generateConnectionURL(db) return strings.TrimSpace(db.ConnectionURL)
} }
func (h *DatabaseHandler) generateConnectionURL(db DatabaseService) string { // requestHostname returns the host the client used to reach the API, without
safeName := sanitizeDBIdentifier(db.Name, "app") // the port. Empty when it cannot be determined.
switch db.Type { func requestHostname(c *gin.Context) string {
case "postgresql": host := strings.TrimSpace(c.Request.Host)
return fmt.Sprintf("postgresql://user:password@%s.containr.local:5432/%s", safeName, safeName) if host == "" {
case "redis":
return fmt.Sprintf("redis://%s.containr.local:6379", safeName)
case "dragonfly":
return fmt.Sprintf("redis://%s.containr.local:6379", safeName)
case "mysql":
return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", safeName, safeName)
case "mariadb":
return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", safeName, safeName)
case "mongodb":
return fmt.Sprintf("mongodb://user:password@%s.containr.local:27017/%s", safeName, safeName)
case "clickhouse":
return fmt.Sprintf("http://%s.containr.local:8123", safeName)
default:
return "" return ""
} }
if h, _, err := net.SplitHostPort(host); err == nil {
return strings.Trim(h, "[]")
}
return strings.Trim(host, "[]")
}
// rewriteLoopbackURL swaps loopback/unspecified hosts in rawURL for the host
// the caller actually used, so URLs handed to remote browsers are usable.
func rewriteLoopbackURL(rawURL, host string) string {
if rawURL == "" || host == "" {
return rawURL
}
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
switch strings.ToLower(u.Hostname()) {
case "localhost", "127.0.0.1", "0.0.0.0", "::1", "":
default:
return rawURL
}
port := u.Port()
if port == "" {
u.Host = host
} else {
u.Host = net.JoinHostPort(host, port)
}
return u.String()
} }
func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService { func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService {
if h.dockerClient == nil { if h.dockerClient == nil || db.Provider == "external" {
return db return db
} }
@@ -1039,7 +1172,7 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
} }
func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService { func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
return DatabaseService{ svc := DatabaseService{
ID: row.ID, ID: row.ID,
OwnerID: row.UserID, OwnerID: row.UserID,
Name: row.Name, Name: row.Name,
@@ -1050,10 +1183,25 @@ func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
Region: row.Region, Region: row.Region,
BackupSchedule: databaseNullString(row.BackupSchedule), BackupSchedule: databaseNullString(row.BackupSchedule),
NextBackupAt: databaseNullTimePtr(row.NextBackupAt), NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
Provider: row.Provider,
PublicPort: row.PublicPort,
BackupTargetID: databaseNullString(row.BackupTargetID),
ConnectionURL: databaseNullString(row.ConnectionUrl), ConnectionURL: databaseNullString(row.ConnectionUrl),
CreatedAt: databaseNullTime(row.CreatedAt), CreatedAt: databaseNullTime(row.CreatedAt),
UpdatedAt: databaseNullTime(row.UpdatedAt), UpdatedAt: databaseNullTime(row.UpdatedAt),
} }
if svc.Provider == "external" {
svc.External = &DatabaseExternalRef{
Host: databaseNullString(row.ExternalHost),
Port: int(row.ExternalPort.Int32),
Database: databaseNullString(row.ExternalName),
Username: databaseNullString(row.ExternalUsername),
HasPassword: row.ExternalPassword != "",
SSL: row.ExternalSsl,
}
svc.externalPasswordEnc = row.ExternalPassword
}
return svc
} }
func databaseNullTime(value sql.NullTime) time.Time { func databaseNullTime(value sql.NullTime) time.Time {
@@ -1215,21 +1363,32 @@ type databaseRuntimePlan struct {
} }
func (h *DatabaseHandler) provisionDatabase(databaseID, databaseName, dbType string) { func (h *DatabaseHandler) provisionDatabase(databaseID, databaseName, dbType string) {
h.provisionDatabaseWithVariables(databaseID, databaseName, dbType, nil) h.provisionDatabaseWithVariables(databaseID, databaseName, dbType, nil, dbNetworkAttach{})
} }
func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string) { // reprovisionManagedDatabase recreates a managed container so changed
// runtime settings (public_port) take effect. The data volume persists.
func (h *DatabaseHandler) reprovisionManagedDatabase(databaseID, databaseName, dbType string) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if h.dockerClient != nil {
_ = h.dockerClient.RemoveContainer(ctx, managedDatabaseContainerName(databaseID), true)
}
h.provisionDatabase(databaseID, databaseName, dbType)
}
func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string, attach dbNetworkAttach) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
if h.dockerClient == nil { if h.dockerClient == nil {
fallbackURL := h.generateConnectionURL(DatabaseService{Name: databaseName, Type: dbType}) _ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{})
_ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{String: fallbackURL, Valid: fallbackURL != ""})
return return
} }
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables) connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables, attach)
if err != nil { if err != nil {
log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err)
_ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{}) _ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{})
return return
} }
@@ -1249,7 +1408,7 @@ func (h *DatabaseHandler) setDatabaseStatusAndConnection(databaseID, status stri
}) })
} }
func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, databaseID, databaseName, dbType string, runtimeVariables map[string]string) (string, error) { func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, databaseID, databaseName, dbType string, runtimeVariables map[string]string, attach dbNetworkAttach) (string, error) {
plan, err := buildDatabaseRuntimePlan(dbType, databaseName, runtimeVariables) plan, err := buildDatabaseRuntimePlan(dbType, databaseName, runtimeVariables)
if err != nil { if err != nil {
return "", err return "", err
@@ -1274,8 +1433,12 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
} }
} }
hostIP := "127.0.0.1"
if row, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil && row.PublicPort {
hostIP = "0.0.0.0"
}
portBindings := nat.PortMap{ portBindings := nat.PortMap{
plan.Port: []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: ""}}, plan.Port: []nat.PortBinding{{HostIP: hostIP, HostPort: ""}},
} }
exposedPorts := nat.PortSet{plan.Port: struct{}{}} exposedPorts := nat.PortSet{plan.Port: struct{}{}}
@@ -1333,11 +1496,22 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
return "", fmt.Errorf("failed to start container: %w", err) return "", fmt.Errorf("failed to start container: %w", err)
} }
// Graph-template deploys attach the database to the project network so
// sibling services resolve it by alias ({{service.<key>.host}}).
if attach.ProjectID != "" && attach.Alias != "" {
projectNet := deployment.ProjectNetworkName(attach.ProjectID)
if err := h.dockerClient.ConnectNetwork(ctx, projectNet, containerID, network.EndpointSettings{
Aliases: []string{attach.Alias},
}); err != nil {
log.Printf("containr: database %s attach to %s failed: %v", databaseID, projectNet, err)
}
}
hostPort, err := h.resolvePublishedHostPort(ctx, containerID, plan.Port) hostPort, err := h.resolvePublishedHostPort(ctx, containerID, plan.Port)
if err != nil { if err != nil {
return "", err return "", err
} }
if err := waitForRuntimePortReadiness(ctx, hostPort); err != nil { if err := h.waitForDatabaseReadiness(ctx, containerID, hostPort, plan.Port.Port()); err != nil {
return "", err return "", err
} }
@@ -1371,12 +1545,24 @@ func (h *DatabaseHandler) resolvePublishedHostPort(ctx context.Context, containe
return "", fmt.Errorf("published host port not available") return "", fmt.Errorf("published host port not available")
} }
func waitForRuntimePortReadiness(ctx context.Context, hostPort string) error { // waitForDatabaseReadiness probes the database's TCP port until it accepts a
addr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort)) // connection. The published port lives on host loopback, which a containerized
deadline := time.Now().Add(30 * time.Second) // backend cannot reach; in that case the database is additionally attached to
// the backend's own network and probed by container IP.
func (h *DatabaseHandler) waitForDatabaseReadiness(ctx context.Context, containerID, hostPort, containerPort string) error {
probeAddr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort))
if backendNet := h.dockerClient.OwnNetworkName(ctx); backendNet != "" {
if err := h.dockerClient.ConnectNetwork(ctx, backendNet, containerID, network.EndpointSettings{}); err != nil {
log.Printf("containr: attach database %s to %s failed: %v", containerID, backendNet, err)
}
if ip := h.containerIPOnNetwork(ctx, containerID, backendNet); ip != "" {
probeAddr = net.JoinHostPort(ip, containerPort)
}
}
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 1500*time.Millisecond) conn, err := net.DialTimeout("tcp", probeAddr, 1500*time.Millisecond)
if err == nil { if err == nil {
_ = conn.Close() _ = conn.Close()
return nil return nil
@@ -1389,7 +1575,18 @@ func waitForRuntimePortReadiness(ctx context.Context, hostPort string) error {
} }
} }
return fmt.Errorf("database runtime did not become reachable on %s", addr) return fmt.Errorf("database runtime did not become reachable on %s", probeAddr)
}
func (h *DatabaseHandler) containerIPOnNetwork(ctx context.Context, containerID, networkName string) string {
inspect, err := h.dockerClient.GetContainer(ctx, containerID)
if err != nil || inspect.NetworkSettings == nil {
return ""
}
if ep, ok := inspect.NetworkSettings.Networks[networkName]; ok && ep != nil {
return strings.TrimSpace(ep.IPAddress)
}
return ""
} }
func (h *DatabaseHandler) ensureImage(ctx context.Context, imageRef string) error { func (h *DatabaseHandler) ensureImage(ctx context.Context, imageRef string) error {
@@ -1671,10 +1868,17 @@ func (h *DatabaseHandler) createBackupProcess(databaseID, backupID, backupPath s
_ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false) _ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false)
return return
} }
if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil {
if key, err := h.uploadBackupToTarget(ctx, dbRow, backupPath, backupID); err != nil {
log.Printf("containr: backup %s remote upload failed: %v (local archive kept)", backupID, err)
} else if key != "" {
log.Printf("containr: backup %s shipped to %s", backupID, key)
}
}
_ = h.setDatabaseBackupStatus(backupID, "completed", sizeLabel, true) _ = h.setDatabaseBackupStatus(backupID, "completed", sizeLabel, true)
} }
func (h *DatabaseHandler) restoreBackupProcess(databaseID, _ string, backupPath string) { func (h *DatabaseHandler) restoreBackupProcess(databaseID, backupID string, backupPath string) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel() defer cancel()
@@ -1683,6 +1887,22 @@ func (h *DatabaseHandler) restoreBackupProcess(databaseID, _ string, backupPath
return return
} }
// Restore the archive from the configured S3 target when the local copy
// is gone (fresh node, pruned volume) — local takes precedence.
if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil && dbRow.BackupTargetID.Valid && dbRow.BackupTargetID.String != "" {
if ok, _ := h.backupArchiveExists(ctx, backupPath); !ok {
if b, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(ctx, sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{
ID: backupID, DatabaseID: databaseID, UserID: dbRow.UserID,
}); err == nil && b.RemoteKey.Valid && b.RemoteKey.String != "" {
if err := h.downloadBackupToVolume(ctx, dbRow, b.RemoteKey.String, backupPath); err != nil {
log.Printf("containr: remote restore fetch failed for backup %s: %v", backupID, err)
_ = h.setDatabaseStatus(databaseID, "error")
return
}
}
}
}
_ = h.setDatabaseStatus(databaseID, "building") _ = h.setDatabaseStatus(databaseID, "building")
_ = h.stopManagedDatabase(databaseID) _ = h.stopManagedDatabase(databaseID)
+39 -7
View File
@@ -51,15 +51,16 @@ func TestDatabaseConnectionURLAndDefaultVersion(t *testing.T) {
} }
for _, tc := range cases { for _, tc := range cases {
url := handler.generateConnectionURL(DatabaseService{ plan, err := buildDatabaseRuntimePlan(tc.dbType, "example", nil)
Type: tc.dbType, if err != nil {
Name: "example", t.Fatalf("buildDatabaseRuntimePlan(%q) failed: %v", tc.dbType, err)
})
if url == "" {
t.Fatalf("generateConnectionURL returned empty for type %q", tc.dbType)
} }
url := plan.ConnectionURL("15432")
if len(tc.urlContains) > 0 && !strings.HasPrefix(url, tc.urlContains) { if len(tc.urlContains) > 0 && !strings.HasPrefix(url, tc.urlContains) {
t.Fatalf("generateConnectionURL(%q) = %q, expected prefix %q", tc.dbType, url, tc.urlContains) t.Fatalf("ConnectionURL(%q) = %q, expected prefix %q", tc.dbType, url, tc.urlContains)
}
if !strings.Contains(url, ":15432") {
t.Fatalf("ConnectionURL(%q) = %q, expected published port", tc.dbType, url)
} }
gotVersion := handler.getDefaultVersion(tc.dbType) gotVersion := handler.getDefaultVersion(tc.dbType)
@@ -69,6 +70,17 @@ func TestDatabaseConnectionURLAndDefaultVersion(t *testing.T) {
} }
} }
func TestResolveConnectionURLNeverFabricates(t *testing.T) {
handler := &DatabaseHandler{}
if got := handler.resolveConnectionURL(DatabaseService{Type: "postgresql", Name: "example"}); got != "" {
t.Fatalf("resolveConnectionURL with no stored URL = %q, want empty", got)
}
if got := handler.resolveConnectionURL(DatabaseService{ConnectionURL: "postgresql://u:p@localhost:15432/db"}); got != "postgresql://u:p@localhost:15432/db" {
t.Fatalf("resolveConnectionURL = %q, want stored URL", got)
}
}
func TestBuildDatabaseRuntimePlanSupportsAllTypes(t *testing.T) { func TestBuildDatabaseRuntimePlanSupportsAllTypes(t *testing.T) {
cases := []struct { cases := []struct {
dbType string dbType string
@@ -199,3 +211,23 @@ func TestHumanReadableBytes(t *testing.T) {
} }
} }
} }
func TestRewriteLoopbackURL(t *testing.T) {
tests := []struct {
name, raw, host, want string
}{
{"service url", "http://localhost:35981", "10.0.0.182", "http://10.0.0.182:35981"},
{"db url keeps creds", "postgresql://u:p@localhost:35432/db?sslmode=disable", "10.0.0.182", "postgresql://u:[email protected]:35432/db?sslmode=disable"},
{"ipv4 loopback", "redis://:[email protected]:36379/0", "host.lan", "redis://:[email protected]:36379/0"},
{"unspecified", "http://0.0.0.0:9000/x", "10.0.0.182", "http://10.0.0.182:9000/x"},
{"external host untouched", "https://example.com:443/x", "10.0.0.182", "https://example.com:443/x"},
{"empty raw", "", "10.0.0.182", ""},
{"empty host", "http://localhost:35981", "", "http://localhost:35981"},
{"no port", "http://localhost/path", "10.0.0.182", "http://10.0.0.182/path"},
}
for _, tt := range tests {
if got := rewriteLoopbackURL(tt.raw, tt.host); got != tt.want {
t.Errorf("%s: rewriteLoopbackURL(%q,%q) = %q, want %q", tt.name, tt.raw, tt.host, got, tt.want)
}
}
}
+319
View File
@@ -0,0 +1,319 @@
package api
import (
"context"
"crypto/tls"
"database/sql"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"containr/internal/database/sqlcdb"
"containr/internal/secrets"
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
)
// externalDatabaseRequest is the shared payload for registering an external
// database and for ad-hoc connection tests.
type externalDatabaseRequest struct {
Name string `json:"name"`
Type string `json:"type" binding:"required"`
Host string `json:"host" binding:"required"`
Port int `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password"`
SSL bool `json:"ssl"`
}
var externalDefaultPorts = map[string]int{
"postgresql": 5432,
"mysql": 3306,
"mariadb": 3306,
"mongodb": 27017,
"redis": 6379,
"dragonfly": 6379,
"clickhouse": 9000,
}
// externalConnectionURL composes a DSN for display/bind use. The password is
// embedded only on privileged reads — the value is decrypted at that point.
func externalConnectionURL(dbType string, ext *DatabaseExternalRef, password string) string {
host := net.JoinHostPort(ext.Host, fmt.Sprintf("%d", ext.Port))
creds := ""
if ext.Username != "" {
creds = url.QueryEscape(ext.Username)
if password != "" {
creds += ":" + url.QueryEscape(password)
}
creds += "@"
}
switch dbType {
case "postgresql":
u := fmt.Sprintf("postgres://%s%s/%s", creds, host, ext.Database)
if ext.SSL {
u += "?sslmode=require"
}
return u
case "mysql", "mariadb":
return fmt.Sprintf("mysql://%s%s/%s", creds, host, ext.Database)
case "mongodb":
u := fmt.Sprintf("mongodb://%s%s/%s", creds, host, ext.Database)
if ext.SSL {
u += "?tls=true"
}
return u
case "redis", "dragonfly":
scheme := "redis"
if ext.SSL {
scheme = "rediss"
}
// Redis auth is password-only; username support is ACL-era.
if password != "" {
creds = ":" + url.QueryEscape(password) + "@"
if ext.Username != "" {
creds = url.QueryEscape(ext.Username) + creds
}
}
return fmt.Sprintf("%s://%s%s", scheme, creds, host)
case "clickhouse":
return fmt.Sprintf("clickhouse://%s%s/%s", creds, host, ext.Database)
}
return fmt.Sprintf("%s://%s%s", dbType, creds, host)
}
// probeExternalDatabase verifies reachability. postgres and redis-family get
// real protocol checks via the vendored drivers; everything else gets a TCP
// dial — enough to catch typos and firewall issues without new dependencies.
func probeExternalDatabase(ctx context.Context, req externalDatabaseRequest) (time.Duration, error) {
addr := net.JoinHostPort(req.Host, fmt.Sprintf("%d", req.Port))
dialer := &net.Dialer{Timeout: 5 * time.Second}
start := time.Now()
switch req.Type {
case "postgresql":
sslmode := "disable"
if req.SSL {
sslmode = "require"
}
dsn := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s&connect_timeout=5",
url.QueryEscape(req.Username), url.QueryEscape(req.Password), addr,
url.QueryEscape(req.Database), sslmode)
conn, err := sql.Open("postgres", dsn)
if err != nil {
return 0, err
}
defer conn.Close()
if err := conn.PingContext(ctx); err != nil {
return 0, err
}
case "redis", "dragonfly":
client := redis.NewClient(&redis.Options{
Addr: addr,
Username: req.Username,
Password: req.Password,
DialTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
TLSConfig: redisTLSConfig(req.SSL, req.Host),
})
defer client.Close()
if err := client.Ping(ctx).Err(); err != nil {
return 0, err
}
default:
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return 0, err
}
conn.Close()
}
return time.Since(start), nil
}
func redisTLSConfig(enabled bool, host string) *tls.Config {
if !enabled {
return nil
}
return &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
}
// handleTestDatabaseConnection probes arbitrary connection parameters — used
// by the register form's "test" button and by agents before registering.
func (h *DatabaseHandler) TestDatabaseConnection(c *gin.Context) {
if _, ok := requireAuthenticatedUserID(c); !ok {
return
}
var req externalDatabaseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
if err := normalizeExternalRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
latency, err := probeExternalDatabase(ctx, req)
if err != nil {
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
}
// TestDatabaseConnectionByID re-probes a registered external database using
// its stored (decrypted) credentials.
func (h *DatabaseHandler) TestDatabaseConnectionByID(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID,
UserID: userID,
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"})
return
}
if row.Provider != "external" {
c.JSON(http.StatusBadRequest, gin.H{"error": "test-connection applies to external databases", "code": "VALIDATION"})
return
}
req := externalDatabaseRequest{
Type: row.Type,
Host: databaseNullString(row.ExternalHost),
Port: int(row.ExternalPort.Int32),
Database: databaseNullString(row.ExternalName),
Username: databaseNullString(row.ExternalUsername),
Password: secrets.Decrypt(row.ExternalPassword),
SSL: row.ExternalSsl,
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
latency, probeErr := probeExternalDatabase(ctx, req)
if probeErr != nil {
c.JSON(http.StatusOK, gin.H{"ok": false, "error": probeErr.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
}
func normalizeExternalRequest(req *externalDatabaseRequest) error {
req.Type = normalizeDatabaseType(req.Type)
if _, ok := supportedDatabaseTypes[req.Type]; !ok {
return fmt.Errorf("%w: %s", errUnsupportedDatabaseType, req.Type)
}
req.Host = strings.TrimSpace(req.Host)
if req.Host == "" {
return errors.New("host is required")
}
if req.Port <= 0 {
req.Port = externalDefaultPorts[req.Type]
}
if req.Port <= 0 || req.Port > 65535 {
return errors.New("port must be 1-65535")
}
return nil
}
// RegisterExternalDatabase records a database hosted elsewhere — same list,
// same bind surface, no container. The connection is probed first so typos
// fail at registration, not at the first service bind.
func (h *DatabaseHandler) RegisterExternalDatabase(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
var req externalDatabaseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"})
return
}
if err := normalizeExternalRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
if _, err := probeExternalDatabase(probeCtx, req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Connection check failed: %v", err),
"code": "DEPENDENCY_UNAVAILABLE",
})
return
}
count, err := h.queries.CountDatabaseServicesByUserAndName(c.Request.Context(), sqlcdb.CountDatabaseServicesByUserAndNameParams{
UserID: userID,
Name: req.Name,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate database name"})
return
}
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "database name already exists", "code": "CONFLICT"})
return
}
databaseID := generateDatabaseID(req.Name)
now := sql.NullTime{Time: time.Now(), Valid: true}
encPassword := ""
if req.Password != "" {
encPassword = secrets.Encrypt(req.Password)
}
if err := h.queries.CreateExternalDatabaseService(c.Request.Context(), sqlcdb.CreateExternalDatabaseServiceParams{
ID: databaseID,
UserID: userID,
Name: req.Name,
Type: req.Type,
Version: "",
Plan: "external",
Region: "external",
ExternalHost: sql.NullString{String: req.Host, Valid: true},
ExternalPort: sql.NullInt32{Int32: int32(req.Port), Valid: true},
ExternalName: sql.NullString{String: req.Database, Valid: req.Database != ""},
ExternalUsername: sql.NullString{String: req.Username, Valid: req.Username != ""},
ExternalPassword: encPassword,
ExternalSsl: req.SSL,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register database"})
return
}
LogAudit(userID, "database", databaseID, "register-external", map[string]interface{}{
"name": req.Name,
"type": req.Type,
"host": req.Host,
})
c.JSON(http.StatusCreated, gin.H{
"id": databaseID,
"message": "External database registered",
"status": "running",
})
}
@@ -0,0 +1,97 @@
package api
import (
"context"
"net"
"testing"
)
func TestNormalizeExternalRequestDefaults(t *testing.T) {
req := externalDatabaseRequest{Type: "PostgreSQL", Host: " db.example.com ", Port: 0}
if err := normalizeExternalRequest(&req); err != nil {
t.Fatalf("normalize failed: %v", err)
}
if req.Type != "postgresql" {
t.Fatalf("type not normalized: %q", req.Type)
}
if req.Host != "db.example.com" {
t.Fatalf("host not trimmed: %q", req.Host)
}
if req.Port != 5432 {
t.Fatalf("default port = %d, want 5432", req.Port)
}
}
func TestNormalizeExternalRequestRejects(t *testing.T) {
cases := []externalDatabaseRequest{
{Type: "sqlite", Host: "x"}, // unsupported type
{Type: "postgresql", Host: " "}, // empty host
{Type: "postgresql", Host: "h", Port: 70000}, // out of range
}
for i, req := range cases {
if err := normalizeExternalRequest(&req); err == nil {
t.Fatalf("case %d: expected error", i)
}
}
}
func TestExternalConnectionURLMasking(t *testing.T) {
ext := &DatabaseExternalRef{
Host: "db.example.com",
Port: 5432,
Database: "appdb",
Username: "svc",
SSL: true,
}
u := externalConnectionURL("postgresql", ext, "s3cret")
if u != "postgres://svc:[email protected]:5432/appdb?sslmode=require" {
t.Fatalf("unexpected pg url: %s", u)
}
u = externalConnectionURL("postgresql", ext, "")
if u != "postgres://[email protected]:5432/appdb?sslmode=require" {
t.Fatalf("unexpected passwordless url: %s", u)
}
r := &DatabaseExternalRef{Host: "cache.internal", Port: 6379, SSL: true}
u = externalConnectionURL("redis", r, "pw")
if u != "rediss://:[email protected]:6379" {
t.Fatalf("unexpected redis url: %s", u)
}
}
func TestProbeExternalDatabaseTCP(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
port := ln.Addr().(*net.TCPAddr).Port
// TCP fallback path — mysql uses a plain dial.
latency, err := probeExternalDatabase(context.Background(), externalDatabaseRequest{
Type: "mysql", Host: "127.0.0.1", Port: port,
})
if err != nil {
t.Fatalf("probe should succeed against open listener: %v", err)
}
if latency <= 0 {
t.Fatalf("latency not recorded")
}
// Closed port must fail fast.
if _, err := probeExternalDatabase(context.Background(), externalDatabaseRequest{
Type: "mysql", Host: "127.0.0.1", Port: 1,
}); err == nil {
t.Fatalf("probe should fail against closed port")
}
}
func TestRedisTLSConfig(t *testing.T) {
if redisTLSConfig(false, "h") != nil {
t.Fatalf("disabled ssl must produce nil tls config")
}
cfg := redisTLSConfig(true, "db.example.com")
if cfg == nil || cfg.ServerName != "db.example.com" {
t.Fatalf("expected ServerName passthrough, got %+v", cfg)
}
}
+253 -36
View File
@@ -3,10 +3,13 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/docker"
"context" "context"
"errors"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -35,6 +38,7 @@ type CreateDeploymentRequest struct {
Branch string `json:"branch"` Branch string `json:"branch"`
Trigger string `json:"trigger"` Trigger string `json:"trigger"`
EnvVars map[string]string `json:"env_vars"` EnvVars map[string]string `json:"env_vars"`
NoCache bool `json:"no_cache"`
} }
type DeploymentResponse struct { type DeploymentResponse struct {
@@ -74,14 +78,21 @@ func handleGetDeployments(c *gin.Context) {
return return
} }
offset, limit := pageWindow(c, 50, 100)
var total int
_ = db.(*database.DB).QueryRow(
`SELECT COUNT(*) FROM deployments WHERE service_id = $1`, serviceID,
).Scan(&total)
rows, err := db.(*database.DB).Query( rows, err := db.(*database.DB).Query(
`SELECT id, service_id, commit_hash, status, image_name, image_tag, `SELECT id, service_id, commit_hash, status, image_name, image_tag,
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
FROM deployments FROM deployments
WHERE service_id = $1 WHERE service_id = $1
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 50`, LIMIT $2 OFFSET $3`,
serviceID, serviceID, limit, offset,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
@@ -104,7 +115,10 @@ func handleGetDeployments(c *gin.Context) {
deployments = append(deployments, d) deployments = append(deployments, d)
} }
c.JSON(http.StatusOK, gin.H{"deployments": deployments}) c.JSON(http.StatusOK, gin.H{
"deployments": deployments,
"pagination": paginationMeta(offset, limit, total),
})
} }
// RecentDeployment is a deployment row joined with its service and project names. // RecentDeployment is a deployment row joined with its service and project names.
@@ -132,11 +146,19 @@ func handleGetRecentDeployments(c *gin.Context) {
userID := optionalUserUUID(c) userID := optionalUserUUID(c)
isAdmin := contextIsAdmin(c) isAdmin := contextIsAdmin(c)
limit := 10 offset, limit := pageWindow(c, 10, 50)
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 { var total int
limit = n if err := db.(*database.DB).QueryRow(
} `SELECT COUNT(*)
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`,
userID, isAdmin,
).Scan(&total); err != nil {
total = 0
} }
rows, err := db.(*database.DB).Query( rows, err := db.(*database.DB).Query(
@@ -148,8 +170,8 @@ func handleGetRecentDeployments(c *gin.Context) {
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1) OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
ORDER BY d.created_at DESC ORDER BY d.created_at DESC
LIMIT $3`, LIMIT $3 OFFSET $4`,
userID, isAdmin, limit, userID, isAdmin, limit, offset,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
@@ -168,7 +190,10 @@ func handleGetRecentDeployments(c *gin.Context) {
deployments = append(deployments, d) deployments = append(deployments, d)
} }
c.JSON(http.StatusOK, gin.H{"deployments": deployments}) c.JSON(http.StatusOK, gin.H{
"deployments": deployments,
"pagination": paginationMeta(offset, limit, total),
})
} }
func handleCreateDeployment(c *gin.Context) { func handleCreateDeployment(c *gin.Context) {
@@ -209,6 +234,9 @@ func handleCreateDeployment(c *gin.Context) {
COALESCE(s.replicas, 1), COALESCE(s.port, 0), COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'), COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at, p.owner_id s.created_at, s.updated_at, p.owner_id
FROM services s FROM services s
JOIN projects p ON s.project_id = p.id JOIN projects p ON s.project_id = p.id
@@ -219,7 +247,9 @@ func handleCreateDeployment(c *gin.Context) {
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectOwner, &service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
) )
if err != nil { if err != nil {
@@ -289,7 +319,23 @@ func handleCreateDeployment(c *gin.Context) {
} }
engineInstance := engine.(*deployment.DeploymentEngine) engineInstance := engine.(*deployment.DeploymentEngine)
go runDeploymentAndSync(context.Background(), db.(*database.DB), engineInstance, &d, service, req, userID.(string)) pos := getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
DeploymentID: d.ID,
Run: func(jctx context.Context) {
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, service, req, userID.(string))
},
})
if pos > 0 {
d.Status = "queued"
_, _ = db.(*database.DB).Exec(
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), d.ID,
)
_, _ = db.(*database.DB).Exec(
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
)
}
} }
c.JSON(http.StatusCreated, DeploymentResponse{ c.JSON(http.StatusCreated, DeploymentResponse{
@@ -356,6 +402,25 @@ func runDeploymentAndSyncWithImage(
replicas = 1 replicas = 1
} }
var publishedPort int32
// Best effort: reuse the host port from the last live deployment so public
// URLs survive redeploys. Column exists post-migration; older DBs get
// ephemeral ports.
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
publicPort := int32(service.Port)
if service.Builder == "static" && publicPort == 0 {
// The static builder's image is nginx — route to its default port
// unless the user picked another.
publicPort = 80
}
maintenanceMode, basicAuthUsers := serviceAccess(db, service.ID)
maintURL := ""
if maintenanceMode {
maintURL = maintenanceURL(db)
}
deployReq := &deployment.DeploymentRequest{ deployReq := &deployment.DeploymentRequest{
ProjectID: service.ProjectID.String(), ProjectID: service.ProjectID.String(),
ServiceID: service.ID.String(), ServiceID: service.ID.String(),
@@ -366,14 +431,22 @@ func runDeploymentAndSyncWithImage(
Command: command, Command: command,
Environment: env, Environment: env,
Replicas: replicas, Replicas: replicas,
PublicPort: int32(service.Port), PublicPort: publicPort,
PublishedPort: publishedPort,
Domain: service.Domain, Domain: service.Domain,
HealthPath: service.HealthCheckPath, HealthPath: service.HealthCheckPath,
RestartPolicy: service.RestartPolicy, RestartPolicy: service.RestartPolicy,
VolumeMounts: loadServiceVolumes(db, service.ID),
Domains: serviceDomainNames(db, service.ID, service.Domain),
Resources: deployment.ResourceLimits{ Resources: deployment.ResourceLimits{
MemoryBytes: parseMemoryLimit(service.Memory), MemoryBytes: parseMemoryLimit(service.Memory),
MemoryReservation: parseMemoryLimit(service.MemoryReserve),
CPUQuota: parseCPULimit(service.CPU), CPUQuota: parseCPULimit(service.CPU),
CPUShares: parseCPUReserveShares(service.CPUReserve),
}, },
Maintenance: maintenanceMode,
MaintenanceURL: maintURL,
BasicAuthUsers: basicAuthUsers,
}, },
Trigger: deployment.TriggerConfig{ Trigger: deployment.TriggerConfig{
Type: req.Trigger, Type: req.Trigger,
@@ -383,24 +456,50 @@ func runDeploymentAndSyncWithImage(
}, },
} }
if imageOverride != "" { if imageOverride != "" || service.GitRepo == "" {
deployReq.BuildConfig = &deployment.BuildConfig{ // Prebuilt image deploy (rollback, redeploy, or image-sourced
BuildType: "prebuilt", // service): resolve private-registry credentials by image host.
PrebuiltImage: imageOverride, image := imageOverride
if image == "" {
image = service.Image
} }
} else if service.GitRepo == "" { var ownerID string
// Image-sourced service: pull the image instead of building. _ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
auth := registryAuthFor(db, ownerID, image)
deployReq.BuildConfig = &deployment.BuildConfig{ deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "prebuilt", BuildType: "prebuilt",
PrebuiltImage: service.Image, PrebuiltImage: image,
PullUsername: auth.Username,
PullPassword: auth.Password,
} }
} else { } else {
// "auto" leaves BuildType empty so the manager detects it; the static
// builder additionally feeds its generated Dockerfile the service's
// build command and output dir.
buildType := ""
if service.Builder != "" && service.Builder != "auto" {
buildType = service.Builder
}
deployReq.BuildConfig = &deployment.BuildConfig{ deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "nixpacks", BuildType: buildType,
SourcePath: sourcePath, SourcePath: sourcePath,
Branch: req.Branch, Branch: req.Branch,
Commit: req.CommitHash, Commit: req.CommitHash,
NoCache: req.NoCache,
} }
if buildType == "static" {
deployReq.BuildConfig.BuildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build")
deployReq.BuildConfig.BuildArgs = map[string]string{"STATIC_DIR": firstNonEmpty(service.StaticDir, "dist")}
}
}
if msg := capacityCheck(parentCtx, db, engine.DockerClient(), service, replicas); msg != "" {
failedAt := time.Now()
_, _ = db.Exec(
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
msg, failedAt, dbDeployment.ID,
)
return
} }
engineDeployment, err := engine.Deploy(ctx, deployReq) engineDeployment, err := engine.Deploy(ctx, deployReq)
@@ -427,16 +526,19 @@ func runDeploymentAndSyncWithImage(
select { select {
case <-ctx.Done(): case <-ctx.Done():
failedAt := time.Now() failedAt := time.Now()
timeoutErr := "Deployment timed out before completion" finalStatus, finalErr := "failed", "Deployment timed out before completion"
if errors.Is(ctx.Err(), context.Canceled) {
finalStatus, finalErr = "cancelled", "Deployment cancelled"
}
_, _ = db.Exec( _, _ = db.Exec(
`UPDATE deployments `UPDATE deployments
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 SET status = $1, error = $2, completed_at = $3, updated_at = $3
WHERE id = $3`, WHERE id = $4`,
timeoutErr, failedAt, dbDeployment.ID, finalStatus, finalErr, failedAt, dbDeployment.ID,
) )
_, _ = db.Exec( _, _ = db.Exec(
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, `UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`,
failedAt, service.ID, finalStatus, failedAt, service.ID,
) )
return return
case <-syncTicker.C: case <-syncTicker.C:
@@ -503,14 +605,87 @@ func runDeploymentAndSyncWithImage(
} }
func mapEngineStatusToDBStatus(status string) string { func mapEngineStatusToDBStatus(status string) string {
switch status { if status == "running" {
case "running":
return "deployed" return "deployed"
case "cancelled": }
return "failed"
default:
return status return status
} }
// getDeployQueue returns the per-service deploy queue. SetupRoutes always
// sets it; the fallback keeps handler tests that skip SetupRoutes working.
func getDeployQueue(c *gin.Context) *deployqueue.Queue {
if v, exists := c.Get("deploy_queue"); exists {
if q, ok := v.(*deployqueue.Queue); ok && q != nil {
return q
}
}
return deployqueue.New()
}
// handleCancelDeployment cancels a queued or in-flight deployment. Queued
// jobs are dropped and marked cancelled immediately; active jobs get their
// context cancelled so the build/reconcile aborts and the sync loop writes
// the terminal 'cancelled' status.
func handleCancelDeployment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available", "code": "DEPENDENCY_UNAVAILABLE"})
return
}
deploymentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID", "code": "VALIDATION"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated", "code": "UNAUTHENTICATED"})
return
}
var status, owner string
var serviceID uuid.UUID
err = db.(*database.DB).QueryRow(
`SELECT d.status, d.service_id, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(&status, &serviceID, &owner)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found", "code": "NOT_FOUND"})
return
}
if owner != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied", "code": "FORBIDDEN"})
return
}
switch status {
case "deployed", "failed", "cancelled":
c.JSON(http.StatusConflict, gin.H{"error": "Deployment already finished", "code": "NOT_CANCELLABLE"})
return
}
switch getDeployQueue(c).Cancel(serviceID, deploymentID) {
case deployqueue.WasQueued:
now := time.Now()
_, _ = db.(*database.DB).Exec(
`UPDATE deployments SET status = 'cancelled', error = 'Deployment cancelled', completed_at = $1, updated_at = $1 WHERE id = $2`,
now, deploymentID,
)
_, _ = db.(*database.DB).Exec(
`UPDATE services SET status = 'cancelled', updated_at = $1 WHERE id = $2`,
now, serviceID,
)
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
case deployqueue.WasActive:
c.JSON(http.StatusAccepted, gin.H{"status": "cancelling"})
default:
c.JSON(http.StatusConflict, gin.H{"error": "Deployment is not running on this node", "code": "NOT_CANCELLABLE"})
}
} }
func splitImageReference(image, fallbackTag string) (string, string) { func splitImageReference(image, fallbackTag string) (string, string) {
@@ -688,6 +863,9 @@ func handleRollbackDeployment(c *gin.Context) {
COALESCE(s.replicas, 1), COALESCE(s.port, 0), COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'), COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at s.created_at, s.updated_at
FROM services s WHERE s.id = $1`, serviceID, FROM services s WHERE s.id = $1`, serviceID,
).Scan( ).Scan(
@@ -695,7 +873,9 @@ func handleRollbackDeployment(c *gin.Context) {
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt,
) )
rollbackReq := CreateDeploymentRequest{ rollbackReq := CreateDeploymentRequest{
@@ -708,7 +888,12 @@ func handleRollbackDeployment(c *gin.Context) {
Trigger: "rollback", Trigger: "rollback",
} }
// Reuse the stored image directly — no rebuild. // Reuse the stored image directly — no rebuild.
go runDeploymentAndSyncWithImage(context.Background(), db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage) getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
DeploymentID: rollback.ID,
Run: func(jctx context.Context) {
runDeploymentAndSyncWithImage(jctx, db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
},
})
} }
c.JSON(http.StatusCreated, gin.H{ c.JSON(http.StatusCreated, gin.H{
@@ -724,3 +909,35 @@ func handleRollbackDeployment(c *gin.Context) {
"message": "Rollback initiated", "message": "Rollback initiated",
}) })
} }
// capacityCheck compares the requested per-replica memory (limit, falling
// back to reservation) against node capacity. app_settings.capacity_policy
// = "block" fails the deployment; the default "warn" only logs. CPU is not
// gated — shares are a weight, not a reservation.
func capacityCheck(ctx context.Context, db *database.DB, client *docker.Client, service Service, replicas int) string {
if client == nil {
return ""
}
requested := parseMemoryLimit(service.Memory)
if requested == 0 {
requested = parseMemoryLimit(service.MemoryReserve)
}
if requested == 0 {
return ""
}
info, err := client.GetSystemInfo(ctx)
if err != nil || info.MemTotal <= 0 {
return ""
}
need := requested * int64(replicas)
if need <= info.MemTotal {
return ""
}
msg := fmt.Sprintf("requested %d bytes x %d replicas exceeds node memory (%d bytes)",
requested, replicas, info.MemTotal)
if strings.ToLower(settingValue(db, "capacity_policy", "", "warn")) == "block" {
return msg
}
slog.Warn("capacity check exceeded", "service_id", service.ID, "detail", msg)
return ""
}
+375
View File
@@ -0,0 +1,375 @@
package api
import (
"context"
"database/sql"
"net"
"net/http"
"regexp"
"strings"
"time"
"containr/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// ServiceDomain is one hostname routed to a service. services.domain stays
// the derived default; this table is the source of truth for the full set.
type ServiceDomain struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Domain string `json:"domain"`
IsDefault bool `json:"is_default"`
CertType string `json:"cert_type"`
CertStatus string `json:"cert_status"`
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// BasicAuthCred is a plaintext credential the server encodes to htpasswd.
type BasicAuthCred struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
var hostnameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`)
func validHostname(d string) bool {
return len(d) <= 253 && hostnameRE.MatchString(d)
}
func loadServiceDomains(db *database.DB, serviceID uuid.UUID) []ServiceDomain {
rows, err := db.Query(
`SELECT id, service_id, domain, is_default, cert_type, cert_status, last_checked_at, created_at
FROM service_domains WHERE service_id = $1 ORDER BY is_default DESC, created_at ASC`, serviceID)
if err != nil {
return nil
}
defer rows.Close()
out := []ServiceDomain{}
for rows.Next() {
var d ServiceDomain
if err := rows.Scan(&d.ID, &d.ServiceID, &d.Domain, &d.IsDefault, &d.CertType, &d.CertStatus, &d.LastCheckedAt, &d.CreatedAt); err == nil {
out = append(out, d)
}
}
return out
}
func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) []string {
domains := loadServiceDomains(db, serviceID)
out := make([]string, 0, len(domains))
for _, d := range domains {
out = append(out, d.Domain)
}
if len(out) == 0 && fallback != "" {
out = []string{fallback}
}
return out
}
// syncDefaultDomain writes the is_default row back to services.domain so
// legacy reads (CLI tables, public_url) keep working.
func syncDefaultDomain(db *database.DB, serviceID uuid.UUID) {
var domain string
err := db.QueryRow(
`SELECT domain FROM service_domains WHERE service_id = $1 AND is_default`, serviceID).Scan(&domain)
if err == sql.ErrNoRows {
err = db.QueryRow(
`SELECT domain FROM service_domains WHERE service_id = $1 ORDER BY created_at ASC LIMIT 1`, serviceID).Scan(&domain)
}
if err == sql.ErrNoRows {
domain = ""
}
_, _ = db.Exec(`UPDATE services SET domain = $1 WHERE id = $2`, domain, serviceID)
}
// serviceAccess loads the maintenance/basic-auth columns for the runtime spec.
func serviceAccess(db *database.DB, serviceID uuid.UUID) (maintenance bool, basicAuth string) {
_ = db.QueryRow(`SELECT maintenance_mode, basic_auth_users FROM services WHERE id = $1`, serviceID).
Scan(&maintenance, &basicAuth)
return maintenance, basicAuth
}
// maintenanceURL is the redirect target for maintenance mode — the public
// base URL of this API plus the maintenance page path.
func maintenanceURL(db *database.DB) string {
base := strings.TrimRight(settingValue(db, "base_url", "BASE_URL", ""), "/")
if base == "" {
return ""
}
return base + "/api/v1/maintenance"
}
// encodeBasicAuth turns plaintext creds into Traefik's htpasswd format
// (user:bcrypt hash, comma-separated). Bcrypt is what Traefik expects.
func encodeBasicAuth(creds []BasicAuthCred) (string, error) {
pairs := make([]string, 0, len(creds))
for _, cred := range creds {
if cred.Username == "" || cred.Password == "" {
continue
}
hash, err := bcrypt.GenerateFromPassword([]byte(cred.Password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
pairs = append(pairs, cred.Username+":"+string(hash))
}
return strings.Join(pairs, ","), nil
}
// basicAuthUsernames returns just the usernames for API responses — never
// the hashes.
func basicAuthUsernames(encoded string) []string {
var out []string
for _, pair := range strings.Split(encoded, ",") {
if u, _, ok := strings.Cut(pair, ":"); ok && u != "" {
out = append(out, u)
}
}
return out
}
// serviceWriteAccess mirrors the service update handler's access check.
func serviceWriteAccess(c *gin.Context, db *database.DB, serviceID uuid.UUID) bool {
userID, _ := c.Get("user_id")
var ok bool
err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool))`,
serviceID, userID, contextIsAdmin(c)).Scan(&ok)
return err == nil && ok
}
func handleListServiceDomains(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
c.JSON(http.StatusOK, gin.H{"domains": loadServiceDomains(db.(*database.DB), serviceID)})
}
func handleAddServiceDomain(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
return
}
var req struct {
Domain string `json:"domain" binding:"required"`
IsDefault bool `json:"is_default"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
domain := strings.ToLower(strings.TrimSpace(req.Domain))
if !validHostname(domain) {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid hostname")
return
}
// First domain on a service becomes the default automatically.
var count int
_ = db.(*database.DB).QueryRow(`SELECT COUNT(*) FROM service_domains WHERE service_id = $1`, serviceID).Scan(&count)
makeDefault := req.IsDefault || count == 0
tx, err := db.(*database.DB).Begin()
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
defer tx.Rollback()
if makeDefault {
if _, err := tx.Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
}
var inserted ServiceDomain
err = tx.QueryRow(
`INSERT INTO service_domains (service_id, domain, is_default)
VALUES ($1, $2, $3)
RETURNING id, service_id, domain, is_default, cert_type, cert_status, created_at`,
serviceID, domain, makeDefault).
Scan(&inserted.ID, &inserted.ServiceID, &inserted.Domain, &inserted.IsDefault, &inserted.CertType, &inserted.CertStatus, &inserted.CreatedAt)
if err != nil {
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
respondError(c, http.StatusConflict, "CONFLICT", "Domain already attached to this service")
return
}
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
if err := tx.Commit(); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusCreated, gin.H{"domain": inserted})
}
func handleDeleteServiceDomain(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
return
}
domainID, err := uuid.Parse(c.Param("domain_id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
return
}
res, err := db.(*database.DB).Exec(
`DELETE FROM service_domains WHERE id = $1 AND service_id = $2`, domainID, serviceID)
affected, _ := res.RowsAffected()
if err != nil || affected == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusOK, gin.H{"deleted": domainID})
}
func handleSetDefaultDomain(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
return
}
domainID, err := uuid.Parse(c.Param("domain_id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
return
}
res, err := db.(*database.DB).Exec(
`UPDATE service_domains SET is_default = (id = $1) WHERE service_id = $2`,
domainID, serviceID)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to set default domain")
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusOK, gin.H{"domains": loadServiceDomains(db.(*database.DB), serviceID)})
}
// handleCheckServiceDomains resolves each domain's DNS and compares it to
// the configured expected target (app_settings.public_ip / PUBLIC_IP, or the
// base_url host). Statuses: ok | wrong-target | pending.
func handleCheckServiceDomains(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
dbh := db.(*database.DB)
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
expectedIP := settingValue(dbh, "public_ip", "PUBLIC_IP", "")
expectedHost := ""
if base := settingValue(dbh, "base_url", "BASE_URL", ""); base != "" {
if u := strings.TrimPrefix(strings.TrimPrefix(base, "https://"), "http://"); u != "" {
expectedHost, _, _ = strings.Cut(u, "/")
expectedHost, _, _ = strings.Cut(expectedHost, ":")
}
}
type result struct {
Domain string `json:"domain"`
Status string `json:"status"` // ok | wrong-target | pending
Resolved []string `json:"resolved"`
}
results := []result{}
resolver := net.DefaultResolver
for _, d := range loadServiceDomains(dbh, serviceID) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 4*time.Second)
addrs, _ := resolver.LookupHost(ctx, d.Domain)
cname, _ := resolver.LookupCNAME(ctx, d.Domain)
cancel()
r := result{Domain: d.Domain, Resolved: addrs}
cname = strings.TrimSuffix(cname, ".")
switch {
case len(addrs) == 0:
r.Status = "pending"
case expectedIP != "":
r.Status = "wrong-target"
for _, a := range addrs {
if a == expectedIP {
r.Status = "ok"
}
}
case expectedHost != "":
r.Status = "ok" // resolves; CNAME match is advisory only
if cname != "" && cname != d.Domain && cname != expectedHost {
r.Status = "wrong-target"
}
default:
r.Status = "ok"
}
results = append(results, r)
_, _ = dbh.Exec(`UPDATE service_domains SET last_checked_at = NOW() WHERE id = $1`, d.ID)
}
c.JSON(http.StatusOK, gin.H{"domains": results})
}
// handleMaintenancePage serves the maintenance-mode landing page. Public:
// browsers hit it via the Traefik redirect before any auth.
func handleMaintenancePage(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Maintenance</title><style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0b0e14;color:#e6e8ee;font-family:system-ui,sans-serif}
.card{text-align:center;padding:2rem}.title{font-size:1.4rem;font-weight:600;margin-bottom:.5rem}
.sub{color:#9aa3b2;font-size:.95rem}.brand{margin-top:2rem;color:#5b6472;font-size:.75rem;letter-spacing:.12em;text-transform:uppercase}
</style></head><body><div class="card">
<div class="title">This service is under maintenance</div>
<div class="sub">Please check back shortly.</div>
<div class="brand">Containr</div>
</div></body></html>`))
}
+29
View File
@@ -0,0 +1,29 @@
package api
import (
"github.com/gin-gonic/gin"
)
// Stable machine-readable error codes for agent clients. The "error" field
// stays human-readable; "code" is the contract the CLI/MCP rely on.
const (
CodeUnauthorized = "UNAUTHENTICATED"
CodeInvalidToken = "INVALID_TOKEN"
CodeForbidden = "FORBIDDEN"
CodeAdminRequired = "ADMIN_REQUIRED"
CodeScopeInsufficient = "SCOPE_INSUFFICIENT"
CodeNotFound = "NOT_FOUND"
CodeValidation = "VALIDATION"
CodeConflict = "CONFLICT"
CodeInternal = "INTERNAL"
CodeUnavailable = "DEPENDENCY_UNAVAILABLE"
CodeIdempotencyRunning = "IDEMPOTENCY_IN_PROGRESS"
)
// respondError emits the standard error envelope. Existing handlers return
// bare {"error": msg} — this adds the stable code for new and
// agent-facing paths.
func respondError(c *gin.Context, status int, code, message string) {
c.JSON(status, gin.H{"error": message, "code": code})
c.Abort()
}
+18 -4
View File
@@ -14,6 +14,7 @@ import (
"containr/internal/database" "containr/internal/database"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -178,11 +179,24 @@ func handleGitWebhookPush(c *gin.Context) {
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`, `UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID, time.Now(), service.ID,
) )
go runDeploymentAndSync( // Webhook pushes default to a clean build — stale layers are the
context.Background(), db, engine, &d, service, // classic silent-rollback failure (dflow convention).
CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook"}, webhookReq := CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook", NoCache: true}
repoUserID, if pos := getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
DeploymentID: d.ID,
Run: func(jctx context.Context) {
runDeploymentAndSync(jctx, db, engine, &d, service, webhookReq, repoUserID)
},
}); pos > 0 {
_, _ = db.Exec(
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), d.ID,
) )
_, _ = db.Exec(
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
}
enqueued++ enqueued++
} }
+52
View File
@@ -0,0 +1,52 @@
package api
import (
"encoding/base64"
"fmt"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
// pageWindow parses pagination from either ?page=&limit= (legacy) or
// ?cursor=&limit= (agent-friendly). The cursor is an opaque base64 offset —
// cheap to implement over the existing offset queries while the public
// contract stays cursor-shaped for clients.
func pageWindow(c *gin.Context, defaultLimit, maxLimit int) (offset, limit int) {
limit = defaultLimit
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= maxLimit {
limit = n
}
}
if cur := c.Query("cursor"); cur != "" {
if raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(cur, "o_")); err == nil {
if n, err := strconv.Atoi(string(raw)); err == nil && n >= 0 {
return n, limit
}
}
return 0, limit // invalid cursor → first page, fail soft
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
return (page - 1) * limit, limit
}
// paginationMeta renders the standard envelope: legacy page fields plus
// next_cursor when another page exists.
func paginationMeta(offset, limit, total int) gin.H {
page := offset/limit + 1
meta := gin.H{
"page": page,
"limit": limit,
"total": total,
"pages": (total + limit - 1) / limit,
}
if offset+limit < total {
meta["next_cursor"] = "o_" + base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprint(offset+limit)))
}
return meta
}
@@ -0,0 +1,55 @@
package api
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func ctxWithQuery(q string) *gin.Context {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/x?"+q, nil)
return c
}
func TestPageWindowDefaults(t *testing.T) {
offset, limit := pageWindow(ctxWithQuery(""), 10, 100)
if offset != 0 || limit != 10 {
t.Fatalf("expected 0/10, got %d/%d", offset, limit)
}
}
func TestPageWindowLegacyPage(t *testing.T) {
offset, limit := pageWindow(ctxWithQuery("page=3&limit=20"), 10, 100)
if offset != 40 || limit != 20 {
t.Fatalf("expected 40/20, got %d/%d", offset, limit)
}
}
func TestPageWindowCursor(t *testing.T) {
// cursor for offset 50 produced by paginationMeta
meta := paginationMeta(0, 50, 200)
cursor := meta["next_cursor"].(string)
offset, limit := pageWindow(ctxWithQuery("cursor="+cursor+"&limit=50"), 10, 100)
if offset != 50 || limit != 50 {
t.Fatalf("expected 50/50, got %d/%d", offset, limit)
}
}
func TestPageWindowInvalidCursorFailsSoft(t *testing.T) {
offset, _ := pageWindow(ctxWithQuery("cursor=garbage"), 10, 100)
if offset != 0 {
t.Fatalf("expected offset 0 on invalid cursor, got %d", offset)
}
}
func TestPaginationMetaNextCursorOnlyWhenMore(t *testing.T) {
if _, ok := paginationMeta(0, 10, 10)["next_cursor"]; ok {
t.Fatal("no next page should omit next_cursor")
}
if _, ok := paginationMeta(0, 10, 25)["next_cursor"]; !ok {
t.Fatal("expected next_cursor when more pages exist")
}
}
+2 -18
View File
@@ -8,7 +8,6 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -55,18 +54,9 @@ func handleGetProjects(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB) queries := sqlcdb.New(db.DB)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) offset, limit := pageWindow(c, 10, 100)
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
search := strings.TrimSpace(c.DefaultQuery("search", "")) search := strings.TrimSpace(c.DefaultQuery("search", ""))
if page < 1 {
page = 1
}
if limit > 100 || limit < 1 {
limit = 10
}
offset := (page - 1) * limit
searchParam := sql.NullString{} searchParam := sql.NullString{}
if search != "" { if search != "" {
searchParam = sql.NullString{String: search, Valid: true} searchParam = sql.NullString{String: search, Valid: true}
@@ -99,15 +89,9 @@ func handleGetProjects(c *gin.Context) {
return return
} }
totalCount := int(total)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"projects": projects, "projects": projects,
"pagination": gin.H{ "pagination": paginationMeta(offset, limit, int(total)),
"page": page,
"limit": limit,
"total": totalCount,
"pages": (totalCount + limit - 1) / limit,
},
}) })
} }
+180
View File
@@ -0,0 +1,180 @@
package api
import (
"net/http"
"time"
"containr/internal/database"
"containr/internal/secrets"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Project-level shared variables — referenced from service env values as
// ${{shared.KEY}} and resolved at runtime in resolveServiceEnv.
type ProjectVariable struct {
ID uuid.UUID `json:"id" db:"id"`
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
Key string `json:"key" db:"key"`
Value string `json:"value" db:"value"`
IsSecret bool `json:"is_secret" db:"is_secret"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
func handleGetProjectVariables(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
rows, err := db.(*database.DB).Query(
`SELECT id, project_id, key, value, is_secret, created_at, updated_at
FROM project_variables WHERE project_id = $1 ORDER BY key ASC`,
projectID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
return
}
defer rows.Close()
variables := []ProjectVariable{}
for rows.Next() {
var v ProjectVariable
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan variable"})
return
}
if v.IsSecret {
v.Value = maskedSecretValue
}
variables = append(variables, v)
}
c.JSON(http.StatusOK, gin.H{"variables": variables})
}
func handleUpdateProjectVariables(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var req UpdateVariablesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
storedSecrets := map[string]string{}
secretRows, err := db.(*database.DB).Query(
`SELECT key, value FROM project_variables WHERE project_id = $1 AND is_secret = TRUE`,
projectID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load existing variables"})
return
}
for secretRows.Next() {
var k, v string
if err := secretRows.Scan(&k, &v); err == nil {
storedSecrets[k] = v
}
}
secretRows.Close()
tx, err := db.(*database.DB).Begin()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
return
}
defer tx.Rollback()
if _, err = tx.Exec(`DELETE FROM project_variables WHERE project_id = $1`, projectID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
return
}
now := time.Now()
for _, v := range req.Variables {
value := v.Value
if v.IsSecret && value == maskedSecretValue {
if stored, ok := storedSecrets[v.Key]; ok {
value = stored
}
}
if v.IsSecret {
value = secrets.Encrypt(value)
}
if _, err = tx.Exec(
`INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
uuid.New(), projectID, v.Key, value, v.IsSecret, now, now,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
return
}
}
if err = tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
return
}
rows, err := db.(*database.DB).Query(
`SELECT id, project_id, key, value, is_secret, created_at, updated_at
FROM project_variables WHERE project_id = $1 ORDER BY key ASC`,
projectID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
return
}
defer rows.Close()
variables := []ProjectVariable{}
for rows.Next() {
var v ProjectVariable
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt); err != nil {
continue
}
if v.IsSecret {
v.Value = maskedSecretValue
}
variables = append(variables, v)
}
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Project variables updated successfully"})
}
// resolveSharedVar returns the decrypted value of a project variable.
func resolveSharedVar(db *database.DB, projectID uuid.UUID, key string) (string, bool) {
var value string
err := db.QueryRow(
`SELECT value FROM project_variables WHERE project_id = $1 AND key = $2`,
projectID, key,
).Scan(&value)
if err != nil {
return "", false
}
return secrets.Decrypt(value), true
}
+193
View File
@@ -0,0 +1,193 @@
package api
import (
"net/http"
"strings"
"time"
"containr/internal/database"
"containr/internal/secrets"
"github.com/docker/docker/api/types/registry"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Registry is a stored credential for pulling private images. Password is
// encrypted at rest and never returned — `has_password` signals presence.
type Registry struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Host string `json:"host"` // e.g. ghcr.io, registry.example.com; docker.io for Docker Hub
Username string `json:"username"`
HasPassword bool `json:"has_password"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type registryRequest struct {
Name string `json:"name" binding:"required,min=1,max=255"`
Host string `json:"host" binding:"required,min=1,max=255"`
Username string `json:"username"`
Password string `json:"password"`
}
func handleListRegistries(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
rows, err := db.(*database.DB).Query(
`SELECT id, name, host, username, password <> '', created_at, updated_at
FROM registries WHERE owner_id = $1 ORDER BY created_at DESC`,
userID.(string),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list registries"})
return
}
defer rows.Close()
registries := []Registry{}
for rows.Next() {
var r Registry
if err := rows.Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt); err == nil {
registries = append(registries, r)
}
}
c.JSON(http.StatusOK, gin.H{"registries": registries})
}
func handleCreateRegistry(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
var req registryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
req.Host = strings.TrimSuffix(req.Host, "/")
// Encrypt only a real credential — encrypting "" would still satisfy
// `password <> ''` and report has_password for anonymous registries.
password := ""
if req.Password != "" {
password = secrets.Encrypt(req.Password)
}
var r Registry
err := db.(*database.DB).QueryRow(
`INSERT INTO registries (owner_id, name, host, username, password)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
userID.(string), req.Name, req.Host, req.Username, password,
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
if err != nil {
if strings.Contains(err.Error(), "unique") {
c.JSON(http.StatusConflict, gin.H{"error": "A registry for this host already exists", "code": "CONFLICT"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create registry"})
return
}
c.JSON(http.StatusCreated, gin.H{"registry": r})
}
func handleUpdateRegistry(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
registryID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
return
}
var req registryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
req.Host = strings.TrimSuffix(req.Host, "/")
// Empty password keeps the stored credential — there is no "clear"
// operation; delete + recreate to remove a password.
var passwordArg interface{}
if req.Password != "" {
passwordArg = secrets.Encrypt(req.Password)
}
var r Registry
err = db.(*database.DB).QueryRow(
`UPDATE registries SET name = $1, host = $2, username = $3,
password = COALESCE($4, password), updated_at = now()
WHERE id = $5 AND owner_id = $6
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
req.Name, req.Host, req.Username, passwordArg, registryID, userID.(string),
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
return
}
c.JSON(http.StatusOK, gin.H{"registry": r})
}
func handleDeleteRegistry(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
registryID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
return
}
res, err := db.(*database.DB).Exec(
`DELETE FROM registries WHERE id = $1 AND owner_id = $2`,
registryID, userID.(string),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete registry"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Registry deleted"})
}
// registryAuthFor resolves pull credentials for an image reference: the
// caller's registry whose host matches, else anonymous. docker.io covers
// bare image names (nginx:latest).
func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig {
host := registryHost(imageRef)
var username, password string
err := db.QueryRow(
`SELECT username, password FROM registries WHERE owner_id = $1 AND host = $2`,
ownerID, host,
).Scan(&username, &password)
if err != nil {
return registry.AuthConfig{}
}
return registry.AuthConfig{
Username: username,
Password: secrets.Decrypt(password),
ServerAddress: host,
}
}
@@ -0,0 +1,48 @@
package api
import "testing"
func TestParseCPUReserveShares(t *testing.T) {
cases := map[string]int64{
"": 0,
"0": 0,
"-1": 0,
"abc": 0,
"0.25": 256,
"1": 1024,
"2.5": 2560,
" 0.5 ": 512,
}
for in, want := range cases {
if got := parseCPUReserveShares(in); got != want {
t.Errorf("parseCPUReserveShares(%q) = %d, want %d", in, got, want)
}
}
}
func TestValidateResourceSpecs(t *testing.T) {
if err := validateCPUSpec(""); err != nil {
t.Error("empty cpu should pass")
}
if err := validateCPUSpec("0.5"); err != nil {
t.Error("0.5 should be valid cpu")
}
if err := validateCPUSpec("512mb"); err == nil {
t.Error("512mb should fail cpu validation")
}
if err := validateMemorySpec(""); err != nil {
t.Error("empty memory should pass")
}
if err := validateMemorySpec("512Mi"); err != nil {
t.Error("512Mi should be valid memory")
}
if err := validateMemorySpec("1g"); err != nil {
t.Error("1g should be valid memory")
}
if err := validateMemorySpec("0.5"); err == nil {
t.Error("0.5 should fail memory validation (parses to <1 byte)")
}
if err := validateMemorySpec("lots"); err == nil {
t.Error("'lots' should fail memory validation")
}
}
+123
View File
@@ -4,17 +4,20 @@ import (
"context" "context"
"log" "log"
"net/http" "net/http"
"os"
"time" "time"
"containr/internal/build" "containr/internal/build"
"containr/internal/config" "containr/internal/config"
"containr/internal/database" "containr/internal/database"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/docker" "containr/internal/docker"
"containr/internal/ha" "containr/internal/ha"
"containr/internal/metrics" "containr/internal/metrics"
"containr/internal/middleware" "containr/internal/middleware"
"containr/internal/scaling" "containr/internal/scaling"
"containr/internal/secrets"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -57,6 +60,33 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
} }
} }
haManager := ha.NewHighAvailabilityManager(scheduler, metricsCollector) haManager := ha.NewHighAvailabilityManager(scheduler, metricsCollector)
if db != nil && db.DB != nil {
haManager.WithPersistence(db.DB)
if err := haManager.LoadHealthChecks(context.Background()); err != nil {
log.Printf("Failed to restore health checks: %v", err)
}
}
if dockerClient != nil && db != nil {
// Service-bound checks probe the service DNS name on the project
// network — the backend container joins it on demand. The old
// 127.0.0.1 fallback stays for node-bound checks.
docker := dockerClient
haManager.WithServiceResolver(func(ctx context.Context, serviceID string) (string, int, error) {
var name, projectID string
var port int
err := db.QueryRowContext(ctx,
`SELECT name, project_id::text, COALESCE(port, 0) FROM services WHERE id = $1`,
serviceID,
).Scan(&name, &projectID, &port)
if err != nil {
return "", 0, err
}
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(projectID)); err != nil {
return "", 0, err
}
return name, port, nil
})
}
haAPIManager := NewHAManager(haManager) haAPIManager := NewHAManager(haManager)
// Initialize scaling handler // Initialize scaling handler
@@ -68,9 +98,51 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Start the cron scheduler — executes due cron_jobs via docker exec. // Start the cron scheduler — executes due cron_jobs via docker exec.
StartCronScheduler(context.Background(), db, dockerClient) StartCronScheduler(context.Background(), db, dockerClient)
// Sleep sweeper — scales idle services to zero and prunes wake containers.
StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port)
// Initialize database handler // Initialize database handler
databaseHandler := NewDatabaseHandler(db.DB, dockerClient) databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
// Per-service serialized deploy queue — one active deployment per
// service, the rest wait as 'queued'.
deployQueue := deployqueue.New()
// Encrypt-at-rest for secret env vars. SECRETS_KEY wins, JWT_SECRET is
// the fallback so existing installs need no new config.
secrets.Configure(os.Getenv("SECRETS_KEY"), cfg.JWTSecret)
// Boot sweep: in-memory engine state dies on restart, so anything that
// looked in-flight is actually orphaned — mark it failed rather than
// leaving phantom 'building'/'queued' rows.
_, _ = db.Exec(
`UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW()
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back')`,
)
// Boot sweep: encrypt any plaintext secret values left over from before
// encryption-at-rest landed. Transparent and idempotent.
if encRows, err := db.Query(`SELECT id, value FROM environment_variables WHERE is_secret = TRUE`); err == nil {
type encRow struct {
id string
value string
}
var toEncrypt []encRow
for encRows.Next() {
var r encRow
if err := encRows.Scan(&r.id, &r.value); err == nil && !secrets.IsEncrypted(r.value) {
toEncrypt = append(toEncrypt, r)
}
}
encRows.Close()
for _, r := range toEncrypt {
_, _ = db.Exec(`UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2`, secrets.Encrypt(r.value), r.id)
}
if len(toEncrypt) > 0 {
log.Printf("secrets: encrypted %d plaintext secret variable(s)", len(toEncrypt))
}
}
// Initialize security handler // Initialize security handler
securityHandler := NewSecurityHandler(db, cfg.JWTSecret) securityHandler := NewSecurityHandler(db, cfg.JWTSecret)
@@ -88,6 +160,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
if deploymentEngine != nil { if deploymentEngine != nil {
c.Set("deployment_engine", deploymentEngine) c.Set("deployment_engine", deploymentEngine)
} }
c.Set("deploy_queue", deployQueue)
c.Set("api_port", cfg.Port)
c.Set("scheduler", scheduler) c.Set("scheduler", scheduler)
c.Set("metrics_collector", metricsCollector) c.Set("metrics_collector", metricsCollector)
c.Set("auto_scaler", autoScaler) c.Set("auto_scaler", autoScaler)
@@ -178,6 +252,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.GET("/auth/bootstrap", handleAuthBootstrap) public.GET("/auth/bootstrap", handleAuthBootstrap)
public.POST("/auth/login", handleLogin) public.POST("/auth/login", handleLogin)
public.POST("/auth/register", handleRegister) public.POST("/auth/register", handleRegister)
public.GET("/maintenance", handleMaintenancePage)
// Wake callback for sleep-mode placeholders. Unauthenticated but
// non-destructive: it only reconciles the service back to running.
public.Any("/internal/wake/:id", handleServiceWake)
} }
// Read routes — public browsing. OptionalAuth resolves a session when // Read routes — public browsing. OptionalAuth resolves a session when
@@ -198,6 +277,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
read.GET("/services/:id/metrics", handleGetServiceMetrics) read.GET("/services/:id/metrics", handleGetServiceMetrics)
read.GET("/services/:id/runtime", handleGetServiceRuntime) read.GET("/services/:id/runtime", handleGetServiceRuntime)
read.GET("/services/:id/deployments", handleGetDeployments) read.GET("/services/:id/deployments", handleGetDeployments)
read.GET("/services/:id/domains", handleListServiceDomains)
read.GET("/services/:id/domains/check", handleCheckServiceDomains)
read.GET("/services/:id/env-check", handleServiceEnvCheck)
read.GET("/deployments", handleGetRecentDeployments) read.GET("/deployments", handleGetRecentDeployments)
read.GET("/deployments/:id", handleGetDeployment) read.GET("/deployments/:id", handleGetDeployment)
@@ -229,10 +311,16 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// enforce ownership; platform admins additionally pass every check. // enforce ownership; platform admins additionally pass every check.
authed := v1.Group("/") authed := v1.Group("/")
authed.Use(middleware.Auth(cfg.JWTSecret)) authed.Use(middleware.Auth(cfg.JWTSecret))
authed.Use(middleware.Idempotency())
{ {
authed.GET("/user/profile", handleGetProfile) authed.GET("/user/profile", handleGetProfile)
authed.PUT("/user/profile", handleUpdateProfile) authed.PUT("/user/profile", handleUpdateProfile)
// Personal access tokens for CLI/MCP/agent auth.
authed.GET("/user/tokens", handleListUserTokens)
authed.POST("/user/tokens", handleCreateUserToken)
authed.DELETE("/user/tokens/:id", handleDeleteUserToken)
authed.GET("/notifications", handleListNotifications) authed.GET("/notifications", handleListNotifications)
authed.POST("/notifications/:id/read", handleMarkNotificationRead) authed.POST("/notifications/:id/read", handleMarkNotificationRead)
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead) authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
@@ -250,17 +338,29 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.PUT("/services/:id", handleUpdateService) authed.PUT("/services/:id", handleUpdateService)
authed.DELETE("/services/:id", handleDeleteService) authed.DELETE("/services/:id", handleDeleteService)
authed.POST("/services/:id/domains", handleAddServiceDomain)
authed.DELETE("/services/:id/domains/:domain_id", handleDeleteServiceDomain)
authed.POST("/services/:id/domains/:domain_id/default", handleSetDefaultDomain)
authed.POST("/services/:id/start", handleServiceStart) authed.POST("/services/:id/start", handleServiceStart)
authed.POST("/services/:id/stop", handleServiceStop) authed.POST("/services/:id/stop", handleServiceStop)
authed.POST("/services/:id/restart", handleServiceRestart) authed.POST("/services/:id/restart", handleServiceRestart)
authed.POST("/services/:id/sleep", handleServiceSleep)
authed.POST("/services/:id/wake", handleServiceWakeNow)
authed.POST("/services/:id/redeploy", handleServiceRedeploy) authed.POST("/services/:id/redeploy", handleServiceRedeploy)
authed.POST("/services/:id/clone", handleCloneService)
authed.POST("/services/:id/move", handleMoveService)
authed.POST("/services/:id/deployments", handleCreateDeployment) authed.POST("/services/:id/deployments", handleCreateDeployment)
authed.POST("/deployments/:id/rollback", handleRollbackDeployment) authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
authed.POST("/deployments/:id/cancel", handleCancelDeployment)
// Environment variables — secrets, owner/admin only. // Environment variables — secrets, owner/admin only.
authed.GET("/services/:id/variables", handleGetVariables) authed.GET("/services/:id/variables", handleGetVariables)
authed.PUT("/services/:id/variables", handleUpdateVariables) authed.PUT("/services/:id/variables", handleUpdateVariables)
// Project-level shared variables — ${{shared.KEY}} refs.
authed.GET("/projects/:id/variables", handleGetProjectVariables)
authed.PUT("/projects/:id/variables", handleUpdateProjectVariables)
// Runtime logs — container stdout routinely echoes secrets, // Runtime logs — container stdout routinely echoes secrets,
// so these stay behind authentication like build logs. // so these stay behind authentication like build logs.
authed.GET("/services/:id/logs", handleGetLogs) authed.GET("/services/:id/logs", handleGetLogs)
@@ -269,6 +369,19 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// One-off exec console (docker exec, 30s ceiling) // One-off exec console (docker exec, 30s ceiling)
authed.POST("/services/:id/exec", handleExecInService) authed.POST("/services/:id/exec", handleExecInService)
// S3-compatible backup targets — owner-scoped, keys encrypted at rest.
authed.GET("/backup-targets", databaseHandler.ListBackupTargets)
authed.POST("/backup-targets", databaseHandler.CreateBackupTarget)
authed.PUT("/backup-targets/:id", databaseHandler.UpdateBackupTarget)
authed.DELETE("/backup-targets/:id", databaseHandler.DeleteBackupTarget)
authed.POST("/backup-targets/:id/test", databaseHandler.TestBackupTarget)
// Private-image registries — owner-scoped, credentials encrypted at rest.
authed.GET("/registries", handleListRegistries)
authed.POST("/registries", handleCreateRegistry)
authed.PUT("/registries/:id", handleUpdateRegistry)
authed.DELETE("/registries/:id", handleDeleteRegistry)
// Git integration — providers carry per-user credentials. // Git integration — providers carry per-user credentials.
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL) authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
authed.POST("/git/github-app/connect", handleConnectGitHubApp) authed.POST("/git/github-app/connect", handleConnectGitHubApp)
@@ -292,11 +405,15 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.POST("/builds/plan", buildHandler.GetBuildPlan) authed.POST("/builds/plan", buildHandler.GetBuildPlan)
authed.POST("/databases", databaseHandler.CreateDatabase) authed.POST("/databases", databaseHandler.CreateDatabase)
authed.POST("/databases/register-external", databaseHandler.RegisterExternalDatabase)
authed.POST("/databases/test-connection", databaseHandler.TestDatabaseConnection)
authed.POST("/databases/:id/test-connection", databaseHandler.TestDatabaseConnectionByID)
authed.PUT("/databases/:id", databaseHandler.UpdateDatabase) authed.PUT("/databases/:id", databaseHandler.UpdateDatabase)
authed.DELETE("/databases/:id", databaseHandler.DeleteDatabase) authed.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction) authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
authed.POST("/databases/:id/backup", databaseHandler.CreateBackup) authed.POST("/databases/:id/backup", databaseHandler.CreateBackup)
authed.POST("/databases/:id/restore", databaseHandler.RestoreBackup) authed.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
authed.POST("/databases/:id/backups/import", databaseHandler.ImportBackup)
authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup) authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment) authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
@@ -315,6 +432,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.PUT("/templates/:id", handleUpdateTemplate) authed.PUT("/templates/:id", handleUpdateTemplate)
authed.DELETE("/templates/:id", handleDeleteTemplate) authed.DELETE("/templates/:id", handleDeleteTemplate)
authed.POST("/templates/:id/deploy", handleCreateFromTemplate) authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
authed.POST("/templates/:id/plan", handlePlanTemplate)
authed.POST("/templates/import/compose", handleImportCompose)
authed.POST("/templates/deploy", handleDeployInlineGraph)
authed.POST("/cron-jobs", handleCreateCronJob) authed.POST("/cron-jobs", handleCreateCronJob)
authed.PUT("/cron-jobs/:id", handleUpdateCronJob) authed.PUT("/cron-jobs/:id", handleUpdateCronJob)
@@ -325,6 +445,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Admin routes — platform-wide controls and cross-user management. // Admin routes — platform-wide controls and cross-user management.
admin := v1.Group("/") admin := v1.Group("/")
admin.Use(middleware.Auth(cfg.JWTSecret)) admin.Use(middleware.Auth(cfg.JWTSecret))
admin.Use(middleware.Idempotency())
admin.Use(middleware.RequireAdmin()) admin.Use(middleware.RequireAdmin())
{ {
admin.GET("/admin/overview", handleAdminOverview) admin.GET("/admin/overview", handleAdminOverview)
@@ -345,6 +466,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
scalingHandler.RegisterAdminRoutes(admin) scalingHandler.RegisterAdminRoutes(admin)
agentHandler.SetupAdminRoutes(admin) agentHandler.SetupAdminRoutes(admin)
admin.GET("/admin/volumes", handleAdminListVolumes)
admin.DELETE("/admin/volumes/:name", handleAdminDeleteVolume)
admin.GET("/audit-logs", handleGetAuditLogs) admin.GET("/audit-logs", handleGetAuditLogs)
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs) admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
+277 -8
View File
@@ -11,6 +11,8 @@ import (
"containr/internal/database" "containr/internal/database"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/secrets"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -36,6 +38,10 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
HealthPath: service.HealthCheckPath, HealthPath: service.HealthCheckPath,
RestartPolicy: service.RestartPolicy, RestartPolicy: service.RestartPolicy,
} }
// Best effort: reuse the host port from the last live deployment so the
// public URL survives restarts. Column exists post-migration; older DBs
// silently get ephemeral ports.
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&spec.PublishedPort)
if cmd := strings.TrimSpace(service.Command); cmd != "" { if cmd := strings.TrimSpace(service.Command); cmd != "" {
spec.Command = strings.Fields(cmd) spec.Command = strings.Fields(cmd)
} }
@@ -47,6 +53,19 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
} }
spec.NanoCPUs = parseCPULimit(service.CPU) spec.NanoCPUs = parseCPULimit(service.CPU)
spec.MemoryBytes = parseMemoryLimit(service.Memory) spec.MemoryBytes = parseMemoryLimit(service.Memory)
spec.MemoryReserve = parseMemoryLimit(service.MemoryReserve)
spec.CPUShares = parseCPUReserveShares(service.CPUReserve)
if service.Builder == "static" && spec.Port == 0 {
// The static builder's image is nginx; expose its default port so
// Traefik has something to route to without extra config.
spec.Port = 80
}
spec.Volumes = loadServiceVolumes(db, service.ID)
spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
if spec.Maintenance {
spec.MaintenanceURL = maintenanceURL(db)
}
if spec.Port > 0 { if spec.Port > 0 {
env["PORT"] = strconv.Itoa(service.Port) env["PORT"] = strconv.Itoa(service.Port)
} }
@@ -74,7 +93,7 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
if err := rows.Scan(&k, &v); err != nil { if err := rows.Scan(&k, &v); err != nil {
return nil, err return nil, err
} }
env[k] = v env[k] = secrets.Decrypt(v)
} }
var refServices []struct{ ID, Name string } var refServices []struct{ ID, Name string }
@@ -100,6 +119,9 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
} }
resolve := func(serviceName, key string) (string, bool) { resolve := func(serviceName, key string) (string, bool) {
if strings.EqualFold(serviceName, "shared") {
return resolveSharedVar(db, service.ProjectID, key)
}
id, ok := byName[strings.ToLower(serviceName)] id, ok := byName[strings.ToLower(serviceName)]
if !ok { if !ok {
return "", false return "", false
@@ -122,7 +144,7 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
if err != nil { if err != nil {
return "", false return "", false
} }
return value, true return secrets.Decrypt(value), true
} }
for k, v := range env { for k, v := range env {
@@ -148,6 +170,32 @@ func parseCPULimit(cpu string) int64 {
return int64(v * 1e9) return int64(v * 1e9)
} }
// parseCPUReserveShares maps a cpu fraction to Docker's relative share
// weight: 1024 shares ≈ one CPU.
func parseCPUReserveShares(cpu string) int64 {
v, err := strconv.ParseFloat(strings.TrimSpace(cpu), 64)
if err != nil || v <= 0 {
return 0
}
return int64(v * 1024)
}
// validateCPUSpec and validateMemorySpec reject values that silently parse
// to zero — a typo like "512mb" must fail loudly instead of dropping the limit.
func validateCPUSpec(v string) error {
if s := strings.TrimSpace(v); s != "" && parseCPULimit(s) <= 0 {
return fmt.Errorf("must be a positive cpu fraction (e.g. 0.5, 2)")
}
return nil
}
func validateMemorySpec(v string) error {
if s := strings.TrimSpace(v); s != "" && parseMemoryLimit(s) <= 0 {
return fmt.Errorf("must be bytes with unit (e.g. 256Mi, 1g)")
}
return nil
}
var memUnits = map[string]int64{"k": 1e3, "m": 1e6, "g": 1e9, "ki": 1 << 10, "mi": 1 << 20, "gi": 1 << 30} var memUnits = map[string]int64{"k": 1e3, "m": 1e6, "g": 1e9, "ki": 1 << 10, "mi": 1 << 20, "gi": 1 << 30}
func parseMemoryLimit(mem string) int64 { func parseMemoryLimit(mem string) int64 {
@@ -203,6 +251,9 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
COALESCE(s.replicas, 1), COALESCE(s.port, 0), COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'), COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at, p.owner_id s.created_at, s.updated_at, p.owner_id
FROM services s JOIN projects p ON s.project_id = p.id FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`, WHERE s.id = $1`,
@@ -212,7 +263,9 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner, &service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &owner,
) )
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
@@ -246,6 +299,9 @@ func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
COALESCE(s.replicas, 1), COALESCE(s.port, 0), COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'), COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at, s.project_id s.created_at, s.updated_at, s.project_id
FROM services s FROM services s
WHERE s.id = $1`, WHERE s.id = $1`,
@@ -255,7 +311,9 @@ func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectID, &service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &projectID,
) )
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
@@ -283,6 +341,13 @@ func handleGetServiceRuntime(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
engine.ProbeServiceHealth(c.Request.Context(), state, service.ProjectID.String(), service.Name, service.Port, service.HealthCheckPath)
persistPublishedPort(db, service.ID, state.Ports)
if host := requestHostname(c); host != "" {
for i, u := range state.URLs {
state.URLs[i] = rewriteLoopbackURL(u, host)
}
}
c.JSON(http.StatusOK, gin.H{"runtime": state}) c.JSON(http.StatusOK, gin.H{"runtime": state})
} }
@@ -302,6 +367,7 @@ func reconcileNow(c *gin.Context, engine *deployment.DeploymentEngine, db *datab
status := "stopped" status := "stopped"
if state != nil { if state != nil {
status = state.Status status = state.Status
persistPublishedPort(db, service.ID, state.Ports)
} }
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID) _, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
c.JSON(http.StatusOK, gin.H{"runtime": state}) c.JSON(http.StatusOK, gin.H{"runtime": state})
@@ -369,6 +435,7 @@ func handleServiceRestart(c *gin.Context) {
// handleServiceRedeploy re-applies the current spec: pulls the image for // handleServiceRedeploy re-applies the current spec: pulls the image for
// image-sourced services and reconciles containers (env, ports, replicas). // image-sourced services and reconciles containers (env, ports, replicas).
// Runs through the per-service deploy queue so it never overlaps a build.
func handleServiceRedeploy(c *gin.Context) { func handleServiceRedeploy(c *gin.Context) {
engine, db, ok := getRuntimeEngine(c) engine, db, ok := getRuntimeEngine(c)
if !ok { if !ok {
@@ -394,7 +461,90 @@ func handleServiceRedeploy(c *gin.Context) {
} }
} }
service.Image = image service.Image = image
reconcileNow(c, engine, db, service)
finished := make(chan struct{})
var recErr error
var state *deployment.RuntimeState
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{Run: func(jctx context.Context) {
defer close(finished)
state, recErr = reconcileServiceJob(jctx, db, engine, service)
}})
select {
case <-finished:
if recErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": recErr.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"runtime": state})
case <-c.Request.Context().Done():
c.JSON(http.StatusAccepted, gin.H{"status": "queued"})
}
}
// reconcileServiceJob is the shared queue-job body for redeploy paths:
// build the spec from current DB state, reconcile containers, persist the
// resulting status and published port.
func reconcileServiceJob(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, service Service) (*deployment.RuntimeState, error) {
spec, err := serviceRuntimeSpec(db, service)
if err != nil {
return nil, err
}
state, err := engine.ReconcileService(ctx, spec)
if err != nil {
return nil, err
}
if state != nil {
persistPublishedPort(db, service.ID, state.Ports)
status := "stopped"
if state.Status != "" {
status = state.Status
}
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
}
return state, nil
}
// enqueueServiceRedeploy resolves a deployable image (service.Image, else
// the last successful deployment's tag) and queues a reconcile. Returns
// false when no image is available or the engine is missing.
func enqueueServiceRedeploy(c *gin.Context, db *database.DB, service Service) bool {
engineValue, exists := c.Get("deployment_engine")
if !exists || engineValue == nil {
return false
}
engine, ok := engineValue.(*deployment.DeploymentEngine)
if !ok {
return false
}
image := service.Image
if image == "" {
if err := db.QueryRow(
`SELECT image_name || ':' || image_tag FROM deployments
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
ORDER BY created_at DESC LIMIT 1`,
service.ID,
).Scan(&image); err != nil {
return false
}
}
service.Image = image
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{Run: func(jctx context.Context) {
_, _ = reconcileServiceJob(jctx, db, engine, service)
}})
return true
}
// persistPublishedPort records the host port Docker actually bound so later
// redeploys can reuse it. Idempotent — only writes when the value changed.
func persistPublishedPort(db *database.DB, serviceID uuid.UUID, ports []uint16) {
if len(ports) == 0 {
return
}
_, _ = db.Exec(
`UPDATE services SET published_port = $1 WHERE id = $2 AND published_port <> $1`,
int(ports[0]), serviceID,
)
} }
// liveServiceStatus reconciles the stored status with real container state. // liveServiceStatus reconciles the stored status with real container state.
@@ -415,10 +565,11 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
service.Status = state.Status service.Status = state.Status
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID) _, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID)
} }
persistPublishedPort(db, service.ID, state.Ports)
if service.Domain != "" { if service.Domain != "" {
service.PublicURL = "https://" + service.Domain service.PublicURL = "https://" + service.Domain
} else if len(state.URLs) > 0 { } else if len(state.URLs) > 0 {
service.PublicURL = state.URLs[0] service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c))
} }
} }
@@ -444,6 +595,9 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
COALESCE(s.replicas, 1), COALESCE(s.port, 0), COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'), COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at, p.owner_id s.created_at, s.updated_at, p.owner_id
FROM services s JOIN projects p ON s.project_id = p.id FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`, serviceID, WHERE s.id = $1`, serviceID,
@@ -452,7 +606,9 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner, &service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &owner,
) )
if err != nil { if err != nil {
return err return err
@@ -475,9 +631,122 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
if _, err := engine.ReconcileService(ctx, spec); err != nil { state, err := engine.ReconcileService(ctx, spec)
if err != nil {
return err return err
} }
if state != nil {
persistPublishedPort(db, service.ID, state.Ports)
}
_, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID) _, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID)
return nil return nil
} }
// handleServiceEnvCheck reports environment problems without exposing
// values: empty vars, ${{service.KEY}} refs that fail to resolve, and
// secrets whose ciphertext can't be decrypted (key rotation).
func handleServiceEnvCheck(c *gin.Context) {
dbVal, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
db := dbVal.(*database.DB)
service, ok := loadReadableService(c, db)
if !ok {
return
}
rows, err := db.Query(
`SELECT key, value, COALESCE(is_secret, false) FROM environment_variables WHERE service_id = $1`,
service.ID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
var unresolved, empty, unreadable []string
var raw []struct{ key, val string }
for rows.Next() {
var k, v string
var secret bool
if err := rows.Scan(&k, &v, &secret); err != nil {
continue
}
decrypted := secrets.Decrypt(v)
if secret && secrets.IsEncrypted(v) && decrypted == v {
// Ciphertext failed to decrypt — Decrypt returns the input.
unreadable = append(unreadable, k)
continue
}
if strings.TrimSpace(decrypted) == "" {
empty = append(empty, k)
}
raw = append(raw, struct{ key, val string }{k, decrypted})
}
// Project siblings for ${{name.KEY}} resolution.
siblingIDs := map[string]string{}
svcRows, err := db.Query(`SELECT id, name FROM services WHERE project_id = $1`, service.ProjectID)
if err == nil {
for svcRows.Next() {
var id, name string
if err := svcRows.Scan(&id, &name); err == nil {
siblingIDs[strings.ToLower(name)] = id
}
}
svcRows.Close()
}
resolvable := func(serviceName, key string) bool {
id, ok := siblingIDs[strings.ToLower(serviceName)]
if !ok {
return false
}
if strings.EqualFold(key, "HOST") {
return true
}
if strings.EqualFold(key, "PORT") {
var port int
if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil {
return port > 0
}
return false
}
var n int
if err := db.QueryRow(
`SELECT COUNT(*) FROM environment_variables WHERE service_id = $1 AND key = $2`,
id, key,
).Scan(&n); err == nil {
return n > 0
}
return false
}
for _, kv := range raw {
for _, match := range refVarPattern.FindAllStringSubmatch(kv.val, -1) {
if len(match) == 3 && !resolvable(match[1], match[2]) {
unresolved = append(unresolved, fmt.Sprintf("%s → %s", kv.key, match[0]))
}
}
}
if unresolved == nil {
unresolved = []string{}
}
if empty == nil {
empty = []string{}
}
if unreadable == nil {
unreadable = []string{}
}
okResult := len(unresolved) == 0 && len(empty) == 0 && len(unreadable) == 0
c.JSON(http.StatusOK, gin.H{
"ok": okResult,
"unresolved": unresolved,
"empty": empty,
"unreadable": unreadable,
})
}
+226
View File
@@ -0,0 +1,226 @@
package api
import (
"net/http"
"strings"
"time"
"containr/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type CloneServiceRequest struct {
Name string `json:"name"`
ProjectID uuid.UUID `json:"project_id"` // empty = clone within the same project
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
}
// handleCloneService duplicates a service's full configuration — image/git
// source, volumes, domains, access gates, builder settings, and variables —
// into the same or another project. Containers are not cloned; the copy
// starts stopped until its first deploy.
func handleCloneService(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
var req CloneServiceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
source, ok := loadOwnedService(c, db.(*database.DB))
if !ok {
return
}
targetProjectID := source.ProjectID
if req.ProjectID != uuid.Nil {
targetProjectID = req.ProjectID
}
if !ownsProject(db.(*database.DB), targetProjectID, userID.(string), contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = source.Name + "-copy"
}
var count int
_ = db.(*database.DB).QueryRow(
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
targetProjectID, name,
).Scan(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in target project", "code": "CONFLICT"})
return
}
environment := source.Environment
if req.Environment != "" {
environment = req.Environment
}
if environment == "" {
environment = "production"
}
environmentID, err := getProjectEnvironmentID(db.(*database.DB), targetProjectID, environment)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
return
}
maintenance, basicAuth := serviceAccess(db.(*database.DB), source.ID)
newID := uuid.New()
now := time.Now()
// string() so pq sends text — []byte would go as bytea and fail ::jsonb.
volumesJSON := string(loadServiceVolumesJSON(db.(*database.DB), source.ID))
_, err = db.(*database.DB).Exec(
`INSERT INTO services
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
build_command, start_command, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, volumes, maintenance_mode, basic_auth_users,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
created_at, updated_at)
SELECT $1, $2, $3, $4, service_type, source_type, source_url, image_name,
build_command, start_command, type, 'stopped', image, command, $5,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, $6::jsonb, $7, $8,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
$9, $9
FROM services WHERE id = $10`,
newID, targetProjectID, name, environmentID, environment, volumesJSON,
maintenance, basicAuth, now, source.ID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
return
}
if _, err := db.(*database.DB).Exec(
`INSERT INTO service_domains (service_id, domain, is_default, cert_type)
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
newID, source.ID,
); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service domains"})
return
}
// Variable values are copied as stored — ciphertext stays ciphertext.
if _, err := db.(*database.DB).Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
newID, source.ID,
); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service variables"})
return
}
LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
}
type MoveServiceRequest struct {
ProjectID uuid.UUID `json:"project_id" binding:"required"`
}
// handleMoveService re-parents a service to another project owned by the
// caller. Variables, domains, and volumes follow via the service id;
// running containers keep running (they join the new project's network on
// the next deploy).
func handleMoveService(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID", "code": "VALIDATION"})
return
}
var req MoveServiceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
service, ok := loadOwnedService(c, db.(*database.DB))
if !ok {
return
}
if req.ProjectID == service.ProjectID {
c.JSON(http.StatusBadRequest, gin.H{"error": "Service is already in that project", "code": "VALIDATION"})
return
}
if !ownsProject(db.(*database.DB), req.ProjectID, userID.(string), contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
return
}
var count int
_ = db.(*database.DB).QueryRow(
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
req.ProjectID, service.Name,
).Scan(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "A service with this name already exists in the target project", "code": "CONFLICT"})
return
}
envName := service.Environment
if envName == "" {
envName = "production"
}
environmentID, err := getProjectEnvironmentID(db.(*database.DB), req.ProjectID, envName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
return
}
res, err := db.(*database.DB).Exec(
`UPDATE services SET project_id = $1, environment_id = $2, updated_at = $3 WHERE id = $4`,
req.ProjectID, environmentID, time.Now(), serviceID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to move service"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found", "code": "NOT_FOUND"})
return
}
LogAuditWithRequest(c, "service", serviceID.String(), "service.move", map[string]interface{}{"to_project": req.ProjectID.String()})
c.JSON(http.StatusOK, gin.H{"message": "Service moved", "project_id": req.ProjectID})
}
// ownsProject is true when the user owns the project or is an admin.
func ownsProject(db *database.DB, projectID uuid.UUID, userID string, isAdmin bool) bool {
if isAdmin {
return true
}
var owner string
err := db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, projectID).Scan(&owner)
return err == nil && owner == userID
}
// loadServiceVolumesJSON returns the raw stored mounts for a verbatim copy.
func loadServiceVolumesJSON(db *database.DB, serviceID uuid.UUID) []byte {
var raw []byte
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil || len(raw) == 0 {
return []byte("[]")
}
return raw
}
+304 -17
View File
@@ -3,8 +3,12 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue"
"context" "context"
"encoding/json"
"fmt"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -33,10 +37,84 @@ type Service struct {
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
RestartPolicy string `json:"restart_policy" db:"restart_policy"` RestartPolicy string `json:"restart_policy" db:"restart_policy"`
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB
Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
MaintenanceMode bool `json:"maintenance_mode" db:"-"`
BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes
Builder string `json:"builder" db:"builder"` // auto|railpack|nixpacks|dockerfile|static
CPUReserve string `json:"cpu_reserve,omitempty" db:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve,omitempty" db:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd,omitempty" db:"static_build_cmd"`
StaticDir string `json:"static_dir,omitempty" db:"static_dir"`
SleepEnabled bool `json:"sleep_enabled" db:"-"`
SleepIdleMinutes int `json:"sleep_idle_minutes" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
// loadServiceSleep fills the sleep-mode columns, which live outside the
// wide service SELECTs. Missing columns (pre-migration) degrade to off.
func loadServiceSleep(db *database.DB, s *Service) {
var idle int
if err := db.QueryRow(
`SELECT COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15) FROM services WHERE id = $1`,
s.ID,
).Scan(&s.SleepEnabled, &idle); err == nil {
s.SleepIdleMinutes = idle
}
}
// ServiceVolume is a volume/bind mount applied to every replica.
type ServiceVolume struct {
Type string `json:"type"` // volume | bind (empty = volume)
Source string `json:"source"` // volume name or host path
Target string `json:"target"` // container path (absolute)
ReadOnly bool `json:"read_only"`
}
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades
// to no mounts rather than failing the request.
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
var raw []byte
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
return nil
}
var stored []ServiceVolume
if err := json.Unmarshal(raw, &stored); err != nil {
return nil
}
out := make([]deployment.VolumeMount, 0, len(stored))
for _, v := range stored {
out = append(out, deployment.VolumeMount{
Type: v.Type,
Source: v.Source,
Destination: v.Target,
ReadOnly: v.ReadOnly,
})
}
return out
}
// validateServiceVolumes enforces the mount contract — bad paths fail
// fast at write time instead of surfacing as docker create errors later.
func validateServiceVolumes(vols []ServiceVolume) error {
for i, v := range vols {
if v.Type == "" {
v.Type = "volume"
}
if v.Type != "volume" && v.Type != "bind" {
return fmt.Errorf("volumes[%d].type must be volume or bind", i)
}
if strings.TrimSpace(v.Source) == "" {
return fmt.Errorf("volumes[%d].source is required", i)
}
if !strings.HasPrefix(v.Target, "/") {
return fmt.Errorf("volumes[%d].target must be an absolute container path", i)
}
}
return nil
}
// CreateServiceRequest represents a request to create a service // CreateServiceRequest represents a request to create a service
type CreateServiceRequest struct { type CreateServiceRequest struct {
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
@@ -55,6 +133,14 @@ type CreateServiceRequest struct {
Domain string `json:"domain"` Domain string `json:"domain"`
HealthCheckPath string `json:"healthcheck_path"` HealthCheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"` RestartPolicy string `json:"restart_policy"`
Volumes []ServiceVolume `json:"volumes"`
MaintenanceMode bool `json:"maintenance_mode"`
BasicAuth []BasicAuthCred `json:"basic_auth"`
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
CPUReserve string `json:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd"`
StaticDir string `json:"static_dir"`
} }
// UpdateServiceRequest represents a request to update a service // UpdateServiceRequest represents a request to update a service
@@ -74,6 +160,16 @@ type UpdateServiceRequest struct {
Domain *string `json:"domain"` Domain *string `json:"domain"`
HealthCheckPath *string `json:"healthcheck_path"` HealthCheckPath *string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"` RestartPolicy string `json:"restart_policy"`
Volumes *[]ServiceVolume `json:"volumes"`
MaintenanceMode *bool `json:"maintenance_mode"`
BasicAuth *[]BasicAuthCred `json:"basic_auth"`
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
CPUReserve *string `json:"cpu_reserve"`
MemoryReserve *string `json:"memory_reserve"`
StaticBuildCmd *string `json:"static_build_cmd"`
StaticDir *string `json:"static_dir"`
SleepEnabled *bool `json:"sleep_enabled"`
SleepIdleMinutes *int `json:"sleep_idle_minutes"`
} }
// handleGetServices retrieves all services for a project // handleGetServices retrieves all services for a project
@@ -118,6 +214,10 @@ func handleGetServices(c *gin.Context) {
COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(replicas, 1), COALESCE(port, 0),
COALESCE(domain, ''), COALESCE(healthcheck_path, ''), COALESCE(domain, ''), COALESCE(healthcheck_path, ''),
COALESCE(restart_policy, 'unless-stopped'), COALESCE(restart_policy, 'unless-stopped'),
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''),
COALESCE(memory_reserve, ''), COALESCE(static_build_cmd, ''),
COALESCE(static_dir, ''),
COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15),
created_at, updated_at created_at, updated_at
FROM services FROM services
WHERE project_id = $1 WHERE project_id = $1
@@ -138,7 +238,10 @@ func handleGetServices(c *gin.Context) {
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.SleepEnabled, &service.SleepIdleMinutes,
&service.CreatedAt, &service.UpdatedAt,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
@@ -249,12 +352,38 @@ func handleCreateService(c *gin.Context) {
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
// Set default values if not provided // Set default values if not provided; app_settings.default_cpu /
// default_memory override the hardcoded fallbacks.
if service.CPU == "" { if service.CPU == "" {
service.CPU = "0.5" service.CPU = firstNonEmpty(settingValue(db.(*database.DB), "default_cpu", "", ""), "0.5")
} }
if service.Memory == "" { if service.Memory == "" {
service.Memory = "512Mi" service.Memory = firstNonEmpty(settingValue(db.(*database.DB), "default_memory", "", ""), "512Mi")
}
if req.Builder == "" {
service.Builder = "auto"
} else {
service.Builder = req.Builder
}
service.CPUReserve = req.CPUReserve
service.MemoryReserve = req.MemoryReserve
service.StaticBuildCmd = req.StaticBuildCmd
service.StaticDir = req.StaticDir
if service.Builder == "static" && service.StaticDir == "" {
service.StaticDir = "dist"
}
for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
} {
v := map[string]string{
"cpu": service.CPU, "cpu_reserve": service.CPUReserve,
"memory": service.Memory, "memory_reserve": service.MemoryReserve,
}[label]
if err := check(v); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
return
}
} }
if service.Replicas < 1 { if service.Replicas < 1 {
service.Replicas = 1 service.Replicas = 1
@@ -262,6 +391,15 @@ func handleCreateService(c *gin.Context) {
if service.RestartPolicy == "" { if service.RestartPolicy == "" {
service.RestartPolicy = "unless-stopped" service.RestartPolicy = "unless-stopped"
} }
if err := validateServiceVolumes(req.Volumes); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
service.Volumes = req.Volumes
volumesJSON, _ := json.Marshal(req.Volumes)
if len(req.Volumes) == 0 {
volumesJSON = []byte("[]")
}
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment) environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
if err != nil { if err != nil {
@@ -277,14 +415,18 @@ func handleCreateService(c *gin.Context) {
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name, (id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
build_command, start_command, type, status, image, command, environment, build_command, start_command, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain, git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, created_at, updated_at) healthcheck_path, restart_policy, volumes,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27)`, builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
$27, $28, $29, $30, $31, $32, $33)`,
service.ID, service.ProjectID, service.Name, environmentID, service.Type, service.ID, service.ProjectID, service.Name, environmentID, service.Type,
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image, sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
"", service.Command, service.Type, service.Status, service.Image, service.Command, "", service.Command, service.Type, service.Status, service.Image, service.Command,
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory, service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy, service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
service.CreatedAt, service.UpdatedAt, volumesJSON, service.Builder, service.CPUReserve, service.MemoryReserve,
service.StaticBuildCmd, service.StaticDir, service.CreatedAt, service.UpdatedAt,
) )
if err != nil { if err != nil {
@@ -292,6 +434,26 @@ func handleCreateService(c *gin.Context) {
return return
} }
// Domains live in service_domains; services.domain is the derived default.
if req.Domain != "" && validHostname(strings.ToLower(req.Domain)) {
_, _ = db.(*database.DB).Exec(
`INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true)
ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`,
service.ID, strings.ToLower(req.Domain))
}
if req.MaintenanceMode || len(req.BasicAuth) > 0 {
basicAuthUsers := ""
if encoded, encErr := encodeBasicAuth(req.BasicAuth); encErr == nil {
basicAuthUsers = encoded
}
_, _ = db.(*database.DB).Exec(
`UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`,
req.MaintenanceMode, basicAuthUsers, service.ID)
service.MaintenanceMode = req.MaintenanceMode
service.BasicAuth = basicAuthUsernames(basicAuthUsers)
}
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
c.JSON(http.StatusCreated, gin.H{"service": service}) c.JSON(http.StatusCreated, gin.H{"service": service})
} }
@@ -349,6 +511,9 @@ func handleGetService(c *gin.Context) {
COALESCE(s.replicas, 1), COALESCE(s.port, 0), COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'), COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at s.created_at, s.updated_at
FROM services s FROM services s
JOIN projects p ON s.project_id = p.id JOIN projects p ON s.project_id = p.id
@@ -360,7 +525,9 @@ func handleGetService(c *gin.Context) {
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt,
) )
if err != nil { if err != nil {
@@ -369,6 +536,16 @@ func handleGetService(c *gin.Context) {
} }
liveServiceStatus(c, db.(*database.DB), &service) liveServiceStatus(c, db.(*database.DB), &service)
for _, v := range loadServiceVolumes(db.(*database.DB), service.ID) {
service.Volumes = append(service.Volumes, ServiceVolume{
Type: v.Type, Source: v.Source, Target: v.Destination, ReadOnly: v.ReadOnly,
})
}
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
loadServiceSleep(db.(*database.DB), &service)
maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
service.MaintenanceMode = maintenance
service.BasicAuth = basicAuthUsernames(basicAuth)
c.JSON(http.StatusOK, gin.H{"service": service}) c.JSON(http.StatusOK, gin.H{"service": service})
} }
@@ -417,6 +594,9 @@ func handleUpdateService(c *gin.Context) {
COALESCE(s.replicas, 1), COALESCE(s.port, 0), COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'), COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at s.created_at, s.updated_at
FROM services s FROM services s
JOIN projects p ON s.project_id = p.id JOIN projects p ON s.project_id = p.id
@@ -429,6 +609,9 @@ func handleUpdateService(c *gin.Context) {
&existingService.BuildPath, &existingService.CPU, &existingService.Memory, &existingService.BuildPath, &existingService.CPU, &existingService.Memory,
&existingService.Replicas, &existingService.Port, &existingService.Domain, &existingService.Replicas, &existingService.Port, &existingService.Domain,
&existingService.HealthCheckPath, &existingService.RestartPolicy, &existingService.HealthCheckPath, &existingService.RestartPolicy,
&existingService.Builder, &existingService.CPUReserve,
&existingService.MemoryReserve, &existingService.StaticBuildCmd,
&existingService.StaticDir,
&existingService.CreatedAt, &existingService.UpdatedAt, &existingService.CreatedAt, &existingService.UpdatedAt,
) )
@@ -483,6 +666,60 @@ func handleUpdateService(c *gin.Context) {
if req.RestartPolicy != "" { if req.RestartPolicy != "" {
existingService.RestartPolicy = req.RestartPolicy existingService.RestartPolicy = req.RestartPolicy
} }
if req.Builder != "" {
existingService.Builder = req.Builder
}
if req.CPUReserve != nil {
existingService.CPUReserve = *req.CPUReserve
}
if req.MemoryReserve != nil {
existingService.MemoryReserve = *req.MemoryReserve
}
if req.StaticBuildCmd != nil {
existingService.StaticBuildCmd = *req.StaticBuildCmd
}
if req.StaticDir != nil {
existingService.StaticDir = *req.StaticDir
}
if existingService.Builder == "static" && existingService.StaticDir == "" {
existingService.StaticDir = "dist"
}
loadServiceSleep(db.(*database.DB), &existingService)
if req.SleepEnabled != nil {
existingService.SleepEnabled = *req.SleepEnabled
}
if req.SleepIdleMinutes != nil {
if *req.SleepIdleMinutes < 1 || *req.SleepIdleMinutes > 1440 {
c.JSON(http.StatusBadRequest, gin.H{"error": "sleep_idle_minutes must be 1-1440", "code": "VALIDATION"})
return
}
existingService.SleepIdleMinutes = *req.SleepIdleMinutes
}
for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
} {
v := map[string]string{
"cpu": existingService.CPU, "cpu_reserve": existingService.CPUReserve,
"memory": existingService.Memory, "memory_reserve": existingService.MemoryReserve,
}[label]
if err := check(v); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
return
}
}
// lib/pq sends a nil []byte as an empty bytea literal rather than NULL,
// so the COALESCE fallback needs an untyped nil when volumes aren't sent.
var volumesArg interface{}
if req.Volumes != nil {
if err := validateServiceVolumes(*req.Volumes); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
existingService.Volumes = *req.Volumes
volumesJSON, _ := json.Marshal(*req.Volumes)
volumesArg = volumesJSON
}
existingService.UpdatedAt = time.Now() existingService.UpdatedAt = time.Now()
@@ -492,14 +729,20 @@ func handleUpdateService(c *gin.Context) {
SET name = $1, type = $2, image = $3, command = $4, environment = $5, SET name = $1, type = $2, image = $3, command = $4, environment = $5,
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10, git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
replicas = $11, port = $12, domain = $13, healthcheck_path = $14, replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
restart_policy = $15, updated_at = $16 restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
WHERE id = $17`, builder = $19, cpu_reserve = $20, memory_reserve = $21,
static_build_cmd = $22, static_dir = $23,
sleep_enabled = $24, sleep_idle_minutes = $25
WHERE id = $18`,
existingService.Name, existingService.Type, existingService.Image, existingService.Command, existingService.Name, existingService.Type, existingService.Image, existingService.Command,
existingService.Environment, existingService.GitRepo, existingService.GitBranch, existingService.Environment, existingService.GitRepo, existingService.GitBranch,
existingService.BuildPath, existingService.CPU, existingService.Memory, existingService.BuildPath, existingService.CPU, existingService.Memory,
existingService.Replicas, existingService.Port, existingService.Domain, existingService.Replicas, existingService.Port, existingService.Domain,
existingService.HealthCheckPath, existingService.RestartPolicy, existingService.HealthCheckPath, existingService.RestartPolicy,
existingService.UpdatedAt, existingService.ID, existingService.UpdatedAt, volumesArg, existingService.ID,
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
existingService.StaticBuildCmd, existingService.StaticDir,
existingService.SleepEnabled, existingService.SleepIdleMinutes,
) )
if err != nil { if err != nil {
@@ -507,21 +750,65 @@ func handleUpdateService(c *gin.Context) {
return return
} }
// Domain writes route through service_domains — services.domain is the
// derived default and gets rewritten by syncDefaultDomain.
if req.Domain != nil {
domain := strings.ToLower(strings.TrimSpace(*req.Domain))
if domain == "" {
_, _ = db.(*database.DB).Exec(`DELETE FROM service_domains WHERE service_id = $1`, serviceID)
} else if validHostname(domain) {
_, _ = db.(*database.DB).Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID)
_, _ = db.(*database.DB).Exec(
`INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true)
ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`,
serviceID, domain)
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid hostname", "code": "VALIDATION"})
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
_ = db.(*database.DB).QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&existingService.Domain)
}
if req.MaintenanceMode != nil || req.BasicAuth != nil {
maintenance, basicAuth := serviceAccess(db.(*database.DB), serviceID)
if req.MaintenanceMode != nil {
maintenance = *req.MaintenanceMode
}
if req.BasicAuth != nil {
encoded, encErr := encodeBasicAuth(*req.BasicAuth)
if encErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encode basic auth", "code": "INTERNAL"})
return
}
basicAuth = encoded
}
_, _ = db.(*database.DB).Exec(
`UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`,
maintenance, basicAuth, serviceID)
existingService.MaintenanceMode = maintenance
}
existingService.Domains = loadServiceDomains(db.(*database.DB), serviceID)
_, storedAuth := serviceAccess(db.(*database.DB), serviceID)
existingService.BasicAuth = basicAuthUsernames(storedAuth)
// A replica change applies immediately when the service has live // A replica change applies immediately when the service has live
// containers; other spec fields take effect on the next deploy. // containers; other spec fields take effect on the next deploy.
// Runs through the deploy queue so it can't overlap a build.
if req.Replicas != nil && existingService.Status == "running" { if req.Replicas != nil && existingService.Status == "running" {
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil { if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
engine := engineValue.(*deployment.DeploymentEngine) engine := engineValue.(*deployment.DeploymentEngine)
serviceCopy := existingService serviceCopy := existingService
go func() { dbCopy := db.(*database.DB)
spec, err := serviceRuntimeSpec(db.(*database.DB), serviceCopy) getDeployQueue(c).Enqueue(serviceCopy.ID, deployqueue.Job{Run: func(jctx context.Context) {
spec, err := serviceRuntimeSpec(dbCopy, serviceCopy)
if err != nil { if err != nil {
return return
} }
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) if state, err := engine.ReconcileService(jctx, spec); err == nil && state != nil {
defer cancel() persistPublishedPort(dbCopy, serviceCopy.ID, state.Ports)
_, _ = engine.ReconcileService(ctx, spec) }
}() }})
} }
} }
+461
View File
@@ -0,0 +1,461 @@
package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
"containr/internal/database"
"containr/internal/deployment"
"containr/internal/docker"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Sleep mode: services with sleep_enabled are scaled to zero after
// sleep_idle_minutes without inbound traffic. Idleness is measured as
// NetworkIO byte deltas across the service's containers — we have no
// per-request layer in front of Traefik, so counters are the signal.
//
// When a service sleeps its containers are removed and a tiny busybox
// "wake" container takes over the same Traefik host rule at a higher
// priority: it serves a reloading "waking" page and pings the unauthenticated
// internal wake endpoint, which reconciles the service back to running.
// Wake containers carry a dedicated label (not the service label) so
// RuntimeState and reconcile never count them as replicas; the sweeper
// prunes them once the workload is running again.
const (
wakeImage = "busybox:1.36"
wakeLabel = "containr.wake"
wakeForLabel = "containr.wake-for"
wakeSweepInterval = 30 * time.Second
)
type sleepTracker struct {
mu sync.Mutex
activity map[uuid.UUID]sleepActivity
}
type sleepActivity struct {
bytes uint64
idleSince time.Time
}
var tracker = &sleepTracker{activity: make(map[uuid.UUID]sleepActivity)}
// StartSleepSweeper polls enabled services and scales idle ones to zero.
func StartSleepSweeper(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, apiPort int) {
if db == nil || dc == nil || engine == nil {
return
}
go func() {
ticker := time.NewTicker(wakeSweepInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
sweepSleeping(ctx, db, dc, engine, apiPort)
pruneWakeContainers(ctx, db, dc, engine)
}
}
}()
log.Printf("sleep sweeper: running (interval %s)", wakeSweepInterval)
}
type sleepCandidate struct {
ID uuid.UUID
ProjectID uuid.UUID
Name string
Status string
IdleMinutes int
}
func sweepSleeping(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, apiPort int) {
rows, err := db.QueryContext(ctx,
`SELECT id, project_id, name, COALESCE(status,''), COALESCE(sleep_idle_minutes,15)
FROM services WHERE sleep_enabled AND COALESCE(status,'') IN ('running','deployed','degraded','starting')`)
if err != nil {
return
}
var candidates []sleepCandidate
for rows.Next() {
var s sleepCandidate
if err := rows.Scan(&s.ID, &s.ProjectID, &s.Name, &s.Status, &s.IdleMinutes); err == nil {
candidates = append(candidates, s)
}
}
rows.Close()
for _, svc := range candidates {
containers, err := engine.ListServiceContainers(ctx, svc.ID.String())
if err != nil || len(containers) == 0 {
continue
}
// Ignore wake containers — they're the placeholder, not the workload.
workload := containers[:0]
for _, c := range containers {
if c.Labels[wakeLabel] != "true" {
workload = append(workload, c)
}
}
if len(workload) == 0 {
continue
}
var total uint64
for _, c := range workload {
total += containerNetBytes(ctx, dc, c.ID)
}
if markIdle(svc.ID, total, time.Duration(svc.IdleMinutes)*time.Minute) {
putToSleep(ctx, db, dc, engine, svc, apiPort)
}
}
}
// markIdle returns true when the network counter stopped growing past the
// timeout. First sighting only sets the baseline — no idle without history.
func markIdle(id uuid.UUID, bytes uint64, timeout time.Duration) bool {
tracker.mu.Lock()
defer tracker.mu.Unlock()
a := tracker.activity[id]
if bytes != a.bytes || a.idleSince.IsZero() {
tracker.activity[id] = sleepActivity{bytes: bytes, idleSince: time.Now()}
return false
}
return time.Since(a.idleSince) >= timeout
}
func containerNetBytes(ctx context.Context, dc *docker.Client, id string) uint64 {
stats, err := dc.GetContainerStats(ctx, id, false)
if err != nil || stats == nil {
return 0
}
defer stats.Body.Close()
var snap struct {
Networks map[string]struct {
RxBytes uint64 `json:"rx_bytes"`
TxBytes uint64 `json:"tx_bytes"`
} `json:"networks"`
}
if err := json.NewDecoder(stats.Body).Decode(&snap); err != nil {
return 0
}
var total uint64
for _, n := range snap.Networks {
total += n.RxBytes + n.TxBytes
}
return total
}
// putToSleep removes the workload containers, marks the service sleeping,
// and installs the wake placeholder when the service has a public domain.
func putToSleep(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, svc sleepCandidate, apiPort int) {
containers, err := engine.ListServiceContainers(ctx, svc.ID.String())
if err != nil {
return
}
for _, c := range containers {
if c.Labels[wakeLabel] == "true" {
continue
}
_ = dc.StopContainer(ctx, c.ID, nil)
_ = dc.RemoveContainer(ctx, c.ID, true)
}
tracker.mu.Lock()
delete(tracker.activity, svc.ID)
tracker.mu.Unlock()
_, _ = db.Exec(`UPDATE services SET status = 'sleeping', updated_at = $1 WHERE id = $2`, time.Now(), svc.ID)
log.Printf("sleep: %s (%s) scaled to zero after idle timeout", svc.Name, svc.ID)
var domain string
_ = db.QueryRow(`SELECT COALESCE(domain,'') FROM services WHERE id = $1`, svc.ID).Scan(&domain)
domains := serviceDomainNames(db, svc.ID, domain)
if len(domains) == 0 {
return
}
if err := spawnWakeContainer(ctx, db, dc, engine, svc, domains, apiPort); err != nil {
log.Printf("sleep: wake placeholder for %s failed: %v", svc.Name, err)
}
}
// spawnWakeContainer runs a busybox httpd holding the service's Traefik
// host rule at higher priority. On boot it calls the wake endpoint, then
// keeps serving a reloading page until the next reconcile removes it.
func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, svc sleepCandidate, domains []string, apiPort int) error {
edgeNet := ""
for _, n := range networkList(ctx, dc) {
if n == "containr-network" {
edgeNet = n
}
}
if edgeNet == "" {
return fmt.Errorf("no edge network")
}
router := "svc-" + svc.ID.String()[:8]
sort.Strings(domains)
rules := make([]string, 0, len(domains))
for _, d := range domains {
rules = append(rules, "Host(`"+d+"`)")
}
labels := map[string]string{
deployment.LabelManaged: "true",
deployment.LabelProject: svc.ProjectID.String(),
wakeForLabel: svc.ID.String(),
wakeLabel: "true",
"traefik.enable": "true",
"traefik.docker.network": edgeNet,
"traefik.http.routers." + router + ".rule": strings.Join(rules, " || "),
"traefik.http.routers." + router + ".entrypoints": "web",
"traefik.http.routers." + router + ".priority": "100",
"traefik.http.services." + router + ".loadbalancer.server.port": "80",
}
wakeURL := wakeEndpointURL(ctx, dc, apiPort, svc.ID, edgeNet)
page := `<!doctype html><html><head><meta http-equiv="refresh" content="4"><title>Waking up</title>` +
`<style>body{font-family:system-ui,sans-serif;background:#0b0d12;color:#dde2ea;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}` +
`.card{text-align:center;max-width:34rem;padding:2rem}h1{font-size:1.25rem;font-weight:600}p{color:#98a2b3}</style></head>` +
`<body><div class="card"><h1>Service is waking up</h1><p>This Containr service was sleeping and is resuming now. The page reloads automatically.</p></div></body></html>`
// httpd without -f daemonizes, so the sequence is: write page, start
// httpd, fire the wake callback, then hold PID1 on sleep infinity.
cmd := fmt.Sprintf(
`mkdir -p /www && printf '%%s' '%s' > /www/index.html && `+
`httpd -p 80 -h /www && `+
`(sleep 2; wget -q -O /dev/null --timeout=20 '%s' 2>/dev/null || true) && `+
`sleep infinity`,
strings.ReplaceAll(page, "'", "'\\''"), wakeURL)
endpoints := map[string]*network.EndpointSettings{edgeNet: {}}
if ownNet := dc.OwnNetworkName(ctx); ownNet != "" && ownNet != edgeNet {
endpoints[ownNet] = &network.EndpointSettings{}
}
// Pull is best-effort — the image almost always sits in cache.
_ = dc.PullImageWait(ctx, wakeImage, registry.AuthConfig{})
// A previous placeholder may linger as Exited — clear the name first.
name := fmt.Sprintf("containr-wake-%s", svc.ID.String()[:8])
if stale, err := dc.ListContainersFiltered(ctx, filters.NewArgs(
filters.Arg("name", "^/"+name+"$"),
), true); err == nil {
for _, s := range stale {
_ = dc.RemoveContainer(ctx, s.ID, true)
}
}
id, err := dc.CreateContainer(ctx, docker.ContainerConfig{
Name: name,
Image: wakeImage,
Cmd: []string{"sh", "-c", cmd},
Labels: labels,
Networks: endpoints,
})
if err != nil {
return err
}
return dc.StartContainer(ctx, id)
}
func networkList(ctx context.Context, dc *docker.Client) []string {
networks, err := dc.ListNetworks(ctx)
if err != nil {
return nil
}
names := make([]string, 0, len(networks))
for _, n := range networks {
names = append(names, n.Name)
}
return names
}
// wakeEndpointURL builds the internal URL the wake container calls. The
// edge network's gateway reaches the host's published API port; when the
// API itself is containerized its own name resolves on the shared network.
func wakeEndpointURL(ctx context.Context, dc *docker.Client, apiPort int, serviceID uuid.UUID, edgeNet string) string {
if dc.OwnNetworkName(ctx) != "" {
if self, err := dc.GetContainer(ctx, selfID()); err == nil {
name := strings.TrimPrefix(self.Name, "/")
if name != "" {
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", name, apiPort, serviceID)
}
}
}
if gw := networkGateway(ctx, dc, edgeNet); gw != "" {
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", gw, apiPort, serviceID)
}
if probe := dc.HostProbeHost(ctx); probe != "" {
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", probe, apiPort, serviceID)
}
return fmt.Sprintf("http://127.0.0.1:%d/api/v1/internal/wake/%s", apiPort, serviceID)
}
func networkGateway(ctx context.Context, dc *docker.Client, name string) string {
// ListNetworks does not populate IPAM config — inspect is required.
return dc.NetworkGateway(ctx, name)
}
func selfID() string {
// Inside a container the hostname is the short container ID.
if hn, err := os.Hostname(); err == nil {
return hn
}
return ""
}
// pruneWakeContainers removes wake placeholders whose service is healthy
// again — belt & braces over the reconcile drift sweep.
func pruneWakeContainers(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine) {
containers, err := dc.ListContainersFiltered(ctx, filters.NewArgs(filters.Arg("label", wakeLabel+"=true")), true)
if err != nil {
return
}
for _, c := range containers {
svcID := c.Labels[wakeForLabel]
if svcID == "" {
continue
}
running, _ := engine.ListServiceContainers(ctx, svcID)
live := 0
for _, r := range running {
if r.State == "running" {
live++
}
}
var status string
_ = db.QueryRow(`SELECT COALESCE(status,'') FROM services WHERE id = $1`, svcID).Scan(&status)
if live > 0 || status == "stopped" || status == "" {
_ = dc.RemoveContainer(ctx, c.ID, true)
}
}
}
// handleServiceWake is the unauthenticated internal endpoint the wake
// container hits. It reconciles the service back to running. Waking is
// idempotent and non-destructive, so no token is required; the endpoint
// never mutates configuration.
func handleServiceWake(c *gin.Context) {
dbVal, _ := c.Get("db")
db := dbVal.(*database.DB)
engineVal, _ := c.Get("deployment_engine")
engine, _ := engineVal.(*deployment.DeploymentEngine)
if engine == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "deployment engine unavailable"})
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid service id"})
return
}
var service Service
err = db.QueryRow(
`SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at
FROM services s WHERE s.id = $1`, id,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "service not found"})
return
}
if service.Status == "running" || service.Status == "deployed" {
c.JSON(http.StatusOK, gin.H{"status": service.Status})
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
spec, err := serviceRuntimeSpec(db, service)
if err != nil {
return
}
state, err := engine.ReconcileService(ctx, spec)
if err != nil {
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
return
}
status := "running"
if state != nil {
status = state.Status
persistPublishedPort(db, service.ID, state.Ports)
}
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
}()
c.JSON(http.StatusAccepted, gin.H{"status": "waking"})
}
// handleServiceSleep puts a service to sleep immediately (owner+ only).
func handleServiceSleep(c *gin.Context) {
dbVal, _ := c.Get("db")
db := dbVal.(*database.DB)
engineVal, _ := c.Get("deployment_engine")
engine, _ := engineVal.(*deployment.DeploymentEngine)
dcVal, _ := c.Get("docker_client")
dc, _ := dcVal.(*docker.Client)
if engine == nil || dc == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "deployment engine unavailable"})
return
}
service, ok := loadOwnedService(c, db)
if !ok {
return
}
var port int
_ = db.QueryRow(`SELECT COALESCE(port,0) FROM services WHERE id = $1`, service.ID).Scan(&port)
svc := sleepCandidate{
ID: service.ID,
ProjectID: service.ProjectID,
Name: service.Name,
Status: service.Status,
}
go putToSleep(context.Background(), db, dc, engine, svc, apiPortFromContext(c))
c.JSON(http.StatusAccepted, gin.H{"status": "sleeping"})
}
// handleServiceWakeNow wakes a sleeping service on demand (owner+ only).
func handleServiceWakeNow(c *gin.Context) {
if _, ok := loadOwnedService(c, c.MustGet("db").(*database.DB)); !ok {
return
}
handleServiceWake(c)
}
// apiPortFromContext pulls the configured HTTP port for the wake callback URL.
func apiPortFromContext(c *gin.Context) int {
if v, exists := c.Get("api_port"); exists {
if p, ok := v.(int); ok {
return p
}
}
return 8080
}
+41
View File
@@ -0,0 +1,41 @@
package api
import (
"testing"
"time"
"github.com/google/uuid"
)
func TestMarkIdleTracksCounter(t *testing.T) {
id := uuid.New()
timeout := 50 * time.Millisecond
// First sighting sets the baseline only.
if markIdle(id, 100, timeout) {
t.Fatal("slept on first sighting")
}
// Counter growth resets the idle clock.
if markIdle(id, 200, timeout) {
t.Fatal("slept while counter grew")
}
// Flat counter within the window doesn't sleep.
if markIdle(id, 200, timeout) {
t.Fatal("slept before timeout")
}
time.Sleep(60 * time.Millisecond)
if !markIdle(id, 200, timeout) {
t.Fatal("did not sleep after timeout")
}
}
func TestMarkIdleZeroCounterSleeps(t *testing.T) {
id := uuid.New()
timeout := 20 * time.Millisecond
markIdle(id, 0, timeout)
time.Sleep(30 * time.Millisecond)
if !markIdle(id, 0, timeout) {
t.Fatal("zero-traffic service never slept")
}
}
+8
View File
@@ -40,6 +40,9 @@ type hostMonitoringResponse struct {
UptimeSeconds int64 `json:"uptime_seconds"` UptimeSeconds int64 `json:"uptime_seconds"`
DockerAvailable bool `json:"docker_available"` DockerAvailable bool `json:"docker_available"`
Docker map[string]any `json:"docker,omitempty"` Docker map[string]any `json:"docker,omitempty"`
// Scope names what the telemetry actually measures: the physical host for
// native dockerd, or the Docker Desktop VM when the daemon runs in one.
Scope string `json:"scope"`
CollectedAt string `json:"collected_at"` CollectedAt string `json:"collected_at"`
} }
@@ -159,6 +162,7 @@ func handleGetHostMonitoring(c *gin.Context) {
Storage: storage, Storage: storage,
Load: load, Load: load,
UptimeSeconds: uptime, UptimeSeconds: uptime,
Scope: "host",
CollectedAt: time.Now().UTC().Format(time.RFC3339), CollectedAt: time.Now().UTC().Format(time.RFC3339),
} }
@@ -173,6 +177,10 @@ func handleGetHostMonitoring(c *gin.Context) {
"images": info.Images, "images": info.Images,
"driver": info.Driver, "driver": info.Driver,
"server": info.ServerVersion, "server": info.ServerVersion,
"os": info.OperatingSystem,
}
if strings.Contains(strings.ToLower(info.OperatingSystem), "docker desktop") {
response.Scope = "docker-desktop-vm"
} }
} }
} }
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,202 @@
package api
import (
"encoding/json"
"strings"
"testing"
)
func TestTopoSortServicesOrdersByDependsOn(t *testing.T) {
sorted, err := topoSortServices([]TemplateServiceSpec{
{Key: "web", DependsOn: []string{"db"}},
{Key: "db", Type: "database"},
{Key: "worker", DependsOn: []string{"web", "db"}},
})
if err != nil {
t.Fatalf("topoSortServices: %v", err)
}
if sorted[0].Key != "db" || sorted[1].Key != "web" || sorted[2].Key != "worker" {
t.Fatalf("unexpected order: %v", []string{sorted[0].Key, sorted[1].Key, sorted[2].Key})
}
}
func TestTopoSortServicesRejectsCycle(t *testing.T) {
_, err := topoSortServices([]TemplateServiceSpec{
{Key: "a", DependsOn: []string{"b"}},
{Key: "b", DependsOn: []string{"a"}},
})
if err == nil || !strings.Contains(err.Error(), "cycle") {
t.Fatalf("expected cycle error, got %v", err)
}
}
func TestTopoSortServicesRejectsUnknownDep(t *testing.T) {
_, err := topoSortServices([]TemplateServiceSpec{
{Key: "a", DependsOn: []string{"ghost"}},
})
if err == nil || !strings.Contains(err.Error(), "unknown key") {
t.Fatalf("expected unknown-key error, got %v", err)
}
}
func TestResolveStaticExpr(t *testing.T) {
unresolved := []string{}
var generated bool
v := resolveStaticExpr("{{secret}}", nil, &unresolved, &generated)
if len(v) != 32 || !generated {
t.Fatalf("secret: got %q generated=%v", v, generated)
}
v = resolveStaticExpr("{{secret(8)}}", nil, &unresolved, &generated)
if len(v) != 16 {
t.Fatalf("secret(8): got %q", v)
}
v = resolveStaticExpr("port-{{random:10}}", nil, &unresolved, &generated)
if !strings.HasPrefix(v, "port-") {
t.Fatalf("random: got %q", v)
}
v = resolveStaticExpr("{{DOMAIN:-example.com}}", nil, &unresolved, &generated)
if v != "example.com" {
t.Fatalf("var default: got %q", v)
}
v = resolveStaticExpr("{{DOMAIN}}", map[string]string{"DOMAIN": "x.io"}, &unresolved, &generated)
if v != "x.io" {
t.Fatalf("var override: got %q", v)
}
v = resolveStaticExpr("{{MISSING}}", nil, &unresolved, &generated)
if v != "" || len(unresolved) == 0 || unresolved[0] != "MISSING" {
t.Fatalf("missing var: got %q unresolved=%v", v, unresolved)
}
}
func TestResolveTemplateGraphDatabaseRefs(t *testing.T) {
sorted, err := topoSortServices([]TemplateServiceSpec{
{Key: "db", Type: "database", Runtime: "postgresql", Environment: map[string]string{
"POSTGRES_DB": "shop",
}},
{Key: "web", Type: "web", Runtime: "nginx", DependsOn: []string{"db"}, Environment: map[string]string{
"DATABASE_URL": "{{service.db.url}}",
"DB_HOST": "{{service.db.host}}",
"DB_PORT": "{{service.db.port}}",
"SESSION_TOKEN": "{{secret}}",
}},
})
if err != nil {
t.Fatalf("topo: %v", err)
}
graph := resolveTemplateGraph(sorted, nil)
if len(graph.unresolved) > 0 {
t.Fatalf("unresolved: %v", graph.unresolved)
}
web := graph.members[1]
if web.env["DB_HOST"] != "db" {
t.Fatalf("DB_HOST=%q", web.env["DB_HOST"])
}
if web.env["DB_PORT"] != "5432" {
t.Fatalf("DB_PORT=%q", web.env["DB_PORT"])
}
dbURL := web.env["DATABASE_URL"]
if !strings.HasPrefix(dbURL, "postgresql://") || !strings.Contains(dbURL, "@db:5432/shop") {
t.Fatalf("DATABASE_URL=%q", dbURL)
}
if len(web.env["SESSION_TOKEN"]) != 32 || !web.secretKeys["SESSION_TOKEN"] {
t.Fatalf("SESSION_TOKEN=%q secret=%v", web.env["SESSION_TOKEN"], web.secretKeys["SESSION_TOKEN"])
}
// Database member must carry the generated password so refs and the
// container agree.
db := graph.members[0]
if pw := db.env["POSTGRES_PASSWORD"]; len(pw) != 32 || !db.secretKeys["POSTGRES_PASSWORD"] {
t.Fatalf("POSTGRES_PASSWORD=%q secret=%v", pw, db.secretKeys["POSTGRES_PASSWORD"])
}
if !strings.Contains(dbURL, db.env["POSTGRES_PASSWORD"]) {
t.Fatalf("url %q does not carry the resolved password", dbURL)
}
}
func TestResolveTemplateGraphServiceRefs(t *testing.T) {
sorted, err := topoSortServices([]TemplateServiceSpec{
{Key: "api", Type: "web", Runtime: "ghcr.io/x/api:1", Port: 8080},
{Key: "front", Type: "web", Runtime: "ghcr.io/x/front:1", Port: 3000, DependsOn: []string{"api"}, Environment: map[string]string{
"API_URL": "{{service.api.url}}/v2",
}},
})
if err != nil {
t.Fatalf("topo: %v", err)
}
graph := resolveTemplateGraph(sorted, nil)
if got := graph.members[1].env["API_URL"]; got != "http://api:8080/v2" {
t.Fatalf("API_URL=%q", got)
}
}
func TestResolveTemplateGraphUnresolvedServiceProp(t *testing.T) {
sorted, _ := topoSortServices([]TemplateServiceSpec{
{Key: "web", Type: "web", Runtime: "nginx", Environment: map[string]string{
"X": "{{service.nope.host}}",
}},
})
graph := resolveTemplateGraph(sorted, nil)
if len(graph.unresolved) == 0 {
t.Fatal("expected unresolved ref")
}
}
func TestValidateTemplateGraph(t *testing.T) {
if err := validateTemplateGraph([]TemplateServiceSpec{
{Key: "db", Type: "database", Runtime: "postgresql"},
}); err != nil {
t.Fatalf("valid db spec rejected: %v", err)
}
if err := validateTemplateGraph([]TemplateServiceSpec{
{Key: "db", Type: "database", Runtime: "nonsense"},
}); err == nil {
t.Fatal("expected unsupported db engine error")
}
if err := validateTemplateGraph([]TemplateServiceSpec{
{Key: "web", Type: "web"},
}); err == nil {
t.Fatal("expected missing runtime error")
}
}
// TestOfficialTemplatesAllParse walks every seeded template and asserts the
// config parses, graph templates topo-sort + validate, and a dry-run resolve
// produces no unresolved refs (defaults cover all expressions).
func TestOfficialTemplatesAllParse(t *testing.T) {
for _, tpl := range SeedTemplates() {
var cfg TemplateConfig
if err := json.Unmarshal([]byte(tpl.Config), &cfg); err != nil {
t.Errorf("%s: config parse: %v", tpl.ID, err)
continue
}
if len(cfg.Services) == 0 {
continue
}
sorted, err := topoSortServices(cfg.Services)
if err != nil {
t.Errorf("%s: topo sort: %v", tpl.ID, err)
continue
}
if err := validateTemplateGraph(sorted); err != nil {
t.Errorf("%s: validate: %v", tpl.ID, err)
continue
}
var vars []TemplateVariable
if tpl.Variables != "" {
if err := json.Unmarshal([]byte(tpl.Variables), &vars); err != nil {
t.Errorf("%s: variables parse: %v", tpl.ID, err)
continue
}
}
userVars := map[string]string{}
for _, v := range vars {
if v.Default != "" {
userVars[v.Key] = v.Default
}
}
graph := resolveTemplateGraph(sorted, userVars)
if len(graph.unresolved) > 0 {
t.Errorf("%s: unresolved refs: %v", tpl.ID, graph.unresolved)
}
}
}
+174 -11
View File
@@ -3,6 +3,8 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb" "containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/secrets"
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
@@ -28,11 +30,13 @@ type ServiceTemplate struct {
Variables string `json:"variables" db:"variables"` Variables string `json:"variables" db:"variables"`
IsOfficial bool `json:"is_official" db:"is_official"` IsOfficial bool `json:"is_official" db:"is_official"`
OwnerID string `json:"owner_id,omitempty" db:"owner_id"` OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
IsPublic bool `json:"is_public" db:"is_public"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
type TemplateConfig struct { type TemplateConfig struct {
Version int `json:"version,omitempty"`
Type string `json:"type"` Type string `json:"type"`
Runtime string `json:"runtime"` Runtime string `json:"runtime"`
BuildCommand string `json:"build_command"` BuildCommand string `json:"build_command"`
@@ -42,6 +46,9 @@ type TemplateConfig struct {
Environment map[string]string `json:"environment"` Environment map[string]string `json:"environment"`
Dockerfile string `json:"dockerfile,omitempty"` Dockerfile string `json:"dockerfile,omitempty"`
NixpacksConfig map[string]string `json:"nixpacks_config,omitempty"` NixpacksConfig map[string]string `json:"nixpacks_config,omitempty"`
// Services makes this a v2 graph template. When non-empty the flat
// fields above are ignored and deployTemplateGraph handles the deploy.
Services []TemplateServiceSpec `json:"services,omitempty"`
} }
type TemplateVariable struct { type TemplateVariable struct {
@@ -146,7 +153,7 @@ func handleCreateFromTemplate(c *gin.Context) {
var req struct { var req struct {
ProjectID string `json:"project_id" binding:"required"` ProjectID string `json:"project_id" binding:"required"`
Name string `json:"name" binding:"required"` Name string `json:"name"`
Plan string `json:"plan,omitempty"` Plan string `json:"plan,omitempty"`
Region string `json:"region,omitempty"` Region string `json:"region,omitempty"`
Variables map[string]string `json:"variables"` Variables map[string]string `json:"variables"`
@@ -159,10 +166,6 @@ func handleCreateFromTemplate(c *gin.Context) {
req.ProjectID = strings.TrimSpace(req.ProjectID) req.ProjectID = strings.TrimSpace(req.ProjectID)
req.Name = strings.TrimSpace(req.Name) req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
projectID, err := uuid.Parse(req.ProjectID) projectID, err := uuid.Parse(req.ProjectID)
if err != nil { if err != nil {
@@ -212,6 +215,51 @@ func handleCreateFromTemplate(c *gin.Context) {
return return
} }
if len(config.Services) > 0 {
var engine *deployment.DeploymentEngine
if v, exists := c.Get("deployment_engine"); exists && v != nil {
engine, _ = v.(*deployment.DeploymentEngine)
}
var dbHandler *DatabaseHandler
if v, exists := c.Get("database_handler"); exists {
dbHandler, _ = v.(*DatabaseHandler)
}
userVars := map[string]string{}
for _, v := range templateVars {
if override, ok := req.Variables[v.Key]; ok {
userVars[v.Key] = override
} else if v.Default != "" {
userVars[v.Key] = v.Default
}
}
for k, v := range req.Variables {
userVars[k] = v
}
created, fail := deployTemplateGraph(c, db, engine, dbHandler, projectID, userID,
config.Services, templateVars, userVars, req.Plan, req.Region, "template:"+templateID)
if fail != nil {
status := http.StatusBadRequest
if (*fail)["code"] == "CONFLICT" {
status = http.StatusConflict
}
c.JSON(status, *fail)
return
}
c.JSON(http.StatusCreated, gin.H{
"resource": "stack",
"created": created,
"message": fmt.Sprintf("Deployed %d resources from template %s", len(created), template.Name),
})
return
}
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
serviceType, err := normalizeTemplateServiceType(config.Type) serviceType, err := normalizeTemplateServiceType(config.Type)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -335,16 +383,30 @@ func handleCreateFromTemplate(c *gin.Context) {
return return
} }
// sqlc's schema predates the runtime columns; port and healthcheck_path are
// written via raw SQL like every other service mutation path.
if _, err = tx.ExecContext(ctx,
`UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3`,
config.Port, strings.TrimSpace(config.HealthCheck), serviceID,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
return
}
for key, value := range envVars { for key, value := range envVars {
if strings.TrimSpace(key) == "" { if strings.TrimSpace(key) == "" {
continue continue
} }
stored := value
if secretKeys[key] {
stored = secrets.Encrypt(value)
}
err = txQueries.UpsertEnvironmentVariable(ctx, sqlcdb.UpsertEnvironmentVariableParams{ err = txQueries.UpsertEnvironmentVariable(ctx, sqlcdb.UpsertEnvironmentVariableParams{
ID: uuid.New(), ID: uuid.New(),
ServiceID: serviceID, ServiceID: serviceID,
Key: key, Key: key,
Value: value, Value: stored,
IsSecret: sql.NullBool{Bool: secretKeys[key], Valid: true}, IsSecret: sql.NullBool{Bool: secretKeys[key], Valid: true},
CreatedAt: sql.NullTime{Time: now, Valid: true}, CreatedAt: sql.NullTime{Time: now, Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true}, UpdatedAt: sql.NullTime{Time: now, Valid: true},
@@ -383,6 +445,7 @@ type templateWriteRequest struct {
Logo string `json:"logo"` Logo string `json:"logo"`
Config json.RawMessage `json:"config" binding:"required"` Config json.RawMessage `json:"config" binding:"required"`
Variables json.RawMessage `json:"variables"` Variables json.RawMessage `json:"variables"`
IsPublic bool `json:"is_public"`
} }
var templateCategories = map[string]bool{ var templateCategories = map[string]bool{
@@ -483,6 +546,7 @@ func handleCreateTemplate(c *gin.Context) {
Config: req.Config, Config: req.Config,
Variables: templateVariablesParam(req.Variables), Variables: templateVariablesParam(req.Variables),
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true}, OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
IsPublic: req.IsPublic,
}); err != nil { }); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"})
return return
@@ -529,6 +593,7 @@ func handleUpdateTemplate(c *gin.Context) {
Logo: nullableText(req.Logo), Logo: nullableText(req.Logo),
Config: req.Config, Config: req.Config,
Variables: templateVariablesParam(req.Variables), Variables: templateVariablesParam(req.Variables),
IsPublic: req.IsPublic,
ID: templateID, ID: templateID,
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true}, OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
}) })
@@ -727,7 +792,7 @@ func SeedTemplates() []ServiceTemplate {
Name: "Background Worker", Name: "Background Worker",
Description: "Background job processing service", Description: "Background job processing service",
Category: "worker", Category: "worker",
Logo: "https://cdn.simpleicons.org/terminal", Logo: "https://cdn.simpleicons.org/gnubash",
Config: `{"type":"worker","runtime":"node","build_command":"npm install","start_command":"npm run worker"}`, Config: `{"type":"worker","runtime":"node","build_command":"npm install","start_command":"npm run worker"}`,
Variables: `[{"key":"WORKER_CONCURRENCY","label":"Concurrency","default":"4","required":false,"secret":false}]`, Variables: `[{"key":"WORKER_CONCURRENCY","label":"Concurrency","default":"4","required":false,"secret":false}]`,
IsOfficial: true, IsOfficial: true,
@@ -737,7 +802,7 @@ func SeedTemplates() []ServiceTemplate {
Name: "Cron Job", Name: "Cron Job",
Description: "Scheduled task runner", Description: "Scheduled task runner",
Category: "cron", Category: "cron",
Logo: "https://cdn.simpleicons.org/clock", Logo: "https://cdn.simpleicons.org/clockify",
Config: `{"type":"cron","runtime":"node","build_command":"npm install","start_command":"npm run cron"}`, Config: `{"type":"cron","runtime":"node","build_command":"npm install","start_command":"npm run cron"}`,
Variables: `[{"key":"CRON_SCHEDULE","label":"Schedule","default":"0 * * * *","required":true,"secret":false}]`, Variables: `[{"key":"CRON_SCHEDULE","label":"Schedule","default":"0 * * * *","required":true,"secret":false}]`,
IsOfficial: true, IsOfficial: true,
@@ -913,6 +978,102 @@ func SeedTemplates() []ServiceTemplate {
Variables: `[]`, Variables: `[]`,
IsOfficial: true, IsOfficial: true,
}, },
// Multi-service stacks — v2 graph configs. Database members become
// managed databases attached to the project network under their key.
{
ID: "tpl-wordpress-stack",
Name: "WordPress + MySQL",
Description: "WordPress with a managed MySQL database on a private project network",
Category: "stack",
Logo: "https://cdn.simpleicons.org/wordpress",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"mysql","environment":{"MYSQL_DATABASE":"wordpress"}},
{"key":"web","name":"wordpress","type":"web","runtime":"wordpress:6-apache","port":80,"health_check":"/wp-login.php","depends_on":["db"],
"environment":{"WORDPRESS_DB_HOST":"{{service.db.host}}","WORDPRESS_DB_USER":"{{service.db.user}}","WORDPRESS_DB_PASSWORD":"{{service.db.password}}","WORDPRESS_DB_NAME":"{{service.db.database}}"}}]}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-n8n-stack",
Name: "n8n + PostgreSQL",
Description: "n8n workflow automation backed by a managed PostgreSQL database",
Category: "stack",
Logo: "https://cdn.simpleicons.org/n8n",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql"},
{"key":"n8n","type":"web","runtime":"n8nio/n8n:latest","port":5678,"health_check":"/healthz","depends_on":["db"],
"environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"{{service.db.port}}","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","N8N_ENCRYPTION_KEY":"{{secret}}"}}]}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-ghost-stack",
Name: "Ghost + MySQL",
Description: "Ghost publishing platform backed by a managed MySQL database",
Category: "stack",
Logo: "https://cdn.simpleicons.org/ghost",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"mysql","environment":{"MYSQL_DATABASE":"ghost"}},
{"key":"web","name":"ghost","type":"web","runtime":"ghost:5-alpine","port":2368,"depends_on":["db"],
"environment":{"url":"{{APP_URL:-http://localhost:2368}}","database__client":"mysql","database__connection__host":"{{service.db.host}}","database__connection__user":"{{service.db.user}}","database__connection__password":"{{service.db.password}}","database__connection__database":"{{service.db.database}}"}}]}`,
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-gitea-stack",
Name: "Gitea + PostgreSQL",
Description: "Self-hosted Git service backed by a managed PostgreSQL database",
Category: "stack",
Logo: "https://cdn.simpleicons.org/gitea",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"gitea"}},
{"key":"web","name":"gitea","type":"web","runtime":"gitea/gitea:1","port":3000,"health_check":"/api/healthz","depends_on":["db"],
"environment":{"GITEA__database__DB_TYPE":"postgres","GITEA__database__HOST":"{{service.db.host}}:{{service.db.port}}","GITEA__database__NAME":"{{service.db.database}}","GITEA__database__USER":"{{service.db.user}}","GITEA__database__PASSWD":"{{service.db.password}}","GITEA__security__SECRET_KEY":"{{secret}}","GITEA__security__INTERNAL_TOKEN":"{{secret(64)}}"}}]}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-nextcloud-stack",
Name: "Nextcloud + PostgreSQL + Redis",
Description: "Nextcloud with a managed PostgreSQL database and Redis cache",
Category: "stack",
Logo: "https://cdn.simpleicons.org/nextcloud",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"nextcloud"}},
{"key":"cache","type":"database","runtime":"redis"},
{"key":"web","name":"nextcloud","type":"web","runtime":"nextcloud:30-apache","port":80,"health_check":"/status.php","depends_on":["db","cache"],
"environment":{"POSTGRES_HOST":"{{service.db.host}}","POSTGRES_DB":"{{service.db.database}}","POSTGRES_USER":"{{service.db.user}}","POSTGRES_PASSWORD":"{{service.db.password}}","REDIS_HOST":"{{service.cache.host}}","REDIS_HOST_PASSWORD":"{{service.cache.password}}","NEXTCLOUD_ADMIN_USER":"admin","NEXTCLOUD_ADMIN_PASSWORD":"{{secret}}","NEXTCLOUD_TRUSTED_DOMAINS":"{{APP_DOMAIN:-localhost}}"}}]}`,
Variables: `[{"key":"APP_DOMAIN","label":"Trusted domain","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-paperless-stack",
Name: "Paperless-ngx + PostgreSQL + Redis",
Description: "Document management with managed PostgreSQL and Redis",
Category: "stack",
Logo: "https://cdn.simpleicons.org/paperlessngx",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"paperless"}},
{"key":"cache","type":"database","runtime":"redis"},
{"key":"web","name":"paperless","type":"web","runtime":"ghcr.io/paperless-ngx/paperless-ngx:latest","port":8000,"depends_on":["db","cache"],
"environment":{"PAPERLESS_DBHOST":"{{service.db.host}}","PAPERLESS_DBPORT":"{{service.db.port}}","PAPERLESS_DBNAME":"{{service.db.database}}","PAPERLESS_DBUSER":"{{service.db.user}}","PAPERLESS_DBPASS":"{{service.db.password}}","PAPERLESS_REDIS":"{{service.cache.url}}","PAPERLESS_ADMIN_USER":"admin","PAPERLESS_ADMIN_PASSWORD":"{{secret}}","PAPERLESS_SECRET_KEY":"{{secret(48)}}","PAPERLESS_URL":"{{APP_URL:-http://localhost:8000}}"}}]}`,
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-plausible-stack",
Name: "Plausible + PostgreSQL + ClickHouse",
Description: "Privacy-friendly analytics with managed PostgreSQL and ClickHouse",
Category: "stack",
Logo: "https://cdn.simpleicons.org/plausibleanalytics",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"plausible"}},
{"key":"events","type":"database","runtime":"clickhouse","environment":{"CLICKHOUSE_DB":"plausible"}},
{"key":"web","name":"plausible","type":"web","runtime":"ghcr.io/plausible/community-edition:v2","port":8000,"health_check":"/api/health","depends_on":["db","events"],
"environment":{"BASE_URL":"{{APP_URL:-http://localhost:8000}}","SECRET_KEY_BASE":"{{secret(64)}}","DATABASE_URL":"{{service.db.url}}","CLICKHOUSE_DATABASE_URL":"http://{{service.events.host}}:{{service.events.port}}/{{service.events.database}}","TOTP_VAULT_KEY":"{{secret(32)}}"}}]}`,
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
} }
return templates return templates
} }
@@ -1013,10 +1174,11 @@ func defaultTemplateResources(serviceType string) (cpu, memory string) {
} }
} }
// templateVisibleTo reports whether the caller may see a template: official // templateVisibleTo reports whether the caller may see a template: official and
// templates are public, user templates are owner/admin only. // published (is_public) templates are public, other user templates are
// owner/admin only.
func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool { func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool {
if t.IsOfficial { if t.IsOfficial || t.IsPublic {
return true return true
} }
if contextIsAdmin(c) { if contextIsAdmin(c) {
@@ -1047,6 +1209,7 @@ func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
Variables: variables, Variables: variables,
IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool, IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool,
OwnerID: ownerID, OwnerID: ownerID,
IsPublic: row.IsPublic,
CreatedAt: templateNullTime(row.CreatedAt), CreatedAt: templateNullTime(row.CreatedAt),
UpdatedAt: templateNullTime(row.UpdatedAt), UpdatedAt: templateNullTime(row.UpdatedAt),
} }
+194
View File
@@ -0,0 +1,194 @@
package api
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"net/http"
"strings"
"time"
"containr/internal/database"
sqlcdb "containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Personal access tokens let the CLI, MCP server, and third-party agents
// authenticate without a browser session. The raw token (cnp_…) is
// returned exactly once at creation; only its sha256 hash persists.
var userTokenScopes = map[string]bool{"read": true, "write": true, "admin": true}
func generateUserToken() (token string, prefix string, hash string, err error) {
buf := make([]byte, 24)
if _, err = rand.Read(buf); err != nil {
return "", "", "", err
}
token = "cnp_" + hex.EncodeToString(buf)
prefix = token[:12] // cnp_ + 8 hex chars, enough to identify in a list
sum := sha256.Sum256([]byte(token))
return token, prefix, hex.EncodeToString(sum[:]), nil
}
func userTokenJSON(t sqlcdb.UserToken) gin.H {
row := gin.H{
"id": t.ID.String(),
"name": t.Name,
"key_prefix": t.KeyPrefix,
"scope": t.Scope,
}
if t.CreatedAt.Valid {
row["created_at"] = t.CreatedAt.Time
}
if t.LastUsedAt.Valid {
row["last_used_at"] = t.LastUsedAt.Time
}
if t.ExpiresAt.Valid {
row["expires_at"] = t.ExpiresAt.Time
}
return row
}
func handleCreateUserToken(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
var req struct {
Name string `json:"name"`
Scope string `json:"scope"`
ExpiresInDays int `json:"expires_in_days"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"})
return
}
if req.Scope == "" {
req.Scope = "write"
}
if !userTokenScopes[req.Scope] {
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be one of: read, write, admin", "code": "VALIDATION"})
return
}
// A token can never outrank its owner: non-admin users cannot mint
// admin-scope tokens even for themselves.
if req.Scope == "admin" && !c.GetBool("is_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "admin scope requires a platform admin account", "code": "FORBIDDEN"})
return
}
if req.ExpiresInDays < 0 || req.ExpiresInDays > 3650 {
c.JSON(http.StatusBadRequest, gin.H{"error": "expires_in_days must be between 0 (never) and 3650", "code": "VALIDATION"})
return
}
token, prefix, hash, err := generateUserToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token", "code": "INTERNAL"})
return
}
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
return
}
expiresAt := sql.NullTime{}
if req.ExpiresInDays > 0 {
expiresAt = sql.NullTime{Time: time.Now().Add(time.Duration(req.ExpiresInDays) * 24 * time.Hour), Valid: true}
}
row, err := queries.CreateUserToken(c.Request.Context(), sqlcdb.CreateUserTokenParams{
UserID: ownerUUID,
Name: req.Name,
KeyPrefix: prefix,
TokenHash: hash,
Scope: req.Scope,
ExpiresAt: expiresAt,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token", "code": "INTERNAL"})
return
}
LogAudit(userID, "user_token", row.ID.String(), "create", map[string]interface{}{"name": req.Name, "scope": req.Scope})
payload := userTokenJSON(row)
payload["token"] = token
c.JSON(http.StatusCreated, payload)
}
func handleListUserTokens(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
return
}
rows, err := queries.ListUserTokens(c.Request.Context(), ownerUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens", "code": "INTERNAL"})
return
}
out := make([]gin.H, 0, len(rows))
for _, t := range rows {
out = append(out, userTokenJSON(t))
}
c.JSON(http.StatusOK, gin.H{"tokens": out})
}
func handleDeleteUserToken(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
return
}
tokenUUID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID", "code": "VALIDATION"})
return
}
affected, err := queries.RevokeUserToken(c.Request.Context(), sqlcdb.RevokeUserTokenParams{
ID: tokenUUID,
UserID: ownerUUID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token", "code": "INTERNAL"})
return
}
if affected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found", "code": "NOT_FOUND"})
return
}
LogAudit(userID, "user_token", tokenUUID.String(), "revoke", nil)
c.JSON(http.StatusOK, gin.H{"message": "Token revoked"})
}
+25 -1
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/secrets"
"net/http" "net/http"
"time" "time"
@@ -25,6 +26,7 @@ type EnvironmentVariable struct {
type UpdateVariablesRequest struct { type UpdateVariablesRequest struct {
Variables []VariableInput `json:"variables" binding:"required"` Variables []VariableInput `json:"variables" binding:"required"`
Redeploy bool `json:"redeploy"`
} }
type VariableInput struct { type VariableInput struct {
@@ -159,6 +161,9 @@ func handleUpdateVariables(c *gin.Context) {
value = stored value = stored
} }
} }
if v.IsSecret {
value = secrets.Encrypt(value)
}
varID := uuid.New() varID := uuid.New()
_, err = tx.Exec( _, err = tx.Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at) `INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
@@ -204,5 +209,24 @@ func handleUpdateVariables(c *gin.Context) {
variables = append(variables, v) variables = append(variables, v)
} }
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Environment variables updated successfully"}) resp := gin.H{"variables": variables, "message": "Environment variables updated successfully"}
if req.Redeploy {
// Load the service spec inputs and queue a reconcile behind any
// in-flight deployment — new env lands in the recreated containers.
var svc Service
svcErr := db.(*database.DB).QueryRow(
`SELECT id, project_id, name, COALESCE(image, ''), COALESCE(command, ''),
COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(domain, ''),
COALESCE(healthcheck_path, ''), COALESCE(restart_policy, 'unless-stopped'),
COALESCE(cpu, ''), COALESCE(memory, ''),
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''), COALESCE(memory_reserve, '')
FROM services WHERE id = $1`,
serviceID,
).Scan(&svc.ID, &svc.ProjectID, &svc.Name, &svc.Image, &svc.Command,
&svc.Replicas, &svc.Port, &svc.Domain, &svc.HealthCheckPath,
&svc.RestartPolicy, &svc.CPU, &svc.Memory,
&svc.Builder, &svc.CPUReserve, &svc.MemoryReserve)
resp["redeploy_queued"] = svcErr == nil && enqueueServiceRedeploy(c, db.(*database.DB), svc)
}
c.JSON(http.StatusOK, resp)
} }
+105
View File
@@ -0,0 +1,105 @@
package api
import (
"net/http"
"time"
"containr/internal/docker"
"github.com/gin-gonic/gin"
)
// VolumeInfo is one docker volume plus whether any container mounts it.
type VolumeInfo struct {
Name string `json:"name"`
Driver string `json:"driver"`
Mountpoint string `json:"mountpoint"`
Scope string `json:"scope"`
InUse bool `json:"in_use"`
UsedBy []string `json:"used_by,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
func dockerClientOr500(c *gin.Context) (*docker.Client, bool) {
v, exists := c.Get("docker_client")
if !exists || v == nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Docker client not available")
return nil, false
}
return v.(*docker.Client), true
}
// handleAdminListVolumes returns the node's docker volume inventory with
// in-use flags. Admin-only: mountpoints reveal host filesystem layout.
func handleAdminListVolumes(c *gin.Context) {
client, ok := dockerClientOr500(c)
if !ok {
return
}
ctx := c.Request.Context()
vols, err := client.ListVolumes(ctx)
if err != nil {
respondError(c, http.StatusBadGateway, "DEPENDENCY_UNAVAILABLE", "Failed to list volumes: "+err.Error())
return
}
containers, err := client.ListContainers(ctx, true)
if err != nil {
respondError(c, http.StatusBadGateway, "DEPENDENCY_UNAVAILABLE", "Failed to list containers: "+err.Error())
return
}
inUse := map[string][]string{}
for _, cont := range containers {
for _, m := range cont.Mounts {
if m.Type == "volume" {
name := cont.ID[:12]
if len(cont.Names) > 0 {
name = cont.Names[0]
}
inUse[m.Name] = append(inUse[m.Name], name)
}
}
}
out := make([]VolumeInfo, 0, len(vols.Volumes))
for _, v := range vols.Volumes {
if v == nil {
continue
}
info := VolumeInfo{
Name: v.Name,
Driver: v.Driver,
Mountpoint: v.Mountpoint,
Scope: v.Scope,
Labels: v.Labels,
UsedBy: inUse[v.Name],
}
info.InUse = len(info.UsedBy) > 0
if t, err := time.Parse(time.RFC3339Nano, v.CreatedAt); err == nil {
info.CreatedAt = t
}
out = append(out, info)
}
c.JSON(http.StatusOK, gin.H{"volumes": out})
}
// handleAdminDeleteVolume removes a docker volume. Docker refuses removal
// of in-use volumes — that error is surfaced as a conflict.
func handleAdminDeleteVolume(c *gin.Context) {
client, ok := dockerClientOr500(c)
if !ok {
return
}
name := c.Param("name")
if name == "" {
respondError(c, http.StatusBadRequest, "VALIDATION", "Volume name is required")
return
}
if err := client.RemoveVolume(c.Request.Context(), name, false); err != nil {
respondError(c, http.StatusConflict, "CONFLICT", "Failed to remove volume: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"deleted": name})
}
+7 -2
View File
@@ -211,8 +211,12 @@ func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest)
// Build Docker image // Build Docker image
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag) imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
// Create build context tar // createBuildContext wants the Dockerfile contents, not its path.
buildCtx, err := createBuildContext(req.SourcePath, optimizedDockerfile) dockerfileContent, err := os.ReadFile(optimizedDockerfile)
if err != nil {
return nil, fmt.Errorf("failed to read Dockerfile: %w", err)
}
buildCtx, err := createBuildContext(req.SourcePath, string(dockerfileContent))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create build context: %w", err) return nil, fmt.Errorf("failed to create build context: %w", err)
} }
@@ -222,6 +226,7 @@ func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest)
Dockerfile: "Dockerfile", Dockerfile: "Dockerfile",
Tags: []string{imageName}, Tags: []string{imageName},
BuildArgs: buildArgs, BuildArgs: buildArgs,
NoCache: req.NoCache,
Remove: true, Remove: true,
} }
+6 -1
View File
@@ -16,6 +16,7 @@ type BuildManager struct {
railpackBuilder *RailpackBuilder railpackBuilder *RailpackBuilder
nixpacksBuilder *NixpacksBuilder nixpacksBuilder *NixpacksBuilder
dockerfileBuilder *DockerfileBuilder dockerfileBuilder *DockerfileBuilder
staticBuilder *StaticBuilder
dockerClient *docker.Client dockerClient *docker.Client
cacheManager *CacheManager cacheManager *CacheManager
parallelBuilder *ParallelBuilder parallelBuilder *ParallelBuilder
@@ -28,6 +29,7 @@ const (
BuildTypeRailpack BuildType = "railpack" BuildTypeRailpack BuildType = "railpack"
BuildTypeNixpacks BuildType = "nixpacks" BuildTypeNixpacks BuildType = "nixpacks"
BuildTypeDockerfile BuildType = "dockerfile" BuildTypeDockerfile BuildType = "dockerfile"
BuildTypeStatic BuildType = "static"
BuildTypePrebuilt BuildType = "prebuilt" BuildTypePrebuilt BuildType = "prebuilt"
) )
@@ -39,6 +41,7 @@ func NewBuildManager(workDir string, dockerClient *docker.Client) *BuildManager
railpackBuilder: NewRailpackBuilder(workDir, dockerClient), railpackBuilder: NewRailpackBuilder(workDir, dockerClient),
nixpacksBuilder: NewNixpacksBuilder(workDir, dockerClient), nixpacksBuilder: NewNixpacksBuilder(workDir, dockerClient),
dockerfileBuilder: NewDockerfileBuilder(workDir, dockerClient), dockerfileBuilder: NewDockerfileBuilder(workDir, dockerClient),
staticBuilder: NewStaticBuilder(dockerClient),
dockerClient: dockerClient, dockerClient: dockerClient,
cacheManager: cacheManager, cacheManager: cacheManager,
parallelBuilder: parallelBuilder, parallelBuilder: parallelBuilder,
@@ -80,6 +83,8 @@ func (bm *BuildManager) Build(ctx context.Context, req *types.BuildRequest) (*ty
return bm.nixpacksBuilder.Build(ctx, req) return bm.nixpacksBuilder.Build(ctx, req)
case BuildTypeDockerfile: case BuildTypeDockerfile:
return bm.dockerfileBuilder.Build(ctx, req) return bm.dockerfileBuilder.Build(ctx, req)
case BuildTypeStatic:
return bm.staticBuilder.Build(ctx, req)
case BuildTypePrebuilt: case BuildTypePrebuilt:
return bm.buildPrebuilt(ctx, req) return bm.buildPrebuilt(ctx, req)
default: default:
@@ -95,7 +100,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
// Pull the prebuilt image — must drain the pull stream or the daemon // Pull the prebuilt image — must drain the pull stream or the daemon
// aborts it and the tag below races a missing image. // aborts it and the tag below races a missing image.
auth := registry.AuthConfig{} auth := registry.AuthConfig{Username: req.PullUsername, Password: req.PullPassword}
if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil { if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil {
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err) return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
} }
+1
View File
@@ -498,6 +498,7 @@ func (n *NixpacksBuilder) Build(ctx context.Context, req *types.BuildRequest) (*
BuildArgs: map[string]*string{ BuildArgs: map[string]*string{
"BUILDKIT_INLINE_CACHE": strPtr("1"), "BUILDKIT_INLINE_CACHE": strPtr("1"),
}, },
NoCache: req.NoCache,
Remove: true, Remove: true,
} }
+1
View File
@@ -156,6 +156,7 @@ func (rb *RailpackBuilder) Build(ctx context.Context, req *types.BuildRequest) (
Tags: []string{imageName}, Tags: []string{imageName},
BuildArgs: buildArgs, BuildArgs: buildArgs,
Labels: req.Labels, Labels: req.Labels,
NoCache: req.NoCache,
Remove: true, Remove: true,
} }
+95
View File
@@ -0,0 +1,95 @@
package build
import (
"context"
"fmt"
"containr/internal/docker"
"containr/internal/types"
)
// StaticBuilder produces an nginx-served image from a repo's built assets.
// STATIC_BUILD_CMD runs inside a node build stage; STATIC_DIR names the
// output directory copied into the nginx image. Both are overridable via
// build args so repos with non-node toolchains can swap the build image too.
type StaticBuilder struct {
dockerClient *docker.Client
}
func NewStaticBuilder(dockerClient *docker.Client) *StaticBuilder {
return &StaticBuilder{dockerClient: dockerClient}
}
const staticDockerfile = `ARG BUILD_IMAGE=node:20-alpine
FROM ${BUILD_IMAGE} AS assets
WORKDIR /app
COPY . .
ARG STATIC_BUILD_CMD
RUN sh -c "${STATIC_BUILD_CMD}"
FROM nginx:alpine
ARG STATIC_DIR=dist
COPY --from=assets /app/${STATIC_DIR} /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
`
func (s *StaticBuilder) Build(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
buildCmd := req.BuildCommand
if buildCmd == "" {
buildCmd = "npm ci && npm run build"
}
staticDir := req.BuildArgs["STATIC_DIR"]
if staticDir == "" {
staticDir = "dist"
}
buildArgs := map[string]*string{}
for k, v := range req.BuildArgs {
v := v
buildArgs[k] = &v
}
for k, v := range req.Environment {
v := v
buildArgs[k] = &v
}
buildArgs["STATIC_BUILD_CMD"] = &buildCmd
buildArgs["STATIC_DIR"] = &staticDir
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
buildCtx, err := createBuildContext(req.SourcePath, staticDockerfile)
if err != nil {
return nil, fmt.Errorf("failed to create build context: %w", err)
}
defer buildCtx.Close()
if _, err := s.dockerClient.BuildImage(ctx, buildCtx, docker.BuildOptions{
Dockerfile: "Dockerfile",
Tags: []string{imageName},
BuildArgs: buildArgs,
NoCache: req.NoCache,
Remove: true,
}); err != nil {
return nil, fmt.Errorf("failed to build static image: %w", err)
}
if req.RegistryURL != "" {
full := fmt.Sprintf("%s/%s", req.RegistryURL, imageName)
if err := s.dockerClient.PushImage(ctx, imageName, req.RegistryURL); err != nil {
return nil, fmt.Errorf("failed to push image: %w", err)
}
imageName = full
}
imageInfo, err := s.dockerClient.GetImageInfo(ctx, imageName)
if err != nil {
imageInfo = &docker.ImageInfo{Size: 0}
}
return &types.BuildResponse{
ImageName: imageName,
ImageTag: req.ImageTag,
Size: imageInfo.Size,
Digest: imageInfo.Digest,
}, nil
}
+361
View File
@@ -0,0 +1,361 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// AdminCmd groups platform-admin operations.
var AdminCmd = &cobra.Command{
Use: "admin",
Short: "Platform administration (admin scope required)",
}
var adminOverviewCmd = &cobra.Command{
Use: "overview",
Short: "Platform overview",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/admin/overview", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var adminUsersCmd = &cobra.Command{
Use: "users",
Short: "List all users",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/admin/users", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "users")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, u := range items {
rows = append(rows, []string{
str(u, "id"), str(u, "email"), str(u, "name"), str(u, "is_admin"), relTime(u, "created_at"),
})
}
printRows(data, []string{"ID", "EMAIL", "NAME", "ADMIN", "CREATED"}, rows)
return nil
},
}
var adminSettingsCmd = &cobra.Command{
Use: "settings",
Short: "Show platform settings",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/settings", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var adminAuditCmd = &cobra.Command{
Use: "audit-logs",
Short: "List audit log entries",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/audit-logs", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// GatewayCmd manages the API gateway (admin).
var GatewayCmd = &cobra.Command{
Use: "gateway",
Short: "Manage the API gateway (admin)",
}
var gatewayServicesCmd = &cobra.Command{
Use: "services",
Short: "List gateway services",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/gateway/services", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var gatewayKeysCmd = &cobra.Command{
Use: "keys",
Short: "List gateway API keys",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/gateway/keys", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var gatewayAnalyticsCmd = &cobra.Command{
Use: "analytics",
Short: "Gateway traffic analytics",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
kind, _ := cmd.Flags().GetString("type")
data, err := c.Do("GET", "/gateway/analytics/"+kind, nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// HACmd manages high availability.
var HACmd = &cobra.Command{
Use: "ha",
Short: "High availability controls",
}
var haStatusCmd = &cobra.Command{
Use: "status",
Short: "Show HA status",
RunE: simpleGet("/ha/status"),
}
var haAlertsCmd = &cobra.Command{
Use: "alerts",
Short: "List active HA alerts",
RunE: simpleGet("/ha/alerts/active"),
}
var haHealthCmd = &cobra.Command{
Use: "health",
Short: "List health check results",
RunE: simpleGet("/ha/health/results"),
}
var haPoliciesCmd = &cobra.Command{
Use: "policies",
Short: "List failover policies",
RunE: simpleGet("/ha/failover/policies"),
}
var haEnableCmd = &cobra.Command{
Use: "enable",
Short: "Enable the HA manager",
RunE: simplePost("/ha/enable"),
}
var haDisableCmd = &cobra.Command{
Use: "disable",
Short: "Disable the HA manager",
RunE: func(cmd *cobra.Command, args []string) error {
if !Confirm("Disable HA? Services will no longer be failed over.") {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
return simplePost("/ha/disable")(cmd, args)
},
}
var haFailoverCmd = &cobra.Command{
Use: "failover",
Short: "Trigger a manual failover",
RunE: func(cmd *cobra.Command, args []string) error {
if !Confirm("Trigger a platform failover now? Services may be rescheduled.") {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
return simplePost("/ha/failover")(cmd, args)
},
}
// ScalingCmd manages autoscaling.
var ScalingCmd = &cobra.Command{
Use: "scaling",
Short: "Autoscaling controls",
}
var scalingStatusCmd = &cobra.Command{
Use: "status",
Short: "Show autoscaler status",
RunE: simpleGet("/scaling/status"),
}
var scalingPoliciesCmd = &cobra.Command{
Use: "policies",
Short: "List scaling policies",
RunE: simpleGet("/scaling/policies"),
}
var scalingServicesCmd = &cobra.Command{
Use: "services",
Short: "Show per-service scaling state",
RunE: simpleGet("/scaling/services"),
}
var scalingScaleCmd = &cobra.Command{
Use: "scale <service-id> <replicas>",
Short: "Manually scale a service",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
var replicas int
fmt.Sscanf(args[1], "%d", &replicas)
data, err := c.Do("POST", "/scaling/services/"+args[0]+"/scale", map[string]int{"replicas": replicas})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// SecurityCmd manages security scans and findings.
var SecurityCmd = &cobra.Command{
Use: "security",
Short: "Security scans and vulnerabilities",
}
var securityScanCmd = &cobra.Command{
Use: "scan <project-id>",
Short: "Run a security scan",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
scanType, _ := cmd.Flags().GetString("type")
serviceID, _ := cmd.Flags().GetString("service")
body := map[string]interface{}{"project_id": args[0], "scan_type": scanType}
if serviceID != "" {
body["service_id"] = serviceID
}
data, err := c.Do("POST", "/security/scans", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var securityVulnsCmd = &cobra.Command{
Use: "vulnerabilities <project-id>",
Short: "List a project's vulnerabilities",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/projects/"+args[0]+"/vulnerabilities", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var securityMetricsCmd = &cobra.Command{
Use: "metrics <project-id>",
Short: "Show a project's security metrics",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/projects/"+args[0]+"/security/metrics", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// simpleGet/simplePost cover the many read/trigger endpoints whose full
// JSON response is the useful output.
func simpleGet(path string) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", path, nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
}
}
func simplePost(path string) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", path, map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
}
}
func init() {
gatewayAnalyticsCmd.Flags().String("type", "traffic", "traffic|ops")
securityScanCmd.Flags().String("type", "configuration", "dependency|configuration|comprehensive")
securityScanCmd.Flags().String("service", "", "limit scan to one service")
AdminCmd.AddCommand(adminOverviewCmd, adminUsersCmd, adminSettingsCmd, adminAuditCmd)
GatewayCmd.AddCommand(gatewayServicesCmd, gatewayKeysCmd, gatewayAnalyticsCmd)
HACmd.AddCommand(haStatusCmd, haAlertsCmd, haHealthCmd, haPoliciesCmd, haEnableCmd, haDisableCmd, haFailoverCmd)
ScalingCmd.AddCommand(scalingStatusCmd, scalingPoliciesCmd, scalingServicesCmd, scalingScaleCmd)
SecurityCmd.AddCommand(securityScanCmd, securityVulnsCmd, securityMetricsCmd)
}
+94 -83
View File
@@ -2,139 +2,150 @@ package commands
import ( import (
"fmt" "fmt"
"net/http" "os"
"strings"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "golang.org/x/term"
) )
// AuthCmd represents the auth command // AuthCmd groups authentication commands.
var AuthCmd = &cobra.Command{ var AuthCmd = &cobra.Command{
Use: "auth", Use: "auth",
Short: "Authenticate with Containr API", Short: "Authenticate with a Containr instance",
Long: `Manage authentication with the Containr API. Long: `Login with a personal access token (cnp_...) created under
You can login, logout, and check your current authentication status.`, Settings → Personal Access Tokens, check status, or switch profiles.`,
} }
// loginCmd represents the login command
var loginCmd = &cobra.Command{ var loginCmd = &cobra.Command{
Use: "login [token]", Use: "login [token]",
Short: "Login to Containr", Short: "Store a personal access token",
Long: `Login to Containr using your API token. Long: `Login with a cnp_ token. Reads interactively when no argument is
You can get your token from the Containr web interface.`, given. The token is verified against the API before it is stored.`,
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
RunE: runLogin, RunE: runLogin,
} }
// logoutCmd represents the logout command
var logoutCmd = &cobra.Command{ var logoutCmd = &cobra.Command{
Use: "logout", Use: "logout",
Short: "Logout from Containr", Short: "Remove the stored token",
Long: `Remove stored authentication credentials.`,
RunE: runLogout, RunE: runLogout,
} }
// statusCmd represents the status command
var statusCmd = &cobra.Command{ var statusCmd = &cobra.Command{
Use: "status", Use: "status",
Short: "Check authentication status", Short: "Show the active profile and verify the token",
Long: `Check if you are currently authenticated with Containr.`,
RunE: runStatus, RunE: runStatus,
} }
func init() { func init() {
AuthCmd.AddCommand(loginCmd) loginCmd.Flags().String("url", "", "API URL to store in the profile (e.g. https://containr.example.com)")
AuthCmd.AddCommand(logoutCmd) loginCmd.Flags().String("name", "", "profile name to save under (default: active profile)")
AuthCmd.AddCommand(statusCmd) AuthCmd.AddCommand(loginCmd, logoutCmd, statusCmd)
}
type profileResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
IsAdmin bool `json:"is_admin"`
} }
func runLogin(cmd *cobra.Command, args []string) error { func runLogin(cmd *cobra.Command, args []string) error {
var token string var token string
if len(args) > 0 { if len(args) > 0 {
token = args[0] token = args[0]
} else { } else if isTerminal() {
// Prompt for token fmt.Fprint(os.Stderr, "Token: ")
fmt.Print("Enter your Containr API token: ") raw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Scanln(&token) fmt.Fprintln(os.Stderr)
if err != nil {
return err
}
token = string(raw)
} }
if token == "" { if token == "" {
return fmt.Errorf("token is required") return &APIError{Message: "token is required — pass it as an argument or pipe it via stdin", ExitCode: ExitValidation}
} }
// Store token in config // Verify before saving so a mistyped token fails loudly.
viper.Set("token", token) urlFlag, _ := cmd.Flags().GetString("url")
if err := viper.WriteConfig(); err != nil { probe := &Client{BaseURL: ResolveAPIURL(), Token: token}
return fmt.Errorf("failed to save token: %w", err) if urlFlag != "" {
probe.BaseURL = normalizeBase(urlFlag)
}
var profile profileResponse
if err := probe.DoJSON("GET", "/user/profile", nil, &profile); err != nil {
return fmt.Errorf("token verification failed: %w", err)
} }
fmt.Println("✓ Successfully logged in to Containr") name, _ := cmd.Flags().GetString("name")
if name == "" {
name = ActiveProfile()
}
if err := SaveProfile(name, probe.BaseURL, token); err != nil {
return fmt.Errorf("failed to save profile: %w", err)
}
if JSONMode() {
return PrintJSON(map[string]interface{}{
"profile": name, "api_url": probe.BaseURL,
"user": profile,
})
}
fmt.Printf("Logged in as %s (%s) — profile %q → %s\n", profile.Name, profile.Email, name, probe.BaseURL)
return nil return nil
} }
func runLogout(cmd *cobra.Command, args []string) error { func runLogout(cmd *cobra.Command, args []string) error {
// Remove token from config name := ActiveProfile()
viper.Set("token", "") if err := ClearProfileToken(name); err != nil {
if err := viper.WriteConfig(); err != nil { return fmt.Errorf("failed to update config: %w", err)
return fmt.Errorf("failed to remove token: %w", err) }
if !JSONMode() {
fmt.Printf("Removed token from profile %q\n", name)
} else {
return PrintJSON(map[string]string{"status": "ok", "profile": name})
} }
fmt.Println("✓ Successfully logged out from Containr")
return nil return nil
} }
func runStatus(cmd *cobra.Command, args []string) error { func runStatus(cmd *cobra.Command, args []string) error {
token := viper.GetString("token") client := NewClient()
if token == "" { profile := ActiveProfile()
fmt.Println("❌ Not authenticated")
fmt.Println("Run 'containr auth login <token>' to authenticate") if JSONMode() {
out := map[string]interface{}{
"profile": profile,
"api_url": client.BaseURL,
}
if client.Token != "" {
var p profileResponse
if err := client.DoJSON("GET", "/user/profile", nil, &p); err == nil {
out["user"] = p
out["authenticated"] = true
} else {
out["authenticated"] = false
}
} else {
out["authenticated"] = false
}
return PrintJSON(out)
}
fmt.Printf("Profile: %s\n", profile)
fmt.Printf("API URL: %s\n", client.BaseURL)
if client.Token == "" {
fmt.Println("Token: not set — run `containr auth login <token>`")
return nil return nil
} }
var p profileResponse
fmt.Println("✓ Authenticated with Containr") if err := client.DoJSON("GET", "/user/profile", nil, &p); err != nil {
apiURL := buildAPIURL("/user/profile") fmt.Printf("Token: present but rejected (%v)\n", err)
fmt.Printf("API URL: %s\n", strings.TrimSuffix(buildAPIURL(""), "/"))
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return fmt.Errorf("failed to create verification request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("⚠ Token verification unavailable: %v\n", err)
return nil return nil
} }
defer resp.Body.Close() fmt.Printf("Identity: %s <%s>\n", p.Name, p.Email)
if p.IsAdmin {
switch resp.StatusCode { fmt.Println("Role: platform admin")
case http.StatusOK:
fmt.Println("✓ Token verified against API")
case http.StatusUnauthorized, http.StatusForbidden:
fmt.Println("❌ Token rejected by API (expired/invalid)")
default:
fmt.Printf("⚠ Token verification returned unexpected status: %s\n", resp.Status)
} }
return nil return nil
} }
func buildAPIURL(endpoint string) string {
baseURL := viper.GetString("api-url")
if baseURL == "" {
baseURL = "http://localhost:8080/api/v1"
}
baseURL = strings.TrimSuffix(baseURL, "/")
if endpoint == "" {
return baseURL
}
if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
return baseURL + endpoint
}
+41 -5
View File
@@ -6,16 +6,52 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
) )
func TestBuildAPIURLDefaultsAndTrimming(t *testing.T) { func TestResolveAPIURLDefaultsAndTrimming(t *testing.T) {
viper.Set("api-url", "") viper.Set("api-url", "")
got := buildAPIURL("/user/profile") viper.Set("current_profile", "")
if got != "http://localhost:8080/api/v1/user/profile" { viper.Set("profiles.default.api_url", "")
if got := ResolveAPIURL(); got != "http://localhost:8080/api/v1" {
t.Fatalf("unexpected default api url: %s", got) t.Fatalf("unexpected default api url: %s", got)
} }
viper.Set("api-url", "https://api.example.com/api/v1/") viper.Set("api-url", "https://api.example.com/api/v1/")
got = buildAPIURL("user/profile") if got := ResolveAPIURL(); got != "https://api.example.com/api/v1" {
if got != "https://api.example.com/api/v1/user/profile" {
t.Fatalf("unexpected trimmed api url: %s", got) t.Fatalf("unexpected trimmed api url: %s", got)
} }
// Bare hosts gain the /api/v1 suffix.
viper.Set("api-url", "https://containr.example.com")
if got := ResolveAPIURL(); got != "https://containr.example.com/api/v1" {
t.Fatalf("unexpected suffixed api url: %s", got)
}
viper.Set("api-url", "")
}
func TestResolveTokenProfileAndEnv(t *testing.T) {
viper.Set("token", "")
viper.Set("current_profile", "staging")
viper.Set("profiles.staging.token", "cnp_staging")
if got := ResolveToken(); got != "cnp_staging" {
t.Fatalf("expected profile token, got %s", got)
}
t.Setenv("CONTAINR_TOKEN", "cnp_env")
if got := ResolveToken(); got != "cnp_env" {
t.Fatalf("env should win over profile, got %s", got)
}
viper.Set("token", "cnp_flag")
if got := ResolveToken(); got != "cnp_flag" {
t.Fatalf("flag should win over env, got %s", got)
}
}
func TestExitCodeMapping(t *testing.T) {
if got := exitCodeFor(401); got != ExitAuth {
t.Fatalf("401 → %d, want %d", got, ExitAuth)
}
if got := exitCodeFor(404); got != ExitNotFound {
t.Fatalf("404 → %d, want %d", got, ExitNotFound)
}
if got := exitCodeFor(400); got != ExitValidation {
t.Fatalf("400 → %d, want %d", got, ExitValidation)
}
} }
@@ -0,0 +1,204 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// BackupTargetsCmd manages S3-compatible backup destinations.
var BackupTargetsCmd = &cobra.Command{
Use: "backup-targets",
Aliases: []string{"backup-target", "targets"},
Short: "Manage S3-compatible backup targets",
}
var btListCmd = &cobra.Command{
Use: "list",
Short: "List backup targets",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/backup-targets", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "backup_targets")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, t := range items {
creds := "no"
if str(t, "has_credentials") == "true" {
creds = "yes"
}
rows = append(rows, []string{str(t, "id"), str(t, "name"), str(t, "endpoint"), str(t, "bucket"), creds})
}
printRows(data, []string{"ID", "NAME", "ENDPOINT", "BUCKET", "CREDS"}, rows)
return nil
},
}
var btAddCmd = &cobra.Command{
Use: "add <name>",
Short: "Register an S3-compatible backup target (probed before storing)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{"name": args[0]}
for _, f := range []string{"endpoint", "bucket", "region", "prefix", "access-key", "secret-key"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := f
switch f {
case "access-key":
key = "access_key"
case "secret-key":
key = "secret_key"
}
body[key] = v
}
}
if cmd.Flags().Changed("no-tls") {
v, _ := cmd.Flags().GetBool("no-tls")
body["use_tls"] = !v
}
data, err := c.Do("POST", "/backup-targets", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var btUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a backup target (re-probed; empty keys keep stored credentials)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
// endpoint/bucket are required by the API — fall back to stored values.
for _, f := range []string{"name", "endpoint", "bucket", "region", "prefix", "access-key", "secret-key"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := f
switch f {
case "access-key":
key = "access_key"
case "secret-key":
key = "secret_key"
}
body[key] = v
}
}
if _, hasEndpoint := body["endpoint"]; !hasEndpoint {
data, err := c.Do("GET", "/backup-targets", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "backup_targets")
if err != nil {
return err
}
for _, t := range items {
if str(t, "id") == args[0] {
body["endpoint"] = str(t, "endpoint")
if _, ok := body["bucket"]; !ok {
body["bucket"] = str(t, "bucket")
}
if _, ok := body["name"]; !ok {
body["name"] = str(t, "name")
}
break
}
}
}
if cmd.Flags().Changed("no-tls") {
v, _ := cmd.Flags().GetBool("no-tls")
body["use_tls"] = !v
}
data, err := c.Do("PUT", "/backup-targets/"+args[0], body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var btRemoveCmd = &cobra.Command{
Use: "remove <id>",
Short: "Delete a backup target (fails while databases still point at it)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete backup target %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/backup-targets/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted backup target %s\n", args[0])
return nil
},
}
var btTestCmd = &cobra.Command{
Use: "test <id>",
Short: "Re-probe a backup target's bucket access",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/backup-targets/"+args[0]+"/test", nil)
if err != nil {
return err
}
m, _ := unwrapObject(data)
if str(m, "ok") == "false" {
return &APIError{Message: "probe failed: " + str(m, "error"), ExitCode: ExitError}
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Target ok (%sms)\n", str(m, "latency_ms"))
return nil
},
}
func init() {
btAddCmd.Flags().String("endpoint", "", "S3 endpoint host[:port] (e.g. s3.amazonaws.com, garage.local:3900)")
btAddCmd.Flags().String("bucket", "", "bucket name")
btAddCmd.Flags().String("region", "", "region (optional for most S3-compatible stores)")
btAddCmd.Flags().String("prefix", "", "key prefix for archives")
btAddCmd.Flags().String("access-key", "", "access key (stored encrypted)")
btAddCmd.Flags().String("secret-key", "", "secret key (stored encrypted)")
btAddCmd.Flags().Bool("no-tls", false, "use plain HTTP")
btUpdateCmd.Flags().String("name", "", "new name")
btUpdateCmd.Flags().String("endpoint", "", "S3 endpoint host[:port]")
btUpdateCmd.Flags().String("bucket", "", "bucket name")
btUpdateCmd.Flags().String("region", "", "region")
btUpdateCmd.Flags().String("prefix", "", "key prefix")
btUpdateCmd.Flags().String("access-key", "", "access key")
btUpdateCmd.Flags().String("secret-key", "", "secret key")
btUpdateCmd.Flags().Bool("no-tls", false, "use plain HTTP")
BackupTargetsCmd.AddCommand(btListCmd, btAddCmd, btUpdateCmd, btRemoveCmd, btTestCmd)
}
+211
View File
@@ -0,0 +1,211 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/spf13/viper"
)
// Exit codes — stable for scripting and agent use.
const (
ExitOK = 0
ExitError = 1
ExitAuth = 2
ExitNotFound = 3
ExitValidation = 4
ExitUnavailable = 5
)
// APIError is a non-2xx response from the Containr API. Code carries the
// stable exit code so `main` can map errors to exit statuses.
type APIError struct {
Status int
Message string
ExitCode int
}
func (e *APIError) Error() string {
if e.Message != "" {
return e.Message
}
return fmt.Sprintf("request failed with status %d", e.Status)
}
func exitCodeFor(status int) int {
switch status {
case http.StatusUnauthorized, http.StatusForbidden:
return ExitAuth
case http.StatusNotFound:
return ExitNotFound
case http.StatusBadRequest, http.StatusConflict, http.StatusUnprocessableEntity:
return ExitValidation
default:
return ExitError
}
}
// Client is a thin authenticated HTTP client for the Containr API. All CLI
// commands go through it — no command builds its own request.
type Client struct {
BaseURL string
Token string
HTTPClient *http.Client
}
// NewClient resolves the active profile (flag > env > current_profile) and
// returns a client ready to call the API. Call RequireAuth() for commands
// that need a token.
func NewClient() *Client {
return &Client{
BaseURL: ResolveAPIURL(),
Token: ResolveToken(),
HTTPClient: &http.Client{Timeout: 60 * time.Second},
}
}
// RequireAuth returns an error suitable for the CLI when no token is set.
func (c *Client) RequireAuth() error {
if c.Token == "" {
return &APIError{
Status: 0,
Message: "not authenticated — run `containr auth login <token>` or set CONTAINR_TOKEN",
ExitCode: ExitAuth,
}
}
return nil
}
// Do performs an API request. body may be nil, a []byte, or any value that
// marshals to JSON. Returns the raw response body.
func (c *Client) Do(method, path string, body interface{}) ([]byte, error) {
var reader io.Reader
if body != nil {
var data []byte
switch v := body.(type) {
case []byte:
data = v
default:
var err error
data, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to encode request: %w", err)
}
}
reader = bytes.NewReader(data)
}
url := c.BaseURL + path
req, err := http.NewRequest(method, url, reader)
if err != nil {
return nil, err
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if k := viper.GetString("idempotency-key"); k != "" && method == http.MethodPost {
req.Header.Set("Idempotency-Key", k)
}
req.Header.Set("Accept", "application/json")
hc := c.HTTPClient
if hc == nil {
hc = &http.Client{Timeout: 60 * time.Second}
}
resp, err := hc.Do(req)
if err != nil {
return nil, &APIError{
Status: 0,
Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err),
ExitCode: ExitUnavailable,
}
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode >= 400 {
msg := strings.TrimSpace(string(data))
var envelope struct {
Error string `json:"error"`
Code string `json:"code"`
}
if json.Unmarshal(data, &envelope) == nil && envelope.Error != "" {
msg = envelope.Error
if envelope.Code != "" {
msg = envelope.Code + ": " + msg
}
}
return nil, &APIError{
Status: resp.StatusCode,
Message: msg,
ExitCode: exitCodeFor(resp.StatusCode),
}
}
return data, nil
}
// DoRaw sends a non-JSON request body (file uploads) and returns the response.
func (c *Client) DoRaw(method, path string, body io.Reader, contentType string) ([]byte, error) {
req, err := http.NewRequest(method, c.BaseURL+path, body)
if err != nil {
return nil, err
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
req.Header.Set("Content-Type", contentType)
req.Header.Set("Accept", "application/json")
hc := c.HTTPClient
if hc == nil {
hc = &http.Client{Timeout: 5 * time.Minute}
}
resp, err := hc.Do(req)
if err != nil {
return nil, &APIError{Status: 0, Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err), ExitCode: ExitUnavailable}
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode >= 400 {
msg := strings.TrimSpace(string(data))
var envelope struct {
Error string `json:"error"`
Code string `json:"code"`
}
if json.Unmarshal(data, &envelope) == nil && envelope.Error != "" {
msg = envelope.Error
if envelope.Code != "" {
msg = envelope.Code + ": " + msg
}
}
return nil, &APIError{Status: resp.StatusCode, Message: msg, ExitCode: exitCodeFor(resp.StatusCode)}
}
return data, nil
}
// DoJSON performs a request and unmarshals the response into out.
func (c *Client) DoJSON(method, path string, body interface{}, out interface{}) error {
data, err := c.Do(method, path, body)
if err != nil {
return err
}
if out == nil || len(data) == 0 {
return nil
}
return json.Unmarshal(data, out)
}
+106
View File
@@ -0,0 +1,106 @@
package commands
import (
"fmt"
"os"
"strings"
"github.com/spf13/viper"
)
// ActiveProfile resolves which profile applies: --profile flag >
// CONTAINR_PROFILE env > current_profile in config > "default".
func ActiveProfile() string {
if p := viper.GetString("profile"); p != "" {
return p
}
if p := os.Getenv("CONTAINR_PROFILE"); p != "" {
return p
}
if p := viper.GetString("current_profile"); p != "" {
return p
}
return "default"
}
// ResolveAPIURL returns the effective API base URL:
// --api-url flag > CONTAINR_API_URL env > profile.api_url > localhost default.
func ResolveAPIURL() string {
if u := viper.GetString("api-url"); u != "" {
return normalizeBase(u)
}
if u := os.Getenv("CONTAINR_API_URL"); u != "" {
return normalizeBase(u)
}
if u := viper.GetString("profiles." + ActiveProfile() + ".api_url"); u != "" {
return normalizeBase(u)
}
return "http://localhost:8080/api/v1"
}
// ResolveToken returns the effective auth token:
// --token flag > CONTAINR_TOKEN env > profile.token.
func ResolveToken() string {
if t := viper.GetString("token"); t != "" {
return t
}
if t := os.Getenv("CONTAINR_TOKEN"); t != "" {
return t
}
return viper.GetString("profiles." + ActiveProfile() + ".token")
}
func normalizeBase(u string) string {
u = strings.TrimSuffix(strings.TrimSpace(u), "/")
// Accept the bare host too — append the API prefix when missing.
if u != "" && !strings.HasSuffix(u, "/api/v1") {
u += "/api/v1"
}
return u
}
// SaveProfile persists api_url/token under a named profile and makes it
// current. Creates the config file if none exists.
func SaveProfile(name, apiURL, token string) error {
base := "profiles." + name
if apiURL != "" {
viper.Set(base+".api_url", apiURL)
}
if token != "" {
viper.Set(base+".token", token)
}
viper.Set("current_profile", name)
return writeConfig()
}
// ClearProfileToken removes only the token from the active profile.
func ClearProfileToken(name string) error {
viper.Set("profiles."+name+".token", "")
return writeConfig()
}
// ListProfiles returns profile name → api_url for `auth status --all`.
func ListProfiles() map[string]string {
out := map[string]string{}
profiles := viper.GetStringMap("profiles")
for name := range profiles {
if u := viper.GetString("profiles." + name + ".api_url"); u != "" {
out[name] = u
} else {
out[name] = "(no url)"
}
}
return out
}
func writeConfig() error {
if err := viper.WriteConfig(); err != nil {
// No config file yet — create one.
home, herr := os.UserHomeDir()
if herr != nil {
return fmt.Errorf("cannot locate home directory: %w", herr)
}
return viper.WriteConfigAs(home + "/.containr.yaml")
}
return nil
}
+169
View File
@@ -0,0 +1,169 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// CronCmd manages scheduled jobs.
var CronCmd = &cobra.Command{
Use: "cron",
Aliases: []string{"cron-jobs"},
Short: "Manage cron jobs",
}
var cronListCmd = &cobra.Command{
Use: "list",
Short: "List cron jobs",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/cron-jobs", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "cron_jobs", "jobs")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, j := range items {
rows = append(rows, []string{
str(j, "id"), str(j, "name"), str(j, "schedule"), str(j, "service_id"),
str(j, "enabled", "is_active"), relTime(j, "last_run_at"),
})
}
printRows(data, []string{"ID", "NAME", "SCHEDULE", "SERVICE", "ENABLED", "LAST RUN"}, rows)
return nil
},
}
var cronGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a cron job",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/cron-jobs/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var cronCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a cron job (5-field expression)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
for _, f := range []string{"name", "schedule", "command", "service-id", "timezone"} {
v, _ := cmd.Flags().GetString(f)
if v != "" {
key := f
if f == "service-id" {
key = "service_id"
}
body[key] = v
}
}
if body["service_id"] == nil || body["schedule"] == nil || body["command"] == nil {
return &APIError{Message: "--service-id, --schedule, and --command are required", ExitCode: ExitValidation}
}
data, err := c.Do("POST", "/cron-jobs", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var cronDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a cron job",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete cron job %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/cron-jobs/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted cron job %s\n", args[0])
return nil
},
}
var cronTriggerCmd = &cobra.Command{
Use: "trigger <id>",
Short: "Run a cron job now",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/cron-jobs/"+args[0]+"/trigger", map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var cronExecutionsCmd = &cobra.Command{
Use: "executions <id>",
Short: "List a cron job's execution history",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/cron-jobs/"+args[0]+"/executions", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "executions")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, e := range items {
rows = append(rows, []string{
str(e, "id"), str(e, "status", "exit_code"), str(e, "started_at"), str(e, "output", "stdout"),
})
}
printRows(data, []string{"ID", "STATUS", "STARTED", "OUTPUT"}, rows)
return nil
},
}
func init() {
cronCreateCmd.Flags().String("name", "", "job name")
cronCreateCmd.Flags().String("service-id", "", "target service id (required)")
cronCreateCmd.Flags().String("schedule", "", "5-field cron expression (required)")
cronCreateCmd.Flags().String("command", "", "command run inside the container (required)")
cronCreateCmd.Flags().String("timezone", "", "IANA timezone (default UTC)")
CronCmd.AddCommand(cronListCmd, cronGetCmd, cronCreateCmd, cronDeleteCmd, cronTriggerCmd, cronExecutionsCmd)
}
@@ -0,0 +1,366 @@
package commands
import (
"fmt"
"io"
"os"
"github.com/spf13/cobra"
)
// DatabasesCmd manages managed databases.
var DatabasesCmd = &cobra.Command{
Use: "databases",
Aliases: []string{"database", "db"},
Short: "Manage databases",
}
var dbListCmd = &cobra.Command{
Use: "list",
Short: "List managed databases",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/databases", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "databases")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, d := range items {
rows = append(rows, []string{
str(d, "id"), str(d, "name"), str(d, "type", "engine"), str(d, "status"),
})
}
printRows(data, []string{"ID", "NAME", "TYPE", "STATUS"}, rows)
return nil
},
}
var dbGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a database (connection URL included)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/databases/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbCreateCmd = &cobra.Command{
Use: "create <name>",
Short: "Provision a managed database",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
dbType, _ := cmd.Flags().GetString("type")
body := map[string]interface{}{"name": args[0], "type": dbType}
for _, f := range []string{"version", "storage-size"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := f
if f == "storage-size" {
key = "storage_size"
}
body[key] = v
}
}
if v, _ := cmd.Flags().GetBool("public"); v {
body["public_port"] = true
}
data, err := c.Do("POST", "/databases", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a database (--public/--public=false toggles the exposed host port)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
if cmd.Flags().Changed("public") {
v, _ := cmd.Flags().GetBool("public")
body["public_port"] = v
}
if v, _ := cmd.Flags().GetString("name"); v != "" {
body["name"] = v
}
if cmd.Flags().Changed("backup-schedule") {
v, _ := cmd.Flags().GetString("backup-schedule")
body["backup_schedule"] = v
}
if cmd.Flags().Changed("backup-target") {
v, _ := cmd.Flags().GetString("backup-target")
body["backup_target_id"] = v
}
if len(body) == 0 {
return &APIError{Message: "nothing to update — pass --public/--private-style flags, --name, --backup-schedule, or --backup-target", ExitCode: ExitError}
}
data, err := c.Do("PUT", "/databases/"+args[0], body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a database and its data",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete database %s and all its data?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/databases/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted database %s\n", args[0])
return nil
},
}
var dbActionCmd = &cobra.Command{
Use: "action <id> <start|stop|restart>",
Short: "Start, stop, or restart a database",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/databases/"+args[0]+"/action", map[string]string{"action": args[1]})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbBackupCmd = &cobra.Command{
Use: "backup <id>",
Short: "Take a manual backup snapshot",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/databases/"+args[0]+"/backup", map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbRestoreCmd = &cobra.Command{
Use: "restore <id> <backup-id>",
Short: "Restore a database from a backup",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Restore database %s from backup %s? Current data will be overwritten.", args[0], args[1])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
data, err := c.Do("POST", "/databases/"+args[0]+"/restore", map[string]string{"backup_id": args[1], "database_id": args[0]})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbImportBackupCmd = &cobra.Command{
Use: "import-backup <id> <file.tar.gz|->",
Short: "Import a .tar.gz archive as a restore point (use '-' for stdin)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
var src io.ReadCloser
if args[1] == "-" {
src = io.NopCloser(os.Stdin)
} else {
f, err := os.Open(args[1])
if err != nil {
return err
}
src = f
}
defer src.Close()
data, err := c.DoRaw("POST", "/databases/"+args[0]+"/backups/import", src, "application/gzip")
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbDownloadCmd = &cobra.Command{
Use: "download-backup <id> <backup-id> [file]",
Short: "Download a backup archive",
Args: cobra.RangeArgs(2, 3),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", fmt.Sprintf("/databases/%s/backups/%s/download", args[0], args[1]), nil)
if err != nil {
return err
}
out := args[1] + ".tar.gz"
if len(args) > 2 {
out = args[2]
}
if err := os.WriteFile(out, data, 0o600); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"file": out})
}
fmt.Printf("Wrote %s (%d bytes)\n", out, len(data))
return nil
},
}
var dbRegisterCmd = &cobra.Command{
Use: "register <name>",
Short: "Register an external database (probes the connection first)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{"name": args[0]}
for _, f := range []string{"type", "host", "database", "username", "password"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
body[f] = v
}
}
if v, _ := cmd.Flags().GetInt("port"); v > 0 {
body["port"] = v
}
if v, _ := cmd.Flags().GetBool("ssl"); v {
body["ssl"] = true
}
data, err := c.Do("POST", "/databases/register-external", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
m, _ := unwrapObject(data)
fmt.Printf("Registered external database %s (%s)\n", args[0], str(m, "id"))
return nil
},
}
var dbTestConnCmd = &cobra.Command{
Use: "test-connection [<id>]",
Short: "Probe a database connection — by id, or with --type/--host flags",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
var data []byte
if len(args) == 1 {
data, err = c.Do("POST", "/databases/"+args[0]+"/test-connection", map[string]interface{}{})
} else {
body := map[string]interface{}{}
for _, f := range []string{"type", "host", "database", "username", "password"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
body[f] = v
}
}
if v, _ := cmd.Flags().GetInt("port"); v > 0 {
body["port"] = v
}
if v, _ := cmd.Flags().GetBool("ssl"); v {
body["ssl"] = true
}
data, err = c.Do("POST", "/databases/test-connection", body)
}
if err != nil {
return err
}
m, _ := unwrapObject(data)
if str(m, "ok") == "false" {
return &APIError{Message: "connection failed: " + str(m, "error"), ExitCode: ExitError}
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Connection ok (%sms)\n", str(m, "latency_ms"))
return nil
},
}
func init() {
dbCreateCmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
dbCreateCmd.Flags().String("version", "", "engine version tag")
dbCreateCmd.Flags().String("storage-size", "", "volume size (e.g. 10Gi)")
dbCreateCmd.Flags().Bool("public", false, "bind the database port on all interfaces (default: localhost only)")
dbUpdateCmd.Flags().Bool("public", false, "bind the database port on all interfaces")
dbUpdateCmd.Flags().String("name", "", "rename the database")
dbUpdateCmd.Flags().String("backup-schedule", "", "cron expression for automatic backups (empty clears)")
dbUpdateCmd.Flags().String("backup-target", "", "backup target id for offsite archives (empty clears)")
for _, cmd := range []*cobra.Command{dbRegisterCmd, dbTestConnCmd} {
cmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
cmd.Flags().String("host", "", "database host")
cmd.Flags().Int("port", 0, "database port (default per type)")
cmd.Flags().String("database", "", "database name")
cmd.Flags().String("username", "", "database username")
cmd.Flags().String("password", "", "database password (stored encrypted on register)")
cmd.Flags().Bool("ssl", false, "require TLS")
}
DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbUpdateCmd, dbRegisterCmd, dbTestConnCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbImportBackupCmd, dbDownloadCmd)
}
+162
View File
@@ -0,0 +1,162 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// DeployCmd triggers a deployment for a service.
var DeployCmd = &cobra.Command{
Use: "deploy <service-id>",
Short: "Trigger a deployment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{"trigger": "cli"}
if nc, _ := cmd.Flags().GetBool("no-cache"); nc {
body["no_cache"] = true
}
for _, f := range []string{"commit-hash", "branch"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := "commit_hash"
if f == "branch" {
key = "branch"
}
body[key] = v
}
}
data, err := c.Do("POST", "/services/"+args[0]+"/deployments", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
d, _ := unwrapObject(data, "deployment")
fmt.Printf("Deployment %s started for service %s (status: %s)\n",
str(d, "id"), args[0], str(d, "status"))
fmt.Println("Follow with: containr deployments logs " + str(d, "id"))
return nil
},
}
// DeploymentsCmd manages deployment history.
var DeploymentsCmd = &cobra.Command{
Use: "deployments",
Aliases: []string{"deployment"},
Short: "Inspect deployments",
}
var deploymentsListCmd = &cobra.Command{
Use: "list [service-id]",
Short: "List deployments (all recent, or for one service)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
path := "/deployments"
if len(args) > 0 {
path = "/services/" + args[0] + "/deployments"
}
data, err := c.Do("GET", path, nil)
if err != nil {
return err
}
items, err := unwrapList(data, "deployments")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, d := range items {
rows = append(rows, []string{
str(d, "id"), str(d, "service_id"), str(d, "status"), str(d, "trigger"), relTime(d, "created_at", "started_at"),
})
}
printRows(data, []string{"ID", "SERVICE", "STATUS", "TRIGGER", "STARTED"}, rows)
return nil
},
}
var deploymentsGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a deployment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/deployments/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var deploymentsLogsCmd = &cobra.Command{
Use: "logs <id>",
Short: "Stream a deployment's build/deploy logs",
Args: cobra.ExactArgs(1),
RunE: runDeploymentLogs,
}
var deploymentsCancelCmd = &cobra.Command{
Use: "cancel <id>",
Short: "Cancel a queued or running deployment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/deployments/"+args[0]+"/cancel", map[string]interface{}{})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
obj, _ := unwrapObject(data)
fmt.Printf("Deployment %s: %s\n", args[0], str(obj, "status"))
return nil
},
}
var deploymentsRollbackCmd = &cobra.Command{
Use: "rollback <id>",
Short: "Roll back to a deployment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/deployments/"+args[0]+"/rollback", map[string]interface{}{})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Rolled back to deployment %s\n", args[0])
return nil
},
}
func init() {
DeployCmd.Flags().String("commit-hash", "", "commit to deploy")
DeployCmd.Flags().String("branch", "", "branch to deploy")
DeployCmd.Flags().Bool("no-cache", false, "build without Docker layer cache")
DeploymentsCmd.AddCommand(deploymentsListCmd, deploymentsGetCmd, deploymentsLogsCmd, deploymentsRollbackCmd, deploymentsCancelCmd)
}
@@ -0,0 +1,113 @@
package commands
import (
"encoding/json"
"fmt"
"time"
)
// client returns an authenticated API client or an ExitAuth error.
func client() (*Client, error) {
c := NewClient()
if err := c.RequireAuth(); err != nil {
return nil, err
}
return c, nil
}
// unwrapList accepts both top-level arrays and {"<key>": [...]} envelopes,
// which the Containr API uses inconsistently between endpoints.
func unwrapList(data []byte, keys ...string) ([]map[string]interface{}, error) {
var arr []map[string]interface{}
if err := json.Unmarshal(data, &arr); err == nil {
return arr, nil
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(data, &obj); err != nil {
return nil, fmt.Errorf("unexpected response shape: %w", err)
}
for _, k := range keys {
if raw, ok := obj[k]; ok {
if err := json.Unmarshal(raw, &arr); err != nil {
return nil, fmt.Errorf("cannot decode %q: %w", k, err)
}
return arr, nil
}
}
return []map[string]interface{}{}, nil
}
func unwrapObject(data []byte, keys ...string) (map[string]interface{}, error) {
var obj map[string]interface{}
if err := json.Unmarshal(data, &obj); err != nil {
return nil, fmt.Errorf("unexpected response shape: %w", err)
}
for _, k := range keys {
if inner, ok := obj[k].(map[string]interface{}); ok {
return inner, nil
}
}
return obj, nil
}
// str extracts a display string from a response map.
func str(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
if v, ok := m[k]; ok && v != nil {
switch t := v.(type) {
case string:
return t
case float64:
if t == float64(int64(t)) {
return fmt.Sprintf("%d", int64(t))
}
return fmt.Sprintf("%v", t)
case bool:
return fmt.Sprintf("%t", t)
}
}
}
return "-"
}
func shortID(m map[string]interface{}) string {
id := str(m, "id")
if len(id) > 8 {
return id[:8]
}
return id
}
func relTime(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
s, ok := m[k].(string)
if !ok || s == "" {
continue
}
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return s
}
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
return "-"
}
// printRows renders a table, or raw JSON for the full response in --json mode.
func printRows(raw []byte, headers []string, rows [][]string) {
if JSONMode() {
PrintRaw(raw)
return
}
PrintTable(headers, rows)
}
+171
View File
@@ -0,0 +1,171 @@
package commands
import (
"bufio"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"github.com/spf13/cobra"
)
// LogsCmd prints service runtime logs.
var LogsCmd = &cobra.Command{
Use: "logs <service-id>",
Short: "Print or follow service logs",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
follow, _ := cmd.Flags().GetBool("follow")
tail, _ := cmd.Flags().GetInt("tail")
path := fmt.Sprintf("/services/%s/logs?tail=%d", args[0], tail)
if follow {
return streamSSE(c, path+"&follow=true")
}
data, err := c.Do("GET", path, nil)
if err != nil {
return err
}
return printLogEntries(data)
},
}
// ExecCmd runs a one-off command inside a service container.
var ExecCmd = &cobra.Command{
Use: "exec <service-id> -- <command>",
Short: "Run a one-off command in the service container (30s ceiling)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
command := strings.Join(args[1:], " ")
if command == "" {
return &APIError{Message: "no command given — usage: containr exec <id> -- <command>", ExitCode: ExitValidation}
}
data, err := c.Do("POST", "/services/"+args[0]+"/exec", map[string]string{"command": command})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
resp, _ := unwrapObject(data)
fmt.Print(str(resp, "output", "stdout"))
if code := str(resp, "exit_code", "exitCode"); code != "-" && code != "0" {
return &APIError{Message: fmt.Sprintf("command exited with %s", code), ExitCode: ExitError}
}
return nil
},
}
type logEntry struct {
Timestamp string `json:"timestamp"`
Message string `json:"message"`
Stream string `json:"stream"`
}
// printLogEntries decodes the {"logs":[...]} envelope both log endpoints use.
func printLogEntries(data []byte) error {
var payload struct {
Logs []logEntry `json:"logs"`
Message string `json:"message"`
}
if err := json.Unmarshal(data, &payload); err != nil {
PrintRaw(data)
return nil
}
if JSONMode() {
PrintRaw(data)
return nil
}
for _, e := range payload.Logs {
fmt.Println(e.Message)
}
if len(payload.Logs) == 0 && payload.Message != "" {
fmt.Fprintln(os.Stderr, payload.Message)
}
return nil
}
// runDeploymentLogs prints persisted build+runtime logs for a deployment.
func runDeploymentLogs(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
logType, _ := cmd.Flags().GetString("type")
data, err := c.Do("GET", "/deployments/"+args[0]+"/logs?type="+logType, nil)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
var payload struct {
BuildLog string `json:"build_log"`
RuntimeLog string `json:"runtime_log"`
}
if json.Unmarshal(data, &payload) == nil && (payload.BuildLog != "" || payload.RuntimeLog != "") {
if payload.BuildLog != "" {
fmt.Print(payload.BuildLog)
}
if payload.RuntimeLog != "" {
fmt.Print(payload.RuntimeLog)
}
return nil
}
return printLogEntries(data)
}
// streamSSE consumes a text/event-stream response and prints each
// `data:` line's message field until the server closes the stream.
func streamSSE(c *Client, path string) error {
req, err := http.NewRequest("GET", c.BaseURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Accept", "text/event-stream")
hc := c.HTTPClient
if hc == nil {
hc = http.DefaultClient
}
resp, err := hc.Do(req)
if err != nil {
return &APIError{Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err), ExitCode: ExitUnavailable}
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return &APIError{Status: resp.StatusCode, ExitCode: exitCodeFor(resp.StatusCode)}
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64<<10), 4<<20)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
var e logEntry
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &e); err == nil {
fmt.Println(e.Message)
} else {
fmt.Println(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
return scanner.Err()
}
func init() {
LogsCmd.Flags().BoolP("follow", "f", false, "stream new log lines (SSE)")
LogsCmd.Flags().Int("tail", 100, "number of lines to return")
deploymentsLogsCmd.Flags().String("type", "all", "log type: all|build|runtime")
}
+150
View File
@@ -0,0 +1,150 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// NodesCmd manages node agents (admin).
var NodesCmd = &cobra.Command{
Use: "nodes",
Aliases: []string{"node", "agents"},
Short: "Manage node agents (admin)",
}
var nodesListCmd = &cobra.Command{
Use: "list",
Short: "List registered node agents",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/agents", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "agents")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, a := range items {
rows = append(rows, []string{
str(a, "id"), str(a, "name", "hostname"), str(a, "status"), str(a, "version"), relTime(a, "last_heartbeat_at"),
})
}
printRows(data, []string{"ID", "NAME", "STATUS", "VERSION", "HEARTBEAT"}, rows)
return nil
},
}
var nodesGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a node agent",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/agents/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodesDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Remove a node agent",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Remove node agent %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/agents/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Removed agent %s\n", args[0])
return nil
},
}
// Agent tokens (cagt_) onboard new nodes.
var nodeTokensCmd = &cobra.Command{
Use: "tokens",
Short: "Manage agent onboarding tokens",
}
var nodeTokensIssueCmd = &cobra.Command{
Use: "issue [label]",
Short: "Issue an onboarding token (shown once)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
if len(args) > 0 {
body["label"] = args[0]
}
data, err := c.Do("POST", "/agent-tokens", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodeTokensListCmd = &cobra.Command{
Use: "list",
Short: "List onboarding tokens",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/agent-tokens", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodeTokensRevokeCmd = &cobra.Command{
Use: "revoke <id>",
Short: "Revoke an onboarding token",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("DELETE", "/agent-tokens/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() {
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesDeleteCmd, nodeTokensCmd)
}
@@ -0,0 +1,65 @@
package commands
import (
"github.com/spf13/cobra"
)
// NotificationsCmd lists and manages notifications.
var NotificationsCmd = &cobra.Command{
Use: "notifications",
Aliases: []string{"notifs"},
Short: "Manage notifications",
}
var notifListCmd = &cobra.Command{
Use: "list",
Short: "List notifications",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/notifications", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "notifications")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, n := range items {
rows = append(rows, []string{
str(n, "id"), str(n, "type"), str(n, "title"), str(n, "read"), relTime(n, "created_at"),
})
}
printRows(data, []string{"ID", "TYPE", "TITLE", "READ", "AGE"}, rows)
return nil
},
}
var notifReadCmd = &cobra.Command{
Use: "read <id>|all",
Short: "Mark a notification (or all) as read",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
path := "/notifications/" + args[0] + "/read"
if args[0] == "all" {
path = "/notifications/read-all"
}
data, err := c.Do("POST", path, map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() {
NotificationsCmd.AddCommand(notifListCmd, notifReadCmd)
}
@@ -0,0 +1,95 @@
package commands
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/viper"
)
// JSONMode reports whether --json was passed. In JSON mode every command
// prints the raw API response and nothing else — safe for agents and pipes.
func JSONMode() bool {
return viper.GetBool("json")
}
// PrintResult renders data: pretty JSON in --json mode, otherwise the
// default JSON rendering is used unless a command prints its own table
// first (commands that render a table should check JSONMode() themselves).
func PrintJSON(v interface{}) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}
// PrintRaw prints raw response bytes as pretty JSON when they decode as
// JSON, else verbatim.
func PrintRaw(data []byte) {
var v interface{}
if json.Unmarshal(data, &v) == nil {
out, _ := json.MarshalIndent(v, "", " ")
fmt.Println(string(out))
return
}
fmt.Println(string(data))
}
// PrintTable renders rows under headers with tabwriter alignment.
// Skipped entirely in JSON mode.
func PrintTable(headers []string, rows [][]string) {
if JSONMode() {
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, strings.Join(headers, "\t"))
for _, row := range rows {
fmt.Fprintln(w, strings.Join(row, "\t"))
}
w.Flush()
}
// Confirm asks the user to confirm a destructive action. Returns true
// immediately when --yes/--force was passed or stdin is not a TTY and
// --yes was given. In non-interactive contexts without --yes it refuses.
func Confirm(prompt string) bool {
if viper.GetBool("yes") {
return true
}
if !isTerminal() {
return false
}
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
reader := bufio.NewReader(os.Stdin)
answer, err := reader.ReadString('\n')
if err != nil {
return false
}
answer = strings.ToLower(strings.TrimSpace(answer))
return answer == "y" || answer == "yes"
}
func isTerminal() bool {
info, err := os.Stdin.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
// ExitCode inspects an error and returns the process exit code.
func ExitCode(err error) int {
if err == nil {
return ExitOK
}
if apiErr, ok := err.(*APIError); ok {
return apiErr.ExitCode
}
return ExitError
}
+95 -191
View File
@@ -1,219 +1,123 @@
package commands package commands
import ( import (
"bytes"
"containr/internal/pkg/utils"
"encoding/json"
"fmt" "fmt"
"io"
"net/http"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper"
) )
// Project represents a Containr project // ProjectsCmd manages projects.
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ProjectsCmd represents the projects command
var ProjectsCmd = &cobra.Command{ var ProjectsCmd = &cobra.Command{
Use: "projects", Use: "projects",
Aliases: []string{"project"},
Short: "Manage projects", Short: "Manage projects",
Long: `Manage your Containr projects.
You can list, create, update, and delete projects.`,
} }
// listProjectsCmd represents the list command var projectsListCmd = &cobra.Command{
var listProjectsCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List all projects", Short: "List projects",
Long: `List all your Containr projects.`, RunE: func(cmd *cobra.Command, args []string) error {
RunE: runListProjects, c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/projects", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "projects")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, p := range items {
rows = append(rows, []string{
str(p, "id"), str(p, "name"), str(p, "description"), relTime(p, "created_at"),
})
}
printRows(data, []string{"ID", "NAME", "DESCRIPTION", "CREATED"}, rows)
return nil
},
} }
// createProjectCmd represents the create command var projectsGetCmd = &cobra.Command{
var createProjectCmd = &cobra.Command{ Use: "get <id>",
Use: "create [name]", Short: "Show a project",
Short: "Create a new project",
Long: `Create a new Containr project.
Provide a name and optional description.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runCreateProject, RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/projects/"+args[0], nil)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
p, _ := unwrapObject(data, "project")
fmt.Printf("ID: %s\n", str(p, "id"))
fmt.Printf("Name: %s\n", str(p, "name"))
fmt.Printf("Description: %s\n", str(p, "description"))
fmt.Printf("Created: %s\n", str(p, "created_at"))
return nil
},
} }
// deleteProjectCmd represents the delete command var projectsCreateCmd = &cobra.Command{
var deleteProjectCmd = &cobra.Command{ Use: "create <name>",
Use: "delete [project-id]", Short: "Create a project",
Short: "Delete a project",
Long: `Delete a Containr project by ID.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runDeleteProject, RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
desc, _ := cmd.Flags().GetString("description")
body := map[string]interface{}{"name": args[0]}
if desc != "" {
body["description"] = desc
}
data, err := c.Do("POST", "/projects", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
p, _ := unwrapObject(data, "project")
fmt.Printf("Created project %s (%s)\n", str(p, "name"), str(p, "id"))
return nil
},
} }
var projectDescription string var projectsDeleteCmd = &cobra.Command{
Use: "delete <id>",
// getAPIURL constructs the full API URL for a given endpoint Short: "Delete a project and all its services",
func getAPIURL(endpoint string) string { Args: cobra.ExactArgs(1),
baseURL := viper.GetString("api-url") RunE: func(cmd *cobra.Command, args []string) error {
if baseURL == "" { c, err := client()
baseURL = "http://localhost:8080/api/v1" // Default for development if err != nil {
return err
} }
if !Confirm(fmt.Sprintf("Delete project %s and all its services?", args[0])) {
// Ensure baseURL doesn't end with / and endpoint starts with / return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
baseURL = strings.TrimSuffix(baseURL, "/")
if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
} }
if _, err := c.Do("DELETE", "/projects/"+args[0], nil); err != nil {
return baseURL + endpoint return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted project %s\n", args[0])
return nil
},
} }
func init() { func init() {
ProjectsCmd.AddCommand(listProjectsCmd) projectsCreateCmd.Flags().StringP("description", "d", "", "project description")
ProjectsCmd.AddCommand(createProjectCmd) ProjectsCmd.AddCommand(projectsListCmd, projectsGetCmd, projectsCreateCmd, projectsDeleteCmd)
ProjectsCmd.AddCommand(deleteProjectCmd)
// Add flags
createProjectCmd.Flags().StringVarP(&projectDescription, "description", "d", "", "Project description")
}
func runListProjects(cmd *cobra.Command, args []string) error {
apiURL := getAPIURL("/projects")
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
var projects []Project
if err := json.Unmarshal(body, &projects); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if len(projects) == 0 {
fmt.Println("No projects found")
return nil
}
fmt.Println("Your Projects:")
fmt.Println()
for _, project := range projects {
fmt.Printf("📦 %s (%s)\n", project.Name, project.ID)
if project.Description != "" {
fmt.Printf(" %s\n", project.Description)
}
fmt.Printf(" Created: %s\n", utils.FormatTime(project.CreatedAt))
fmt.Println()
}
return nil
}
func runCreateProject(cmd *cobra.Command, args []string) error {
name := args[0]
projectData := map[string]interface{}{
"name": name,
}
if projectDescription != "" {
projectData["description"] = projectDescription
}
jsonData, err := json.Marshal(projectData)
if err != nil {
return fmt.Errorf("failed to marshal project data: %w", err)
}
apiURL := getAPIURL("/projects")
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
var project Project
if err := json.Unmarshal(body, &project); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
fmt.Printf("✓ Project '%s' created successfully!\n", project.Name)
fmt.Printf("ID: %s\n", project.ID)
return nil
}
func runDeleteProject(cmd *cobra.Command, args []string) error {
projectID := args[0]
apiURL := getAPIURL("/projects/" + projectID)
req, err := http.NewRequest("DELETE", apiURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
}
fmt.Printf("✓ Project '%s' deleted successfully!\n", projectID)
return nil
} }
@@ -0,0 +1,160 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// RegistriesCmd manages private-image registry credentials.
var RegistriesCmd = &cobra.Command{
Use: "registries",
Aliases: []string{"registry"},
Short: "Manage registry credentials",
}
var registriesListCmd = &cobra.Command{
Use: "list",
Short: "List registry credentials",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/registries", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "registries")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, r := range items {
hasPw := ""
if b, ok := r["has_password"].(bool); ok && b {
hasPw = "yes"
}
rows = append(rows, []string{str(r, "id"), str(r, "name"), str(r, "host"), str(r, "username"), hasPw})
}
printRows(data, []string{"ID", "NAME", "HOST", "USERNAME", "PASSWORD"}, rows)
return nil
},
}
var registriesAddCmd = &cobra.Command{
Use: "add <name> <host>",
Short: "Add a registry credential (e.g. ghcr.io, registry.example.com, docker.io)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{"name": args[0], "host": args[1]}
if v, _ := cmd.Flags().GetString("username"); v != "" {
body["username"] = v
}
if v, _ := cmd.Flags().GetString("password"); v != "" {
body["password"] = v
}
data, err := c.Do("POST", "/registries", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
r, _ := unwrapObject(data, "registry")
fmt.Printf("Added registry %s (%s)\n", str(r, "name"), str(r, "host"))
return nil
},
}
var registriesUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a registry credential (empty --password keeps the stored one)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
for _, f := range []string{"name", "host", "username", "password"} {
if cmd.Flags().Changed(f) {
v, _ := cmd.Flags().GetString(f)
body[f] = v
}
}
if len(body) == 0 {
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
}
// The API requires name+host — backfill them from the stored row
// when the caller didn't supply both.
_, hasName := body["name"]
_, hasHost := body["host"]
if !hasName || !hasHost {
data, err := c.Do("GET", "/registries", nil)
if err != nil {
return err
}
items, _ := unwrapList(data, "registries")
for _, r := range items {
if str(r, "id") == args[0] {
if !hasName {
body["name"] = str(r, "name")
}
if !hasHost {
body["host"] = str(r, "host")
}
}
}
}
data, err := c.Do("PUT", "/registries/"+args[0], body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Updated registry %s\n", args[0])
return nil
},
}
var registriesRemoveCmd = &cobra.Command{
Use: "remove <id>",
Short: "Remove a registry credential",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Remove registry %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/registries/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Removed registry %s\n", args[0])
return nil
},
}
func init() {
registriesAddCmd.Flags().String("username", "", "registry username")
registriesAddCmd.Flags().String("password", "", "registry password/token (stored encrypted)")
registriesUpdateCmd.Flags().String("name", "", "display name")
registriesUpdateCmd.Flags().String("host", "", "registry host")
registriesUpdateCmd.Flags().String("username", "", "registry username")
registriesUpdateCmd.Flags().String("password", "", "registry password/token")
RegistriesCmd.AddCommand(registriesListCmd, registriesAddCmd, registriesUpdateCmd, registriesRemoveCmd)
}
@@ -0,0 +1,567 @@
package commands
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
// ServicesCmd manages services.
var ServicesCmd = &cobra.Command{
Use: "services",
Aliases: []string{"service", "svc"},
Short: "Manage services",
}
var servicesListCmd = &cobra.Command{
Use: "list [project-id]",
Short: "List services in a project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/projects/"+args[0]+"/services", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "services")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, s := range items {
rows = append(rows, []string{
str(s, "id"), str(s, "name"), str(s, "type"), str(s, "status"), str(s, "environment"),
})
}
printRows(data, []string{"ID", "NAME", "TYPE", "STATUS", "ENV"}, rows)
return nil
},
}
var servicesGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a service",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0], nil)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
s, _ := unwrapObject(data, "service")
for _, k := range []string{"id", "name", "type", "status", "environment", "image", "git_repo", "git_branch", "domain", "port"} {
fmt.Printf("%-14s %s\n", k+":", str(s, k))
}
return nil
},
}
var servicesCreateCmd = &cobra.Command{
Use: "create <project-id> <name>",
Short: "Create a service (image- or git-based)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{
"name": args[1],
}
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := f
// JSON uses snake_case keys matching the API schema.
switch f {
case "git-repo":
key = "git_repo"
case "git-branch":
key = "git_branch"
case "build-path":
key = "build_path"
case "restart-policy":
key = "restart_policy"
case "healthcheck-path":
key = "healthcheck_path"
case "cpu-reserve":
key = "cpu_reserve"
case "memory-reserve":
key = "memory_reserve"
case "static-cmd":
key = "static_build_cmd"
case "static-dir":
key = "static_dir"
}
body[key] = v
}
}
if v, _ := cmd.Flags().GetInt("port"); v > 0 {
body["port"] = v
}
if v, _ := cmd.Flags().GetInt("replicas"); v > 0 {
body["replicas"] = v
}
data, err := c.Do("POST", "/projects/"+args[0]+"/services", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
s, _ := unwrapObject(data, "service")
fmt.Printf("Created service %s (%s)\n", str(s, "name"), str(s, "id"))
return nil
},
}
var servicesDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a service",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete service %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/services/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted service %s\n", args[0])
return nil
},
}
var servicesUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a service (volumes, replicas, domain, ...)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
if cmd.Flags().Changed(f) {
key := f
switch f {
case "restart-policy":
key = "restart_policy"
case "healthcheck-path":
key = "healthcheck_path"
case "cpu-reserve":
key = "cpu_reserve"
case "memory-reserve":
key = "memory_reserve"
case "static-cmd":
key = "static_build_cmd"
case "static-dir":
key = "static_dir"
}
v, _ := cmd.Flags().GetString(f)
body[key] = v
}
}
if cmd.Flags().Changed("replicas") {
v, _ := cmd.Flags().GetInt("replicas")
body["replicas"] = v
}
if cmd.Flags().Changed("port") {
v, _ := cmd.Flags().GetInt("port")
body["port"] = v
}
if cmd.Flags().Changed("volume") || cmd.Flags().Changed("bind") || cmd.Flags().Changed("clear-volumes") {
volumes := []map[string]interface{}{}
for _, spec := range volumeFlagValues(cmd, "volume", "bind") {
volumes = append(volumes, spec)
}
body["volumes"] = volumes
}
if cmd.Flags().Changed("sleep") {
v, _ := cmd.Flags().GetBool("sleep")
body["sleep_enabled"] = v
}
if cmd.Flags().Changed("sleep-idle") {
v, _ := cmd.Flags().GetInt("sleep-idle")
body["sleep_idle_minutes"] = v
}
if cmd.Flags().Changed("maintenance") {
v, _ := cmd.Flags().GetString("maintenance")
body["maintenance_mode"] = v == "on" || v == "true"
}
if cmd.Flags().Changed("basic-auth") {
creds := []map[string]interface{}{}
pairs, _ := cmd.Flags().GetStringArray("basic-auth")
for _, p := range pairs {
user, pass, ok := strings.Cut(p, ":")
if ok {
creds = append(creds, map[string]interface{}{"username": user, "password": pass})
}
}
body["basic_auth"] = creds
}
if len(body) == 0 {
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
}
data, err := c.Do("PUT", "/services/"+args[0], body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Updated service %s\n", args[0])
return nil
},
}
var servicesCloneCmd = &cobra.Command{
Use: "clone <id>",
Short: "Clone a service (config, volumes, domains, variables)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
for _, f := range []string{"name", "project", "environment"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := f
if f == "project" {
key = "project_id"
}
body[key] = v
}
}
data, err := c.Do("POST", "/services/"+args[0]+"/clone", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
m, _ := unwrapObject(data)
fmt.Printf("Cloned to service %s (%s)\n", str(m, "service_id"), str(m, "name"))
return nil
},
}
var servicesMoveCmd = &cobra.Command{
Use: "move <id> <project-id>",
Short: "Move a service to another project",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/services/"+args[0]+"/move", map[string]interface{}{"project_id": args[1]})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Moved service %s to project %s\n", args[0], args[1])
return nil
},
}
// volumeFlagValues parses repeated "src:target[:ro]" flags into API mount
// objects. Bind mounts are type=bind, named volumes type=volume.
func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} {
var out []map[string]interface{}
for _, f := range flags {
vals, _ := cmd.Flags().GetStringArray(f)
for _, v := range vals {
parts := strings.SplitN(v, ":", 3)
if len(parts) < 2 {
continue
}
m := map[string]interface{}{
"type": f,
"source": parts[0],
"target": parts[1],
}
if len(parts) == 3 && parts[2] == "ro" {
m["read_only"] = true
}
out = append(out, m)
}
}
return out
}
var servicesDomainsCmd = &cobra.Command{
Use: "domains",
Short: "Manage service domains",
}
var domainsListCmd = &cobra.Command{
Use: "list <service-id>",
Short: "List domains attached to a service",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0]+"/domains", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "domains")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, d := range items {
def := ""
if b, ok := d["is_default"].(bool); ok && b {
def = "*"
}
rows = append(rows, []string{str(d, "id"), str(d, "domain"), def, str(d, "cert_status")})
}
printRows(data, []string{"ID", "DOMAIN", "DEFAULT", "CERT"}, rows)
return nil
},
}
var domainsAddCmd = &cobra.Command{
Use: "add <service-id> <domain>",
Short: "Attach a domain to a service",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
def, _ := cmd.Flags().GetBool("default")
data, err := c.Do("POST", "/services/"+args[0]+"/domains", map[string]interface{}{
"domain": args[1], "is_default": def,
})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Added domain %s\n", args[1])
return nil
},
}
var domainsRemoveCmd = &cobra.Command{
Use: "remove <service-id> <domain-id>",
Short: "Detach a domain",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if _, err := c.Do("DELETE", fmt.Sprintf("/services/%s/domains/%s", args[0], args[1]), nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[1]})
}
fmt.Printf("Removed domain %s\n", args[1])
return nil
},
}
var domainsDefaultCmd = &cobra.Command{
Use: "default <service-id> <domain-id>",
Short: "Set the default domain",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", fmt.Sprintf("/services/%s/domains/%s/default", args[0], args[1]), map[string]interface{}{})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Default domain updated\n")
return nil
},
}
var domainsCheckCmd = &cobra.Command{
Use: "check <service-id>",
Short: "DNS preflight for all service domains",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0]+"/domains/check", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "domains")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, d := range items {
resolved, _ := d["resolved"].([]interface{})
addrs := make([]string, 0, len(resolved))
for _, a := range resolved {
if s, ok := a.(string); ok {
addrs = append(addrs, s)
}
}
rows = append(rows, []string{str(d, "domain"), str(d, "status"), strings.Join(addrs, ", ")})
}
printRows(data, []string{"DOMAIN", "STATUS", "RESOLVED"}, rows)
return nil
},
}
// serviceAction returns a RunE for the simple POST action endpoints.
func serviceAction(verb string) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", fmt.Sprintf("/services/%s/%s", args[0], verb), map[string]interface{}{})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("%s: %s\n", verb, args[0])
return nil
}
}
func init() {
f := servicesCreateCmd.Flags()
f.String("type", "web", "service type: web|worker|database|cron")
f.String("image", "", "container image (image-based service)")
f.String("git-repo", "", "git repository URL (build-from-source service)")
f.String("git-branch", "main", "git branch")
f.String("build-path", "", "build context subdirectory")
f.String("command", "", "container start command override")
f.String("environment", "production", "production|preview|development")
f.String("domain", "", "public domain")
f.String("restart-policy", "unless-stopped", "docker restart policy")
f.String("healthcheck-path", "", "HTTP health check path")
f.String("cpu", "", "CPU limit (e.g. 0.5)")
f.String("memory", "", "memory limit (e.g. 512m)")
f.Int("port", 0, "container port")
f.Int("replicas", 0, "replica count")
f.String("builder", "", "build strategy: auto|railpack|nixpacks|dockerfile|static")
f.String("cpu-reserve", "", "soft CPU reservation (e.g. 0.25)")
f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)")
f.String("static-cmd", "", "build command for the static builder")
f.String("static-dir", "", "output dir for the static builder (default dist)")
uf := servicesUpdateCmd.Flags()
uf.String("name", "", "service name")
uf.String("image", "", "container image")
uf.String("command", "", "container start command")
uf.String("domain", "", "public domain (empty string clears)")
uf.String("restart-policy", "", "docker restart policy")
uf.String("healthcheck-path", "", "HTTP health check path")
uf.String("cpu", "", "CPU limit (e.g. 0.5)")
uf.String("memory", "", "memory limit (e.g. 512m)")
uf.Int("replicas", 0, "replica count")
uf.Int("port", 0, "container port")
uf.StringArray("volume", nil, "named volume mount name:path[:ro] (repeatable, replaces all mounts)")
uf.StringArray("bind", nil, "bind mount src:path[:ro] (repeatable)")
uf.Bool("clear-volumes", false, "remove all volume mounts")
uf.String("maintenance", "", "maintenance mode: on|off")
uf.StringArray("basic-auth", nil, "basic-auth credential user:password (repeatable, replaces all)")
uf.String("builder", "", "build strategy: auto|railpack|nixpacks|dockerfile|static")
uf.String("cpu-reserve", "", "soft CPU reservation (empty clears)")
uf.String("memory-reserve", "", "soft memory reservation (empty clears)")
uf.String("static-cmd", "", "build command for the static builder (empty clears)")
uf.String("static-dir", "", "output dir for the static builder (empty clears)")
uf.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
uf.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)")
for _, a := range []string{"start", "stop", "restart", "redeploy", "sleep", "wake"} {
verb := a
ServicesCmd.AddCommand(&cobra.Command{
Use: verb + " <id>",
Short: verb + " a service",
Args: cobra.ExactArgs(1),
RunE: serviceAction(verb),
})
}
servicesCloneCmd.Flags().String("name", "", "name for the clone (default <name>-copy)")
servicesCloneCmd.Flags().String("project", "", "target project id (default: same project)")
servicesCloneCmd.Flags().String("environment", "", "target environment (default: source's)")
domainsAddCmd.Flags().Bool("default", false, "set as the default domain")
servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd)
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd, servicesEnvCheckCmd)
}
var servicesEnvCheckCmd = &cobra.Command{
Use: "env-check <id>",
Short: "Diagnose service environment: empty vars, unresolved ${{refs}}",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0]+"/env-check", nil)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
m, _ := unwrapObject(data)
if b, ok := m["ok"].(bool); ok && b {
fmt.Println("env check: ok")
return nil
}
for _, group := range []string{"unresolved", "empty", "unreadable"} {
if list, ok := m[group].([]interface{}); ok {
for _, item := range list {
fmt.Printf("%s: %v\n", group, item)
}
}
}
return &APIError{Message: "env check failed", ExitCode: ExitError}
},
}
@@ -0,0 +1,261 @@
package commands
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
)
// TemplatesCmd manages the template catalog.
var TemplatesCmd = &cobra.Command{
Use: "templates",
Aliases: []string{"template"},
Short: "Browse and deploy templates",
}
var templatesListCmd = &cobra.Command{
Use: "list",
Short: "List templates",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/templates", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "templates")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, t := range items {
rows = append(rows, []string{
str(t, "id"), str(t, "name"), str(t, "category"), str(t, "description"),
})
}
printRows(data, []string{"ID", "NAME", "CATEGORY", "DESCRIPTION"}, rows)
return nil
},
}
var templatesGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a template definition",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/templates/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesCreateCmd = &cobra.Command{
Use: "create <file>",
Short: "Create a user template from a JSON file",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
raw, err := os.ReadFile(args[0])
if err != nil {
return fmt.Errorf("cannot read %s: %w", args[0], err)
}
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/templates", raw)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a user template",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete template %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/templates/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted template %s\n", args[0])
return nil
},
}
var tplDeployVars []string
var tplDeployName string
var templatesDeployCmd = &cobra.Command{
Use: "deploy <template-id> <project-id>",
Short: "Deploy a template into a project",
Long: "Deploys a single-service template or a multi-service graph (v2) template. Pass --name for single-service templates; graph members take names from the template. Use --var KEY=VALUE to override template variables.",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{"project_id": args[1]}
if tplDeployName != "" {
body["name"] = tplDeployName
}
vars, err := parseKVFlags(tplDeployVars)
if err != nil {
return err
}
if len(vars) > 0 {
body["variables"] = vars
}
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesPlanCmd = &cobra.Command{
Use: "plan <template-id>",
Short: "Dry-resolve a template — expanded expressions, service refs, missing variables",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
vars, err := parseKVFlags(tplDeployVars)
if err != nil {
return err
}
body := map[string]interface{}{}
if len(vars) > 0 {
body["variables"] = vars
}
data, err := c.Do("POST", "/templates/"+args[0]+"/plan", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesImportComposeCmd = &cobra.Command{
Use: "import-compose <compose.yml|->",
Short: "Convert a docker-compose file into a template graph config",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var raw []byte
var err error
if args[0] == "-" {
raw, err = io.ReadAll(os.Stdin)
} else {
raw, err = os.ReadFile(args[0])
}
if err != nil {
return fmt.Errorf("cannot read compose file: %w", err)
}
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/templates/import/compose", map[string]string{"compose_yaml": string(raw)})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesDeployGraphCmd = &cobra.Command{
Use: "deploy-graph <project-id> <config.json|->",
Short: "Deploy an ad-hoc service graph (v2 config with services[]) without a stored template",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
var raw []byte
var err error
if args[1] == "-" {
raw, err = io.ReadAll(os.Stdin)
} else {
raw, err = os.ReadFile(args[1])
}
if err != nil {
return fmt.Errorf("cannot read config: %w", err)
}
var config json.RawMessage
if err := json.Unmarshal(raw, &config); err != nil {
return fmt.Errorf("invalid config JSON: %w", err)
}
c, err := client()
if err != nil {
return err
}
vars, err := parseKVFlags(tplDeployVars)
if err != nil {
return err
}
body := map[string]interface{}{"project_id": args[0]}
var cfg map[string]interface{}
if err := json.Unmarshal(config, &cfg); err != nil {
return fmt.Errorf("invalid config JSON: %w", err)
}
body["config"] = cfg
if len(vars) > 0 {
body["variables"] = vars
}
data, err := c.Do("POST", "/templates/deploy", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// parseKVFlags converts repeated --var KEY=VALUE flags into a map.
func parseKVFlags(flags []string) (map[string]string, error) {
out := map[string]string{}
for _, f := range flags {
idx := strings.Index(f, "=")
if idx <= 0 {
return nil, fmt.Errorf("invalid --var %q — expected KEY=VALUE", f)
}
out[f[:idx]] = f[idx+1:]
}
return out, nil
}
func init() {
templatesDeployCmd.Flags().StringVar(&tplDeployName, "name", "", "Name for the created service (single-service templates)")
templatesDeployCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
templatesPlanCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
templatesDeployGraphCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd, templatesPlanCmd, templatesImportComposeCmd, templatesDeployGraphCmd)
}
+101
View File
@@ -0,0 +1,101 @@
package commands
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
// TokensCmd manages personal access tokens (cnp_...).
var TokensCmd = &cobra.Command{
Use: "tokens",
Short: "Manage personal access tokens",
Long: `Create, list, and revoke the cnp_ tokens used by this CLI,
the MCP server, and external agents.`,
}
var tokensListCmd = &cobra.Command{
Use: "list",
Short: "List your tokens (metadata only)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/user/tokens", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "tokens")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, t := range items {
rows = append(rows, []string{
str(t, "id"), str(t, "name"), str(t, "key_prefix"), str(t, "scope"),
relTime(t, "last_used_at"), str(t, "expires_at"),
})
}
printRows(data, []string{"ID", "NAME", "PREFIX", "SCOPE", "LAST USED", "EXPIRES"}, rows)
return nil
},
}
var tokensCreateCmd = &cobra.Command{
Use: "create <name>",
Short: "Create a token (raw value printed once)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
scope, _ := cmd.Flags().GetString("scope")
days, _ := cmd.Flags().GetInt("expires-in-days")
data, err := c.Do("POST", "/user/tokens", map[string]interface{}{
"name": args[0], "scope": scope, "expires_in_days": days,
})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
t, _ := unwrapObject(data)
fmt.Println(str(t, "token"))
fmt.Fprintln(os.Stderr, "Store it now — it is shown only once. Scope: "+str(t, "scope"))
return nil
},
}
var tokensRevokeCmd = &cobra.Command{
Use: "revoke <id>",
Short: "Revoke a token",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Revoke token %s? Clients using it lose access immediately.", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/user/tokens/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "revoked", "id": args[0]})
}
fmt.Printf("Revoked token %s\n", args[0])
return nil
},
}
func init() {
tokensCreateCmd.Flags().String("scope", "write", "read|write|admin (admin requires an admin account)")
tokensCreateCmd.Flags().Int("expires-in-days", 0, "0 = never expires, max 3650")
TokensCmd.AddCommand(tokensListCmd, tokensCreateCmd, tokensRevokeCmd)
}
+145
View File
@@ -0,0 +1,145 @@
package commands
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// UpCmd deploys the current directory: resolves a project, creates (or
// reuses) a service named after the directory, and triggers a deployment.
// The git remote URL and current branch become the build source.
var UpCmd = &cobra.Command{
Use: "up",
Short: "Deploy the current directory (create service + deploy)",
Long: `Deploy the current directory to Containr.
The git remote and current branch are used as the build source. If no
service named after this directory exists in the project, one is
created; an existing service is redeployed.`,
RunE: runUp,
}
func runUp(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
projectID, _ := cmd.Flags().GetString("project")
if projectID == "" {
projectID = viper.GetString("profiles." + ActiveProfile() + ".project_id")
}
if projectID == "" {
return &APIError{
Message: "no project selected — pass --project <id> or set project_id on the active profile",
ExitCode: ExitValidation,
}
}
cwd, err := os.Getwd()
if err != nil {
return err
}
name, _ := cmd.Flags().GetString("name")
if name == "" {
name = filepath.Base(cwd)
}
repo := gitOutput(cwd, "config", "--get", "remote.origin.url")
branch := gitOutput(cwd, "rev-parse", "--abbrev-ref", "HEAD")
commit := gitOutput(cwd, "rev-parse", "HEAD")
image, _ := cmd.Flags().GetString("image")
if repo == "" && image == "" {
return &APIError{
Message: "no git remote and no --image — either init git with an origin remote or pass --image",
ExitCode: ExitValidation,
}
}
// Reuse an existing same-named service when present.
svcID, _ := cmd.Flags().GetString("service")
if svcID == "" {
if data, err := c.Do("GET", "/projects/"+projectID+"/services", nil); err == nil {
if items, err := unwrapList(data, "services"); err == nil {
for _, s := range items {
if str(s, "name") == name {
svcID = str(s, "id")
break
}
}
}
}
}
if svcID == "" {
body := map[string]interface{}{
"name": name,
"type": "web",
"environment": "production",
}
if repo != "" {
body["git_repo"] = repo
if branch != "" {
body["git_branch"] = branch
}
}
if image != "" {
body["image"] = image
}
data, err := c.Do("POST", "/projects/"+projectID+"/services", body)
if err != nil {
return err
}
s, _ := unwrapObject(data, "service")
svcID = str(s, "id")
if svcID == "" || svcID == "-" {
PrintRaw(data)
return &APIError{Message: "service created but id missing from response", ExitCode: ExitError}
}
if !JSONMode() {
fmt.Fprintf(os.Stderr, "Created service %s (%s)\n", name, svcID)
}
}
deployBody := map[string]interface{}{"trigger": "cli-up"}
if commit != "" {
deployBody["commit_hash"] = commit
}
if branch != "" {
deployBody["branch"] = branch
}
data, err := c.Do("POST", "/services/"+svcID+"/deployments", deployBody)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
d, _ := unwrapObject(data, "deployment")
fmt.Printf("Deploying %s → service %s (deployment %s)\n", name, svcID, str(d, "id"))
return nil
}
func gitOutput(dir string, args ...string) string {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func init() {
UpCmd.Flags().String("project", "", "target project id (or set project_id on the profile)")
UpCmd.Flags().String("name", "", "service name (default: directory name)")
UpCmd.Flags().String("service", "", "existing service id to redeploy")
UpCmd.Flags().String("image", "", "deploy this image instead of building from git")
}
@@ -0,0 +1,225 @@
package commands
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
// VariablesCmd manages service environment variables.
var VariablesCmd = &cobra.Command{
Use: "variables",
Aliases: []string{"vars", "env"},
Short: "Manage service environment variables",
}
var varsListCmd = &cobra.Command{
Use: "list <service-id>",
Short: "List variables (secrets shown masked)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0]+"/variables", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "variables")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, v := range items {
rows = append(rows, []string{str(v, "key"), str(v, "value"), str(v, "is_secret")})
}
printRows(data, []string{"KEY", "VALUE", "SECRET"}, rows)
return nil
},
}
// Variables are replaced wholesale server-side — set/unset fetch the
// current list, mutate it, and PUT it back. Masked secret placeholders are
// preserved by the server.
var varsSetCmd = &cobra.Command{
Use: "set <service-id> KEY=VALUE [KEY=VALUE...]",
Short: "Set one or more variables",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
secret, _ := cmd.Flags().GetBool("secret")
redeploy, _ := cmd.Flags().GetBool("redeploy")
return mutateVars(serviceVarsPath(args[0]), args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
for _, pair := range args[1:] {
k, v, found := strings.Cut(pair, "=")
if !found || k == "" {
continue
}
vars = upsertVar(vars, k, v, secret)
}
return vars
})
},
}
var varsUnsetCmd = &cobra.Command{
Use: "unset <service-id> KEY [KEY...]",
Short: "Remove variables",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
drop := map[string]bool{}
for _, k := range args[1:] {
drop[k] = true
}
redeploy, _ := cmd.Flags().GetBool("redeploy")
return mutateVars(serviceVarsPath(args[0]), args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
out := vars[:0]
for _, v := range vars {
if !drop[str(v, "key")] {
out = append(out, v)
}
}
return out
})
},
}
// Shared variables — project scope, referenced as ${{shared.KEY}}.
var varsSharedCmd = &cobra.Command{
Use: "shared",
Short: "Manage project-level shared variables (${{shared.KEY}})",
}
var sharedVarsListCmd = &cobra.Command{
Use: "list <project-id>",
Short: "List shared project variables (secrets shown masked)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", projectVarsPath(args[0]), nil)
if err != nil {
return err
}
items, err := unwrapList(data, "variables")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, v := range items {
rows = append(rows, []string{str(v, "key"), str(v, "value"), str(v, "is_secret")})
}
printRows(data, []string{"KEY", "VALUE", "SECRET"}, rows)
return nil
},
}
var sharedVarsSetCmd = &cobra.Command{
Use: "set <project-id> KEY=VALUE [KEY=VALUE...]",
Short: "Set shared project variables",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
secret, _ := cmd.Flags().GetBool("secret")
return mutateVars(projectVarsPath(args[0]), args[0], false, func(vars []map[string]interface{}) []map[string]interface{} {
for _, pair := range args[1:] {
k, v, found := strings.Cut(pair, "=")
if !found || k == "" {
continue
}
vars = upsertVar(vars, k, v, secret)
}
return vars
})
},
}
var sharedVarsUnsetCmd = &cobra.Command{
Use: "unset <project-id> KEY [KEY...]",
Short: "Remove shared project variables",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
drop := map[string]bool{}
for _, k := range args[1:] {
drop[k] = true
}
return mutateVars(projectVarsPath(args[0]), args[0], false, func(vars []map[string]interface{}) []map[string]interface{} {
out := vars[:0]
for _, v := range vars {
if !drop[str(v, "key")] {
out = append(out, v)
}
}
return out
})
},
}
func serviceVarsPath(serviceID string) string { return "/services/" + serviceID + "/variables" }
func projectVarsPath(projectID string) string { return "/projects/" + projectID + "/variables" }
func mutateVars(path, label string, redeploy bool, mutate func([]map[string]interface{}) []map[string]interface{}) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", path, nil)
if err != nil {
return err
}
vars, err := unwrapList(data, "variables")
if err != nil {
return err
}
vars = mutate(vars)
payload := make([]map[string]interface{}, 0, len(vars))
for _, v := range vars {
payload = append(payload, map[string]interface{}{
"key": str(v, "key"),
"value": str(v, "value"),
"is_secret": v["is_secret"] == true,
})
}
body := map[string]interface{}{"variables": payload}
if redeploy {
body["redeploy"] = true
}
data, err = c.Do("PUT", path, body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
obj, _ := unwrapObject(data)
if obj["redeploy_queued"] == true {
fmt.Printf("Variables updated on %s — redeploy queued\n", label)
} else {
fmt.Printf("Variables updated on %s — redeploy to apply to running containers\n", label)
}
return nil
}
func upsertVar(vars []map[string]interface{}, key, value string, secret bool) []map[string]interface{} {
for _, v := range vars {
if str(v, "key") == key {
v["value"] = value
v["is_secret"] = secret
return vars
}
}
return append(vars, map[string]interface{}{"key": key, "value": value, "is_secret": secret})
}
func init() {
varsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
varsSetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
varsUnsetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
sharedVarsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
varsSharedCmd.AddCommand(sharedVarsListCmd, sharedVarsSetCmd, sharedVarsUnsetCmd)
VariablesCmd.AddCommand(varsListCmd, varsSetCmd, varsUnsetCmd, varsSharedCmd)
}
@@ -0,0 +1,71 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// VolumesCmd manages docker volumes on the node (admin).
var VolumesCmd = &cobra.Command{
Use: "volumes",
Aliases: []string{"volume"},
Short: "Manage docker volumes (admin)",
}
var volumesListCmd = &cobra.Command{
Use: "list",
Short: "List docker volumes with in-use flags",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/admin/volumes", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "volumes")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, v := range items {
inUse := "no"
if b, ok := v["in_use"].(bool); ok && b {
inUse = "yes"
}
rows = append(rows, []string{str(v, "name"), str(v, "driver"), inUse})
}
printRows(data, []string{"NAME", "DRIVER", "IN USE"}, rows)
return nil
},
}
var volumesDeleteCmd = &cobra.Command{
Use: "delete <name>",
Short: "Delete an unused docker volume",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete volume %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/admin/volumes/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "name": args[0]})
}
fmt.Printf("Deleted volume %s\n", args[0])
return nil
},
}
func init() {
VolumesCmd.AddCommand(volumesListCmd, volumesDeleteCmd)
}
+61 -36
View File
@@ -12,23 +12,35 @@ import (
var cfgFile string var cfgFile string
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "containr", Use: "containr",
Short: "Containr CLI - Manage your self-hosted PaaS", Short: "Containr CLI - manage your self-hosted PaaS",
Long: `Containr CLI is a command-line interface for managing your Containr platform. Long: `Containr CLI controls every part of a Containr instance: projects,
You can manage projects, services, deployments, databases, and more from your terminal.`, services, deployments, databases, cron jobs, templates, nodes, scaling,
Version: "1.0.0", HA, security, and the API gateway.
Authenticate once with a personal access token (Settings → Personal
Access Tokens in the web UI):
containr auth login cnp_...
Every command supports --json for machine-readable output, and
destructive actions accept --yes to skip confirmation.`,
Version: "2.0.0",
SilenceUsage: true,
SilenceErrors: true,
} }
// Execute adds all child commands to the root command and sets flags appropriately. // Execute runs the root command and returns the process exit code.
// This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() int {
func Execute() { if err := rootCmd.Execute(); err != nil {
cobra.CheckErr(rootCmd.Execute()) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return commands.ExitCode(err)
}
return commands.ExitOK
} }
// SetVersion overrides the version reported by `containr --version`. // SetVersion overrides the version reported by `containr --version`.
// Called by main with the value injected via -ldflags at build time.
func SetVersion(v string) { func SetVersion(v string) {
rootCmd.Version = v rootCmd.Version = v
} }
@@ -36,46 +48,59 @@ func SetVersion(v string) {
func init() { func init() {
cobra.OnInitialize(initConfig) cobra.OnInitialize(initConfig)
// Global flags pf := rootCmd.PersistentFlags()
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.containr.yaml)") pf.StringVar(&cfgFile, "config", "", "config file (default $HOME/.containr.yaml)")
rootCmd.PersistentFlags().String("api-url", "", "Containr API URL (default is https://api.containr.dev)") pf.String("api-url", "", "Containr API URL (overrides profile and CONTAINR_API_URL)")
rootCmd.PersistentFlags().String("token", "", "Authentication token") pf.String("token", "", "auth token (overrides profile and CONTAINR_TOKEN)")
pf.StringP("profile", "p", "", "config profile to use (default current_profile)")
pf.Bool("json", false, "machine-readable JSON output")
pf.BoolP("yes", "y", false, "skip confirmation prompts (required for destructive ops in scripts)")
pf.String("idempotency-key", "", "Idempotency-Key header for POST mutations (safe retries)")
// Bind flags to viper viper.BindPFlag("api-url", pf.Lookup("api-url"))
viper.BindPFlag("api-url", rootCmd.PersistentFlags().Lookup("api-url")) viper.BindPFlag("token", pf.Lookup("token"))
viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token")) viper.BindPFlag("profile", pf.Lookup("profile"))
viper.BindPFlag("json", pf.Lookup("json"))
viper.BindPFlag("yes", pf.Lookup("yes"))
viper.BindPFlag("idempotency-key", pf.Lookup("idempotency-key"))
// Add command groups
rootCmd.AddCommand(commands.AuthCmd) rootCmd.AddCommand(commands.AuthCmd)
rootCmd.AddCommand(commands.TokensCmd)
rootCmd.AddCommand(commands.ProjectsCmd) rootCmd.AddCommand(commands.ProjectsCmd)
rootCmd.AddCommand(commands.ServicesCmd)
rootCmd.AddCommand(commands.DeployCmd)
rootCmd.AddCommand(commands.DeploymentsCmd)
rootCmd.AddCommand(commands.LogsCmd)
rootCmd.AddCommand(commands.ExecCmd)
rootCmd.AddCommand(commands.VariablesCmd)
rootCmd.AddCommand(commands.VolumesCmd)
rootCmd.AddCommand(commands.RegistriesCmd)
rootCmd.AddCommand(commands.BackupTargetsCmd)
rootCmd.AddCommand(commands.DatabasesCmd)
rootCmd.AddCommand(commands.CronCmd)
rootCmd.AddCommand(commands.TemplatesCmd)
rootCmd.AddCommand(commands.NodesCmd)
rootCmd.AddCommand(commands.ScalingCmd)
rootCmd.AddCommand(commands.HACmd)
rootCmd.AddCommand(commands.SecurityCmd)
rootCmd.AddCommand(commands.GatewayCmd)
rootCmd.AddCommand(commands.AdminCmd)
rootCmd.AddCommand(commands.NotificationsCmd)
rootCmd.AddCommand(commands.UpCmd)
} }
// initConfig reads in config file and ENV variables if set.
func initConfig() { func initConfig() {
if cfgFile != "" { if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile) viper.SetConfigFile(cfgFile)
} else { } else {
// Find home directory.
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
cobra.CheckErr(err) cobra.CheckErr(err)
// Search config in home directory with name ".containr" (without extension).
viper.AddConfigPath(home) viper.AddConfigPath(home)
viper.SetConfigType("yaml") viper.SetConfigType("yaml")
viper.SetConfigName(".containr") viper.SetConfigName(".containr")
} }
viper.AutomaticEnv()
viper.AutomaticEnv() // read in environment variables that match // CONTAINR_TOKEN / CONTAINR_API_URL are read explicitly in config.go;
// AutomaticEnv covers them only when bound, so this is belt-and-braces.
// If a config file is found, read it in. viper.ReadInConfig()
if err := viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}
// Run executes the CLI
func Run() error {
rootCmd.Execute()
return nil
} }
-25
View File
@@ -1,25 +0,0 @@
package cli
import (
"strings"
"github.com/spf13/viper"
)
// getAPIURL constructs the full API URL for a given endpoint
func getAPIURL(endpoint string) string {
baseURL := viper.GetString("api-url")
if baseURL == "" {
baseURL = "http://localhost:8080/api/v1" // Default for development
}
// Ensure baseURL doesn't end with / and endpoint starts with /
if strings.HasSuffix(baseURL, "/") {
baseURL = baseURL[:len(baseURL)-1]
}
if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
return baseURL + endpoint
}
@@ -0,0 +1,253 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: backup_targets.sql
package sqlcdb
import (
"context"
"database/sql"
)
const countDatabasesUsingBackupTarget = `-- name: CountDatabasesUsingBackupTarget :one
SELECT COUNT(*)
FROM database_services
WHERE backup_target_id = $1
`
func (q *Queries) CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error) {
row := q.db.QueryRowContext(ctx, countDatabasesUsingBackupTarget, targetID)
var count int64
err := row.Scan(&count)
return count, err
}
const createBackupTarget = `-- name: CreateBackupTarget :exec
INSERT INTO backup_targets (id, user_id, name, endpoint, bucket, region, prefix, access_key, secret_key, use_tls, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`
type CreateBackupTargetParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket"`
Region string `json:"region"`
Prefix string `json:"prefix"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
UseTls bool `json:"use_tls"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error {
_, err := q.db.ExecContext(ctx, createBackupTarget,
arg.ID,
arg.UserID,
arg.Name,
arg.Endpoint,
arg.Bucket,
arg.Region,
arg.Prefix,
arg.AccessKey,
arg.SecretKey,
arg.UseTls,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const deleteBackupTargetByIDAndUser = `-- name: DeleteBackupTargetByIDAndUser :exec
DELETE FROM backup_targets
WHERE id = $1 AND user_id = $2
`
type DeleteBackupTargetByIDAndUserParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
}
func (q *Queries) DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error {
_, err := q.db.ExecContext(ctx, deleteBackupTargetByIDAndUser, arg.ID, arg.UserID)
return err
}
const getBackupTargetByID = `-- name: GetBackupTargetByID :one
SELECT backup_targets.id, backup_targets.user_id, backup_targets.name, backup_targets.endpoint, backup_targets.bucket, backup_targets.region, backup_targets.prefix, backup_targets.access_key, backup_targets.secret_key, backup_targets.use_tls, backup_targets.created_at, backup_targets.updated_at
FROM backup_targets
WHERE id = $1
`
func (q *Queries) GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error) {
row := q.db.QueryRowContext(ctx, getBackupTargetByID, id)
var i BackupTarget
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Endpoint,
&i.Bucket,
&i.Region,
&i.Prefix,
&i.AccessKey,
&i.SecretKey,
&i.UseTls,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getBackupTargetByIDAndUser = `-- name: GetBackupTargetByIDAndUser :one
SELECT backup_targets.id, backup_targets.user_id, backup_targets.name, backup_targets.endpoint, backup_targets.bucket, backup_targets.region, backup_targets.prefix, backup_targets.access_key, backup_targets.secret_key, backup_targets.use_tls, backup_targets.created_at, backup_targets.updated_at
FROM backup_targets
WHERE id = $1 AND user_id = $2
`
type GetBackupTargetByIDAndUserParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
}
func (q *Queries) GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error) {
row := q.db.QueryRowContext(ctx, getBackupTargetByIDAndUser, arg.ID, arg.UserID)
var i BackupTarget
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Endpoint,
&i.Bucket,
&i.Region,
&i.Prefix,
&i.AccessKey,
&i.SecretKey,
&i.UseTls,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listBackupTargetsByUser = `-- name: ListBackupTargetsByUser :many
SELECT backup_targets.id, backup_targets.user_id, backup_targets.name, backup_targets.endpoint, backup_targets.bucket, backup_targets.region, backup_targets.prefix, backup_targets.access_key, backup_targets.secret_key, backup_targets.use_tls, backup_targets.created_at, backup_targets.updated_at
FROM backup_targets
WHERE user_id = $1
ORDER BY created_at DESC
`
func (q *Queries) ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error) {
rows, err := q.db.QueryContext(ctx, listBackupTargetsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []BackupTarget{}
for rows.Next() {
var i BackupTarget
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Endpoint,
&i.Bucket,
&i.Region,
&i.Prefix,
&i.AccessKey,
&i.SecretKey,
&i.UseTls,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setDatabaseBackupRemoteKeyByID = `-- name: SetDatabaseBackupRemoteKeyByID :exec
UPDATE database_backups
SET remote_key = $1
WHERE id = $2
`
type SetDatabaseBackupRemoteKeyByIDParams struct {
RemoteKey sql.NullString `json:"remote_key"`
ID string `json:"id"`
}
func (q *Queries) SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error {
_, err := q.db.ExecContext(ctx, setDatabaseBackupRemoteKeyByID, arg.RemoteKey, arg.ID)
return err
}
const setDatabaseBackupTargetByIDAndUser = `-- name: SetDatabaseBackupTargetByIDAndUser :exec
UPDATE database_services
SET backup_target_id = $1, updated_at = $2
WHERE id = $3 AND user_id = $4
`
type SetDatabaseBackupTargetByIDAndUserParams struct {
BackupTargetID sql.NullString `json:"backup_target_id"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID string `json:"id"`
UserID string `json:"user_id"`
}
func (q *Queries) SetDatabaseBackupTargetByIDAndUser(ctx context.Context, arg SetDatabaseBackupTargetByIDAndUserParams) error {
_, err := q.db.ExecContext(ctx, setDatabaseBackupTargetByIDAndUser,
arg.BackupTargetID,
arg.UpdatedAt,
arg.ID,
arg.UserID,
)
return err
}
const updateBackupTargetByIDAndUser = `-- name: UpdateBackupTargetByIDAndUser :exec
UPDATE backup_targets
SET name = $1, endpoint = $2, bucket = $3, region = $4, prefix = $5, access_key = $6, secret_key = $7, use_tls = $8, updated_at = $9
WHERE id = $10 AND user_id = $11
`
type UpdateBackupTargetByIDAndUserParams struct {
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket"`
Region string `json:"region"`
Prefix string `json:"prefix"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
UseTls bool `json:"use_tls"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID string `json:"id"`
UserID string `json:"user_id"`
}
func (q *Queries) UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error {
_, err := q.db.ExecContext(ctx, updateBackupTargetByIDAndUser,
arg.Name,
arg.Endpoint,
arg.Bucket,
arg.Region,
arg.Prefix,
arg.AccessKey,
arg.SecretKey,
arg.UseTls,
arg.UpdatedAt,
arg.ID,
arg.UserID,
)
return err
}
@@ -55,8 +55,8 @@ func (q *Queries) CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBa
} }
const createDatabaseService = `-- name: CreateDatabaseService :exec const createDatabaseService = `-- name: CreateDatabaseService :exec
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, created_at, updated_at) INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
` `
type CreateDatabaseServiceParams struct { type CreateDatabaseServiceParams struct {
@@ -68,6 +68,7 @@ type CreateDatabaseServiceParams struct {
Version string `json:"version"` Version string `json:"version"`
Plan string `json:"plan"` Plan string `json:"plan"`
Region string `json:"region"` Region string `json:"region"`
PublicPort bool `json:"public_port"`
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
@@ -82,6 +83,53 @@ func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseS
arg.Version, arg.Version,
arg.Plan, arg.Plan,
arg.Region, arg.Region,
arg.PublicPort,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const createExternalDatabaseService = `-- name: CreateExternalDatabaseService :exec
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region,
provider, external_host, external_port, external_name, external_username, external_password, external_ssl,
created_at, updated_at)
VALUES ($1, $2, $3, $4, 'running', $5, $6, $7, 'external', $8, $9, $10, $11, $12, $13, $14, $15)
`
type CreateExternalDatabaseServiceParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ExternalHost sql.NullString `json:"external_host"`
ExternalPort sql.NullInt32 `json:"external_port"`
ExternalName sql.NullString `json:"external_name"`
ExternalUsername sql.NullString `json:"external_username"`
ExternalPassword string `json:"external_password"`
ExternalSsl bool `json:"external_ssl"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error {
_, err := q.db.ExecContext(ctx, createExternalDatabaseService,
arg.ID,
arg.UserID,
arg.Name,
arg.Type,
arg.Version,
arg.Plan,
arg.Region,
arg.ExternalHost,
arg.ExternalPort,
arg.ExternalName,
arg.ExternalUsername,
arg.ExternalPassword,
arg.ExternalSsl,
arg.CreatedAt, arg.CreatedAt,
arg.UpdatedAt, arg.UpdatedAt,
) )
@@ -122,7 +170,7 @@ func (q *Queries) DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg Dele
} }
const getDatabaseBackupByIDAndDatabaseAndUser = `-- name: GetDatabaseBackupByIDAndDatabaseAndUser :one const getDatabaseBackupByIDAndDatabaseAndUser = `-- name: GetDatabaseBackupByIDAndDatabaseAndUser :one
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.remote_key, b.created_at, b.completed_at
FROM database_backups b FROM database_backups b
JOIN database_services s ON s.id = b.database_id JOIN database_services s ON s.id = b.database_id
WHERE b.id = $1 AND b.database_id = $2 AND s.user_id = $3 WHERE b.id = $1 AND b.database_id = $2 AND s.user_id = $3
@@ -143,6 +191,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
&i.Size, &i.Size,
&i.Status, &i.Status,
&i.BackupPath, &i.BackupPath,
&i.RemoteKey,
&i.CreatedAt, &i.CreatedAt,
&i.CompletedAt, &i.CompletedAt,
) )
@@ -150,7 +199,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
} }
const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at
FROM database_services FROM database_services
WHERE id = $1 WHERE id = $1
` `
@@ -170,6 +219,15 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
&i.ConnectionUrl, &i.ConnectionUrl,
&i.BackupSchedule, &i.BackupSchedule,
&i.NextBackupAt, &i.NextBackupAt,
&i.Provider,
&i.ExternalHost,
&i.ExternalPort,
&i.ExternalName,
&i.ExternalUsername,
&i.ExternalPassword,
&i.ExternalSsl,
&i.PublicPort,
&i.BackupTargetID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -177,7 +235,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
} }
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at
FROM database_services FROM database_services
WHERE id = $1 AND user_id = $2 WHERE id = $1 AND user_id = $2
` `
@@ -202,6 +260,15 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
&i.ConnectionUrl, &i.ConnectionUrl,
&i.BackupSchedule, &i.BackupSchedule,
&i.NextBackupAt, &i.NextBackupAt,
&i.Provider,
&i.ExternalHost,
&i.ExternalPort,
&i.ExternalName,
&i.ExternalUsername,
&i.ExternalPassword,
&i.ExternalSsl,
&i.PublicPort,
&i.BackupTargetID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -222,7 +289,7 @@ func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (str
} }
const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at
FROM database_services FROM database_services
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@@ -248,6 +315,15 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic
&i.ConnectionUrl, &i.ConnectionUrl,
&i.BackupSchedule, &i.BackupSchedule,
&i.NextBackupAt, &i.NextBackupAt,
&i.Provider,
&i.ExternalHost,
&i.ExternalPort,
&i.ExternalName,
&i.ExternalUsername,
&i.ExternalPassword,
&i.ExternalSsl,
&i.PublicPort,
&i.BackupTargetID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@@ -265,7 +341,7 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic
} }
const listDatabaseBackupsByDatabaseAndUser = `-- name: ListDatabaseBackupsByDatabaseAndUser :many const listDatabaseBackupsByDatabaseAndUser = `-- name: ListDatabaseBackupsByDatabaseAndUser :many
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.remote_key, b.created_at, b.completed_at
FROM database_backups b FROM database_backups b
JOIN database_services s ON s.id = b.database_id JOIN database_services s ON s.id = b.database_id
WHERE b.database_id = $1 AND s.user_id = $2 WHERE b.database_id = $1 AND s.user_id = $2
@@ -294,6 +370,7 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
&i.Size, &i.Size,
&i.Status, &i.Status,
&i.BackupPath, &i.BackupPath,
&i.RemoteKey,
&i.CreatedAt, &i.CreatedAt,
&i.CompletedAt, &i.CompletedAt,
); err != nil { ); err != nil {
@@ -311,7 +388,7 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
} }
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at
FROM database_services FROM database_services
WHERE user_id = $1 WHERE user_id = $1
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -338,6 +415,15 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
&i.ConnectionUrl, &i.ConnectionUrl,
&i.BackupSchedule, &i.BackupSchedule,
&i.NextBackupAt, &i.NextBackupAt,
&i.Provider,
&i.ExternalHost,
&i.ExternalPort,
&i.ExternalName,
&i.ExternalUsername,
&i.ExternalPassword,
&i.ExternalSsl,
&i.PublicPort,
&i.BackupTargetID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@@ -453,6 +539,29 @@ func (q *Queries) SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNe
return err return err
} }
const setDatabaseServicePublicPortByIDAndUser = `-- name: SetDatabaseServicePublicPortByIDAndUser :exec
UPDATE database_services
SET public_port = $1, updated_at = $2
WHERE id = $3 AND user_id = $4
`
type SetDatabaseServicePublicPortByIDAndUserParams struct {
PublicPort bool `json:"public_port"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID string `json:"id"`
UserID string `json:"user_id"`
}
func (q *Queries) SetDatabaseServicePublicPortByIDAndUser(ctx context.Context, arg SetDatabaseServicePublicPortByIDAndUserParams) error {
_, err := q.db.ExecContext(ctx, setDatabaseServicePublicPortByIDAndUser,
arg.PublicPort,
arg.UpdatedAt,
arg.ID,
arg.UserID,
)
return err
}
const setDatabaseServiceStatusAndConnectionByID = `-- name: SetDatabaseServiceStatusAndConnectionByID :exec const setDatabaseServiceStatusAndConnectionByID = `-- name: SetDatabaseServiceStatusAndConnectionByID :exec
UPDATE database_services UPDATE database_services
SET status = $1, connection_url = $2, updated_at = $3 SET status = $1, connection_url = $2, updated_at = $3
@@ -49,6 +49,21 @@ type AgentHeartbeat struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type BackupTarget struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket"`
Region string `json:"region"`
Prefix string `json:"prefix"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
UseTls bool `json:"use_tls"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type ContainerInstance struct { type ContainerInstance struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -75,6 +90,7 @@ type DatabaseBackup struct {
Size string `json:"size"` Size string `json:"size"`
Status string `json:"status"` Status string `json:"status"`
BackupPath sql.NullString `json:"backup_path"` BackupPath sql.NullString `json:"backup_path"`
RemoteKey sql.NullString `json:"remote_key"`
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
CompletedAt sql.NullTime `json:"completed_at"` CompletedAt sql.NullTime `json:"completed_at"`
} }
@@ -91,6 +107,15 @@ type DatabaseService struct {
ConnectionUrl sql.NullString `json:"connection_url"` ConnectionUrl sql.NullString `json:"connection_url"`
BackupSchedule sql.NullString `json:"backup_schedule"` BackupSchedule sql.NullString `json:"backup_schedule"`
NextBackupAt sql.NullTime `json:"next_backup_at"` NextBackupAt sql.NullTime `json:"next_backup_at"`
Provider string `json:"provider"`
ExternalHost sql.NullString `json:"external_host"`
ExternalPort sql.NullInt32 `json:"external_port"`
ExternalName sql.NullString `json:"external_name"`
ExternalUsername sql.NullString `json:"external_username"`
ExternalPassword string `json:"external_password"`
ExternalSsl bool `json:"external_ssl"`
PublicPort bool `json:"public_port"`
BackupTargetID sql.NullString `json:"backup_target_id"`
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
@@ -167,6 +192,17 @@ type ProjectMember struct {
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
} }
type Registry struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Name string `json:"name"`
Host string `json:"host"`
Username string `json:"username"`
Password string `json:"password"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Service struct { type Service struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -195,6 +231,31 @@ type Service struct {
BuildPath sql.NullString `json:"build_path"` BuildPath sql.NullString `json:"build_path"`
Cpu sql.NullString `json:"cpu"` Cpu sql.NullString `json:"cpu"`
Memory sql.NullString `json:"memory"` Memory sql.NullString `json:"memory"`
Replicas int32 `json:"replicas"`
Port int32 `json:"port"`
Domain string `json:"domain"`
HealthcheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
PublishedPort int32 `json:"published_port"`
Volumes json.RawMessage `json:"volumes"`
MaintenanceMode bool `json:"maintenance_mode"`
BasicAuthUsers string `json:"basic_auth_users"`
Builder string `json:"builder"`
CpuReserve string `json:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd"`
StaticDir string `json:"static_dir"`
}
type ServiceDomain struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Domain string `json:"domain"`
IsDefault bool `json:"is_default"`
CertType string `json:"cert_type"`
CertStatus string `json:"cert_status"`
LastCheckedAt sql.NullTime `json:"last_checked_at"`
CreatedAt time.Time `json:"created_at"`
} }
type ServiceTemplate struct { type ServiceTemplate struct {
@@ -207,6 +268,20 @@ type ServiceTemplate struct {
Variables pqtype.NullRawMessage `json:"variables"` Variables pqtype.NullRawMessage `json:"variables"`
IsOfficial sql.NullBool `json:"is_official"` IsOfficial sql.NullBool `json:"is_official"`
OwnerID uuid.NullUUID `json:"owner_id"` OwnerID uuid.NullUUID `json:"owner_id"`
IsPublic bool `json:"is_public"`
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type UserToken struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
TokenHash string `json:"token_hash"`
Scope string `json:"scope"`
ExpiresAt sql.NullTime `json:"expires_at"`
LastUsedAt sql.NullTime `json:"last_used_at"`
RevokedAt sql.NullTime `json:"revoked_at"`
CreatedAt sql.NullTime `json:"created_at"`
}
@@ -6,6 +6,7 @@ package sqlcdb
import ( import (
"context" "context"
"database/sql"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -13,27 +14,34 @@ import (
type Querier interface { type Querier interface {
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error) CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error) CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error)
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error) CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error) CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error) CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error
CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error) CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error)
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error) CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error
CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error
CreateNotification(ctx context.Context, arg CreateNotificationParams) error CreateNotification(ctx context.Context, arg CreateNotificationParams) error
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error DeleteAgent(ctx context.Context, id string) error
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error) GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
GetAgent(ctx context.Context, id string) (NodeAgent, error) GetAgent(ctx context.Context, id string) (NodeAgent, error)
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error)
GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error)
GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error) GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error)
GetContainer(ctx context.Context, id string) (ContainerInstance, error) GetContainer(ctx context.Context, id string) (ContainerInstance, error)
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error) GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
@@ -46,12 +54,14 @@ type Querier interface {
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error) GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
ListAgents(ctx context.Context) ([]NodeAgent, error) ListAgents(ctx context.Context) ([]NodeAgent, error)
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error) ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error) ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
@@ -66,19 +76,26 @@ type Querier interface {
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
SetDatabaseBackupTargetByIDAndUser(ctx context.Context, arg SetDatabaseBackupTargetByIDAndUserParams) error
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
SetDatabaseServicePublicPortByIDAndUser(ctx context.Context, arg SetDatabaseServicePublicPortByIDAndUserParams) error
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error) SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
TouchAgentAuthToken(ctx context.Context, tokenHash string) error TouchAgentAuthToken(ctx context.Context, tokenHash string) error
TouchUserToken(ctx context.Context, id uuid.UUID) error
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
@@ -125,7 +125,7 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi
} }
const createUserTemplate = `-- name: CreateUserTemplate :exec const createUserTemplate = `-- name: CreateUserTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id) INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id, is_public)
VALUES ( VALUES (
$1, $1,
$2, $2,
@@ -135,7 +135,8 @@ VALUES (
$6, $6,
$7, $7,
false, false,
$8 $8,
$9
) )
` `
@@ -148,6 +149,7 @@ type CreateUserTemplateParams struct {
Config json.RawMessage `json:"config"` Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"` Variables pqtype.NullRawMessage `json:"variables"`
OwnerID uuid.NullUUID `json:"owner_id"` OwnerID uuid.NullUUID `json:"owner_id"`
IsPublic bool `json:"is_public"`
} }
func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error { func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error {
@@ -160,6 +162,7 @@ func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplate
arg.Config, arg.Config,
arg.Variables, arg.Variables,
arg.OwnerID, arg.OwnerID,
arg.IsPublic,
) )
return err return err
} }
@@ -198,7 +201,7 @@ func (q *Queries) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUI
} }
const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates FROM service_templates
WHERE id = $1 WHERE id = $1
` `
@@ -216,6 +219,7 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic
&i.Variables, &i.Variables,
&i.IsOfficial, &i.IsOfficial,
&i.OwnerID, &i.OwnerID,
&i.IsPublic,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -223,10 +227,10 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic
} }
const listServiceTemplatesByCategoryForUser = `-- name: ListServiceTemplatesByCategoryForUser :many const listServiceTemplatesByCategoryForUser = `-- name: ListServiceTemplatesByCategoryForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates FROM service_templates
WHERE category = $1 WHERE category = $1
AND (is_official = true OR owner_id = $2) AND (is_official = true OR is_public = true OR owner_id = $2)
ORDER BY is_official DESC, name ASC ORDER BY is_official DESC, name ASC
` `
@@ -254,6 +258,7 @@ func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg
&i.Variables, &i.Variables,
&i.IsOfficial, &i.IsOfficial,
&i.OwnerID, &i.OwnerID,
&i.IsPublic,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@@ -271,9 +276,9 @@ func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg
} }
const listServiceTemplatesForUser = `-- name: ListServiceTemplatesForUser :many const listServiceTemplatesForUser = `-- name: ListServiceTemplatesForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates FROM service_templates
WHERE is_official = true OR owner_id = $1 WHERE is_official = true OR is_public = true OR owner_id = $1
ORDER BY is_official DESC, name ASC ORDER BY is_official DESC, name ASC
` `
@@ -296,6 +301,7 @@ func (q *Queries) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.
&i.Variables, &i.Variables,
&i.IsOfficial, &i.IsOfficial,
&i.OwnerID, &i.OwnerID,
&i.IsPublic,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@@ -320,9 +326,10 @@ SET name = $1,
logo = $4, logo = $4,
config = $5, config = $5,
variables = $6, variables = $6,
is_public = $7,
updated_at = NOW() updated_at = NOW()
WHERE id = $7 WHERE id = $8
AND owner_id = $8 AND owner_id = $9
AND is_official = false AND is_official = false
` `
@@ -333,6 +340,7 @@ type UpdateUserTemplateParams struct {
Logo sql.NullString `json:"logo"` Logo sql.NullString `json:"logo"`
Config json.RawMessage `json:"config"` Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"` Variables pqtype.NullRawMessage `json:"variables"`
IsPublic bool `json:"is_public"`
ID string `json:"id"` ID string `json:"id"`
OwnerID uuid.NullUUID `json:"owner_id"` OwnerID uuid.NullUUID `json:"owner_id"`
} }
@@ -345,6 +353,7 @@ func (q *Queries) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplate
arg.Logo, arg.Logo,
arg.Config, arg.Config,
arg.Variables, arg.Variables,
arg.IsPublic,
arg.ID, arg.ID,
arg.OwnerID, arg.OwnerID,
) )
@@ -0,0 +1,147 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: user_tokens.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const createUserToken = `-- name: CreateUserToken :one
INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at
`
type CreateUserTokenParams struct {
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
TokenHash string `json:"token_hash"`
Scope string `json:"scope"`
ExpiresAt sql.NullTime `json:"expires_at"`
}
func (q *Queries) CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error) {
row := q.db.QueryRowContext(ctx, createUserToken,
arg.UserID,
arg.Name,
arg.KeyPrefix,
arg.TokenHash,
arg.Scope,
arg.ExpiresAt,
)
var i UserToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyPrefix,
&i.TokenHash,
&i.Scope,
&i.ExpiresAt,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const getUserTokenByHash = `-- name: GetUserTokenByHash :one
SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens
WHERE token_hash = $1
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
LIMIT 1
`
func (q *Queries) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) {
row := q.db.QueryRowContext(ctx, getUserTokenByHash, tokenHash)
var i UserToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyPrefix,
&i.TokenHash,
&i.Scope,
&i.ExpiresAt,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const listUserTokens = `-- name: ListUserTokens :many
SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens
WHERE user_id = $1 AND revoked_at IS NULL
ORDER BY created_at DESC
`
func (q *Queries) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) {
rows, err := q.db.QueryContext(ctx, listUserTokens, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []UserToken{}
for rows.Next() {
var i UserToken
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyPrefix,
&i.TokenHash,
&i.Scope,
&i.ExpiresAt,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const revokeUserToken = `-- name: RevokeUserToken :execrows
UPDATE user_tokens SET revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL
`
type RevokeUserTokenParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) {
result, err := q.db.ExecContext(ctx, revokeUserToken, arg.ID, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const touchUserToken = `-- name: TouchUserToken :exec
UPDATE user_tokens SET last_used_at = NOW()
WHERE id = $1 AND revoked_at IS NULL
`
func (q *Queries) TouchUserToken(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, touchUserToken, id)
return err
}
+32 -2
View File
@@ -57,8 +57,16 @@ type ServiceConfig struct {
PublicPort int32 `json:"public_port,omitempty"` PublicPort int32 `json:"public_port,omitempty"`
// Domain is a public hostname routed to PublicPort via Traefik. // Domain is a public hostname routed to PublicPort via Traefik.
Domain string `json:"domain,omitempty"` Domain string `json:"domain,omitempty"`
// Domains is the full hostname set (includes Domain when set).
Domains []string `json:"domains,omitempty"`
Maintenance bool `json:"maintenance,omitempty"`
MaintenanceURL string `json:"maintenance_url,omitempty"`
BasicAuthUsers string `json:"basic_auth_users,omitempty"`
// HealthPath is an HTTP path probed on PublicPort for container health. // HealthPath is an HTTP path probed on PublicPort for container health.
HealthPath string `json:"health_path,omitempty"` HealthPath string `json:"health_path,omitempty"`
// PublishedPort is the host port bound by the previous deployment; replica 0
// reuses it so public URLs survive restarts. 0 = pick ephemeral.
PublishedPort int32 `json:"published_port,omitempty"`
} }
type PortMapping struct { type PortMapping struct {
@@ -77,6 +85,7 @@ type VolumeMount struct {
type ResourceLimits struct { type ResourceLimits struct {
MemoryBytes int64 `json:"memory_bytes,omitempty"` MemoryBytes int64 `json:"memory_bytes,omitempty"`
MemoryReservation int64 `json:"memory_reservation,omitempty"`
CPUQuota int64 `json:"cpu_quota,omitempty"` CPUQuota int64 `json:"cpu_quota,omitempty"`
CPUPeriod int64 `json:"cpu_period,omitempty"` CPUPeriod int64 `json:"cpu_period,omitempty"`
CPUShares int64 `json:"cpu_shares,omitempty"` CPUShares int64 `json:"cpu_shares,omitempty"`
@@ -144,9 +153,14 @@ type BuildConfig struct {
PrebuiltImage string `json:"prebuilt_image"` PrebuiltImage string `json:"prebuilt_image"`
BuildCommand string `json:"build_command"` BuildCommand string `json:"build_command"`
StartCommand string `json:"start_command"` StartCommand string `json:"start_command"`
BuildArgs map[string]string `json:"build_args,omitempty"`
Environment map[string]string `json:"environment"` Environment map[string]string `json:"environment"`
// Pull credentials for private prebuilt images.
PullUsername string `json:"pull_username,omitempty"`
PullPassword string `json:"pull_password,omitempty"`
Branch string `json:"branch"` Branch string `json:"branch"`
Commit string `json:"commit"` Commit string `json:"commit"`
NoCache bool `json:"no_cache"`
} }
type TriggerConfig struct { type TriggerConfig struct {
@@ -157,6 +171,12 @@ type TriggerConfig struct {
Timestamp time.Time `json:"timestamp"` // When trigger occurred Timestamp time.Time `json:"timestamp"` // When trigger occurred
} }
// DockerClient exposes the engine's docker handle for read-only queries
// (capacity checks, image info).
func (de *DeploymentEngine) DockerClient() *docker.Client {
return de.dockerClient
}
func NewDeploymentEngine(buildManager *build.BuildManager, dockerClient *docker.Client) *DeploymentEngine { func NewDeploymentEngine(buildManager *build.BuildManager, dockerClient *docker.Client) *DeploymentEngine {
return &DeploymentEngine{ return &DeploymentEngine{
buildManager: buildManager, buildManager: buildManager,
@@ -281,13 +301,17 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme
ImageTag: deployment.ID, ImageTag: deployment.ID,
BuildCommand: buildConfig.BuildCommand, BuildCommand: buildConfig.BuildCommand,
StartCommand: buildConfig.StartCommand, StartCommand: buildConfig.StartCommand,
BuildArgs: buildConfig.BuildArgs,
Environment: buildConfig.Environment, Environment: buildConfig.Environment,
PullUsername: buildConfig.PullUsername,
PullPassword: buildConfig.PullPassword,
ProjectID: deployment.ProjectID, ProjectID: deployment.ProjectID,
ServiceID: deployment.ServiceID, ServiceID: deployment.ServiceID,
DeploymentID: deployment.ID, DeploymentID: deployment.ID,
TriggeredBy: "deployment_engine", TriggeredBy: "deployment_engine",
Branch: buildConfig.Branch, Branch: buildConfig.Branch,
Commit: buildConfig.Commit, Commit: buildConfig.Commit,
NoCache: buildConfig.NoCache,
} }
response, err := de.buildManager.Build(ctx, buildReq) response, err := de.buildManager.Build(ctx, buildReq)
@@ -315,11 +339,19 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
Env: cfg.Environment, Env: cfg.Environment,
Replicas: cfg.Replicas, Replicas: cfg.Replicas,
Port: cfg.PublicPort, Port: cfg.PublicPort,
PublishedPort: cfg.PublishedPort,
Domain: cfg.Domain, Domain: cfg.Domain,
HealthPath: cfg.HealthPath, HealthPath: cfg.HealthPath,
RestartPolicy: cfg.RestartPolicy, RestartPolicy: cfg.RestartPolicy,
MemoryBytes: cfg.Resources.MemoryBytes, MemoryBytes: cfg.Resources.MemoryBytes,
MemoryReserve: cfg.Resources.MemoryReservation,
NanoCPUs: cfg.Resources.CPUQuota, NanoCPUs: cfg.Resources.CPUQuota,
CPUShares: cfg.Resources.CPUShares,
Volumes: cfg.VolumeMounts,
Domains: cfg.Domains,
Maintenance: cfg.Maintenance,
MaintenanceURL: cfg.MaintenanceURL,
BasicAuthUsers: cfg.BasicAuthUsers,
} }
for _, pm := range cfg.PortMappings { for _, pm := range cfg.PortMappings {
if spec.Port == 0 { if spec.Port == 0 {
@@ -330,8 +362,6 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
spec.RestartPolicy = "unless-stopped" spec.RestartPolicy = "unless-stopped"
} }
// Volume mounts aren't reconciled replicas-aware yet; still applied on the
// container config of every replica via spec extension later if needed.
state, err := de.ReconcileService(ctx, spec) state, err := de.ReconcileService(ctx, spec)
if err != nil { if err != nil {
return err return err
+183 -21
View File
@@ -5,8 +5,11 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@@ -14,6 +17,7 @@ import (
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
) )
@@ -38,11 +42,19 @@ type RuntimeSpec struct {
Env map[string]string // already resolved Env map[string]string // already resolved
Replicas int Replicas int
Port int32 // container port to expose publicly; 0 = none Port int32 // container port to expose publicly; 0 = none
PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral
Domain string // public hostname routed via Traefik; empty = none Domain string // public hostname routed via Traefik; empty = none
HealthPath string // http path probed on Port for container healthcheck HealthPath string // http path probed on Port for container healthcheck
RestartPolicy string RestartPolicy string
MemoryBytes int64 MemoryBytes int64
MemoryReserve int64 // soft reservation; scheduler hint, not a hard cap
NanoCPUs int64 NanoCPUs int64
CPUShares int64 // relative weight (1024 ≈ 1 cpu), not a cap
Volumes []VolumeMount // applied to every replica
Domains []string // all public hostnames; Domain is the default
Maintenance bool // redirect traffic to MaintenanceURL
MaintenanceURL string // absolute URL; empty = serve empty response
BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
} }
// RuntimeContainer describes one live replica. // RuntimeContainer describes one live replica.
@@ -58,10 +70,14 @@ type RuntimeState struct {
Desired int `json:"desired"` Desired int `json:"desired"`
Containers []RuntimeContainer `json:"containers"` Containers []RuntimeContainer `json:"containers"`
URLs []string `json:"urls"` URLs []string `json:"urls"`
Ports []uint16 `json:"ports"`
Health string `json:"health,omitempty"` // healthy, unhealthy, unreachable
Status string `json:"status"` // running, degraded, stopped Status string `json:"status"` // running, degraded, stopped
} }
func projectNetworkName(projectID string) string { // ProjectNetworkName is the deterministic docker network every service in a
// project joins — containr-proj-<first 12 hex chars of the project uuid>.
func ProjectNetworkName(projectID string) string {
short := strings.ReplaceAll(projectID, "-", "") short := strings.ReplaceAll(projectID, "-", "")
if len(short) > 12 { if len(short) > 12 {
short = short[:12] short = short[:12]
@@ -76,7 +92,7 @@ func serviceNamePrefix(serviceID string) string {
// EnsureProjectNetwork creates the per-project bridge network if missing. // EnsureProjectNetwork creates the per-project bridge network if missing.
// Containers on it resolve each other by service name (private networking). // Containers on it resolve each other by service name (private networking).
func (de *DeploymentEngine) EnsureProjectNetwork(ctx context.Context, projectID string) (string, error) { func (de *DeploymentEngine) EnsureProjectNetwork(ctx context.Context, projectID string) (string, error) {
name := projectNetworkName(projectID) name := ProjectNetworkName(projectID)
networks, err := de.dockerClient.ListNetworks(ctx) networks, err := de.dockerClient.ListNetworks(ctx)
if err != nil { if err != nil {
@@ -217,12 +233,45 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
projectNet: {Aliases: []string{spec.Name}}, projectNet: {Aliases: []string{spec.Name}},
} }
if edgeNet != "" { if edgeNet != "" {
router := "svc-" + spec.ServiceID[:8]
domains := spec.Domains
if len(domains) == 0 && spec.Domain != "" {
domains = []string{spec.Domain}
}
sort.Strings(domains)
rules := make([]string, 0, len(domains))
for _, d := range domains {
rules = append(rules, "Host(`"+d+"`)")
}
endpoints[edgeNet] = &network.EndpointSettings{} endpoints[edgeNet] = &network.EndpointSettings{}
labels["traefik.enable"] = "true" labels["traefik.enable"] = "true"
labels["traefik.docker.network"] = edgeNet labels["traefik.docker.network"] = edgeNet
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".rule"] = "Host(`" + spec.Domain + "`)" labels["traefik.http.routers."+router+".rule"] = strings.Join(rules, " || ")
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".entrypoints"] = "web" labels["traefik.http.routers."+router+".entrypoints"] = "web"
labels["traefik.http.services.svc-"+spec.ServiceID[:8]+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port) labels["traefik.http.services."+router+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
var middlewares []string
if spec.Maintenance {
if spec.MaintenanceURL != "" {
mw := router + "-maint"
labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
middlewares = append(middlewares, mw)
} else {
// No redirect target configured — take the site offline with
// empty responses instead of leaking traffic to the app.
labels["traefik.http.routers."+router+".service"] = "noop@internal"
}
}
if spec.BasicAuthUsers != "" {
mw := router + "-auth"
labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
middlewares = append(middlewares, mw)
}
if len(middlewares) > 0 {
labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
}
} }
cfg := docker.ContainerConfig{ cfg := docker.ContainerConfig{
@@ -233,37 +282,91 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
Labels: labels, Labels: labels,
RestartPolicy: spec.RestartPolicy, RestartPolicy: spec.RestartPolicy,
Memory: spec.MemoryBytes, Memory: spec.MemoryBytes,
MemoryReservation: spec.MemoryReserve,
NanoCPUs: spec.NanoCPUs, NanoCPUs: spec.NanoCPUs,
CPUShares: spec.CPUShares,
Networks: endpoints, Networks: endpoints,
} }
if spec.Port > 0 { for _, v := range spec.Volumes {
port := nat.Port(fmt.Sprintf("%d/tcp", spec.Port)) mountType := mount.TypeVolume
cfg.ExposedPorts = nat.PortSet{port: struct{}{}} if v.Type == "bind" {
// Always publish an ephemeral host port for direct access; a configured mountType = mount.TypeBind
// domain is additionally routed via Traefik when the edge network exists.
cfg.PortBindings = nat.PortMap{port: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: ""}}}
if spec.HealthPath != "" {
cfg.Healthcheck = &container.HealthConfig{
Test: []string{"CMD-SHELL", fmt.Sprintf("wget -q -O /dev/null http://127.0.0.1:%d%s || exit 1", spec.Port, spec.HealthPath)},
Interval: 30 * time.Second,
Timeout: 5 * time.Second,
Retries: 3,
StartPeriod: 10 * time.Second,
}
} }
cfg.Mounts = append(cfg.Mounts, mount.Mount{
Type: mountType,
Source: v.Source,
Target: v.Destination,
ReadOnly: v.ReadOnly,
})
} }
var wantPort nat.Port
if spec.Port > 0 {
wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
cfg.ExposedPorts = nat.PortSet{wantPort: struct{}{}}
}
// tryBind creates and starts a container publishing wantPort on hostPort
// ("" = ephemeral). A requested port is verified after start: Docker
// Desktop accepts a conflicting binding at create/start but publishes
// nothing, which would leave the service running with no endpoint.
tryBind := func(hostPort string) (string, error) {
if spec.Port > 0 {
cfg.PortBindings = nat.PortMap{wantPort: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: hostPort}}}
}
id, err := de.dockerClient.CreateContainer(ctx, cfg) id, err := de.dockerClient.CreateContainer(ctx, cfg)
if err != nil { if err != nil {
return "", err return "", err
} }
if err := de.dockerClient.StartContainer(ctx, id); err != nil { if err := de.dockerClient.StartContainer(ctx, id); err != nil {
_ = de.dockerClient.RemoveContainer(ctx, id, true)
return "", fmt.Errorf("start: %w", err) return "", fmt.Errorf("start: %w", err)
} }
if hostPort != "" {
info, err := de.dockerClient.GetContainer(ctx, id)
if err != nil {
_ = de.dockerClient.RemoveContainer(ctx, id, true)
return "", fmt.Errorf("inspect: %w", err)
}
if info.NetworkSettings == nil || len(info.NetworkSettings.Ports[wantPort]) == 0 {
_ = de.dockerClient.RemoveContainer(ctx, id, true)
return "", errPortNotBound
}
}
return id, nil return id, nil
} }
preferred := ""
if index == 0 && spec.PublishedPort > 0 {
preferred = strconv.Itoa(int(spec.PublishedPort))
}
id, err := tryBind(preferred)
if err != nil && preferred != "" && (isPortConflict(err) || errors.Is(err, errPortNotBound)) {
// Stored port got taken since last deploy — fall back to ephemeral; the
// new assignment is persisted on the next runtime read.
id, err = tryBind("")
}
if err != nil {
return "", err
}
return id, nil
}
// errPortNotBound marks a container that started but failed to publish the
// requested host port (seen on Docker Desktop, which reports success while
// vpnkit drops a conflicting binding).
var errPortNotBound = errors.New("requested host port not bound")
// isPortConflict reports whether a container create failed because the
// requested host port was already bound.
func isPortConflict(err error) bool {
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "port is already allocated") ||
strings.Contains(msg, "address already in use") ||
strings.Contains(msg, "cannot assign requested address")
}
// RuntimeState reads live container state for a service. // RuntimeState reads live container state for a service.
func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string, desired int) (*RuntimeState, error) { func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string, desired int) (*RuntimeState, error) {
containers, err := de.ListServiceContainers(ctx, serviceID) containers, err := de.ListServiceContainers(ctx, serviceID)
@@ -282,6 +385,7 @@ func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string,
for _, p := range c.Ports { for _, p := range c.Ports {
if p.PublicPort != 0 { if p.PublicPort != 0 {
state.URLs = append(state.URLs, fmt.Sprintf("http://localhost:%d", p.PublicPort)) state.URLs = append(state.URLs, fmt.Sprintf("http://localhost:%d", p.PublicPort))
state.Ports = append(state.Ports, p.PublicPort)
} }
} }
} }
@@ -299,6 +403,57 @@ func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string,
return state, nil return state, nil
} }
// ProbeServiceHealth performs an HTTP GET on healthPath and records the
// outcome on state.Health. When the backend runs containerized it first
// attaches to the service's project network and probes the service by DNS
// name — this works for every image (no shell or wget required inside the
// container) and checks the service itself, not the published edge. A
// host-run backend falls back to probing the published host port.
func (de *DeploymentEngine) ProbeServiceHealth(ctx context.Context, state *RuntimeState, projectID, serviceName string, port int, healthPath string) {
if state == nil {
return
}
state.Health = ""
if healthPath == "" || port <= 0 {
return
}
if !strings.HasPrefix(healthPath, "/") {
healthPath = "/" + healthPath
}
probeURL := ""
if networkName := ProjectNetworkName(projectID); networkName != "" {
if err := de.dockerClient.ConnectSelfToNetwork(ctx, networkName); err == nil {
probeURL = fmt.Sprintf("http://%s:%d%s", serviceName, port, healthPath)
}
}
if probeURL == "" && len(state.Ports) > 0 {
probeURL = fmt.Sprintf("http://%s:%d%s", de.dockerClient.HostProbeHost(ctx), state.Ports[0], healthPath)
}
if probeURL == "" {
return
}
reqCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, probeURL, nil)
if err != nil {
state.Health = "unreachable"
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
state.Health = "unreachable"
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
state.Health = "healthy"
return
}
state.Health = "unhealthy"
}
// StopService stops (but keeps) all replicas of a service. // StopService stops (but keeps) all replicas of a service.
func (de *DeploymentEngine) StopService(ctx context.Context, serviceID string) error { func (de *DeploymentEngine) StopService(ctx context.Context, serviceID string) error {
containers, err := de.ListServiceContainers(ctx, serviceID) containers, err := de.ListServiceContainers(ctx, serviceID)
@@ -359,7 +514,7 @@ func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, project
// RemoveProjectNetwork removes the per-project bridge network if present. // RemoveProjectNetwork removes the per-project bridge network if present.
func (de *DeploymentEngine) RemoveProjectNetwork(ctx context.Context, projectID string) error { func (de *DeploymentEngine) RemoveProjectNetwork(ctx context.Context, projectID string) error {
return de.dockerClient.RemoveNetwork(ctx, projectNetworkName(projectID)) return de.dockerClient.RemoveNetwork(ctx, ProjectNetworkName(projectID))
} }
func replicaIndex(c container.Summary) int { func replicaIndex(c container.Summary) int {
@@ -375,8 +530,15 @@ func specHash(spec RuntimeSpec) string {
Env map[string]string Env map[string]string
Port int32 Port int32
MemoryBytes, NanoCPUs int64 MemoryBytes, NanoCPUs int64
MemoryReserve, CPUShares int64
Volumes []VolumeMount
Domains []string
Maintenance bool
BasicAuth string
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy, }{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs}) spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs,
spec.MemoryReserve, spec.CPUShares, spec.Volumes,
spec.Domains, spec.Maintenance, spec.BasicAuthUsers})
sum := sha256.Sum256(payload) sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:8]) return hex.EncodeToString(sum[:8])
} }
+146
View File
@@ -0,0 +1,146 @@
package deployqueue
import (
"context"
"sync"
"github.com/google/uuid"
)
// Job is a unit of serialized deployment work. DeploymentID is the
// deployments row it mirrors (uuid.Nil for runtime ops with no row).
// Run is invoked with a cancellable context — cancelled by Cancel for
// active jobs, or the job is dropped before starting when queued.
type Job struct {
DeploymentID uuid.UUID
Run func(ctx context.Context)
}
// Queue serializes deploy work per service: one active job, the rest FIFO.
// A build for service A never races a redeploy or second build of A, while
// services B, C proceed in parallel. Not a global worker pool — per-service
// serialization is what prevents container thrash.
type Queue struct {
mu sync.Mutex
states map[uuid.UUID]*serviceState
cancels map[uuid.UUID]context.CancelFunc
}
type serviceState struct {
running bool
pending []Job
}
func New() *Queue {
return &Queue{
states: make(map[uuid.UUID]*serviceState),
cancels: make(map[uuid.UUID]context.CancelFunc),
}
}
// Enqueue schedules a job. Returns the position: 0 means it starts
// immediately, >0 means that many jobs run first.
func (q *Queue) Enqueue(serviceID uuid.UUID, job Job) int {
q.mu.Lock()
st := q.states[serviceID]
if st == nil {
st = &serviceState{}
q.states[serviceID] = st
}
if st.running {
st.pending = append(st.pending, job)
pos := len(st.pending)
q.mu.Unlock()
return pos
}
st.running = true
q.mu.Unlock()
q.start(serviceID, job)
return 0
}
func (q *Queue) start(serviceID uuid.UUID, job Job) {
ctx, cancel := context.WithCancel(context.Background())
if job.DeploymentID != uuid.Nil {
q.mu.Lock()
q.cancels[job.DeploymentID] = cancel
q.mu.Unlock()
}
go func() {
defer func() {
if job.DeploymentID != uuid.Nil {
q.mu.Lock()
delete(q.cancels, job.DeploymentID)
q.mu.Unlock()
}
cancel()
q.promote(serviceID)
}()
job.Run(ctx)
}()
}
// promote marks the service free and starts the next queued job, if any.
func (q *Queue) promote(serviceID uuid.UUID) {
q.mu.Lock()
st := q.states[serviceID]
if st == nil || len(st.pending) == 0 {
if st != nil {
st.running = false
}
q.mu.Unlock()
return
}
next := st.pending[0]
st.pending = st.pending[1:]
q.mu.Unlock()
q.start(serviceID, next)
}
// CancelState distinguishes what Cancel found.
type CancelState int
const (
NotFound CancelState = iota
WasQueued // removed before it started — caller marks DB cancelled
WasActive // ctx cancelled — the running job marks DB itself
)
// Cancel cancels a job by its deployment row id.
func (q *Queue) Cancel(serviceID, deploymentID uuid.UUID) CancelState {
q.mu.Lock()
if st := q.states[serviceID]; st != nil {
for i, j := range st.pending {
if j.DeploymentID == deploymentID {
st.pending = append(st.pending[:i], st.pending[i+1:]...)
q.mu.Unlock()
return WasQueued
}
}
}
cancel, ok := q.cancels[deploymentID]
q.mu.Unlock()
if ok {
cancel()
return WasActive
}
return NotFound
}
// Do runs fn through the queue and waits for completion — for synchronous
// endpoints (redeploy) that still must not overlap an in-flight deployment.
// ctx bounds the wait, not the job; on timeout the job still runs to
// completion to avoid orphaned reconcile state.
func (q *Queue) Do(ctx context.Context, serviceID uuid.UUID, fn func(ctx context.Context)) {
done := make(chan struct{})
q.Enqueue(serviceID, Job{Run: func(jctx context.Context) {
defer close(done)
fn(jctx)
}})
select {
case <-done:
case <-ctx.Done():
}
}
@@ -0,0 +1,142 @@
package deployqueue
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
)
func TestSerializesPerService(t *testing.T) {
q := New()
svc := uuid.New()
var active, maxActive int32
finish := make(chan struct{})
for i := 0; i < 3; i++ {
q.Enqueue(svc, Job{Run: func(ctx context.Context) {
if n := atomic.AddInt32(&active, 1); n > atomic.LoadInt32(&maxActive) {
atomic.StoreInt32(&maxActive, n)
}
<-finish // hold until released
atomic.AddInt32(&active, -1)
}})
}
deadline := time.After(2 * time.Second)
for atomic.LoadInt32(&active) < 1 {
select {
case <-deadline:
t.Fatal("first job never started")
default:
time.Sleep(time.Millisecond)
}
}
close(finish)
deadline = time.After(2 * time.Second)
for atomic.LoadInt32(&active) > 0 {
select {
case <-deadline:
t.Fatal("jobs did not drain")
default:
time.Sleep(time.Millisecond)
}
}
if got := atomic.LoadInt32(&maxActive); got != 1 {
t.Fatalf("expected max concurrency 1, got %d", got)
}
}
func TestDifferentServicesRunInParallel(t *testing.T) {
q := New()
started := make(chan struct{}, 2)
release := make(chan struct{})
for i := 0; i < 2; i++ {
q.Enqueue(uuid.New(), Job{Run: func(ctx context.Context) {
started <- struct{}{}
<-release
}})
}
for i := 0; i < 2; i++ {
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("parallel services did not both start")
}
}
close(release)
}
func TestCancelQueuedRemovesJob(t *testing.T) {
q := New()
svc := uuid.New()
release := make(chan struct{})
q.Enqueue(svc, Job{DeploymentID: uuid.New(), Run: func(ctx context.Context) { <-release }})
queuedID := uuid.New()
ran := make(chan struct{})
q.Enqueue(svc, Job{DeploymentID: queuedID, Run: func(ctx context.Context) { close(ran) }})
if got := q.Cancel(svc, queuedID); got != WasQueued {
t.Fatalf("expected WasQueued, got %v", got)
}
close(release)
select {
case <-ran:
t.Fatal("cancelled queued job still ran")
case <-time.After(200 * time.Millisecond):
}
}
func TestCancelActiveCancelsContext(t *testing.T) {
q := New()
svc := uuid.New()
id := uuid.New()
ctxDone := make(chan struct{})
q.Enqueue(svc, Job{DeploymentID: id, Run: func(ctx context.Context) {
<-ctx.Done()
close(ctxDone)
}})
deadline := time.After(2 * time.Second)
for {
q.mu.Lock()
_, ok := q.cancels[id]
q.mu.Unlock()
if ok {
break
}
select {
case <-deadline:
t.Fatal("job never registered")
default:
time.Sleep(time.Millisecond)
}
}
if got := q.Cancel(svc, id); got != WasActive {
t.Fatalf("expected WasActive, got %v", got)
}
select {
case <-ctxDone:
case <-time.After(2 * time.Second):
t.Fatal("active job context not cancelled")
}
}
func TestEnqueuePosition(t *testing.T) {
q := New()
svc := uuid.New()
release := make(chan struct{})
if pos := q.Enqueue(svc, Job{Run: func(ctx context.Context) { <-release }}); pos != 0 {
t.Fatalf("first job position = %d, want 0", pos)
}
if pos := q.Enqueue(svc, Job{Run: func(ctx context.Context) {}}); pos != 1 {
t.Fatalf("second job position = %d, want 1", pos)
}
if pos := q.Enqueue(svc, Job{Run: func(ctx context.Context) {}}); pos != 2 {
t.Fatalf("third job position = %d, want 2", pos)
}
close(release)
}
+125 -1
View File
@@ -6,6 +6,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"os"
"sort"
"strings"
"time" "time"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
@@ -24,6 +27,7 @@ import (
// Client wraps the Docker client with additional functionality // Client wraps the Docker client with additional functionality
type Client struct { type Client struct {
cli *client.Client cli *client.Client
probeHost string
} }
// NewClient creates a new Docker client // NewClient creates a new Docker client
@@ -45,6 +49,75 @@ func NewClient() (*Client, error) {
return &Client{cli: cli}, nil return &Client{cli: cli}, nil
} }
// HostProbeHost returns an address through which host-published container
// ports are reachable. When this process runs inside a container, host
// loopback is unreachable; the bridge gateway IP routes to ports published
// on 0.0.0.0. Result is cached for the process lifetime.
func (c *Client) HostProbeHost(ctx context.Context) string {
if c.probeHost != "" {
return c.probeHost
}
host := "127.0.0.1"
if self, err := c.self(ctx); err == nil && self.NetworkSettings != nil {
for _, n := range self.NetworkSettings.Networks {
if n != nil && n.Gateway != "" {
host = n.Gateway
break
}
}
}
c.probeHost = host
return host
}
// OwnNetworkName returns the name of the platform network this process's
// container is attached to, or "" when not running containerized. Per-project
// and default Docker networks are never selected; among remaining networks
// the lexicographically first wins so the choice is deterministic.
func (c *Client) OwnNetworkName(ctx context.Context) string {
self, err := c.self(ctx)
if err != nil || self.NetworkSettings == nil {
return ""
}
names := make([]string, 0, len(self.NetworkSettings.Networks))
for name := range self.NetworkSettings.Networks {
switch {
case name == "bridge" || name == "host" || name == "none":
case strings.HasPrefix(name, "containr-proj-"):
default:
names = append(names, name)
}
}
sort.Strings(names)
if len(names) == 0 {
return ""
}
return names[0]
}
// self inspects the container this process runs in (hostname == container ID).
func (c *Client) self(ctx context.Context) (types.ContainerJSON, error) {
hostname, err := os.Hostname()
if err != nil || hostname == "" {
return types.ContainerJSON{}, fmt.Errorf("hostname unavailable")
}
return c.cli.ContainerInspect(ctx, hostname)
}
// ConnectSelfToNetwork attaches this process's container to the given network.
// No-op when already attached or when not running containerized.
func (c *Client) ConnectSelfToNetwork(ctx context.Context, networkName string) error {
hostname, err := os.Hostname()
if err != nil || hostname == "" {
return fmt.Errorf("hostname unavailable")
}
err = c.cli.NetworkConnect(ctx, networkName, hostname, &network.EndpointSettings{})
if err != nil && strings.Contains(strings.ToLower(err.Error()), "already exists") {
return nil
}
return err
}
// ListContainers returns all containers // ListContainers returns all containers
func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Container, error) { func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Container, error) {
return c.cli.ContainerList(ctx, container.ListOptions{ return c.cli.ContainerList(ctx, container.ListOptions{
@@ -84,7 +157,9 @@ func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (s
Mounts: config.Mounts, Mounts: config.Mounts,
Resources: container.Resources{ Resources: container.Resources{
Memory: config.Memory, Memory: config.Memory,
MemoryReservation: config.MemoryReservation,
NanoCPUs: config.NanoCPUs, NanoCPUs: config.NanoCPUs,
CPUShares: config.CPUShares,
}, },
NetworkMode: container.NetworkMode(config.NetworkMode), NetworkMode: container.NetworkMode(config.NetworkMode),
} }
@@ -163,6 +238,11 @@ func (c *Client) CopyFromContainer(ctx context.Context, containerID, srcPath str
return reader, err return reader, err
} }
// CopyToContainer copies a tar archive stream into a container path.
func (c *Client) CopyToContainer(ctx context.Context, containerID, dstPath string, content io.Reader) error {
return c.cli.CopyToContainer(ctx, containerID, dstPath, content, container.CopyToContainerOptions{})
}
// GetContainerStats returns real-time resource usage statistics for a container // GetContainerStats returns real-time resource usage statistics for a container
func (c *Client) GetContainerStats(ctx context.Context, containerID string, stream bool) (*container.StatsResponseReader, error) { func (c *Client) GetContainerStats(ctx context.Context, containerID string, stream bool) (*container.StatsResponseReader, error) {
resp, err := c.cli.ContainerStats(ctx, containerID, stream) resp, err := c.cli.ContainerStats(ctx, containerID, stream)
@@ -218,13 +298,42 @@ func (c *Client) PullImageWait(ctx context.Context, ref string, auth registry.Au
// BuildImage builds an image from a Dockerfile // BuildImage builds an image from a Dockerfile
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) { func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{ resp, err := c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
Dockerfile: options.Dockerfile, Dockerfile: options.Dockerfile,
Tags: options.Tags, Tags: options.Tags,
BuildArgs: options.BuildArgs, BuildArgs: options.BuildArgs,
Labels: options.Labels, Labels: options.Labels,
Remove: options.Remove, Remove: options.Remove,
NoCache: options.NoCache,
}) })
if err != nil {
return resp, err
}
// The daemon aborts the build if the response stream isn't consumed,
// and reports failures only inside that stream.
defer resp.Body.Close()
var buildErr string
dec := json.NewDecoder(resp.Body)
for {
var line struct {
ErrorDetail struct {
Message string `json:"message"`
} `json:"errorDetail"`
Error string `json:"error"`
}
if err := dec.Decode(&line); err != nil {
break
}
if line.ErrorDetail.Message != "" {
buildErr = line.ErrorDetail.Message
} else if line.Error != "" {
buildErr = line.Error
}
}
if buildErr != "" {
return resp, fmt.Errorf("docker build failed: %s", buildErr)
}
return resp, nil
} }
// RemoveImage removes an image // RemoveImage removes an image
@@ -262,6 +371,21 @@ func (c *Client) RemoveNetwork(ctx context.Context, networkID string) error {
return c.cli.NetworkRemove(ctx, networkID) return c.cli.NetworkRemove(ctx, networkID)
} }
// NetworkGateway returns the IPAM gateway of a network — the address
// containers on it can use to reach host-published ports.
func (c *Client) NetworkGateway(ctx context.Context, networkID string) string {
resp, err := c.cli.NetworkInspect(ctx, networkID, network.InspectOptions{})
if err != nil {
return ""
}
for _, cfg := range resp.IPAM.Config {
if cfg.Gateway != "" {
return cfg.Gateway
}
}
return ""
}
// ConnectNetwork connects a container to a network // ConnectNetwork connects a container to a network
func (c *Client) ConnectNetwork(ctx context.Context, networkID, containerID string, config network.EndpointSettings) error { func (c *Client) ConnectNetwork(ctx context.Context, networkID, containerID string, config network.EndpointSettings) error {
return c.cli.NetworkConnect(ctx, networkID, containerID, &config) return c.cli.NetworkConnect(ctx, networkID, containerID, &config)
+3
View File
@@ -22,7 +22,9 @@ type ContainerConfig struct {
PortBindings nat.PortMap PortBindings nat.PortMap
Mounts []mount.Mount Mounts []mount.Mount
Memory int64 Memory int64
MemoryReservation int64
NanoCPUs int64 NanoCPUs int64
CPUShares int64
NetworkMode string NetworkMode string
Networks map[string]*network.EndpointSettings Networks map[string]*network.EndpointSettings
Healthcheck *container.HealthConfig Healthcheck *container.HealthConfig
@@ -44,6 +46,7 @@ type BuildOptions struct {
BuildArgs map[string]*string BuildArgs map[string]*string
Labels map[string]string Labels map[string]string
Remove bool Remove bool
NoCache bool
} }
// NetworkConfig represents the configuration for creating a network // NetworkConfig represents the configuration for creating a network
+133 -11
View File
@@ -3,6 +3,7 @@ package ha
import ( import (
"bytes" "bytes"
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@@ -80,6 +81,8 @@ type HealthChecker struct {
results map[string]*HealthCheckResult results map[string]*HealthCheckResult
mu sync.RWMutex mu sync.RWMutex
checkInterval time.Duration checkInterval time.Duration
serviceResolver func(ctx context.Context, serviceID string) (host string, port int, err error)
db *sql.DB
} }
// HealthCheck represents a health check configuration // HealthCheck represents a health check configuration
@@ -596,7 +599,11 @@ func (hc *HealthChecker) performHealthChecks(ctx context.Context) error {
result := hc.performHealthCheck(ctx, check) result := hc.performHealthCheck(ctx, check)
hc.mu.Lock() hc.mu.Lock()
hc.results[check.ID] = result hc.results[check.ID] = result
check.Status = result.Status
check.LastCheck = result.Timestamp
hc.mu.Unlock() hc.mu.Unlock()
// Persist last-known state so a restart resumes with honest history.
hc.persistCheck(check)
} }
return nil return nil
@@ -617,12 +624,20 @@ func (hc *HealthChecker) performHealthCheck(ctx context.Context, check *HealthCh
checkCtx, cancel := context.WithTimeout(ctx, timeout) checkCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
var err error host, port, err := hc.resolveTarget(ctx, check)
if err != nil {
result.Status = HealthStatusUnhealthy
result.Message = err.Error()
result.ErrorCode = "TARGET_UNRESOLVED"
result.Latency = time.Since(start)
return result
}
switch check.Type { switch check.Type {
case HealthCheckTypeHTTP: case HealthCheckTypeHTTP:
err = hc.performHTTPCheck(checkCtx, check) err = hc.performHTTPCheck(checkCtx, check, host, port)
case HealthCheckTypeTCP: case HealthCheckTypeTCP:
err = hc.performTCPCheck(checkCtx, check) err = hc.performTCPCheck(checkCtx, check, host, port)
case HealthCheckTypeCommand: case HealthCheckTypeCommand:
err = hc.performCommandCheck(checkCtx, check) err = hc.performCommandCheck(checkCtx, check)
default: default:
@@ -642,13 +657,11 @@ func (hc *HealthChecker) performHealthCheck(ctx context.Context, check *HealthCh
return result return result
} }
func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthCheck) error { func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthCheck, host string, port int) error {
host := hc.resolveHealthCheckHost(check)
protocol := strings.ToLower(strings.TrimSpace(check.Config.Protocol)) protocol := strings.ToLower(strings.TrimSpace(check.Config.Protocol))
if protocol == "" { if protocol == "" {
protocol = "http" protocol = "http"
} }
port := check.Config.Port
if port <= 0 { if port <= 0 {
if protocol == "https" { if protocol == "https" {
port = 443 port = 443
@@ -684,9 +697,7 @@ func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthChec
return nil return nil
} }
func (hc *HealthChecker) performTCPCheck(ctx context.Context, check *HealthCheck) error { func (hc *HealthChecker) performTCPCheck(ctx context.Context, check *HealthCheck, host string, port int) error {
host := hc.resolveHealthCheckHost(check)
port := check.Config.Port
if port <= 0 { if port <= 0 {
port = 80 port = 80
} }
@@ -723,6 +734,27 @@ func (hc *HealthChecker) performCommandCheck(ctx context.Context, check *HealthC
return nil return nil
} }
// WithServiceResolver lets the API layer plug in service-aware targeting: a
// ServiceID-bound check resolves to the service's DNS name on its project
// network instead of the backend container's loopback, where dialing
// 127.0.0.1:<host port> can never succeed.
func (m *HighAvailabilityManager) WithServiceResolver(fn func(ctx context.Context, serviceID string) (host string, port int, err error)) *HighAvailabilityManager {
m.healthChecker.serviceResolver = fn
return m
}
// WithPersistence attaches Postgres so configured checks survive restarts.
// Call LoadHealthChecks once during startup to hydrate state.
func (m *HighAvailabilityManager) WithPersistence(db *sql.DB) *HighAvailabilityManager {
m.healthChecker.db = db
return m
}
// LoadHealthChecks hydrates persisted health checks during startup.
func (m *HighAvailabilityManager) LoadHealthChecks(ctx context.Context) error {
return m.healthChecker.LoadHealthChecks(ctx)
}
func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string { func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string {
if strings.TrimSpace(check.NodeID) != "" && hc.scheduler != nil { if strings.TrimSpace(check.NodeID) != "" && hc.scheduler != nil {
nodes := hc.scheduler.GetNodes() nodes := hc.scheduler.GetNodes()
@@ -739,19 +771,109 @@ func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string {
return "127.0.0.1" return "127.0.0.1"
} }
// resolveTarget picks the dial target for a check: service-bound checks go to
// the service DNS name on the project network; node-bound and free-standing
// checks keep the old host behavior. A service-bound resolver failure is
// surfaced as an error — falling back to loopback would only produce a
// misleading "service down" result.
func (hc *HealthChecker) resolveTarget(ctx context.Context, check *HealthCheck) (string, int, error) {
port := check.Config.Port
if strings.TrimSpace(check.NodeID) == "" && check.ServiceID != "" && hc.serviceResolver != nil {
resolveCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
h, p, err := hc.serviceResolver(resolveCtx, check.ServiceID)
if err != nil {
return "", 0, fmt.Errorf("resolve service target: %w", err)
}
if h == "" {
return "", 0, fmt.Errorf("resolve service target: service %s not found", check.ServiceID)
}
if port <= 0 {
port = p
}
return h, port, nil
}
return hc.resolveHealthCheckHost(check), port, nil
}
// AddHealthCheck adds a new health check // AddHealthCheck adds a new health check
func (hc *HealthChecker) AddHealthCheck(check *HealthCheck) { func (hc *HealthChecker) AddHealthCheck(check *HealthCheck) {
hc.mu.Lock() hc.mu.Lock()
defer hc.mu.Unlock()
hc.checks[check.ID] = check hc.checks[check.ID] = check
hc.mu.Unlock()
hc.persistCheck(check)
} }
// RemoveHealthCheck removes a health check // RemoveHealthCheck removes a health check
func (hc *HealthChecker) RemoveHealthCheck(checkID string) { func (hc *HealthChecker) RemoveHealthCheck(checkID string) {
hc.mu.Lock() hc.mu.Lock()
defer hc.mu.Unlock()
delete(hc.checks, checkID) delete(hc.checks, checkID)
delete(hc.results, checkID) delete(hc.results, checkID)
hc.mu.Unlock()
if hc.db != nil {
_, _ = hc.db.ExecContext(context.Background(), `DELETE FROM ha_health_checks WHERE id = $1`, checkID)
}
}
// persistCheck upserts one check. Best effort — a write failure never blocks
// the in-memory path, matching the scaling-policy persistence style.
func (hc *HealthChecker) persistCheck(check *HealthCheck) {
if hc.db == nil {
return
}
cfg, _ := json.Marshal(check.Config)
var lastCheck interface{}
if !check.LastCheck.IsZero() {
lastCheck = check.LastCheck
}
_, _ = hc.db.ExecContext(context.Background(),
`INSERT INTO ha_health_checks (id, service_id, node_id, type, config, status, last_check, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
ON CONFLICT (id) DO UPDATE
SET service_id = EXCLUDED.service_id, node_id = EXCLUDED.node_id,
type = EXCLUDED.type, config = EXCLUDED.config,
status = EXCLUDED.status, last_check = EXCLUDED.last_check,
updated_at = now()`,
check.ID, check.ServiceID, check.NodeID, string(check.Type), cfg, string(check.Status), lastCheck,
)
}
// LoadHealthChecks hydrates checks persisted across restarts.
func (hc *HealthChecker) LoadHealthChecks(ctx context.Context) error {
if hc.db == nil {
return nil
}
rows, err := hc.db.QueryContext(ctx,
`SELECT id, service_id, node_id, type, config, status, last_check FROM ha_health_checks`)
if err != nil {
return err
}
defer rows.Close()
loaded := 0
for rows.Next() {
var check HealthCheck
var cfg []byte
var status, checkType string
var lastCheck sql.NullTime
if err := rows.Scan(&check.ID, &check.ServiceID, &check.NodeID, &checkType, &cfg, &status, &lastCheck); err != nil {
continue
}
check.Type = HealthCheckType(checkType)
check.Status = HealthStatus(status)
if lastCheck.Valid {
check.LastCheck = lastCheck.Time
}
_ = json.Unmarshal(cfg, &check.Config)
hc.mu.Lock()
hc.checks[check.ID] = &check
hc.mu.Unlock()
loaded++
}
if loaded > 0 {
log.Printf("Restored %d health checks", loaded)
}
return rows.Err()
} }
// GetHealthCheck returns one configured health check. // GetHealthCheck returns one configured health check.
+318
View File
@@ -0,0 +1,318 @@
// Package mcpserver exposes the Containr API as MCP tools over stdio.
// It reuses the CLI's thin client — same PAT auth, same endpoints —
// so agents reach everything the UI and CLI do.
package mcpserver
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"containr/internal/cli/commands"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// spec describes one MCP tool as an HTTP call. {placeholder} segments in
// Path are filled from arguments; remaining string/number/bool args become
// the JSON body for POST/PUT/PATCH.
type spec struct {
name string
description string
method string
path string
// params are the arguments that fill path placeholders (required).
params []string
// bodyKeys restrict which args go into the request body; when empty all
// non-param args are sent.
bodyKeys []string
// destructive tools refuse unless args["confirm"] == true.
destructive bool
}
var tools []spec
func register(s spec) { tools = append(tools, s) }
func init() {
register(spec{"containr_auth_status", "Show which profile/token is active and the authenticated identity", "GET", "/user/profile", nil, nil, false})
// Personal access tokens
register(spec{"containr_tokens_list", "List personal access tokens (metadata only, never values)", "GET", "/user/tokens", nil, nil, false})
register(spec{"containr_tokens_create", "Create a personal access token. Args: name (required), scope (read|write|admin, default write), expires_in_days (0=never). Returns the raw token once.", "POST", "/user/tokens", nil, []string{"name", "scope", "expires_in_days"}, false})
register(spec{"containr_tokens_revoke", "Revoke a personal access token by id. Requires confirm:true.", "DELETE", "/user/tokens/{id}", []string{"id"}, nil, true})
// Projects
register(spec{"containr_projects_list", "List projects", "GET", "/projects", nil, nil, false})
register(spec{"containr_projects_get", "Get a project by id", "GET", "/projects/{id}", []string{"id"}, nil, false})
register(spec{"containr_projects_create", "Create a project. Args: name (required), description.", "POST", "/projects", nil, []string{"name", "description"}, false})
register(spec{"containr_projects_delete", "Delete a project and all its services. Requires confirm:true.", "DELETE", "/projects/{id}", []string{"id"}, nil, true})
// Services
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir.", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
register(spec{"containr_services_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false})
register(spec{"containr_services_sleep", "Put a service to sleep immediately (scale to zero)", "POST", "/services/{id}/sleep", []string{"id"}, nil, false})
register(spec{"containr_services_wake", "Wake a sleeping service", "POST", "/services/{id}/wake", []string{"id"}, nil, false})
register(spec{"containr_services_env_check", "Diagnose service env: returns ok plus unresolved ${{refs}}, empty vars, and undecryptable secrets", "GET", "/services/{id}/env-check", []string{"id"}, nil, false})
register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false})
register(spec{"containr_services_clone", "Clone a service (config, volumes, domains, variables) into the same or another project. Args: id, name, project_id, environment.", "POST", "/services/{id}/clone", []string{"id"}, nil, false})
register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false})
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes.", "PUT", "/services/{id}", []string{"id"}, nil, false})
// Domains
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
register(spec{"containr_domains_add", "Attach a domain to a service. Args: id, domain (required), is_default.", "POST", "/services/{id}/domains", []string{"id"}, []string{"domain", "is_default"}, false})
register(spec{"containr_domains_remove", "Detach a domain. Args: id (service), domain_id.", "DELETE", "/services/{id}/domains/{domain_id}", []string{"id", "domain_id"}, nil, false})
register(spec{"containr_domains_default", "Set the default domain. Args: id (service), domain_id.", "POST", "/services/{id}/domains/{domain_id}/default", []string{"id", "domain_id"}, nil, false})
register(spec{"containr_domains_check", "DNS preflight for all service domains — returns ok|wrong-target|pending per domain", "GET", "/services/{id}/domains/check", []string{"id"}, nil, false})
// Registries — private-image pull credentials, owner-scoped.
register(spec{"containr_registries_list", "List registry credentials (passwords never returned)", "GET", "/registries", nil, nil, false})
register(spec{"containr_registries_create", "Add a registry credential. Args: name (required), host (required — e.g. ghcr.io, docker.io), username, password (stored encrypted).", "POST", "/registries", nil, []string{"name", "host"}, false})
register(spec{"containr_registries_update", "Update a registry credential. Args: id, name, host (required), username, password (empty keeps stored).", "PUT", "/registries/{id}", []string{"id"}, []string{"name", "host"}, false})
register(spec{"containr_registries_delete", "Remove a registry credential. Requires confirm:true.", "DELETE", "/registries/{id}", []string{"id"}, nil, true})
// Docker volumes (admin)
register(spec{"containr_volumes_list", "List docker volumes on the node with in-use flags (admin)", "GET", "/admin/volumes", nil, nil, false})
register(spec{"containr_volumes_delete", "Delete an unused docker volume. Requires confirm:true. (admin)", "DELETE", "/admin/volumes/{name}", []string{"name"}, nil, true})
// Variables
register(spec{"containr_variables_list", "List service environment variables (secrets masked)", "GET", "/services/{id}/variables", []string{"id"}, nil, false})
register(spec{"containr_variables_set", "Replace all variables on a service. Args: id, variables = [{key, value, is_secret}], redeploy (queue a redeploy to apply). Fetch the current list first and merge — the server replaces wholesale.", "PUT", "/services/{id}/variables", []string{"id"}, []string{"variables", "redeploy"}, false})
register(spec{"containr_shared_variables_list", "List project-level shared variables, referenced from service env as ${{shared.KEY}} (secrets masked). Args: id (project).", "GET", "/projects/{id}/variables", []string{"id"}, nil, false})
register(spec{"containr_shared_variables_set", "Replace all shared project variables. Args: id (project), variables = [{key, value, is_secret}]. Services reference them as ${{shared.KEY}} — resolved at deploy/refresh, not live-updated.", "PUT", "/projects/{id}/variables", []string{"id"}, []string{"variables"}, false})
// Deployments
register(spec{"containr_deploy", "Trigger a deployment for a service. Args: id (service), commit_hash, branch, no_cache.", "POST", "/services/{id}/deployments", []string{"id"}, []string{"commit_hash", "branch", "trigger", "no_cache"}, false})
register(spec{"containr_deployments_list", "List recent deployments across services", "GET", "/deployments", nil, nil, false})
register(spec{"containr_services_deployments", "List deployments for one service", "GET", "/services/{id}/deployments", []string{"id"}, nil, false})
register(spec{"containr_deployments_get", "Get a deployment", "GET", "/deployments/{id}", []string{"id"}, nil, false})
register(spec{"containr_deployments_logs", "Get persisted build/runtime logs for a deployment. Args: id, type (all|build|runtime).", "GET", "/deployments/{id}/logs", []string{"id"}, nil, false})
register(spec{"containr_deployments_rollback", "Roll back to a deployment. Requires confirm:true.", "POST", "/deployments/{id}/rollback", []string{"id"}, nil, true})
register(spec{"containr_deployments_cancel", "Cancel a queued or running deployment", "POST", "/deployments/{id}/cancel", []string{"id"}, nil, false})
// Databases
register(spec{"containr_databases_list", "List managed databases", "GET", "/databases", nil, nil, false})
register(spec{"containr_databases_get", "Get a database (connection URL included)", "GET", "/databases/{id}", []string{"id"}, nil, false})
register(spec{"containr_databases_create", "Provision a managed database. Args: name, type (postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse), public_port (bind on all interfaces, default localhost-only).", "POST", "/databases", nil, nil, false})
register(spec{"containr_databases_update", "Update a database. Args: id, name, backup_schedule (empty clears), backup_target_id (empty clears), public_port — toggling recreates the container so the new bind scope applies.", "PUT", "/databases/{id}", []string{"id"}, nil, false})
register(spec{"containr_databases_register_external", "Register a database hosted elsewhere — probed first, password stored encrypted. Args: name (required), type (required), host (required), port, database, username, password, ssl.", "POST", "/databases/register-external", nil, []string{"name", "type", "host"}, false})
register(spec{"containr_databases_test_connection", "Probe a DB connection ad-hoc. Args: type, host (required), port, database, username, password, ssl.", "POST", "/databases/test-connection", nil, []string{"type", "host"}, false})
register(spec{"containr_databases_test_connection_by_id", "Re-probe a registered external database with its stored credentials. Args: id.", "POST", "/databases/{id}/test-connection", []string{"id"}, nil, false})
register(spec{"containr_databases_action", "Start/stop/restart a database. Args: id, action.", "POST", "/databases/{id}/action", []string{"id"}, []string{"action"}, false})
register(spec{"containr_databases_backup", "Take a manual backup snapshot", "POST", "/databases/{id}/backup", []string{"id"}, nil, false})
register(spec{"containr_databases_restore", "Restore a database from a backup — overwrites current data. Requires confirm:true.", "POST", "/databases/{id}/restore", []string{"id"}, []string{"backup_id"}, true})
register(spec{"containr_databases_delete", "Delete a database and its data. Requires confirm:true.", "DELETE", "/databases/{id}", []string{"id"}, nil, true})
// S3-compatible backup targets
register(spec{"containr_backup_targets_list", "List S3-compatible backup targets (credentials never returned)", "GET", "/backup-targets", nil, nil, false})
register(spec{"containr_backup_targets_create", "Register a backup target — probed first, keys stored encrypted. Args: name, endpoint (required), bucket (required), region, prefix, access_key, secret_key, use_tls.", "POST", "/backup-targets", nil, []string{"endpoint", "bucket"}, false})
register(spec{"containr_backup_targets_update", "Update a backup target; empty credentials keep stored keys. Args: id, name, endpoint, bucket, region, prefix, access_key, secret_key, use_tls.", "PUT", "/backup-targets/{id}", []string{"id"}, nil, false})
register(spec{"containr_backup_targets_delete", "Delete a backup target — fails while databases still point at it. Requires confirm:true.", "DELETE", "/backup-targets/{id}", []string{"id"}, nil, true})
register(spec{"containr_backup_targets_test", "Re-probe a backup target's bucket access. Args: id.", "POST", "/backup-targets/{id}/test", []string{"id"}, nil, false})
// Cron
register(spec{"containr_cron_list", "List cron jobs", "GET", "/cron-jobs", nil, nil, false})
register(spec{"containr_cron_create", "Create a cron job. Args: service_id, schedule (5-field), command, name, timezone.", "POST", "/cron-jobs", nil, nil, false})
register(spec{"containr_cron_trigger", "Run a cron job now", "POST", "/cron-jobs/{id}/trigger", []string{"id"}, nil, false})
register(spec{"containr_cron_executions", "List a cron job's execution history", "GET", "/cron-jobs/{id}/executions", []string{"id"}, nil, false})
register(spec{"containr_cron_delete", "Delete a cron job. Requires confirm:true.", "DELETE", "/cron-jobs/{id}", []string{"id"}, nil, true})
// Templates
register(spec{"containr_templates_list", "List templates", "GET", "/templates", nil, nil, false})
register(spec{"containr_templates_get", "Get a template definition", "GET", "/templates/{id}", []string{"id"}, nil, false})
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
register(spec{"containr_templates_plan", "Dry-resolve a template: expanded expressions, service refs, missing variables. Args: id, variables (object).", "POST", "/templates/{id}/plan", []string{"id"}, nil, false})
register(spec{"containr_templates_import_compose", "Convert docker-compose YAML into a v2 template graph config. Args: compose_yaml.", "POST", "/templates/import/compose", nil, []string{"compose_yaml"}, false})
register(spec{"containr_templates_deploy_graph", "Deploy an ad-hoc service graph without a stored template. Args: project_id, config ({services:[...]}), variables, plan, region.", "POST", "/templates/deploy", nil, []string{"project_id", "config", "variables", "plan", "region"}, false})
// Notifications
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
// Nodes / agent tokens (admin)
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false})
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
// Scaling / HA / security
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
register(spec{"containr_scaling_policies", "List scaling policies", "GET", "/scaling/policies", nil, nil, false})
register(spec{"containr_scaling_scale", "Manually scale a service. Args: service_id, replicas. (admin)", "POST", "/scaling/services/{service_id}/scale", []string{"service_id"}, []string{"replicas"}, false})
register(spec{"containr_ha_status", "HA manager status", "GET", "/ha/status", nil, nil, false})
register(spec{"containr_ha_alerts", "Active HA alerts", "GET", "/ha/alerts/active", nil, nil, false})
register(spec{"containr_ha_failover", "Trigger a manual failover — reschedules services. Requires confirm:true. (admin)", "POST", "/ha/failover", nil, nil, true})
register(spec{"containr_security_scan", "Run a security scan. Args: project_id (required), scan_type, service_id.", "POST", "/security/scans", nil, nil, false})
register(spec{"containr_security_vulnerabilities", "List a project's vulnerabilities", "GET", "/projects/{project_id}/vulnerabilities", []string{"project_id"}, nil, false})
// Admin
register(spec{"containr_admin_overview", "Platform overview (admin)", "GET", "/admin/overview", nil, nil, false})
register(spec{"containr_admin_users", "List all users (admin)", "GET", "/admin/users", nil, nil, false})
register(spec{"containr_admin_audit_logs", "List audit log entries (admin)", "GET", "/audit-logs", nil, nil, false})
register(spec{"containr_gateway_services", "List gateway services (admin)", "GET", "/gateway/services", nil, nil, false})
register(spec{"containr_gateway_keys", "List gateway API keys (admin)", "GET", "/gateway/keys", nil, nil, false})
}
// Run starts the stdio MCP server. apiURL/token come from env or config —
// the caller resolves them before calling.
func Run(ctx context.Context, apiURL, token, version string) error {
if token == "" {
return fmt.Errorf("CONTAINR_TOKEN is required (create a cnp_ token under Settings → Personal Access Tokens)")
}
client := &commands.Client{BaseURL: apiURL, Token: token}
server := mcp.NewServer(&mcp.Implementation{
Name: "containr",
Version: version,
}, nil)
for _, s := range tools {
s := s
server.AddTool(&mcp.Tool{
Name: s.name,
Description: s.description,
InputSchema: schemaFor(s),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return call(ctx, client, s, req)
})
}
return server.Run(ctx, &mcp.StdioTransport{})
}
// schemaFor builds a JSON Schema for the tool's arguments: path params +
// body keys + confirm for destructive tools. Properties accept any JSON
// type — the API validates values.
func schemaFor(s spec) map[string]interface{} {
props := map[string]interface{}{}
var required []string
for _, p := range s.params {
props[p] = map[string]interface{}{"type": "string", "description": "path parameter"}
required = append(required, p)
}
bodySrc := s.bodyKeys
if len(bodySrc) == 0 && s.method != "GET" && s.method != "DELETE" {
// Unrestricted body: advertise nothing, accept anything.
}
for _, k := range bodySrc {
props[k] = map[string]interface{}{"description": "request body field"}
}
if s.destructive {
props["confirm"] = map[string]interface{}{"type": "boolean", "description": "must be true — this operation is destructive"}
required = append(required, "confirm")
}
schema := map[string]interface{}{
"type": "object",
"properties": props,
"additionalProperties": true,
}
if len(required) > 0 {
schema["required"] = required
}
return schema
}
func call(ctx context.Context, c *commands.Client, s spec, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args map[string]interface{}
if len(req.Params.Arguments) > 0 {
if err := json.Unmarshal(req.Params.Arguments, &args); err != nil {
return errorResult("invalid arguments: " + err.Error()), nil
}
}
if args == nil {
args = map[string]interface{}{}
}
if s.destructive && args["confirm"] != true {
return errorResult("destructive operation — pass confirm:true to proceed"), nil
}
path := s.path
for _, p := range s.params {
v, ok := args[p]
if !ok || fmt.Sprint(v) == "" {
return errorResult(fmt.Sprintf("missing required argument %q", p)), nil
}
path = strings.ReplaceAll(path, "{"+p+"}", fmt.Sprint(v))
}
var body interface{}
var query []string
for k, v := range args {
if k == "confirm" {
continue
}
inParams := false
for _, p := range s.params {
if k == p {
inParams = true
break
}
}
if inParams {
continue
}
if s.method == "GET" || s.method == "DELETE" {
// Non-path args on reads become query parameters.
query = append(query, url.QueryEscape(k)+"="+url.QueryEscape(fmt.Sprint(v)))
continue
}
if len(s.bodyKeys) > 0 {
allowed := false
for _, b := range s.bodyKeys {
if k == b {
allowed = true
break
}
}
if !allowed {
continue
}
}
if body == nil {
body = map[string]interface{}{}
}
body.(map[string]interface{})[k] = v
}
if len(query) > 0 {
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
path += sep + strings.Join(query, "&")
}
data, err := c.Do(s.method, path, body)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(data), nil
}
func textResult(data []byte) *mcp.CallToolResult {
var v interface{}
if json.Unmarshal(data, &v) != nil {
v = string(data)
}
out, _ := json.MarshalIndent(v, "", " ")
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: string(out)}},
}
}
func errorResult(msg string) *mcp.CallToolResult {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{&mcp.TextContent{Text: msg}},
}
}
@@ -0,0 +1,132 @@
package middleware
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"time"
"containr/internal/database"
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
)
// Idempotency replays POST responses for a repeated Idempotency-Key header
// so agents can safely retry mutations. The first request wins; concurrent
// duplicates get 409 IDEMPOTENCY_IN_PROGRESS; completed responses replay
// verbatim for 24h. Keys are scoped to the authenticated identity.
func Idempotency() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method != http.MethodPost {
c.Next()
return
}
key := c.GetHeader("Idempotency-Key")
if key == "" || len(key) > 128 {
c.Next()
return
}
dbValue, exists := c.Get("redis")
if !exists {
c.Next()
return
}
rdb, ok := dbValue.(*database.Redis)
if !ok || rdb == nil || rdb.Client == nil {
c.Next()
return
}
// Scope by caller identity so a stolen key can't replay across users.
actor := c.GetString("user_id")
if actor == "" {
actor = c.GetHeader("Authorization")
}
sum := sha256.Sum256([]byte(actor + ":" + c.Request.URL.Path + ":" + key))
redisKey := "idem:" + hex.EncodeToString(sum[:16])
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
stored, err := rdb.Client.Get(ctx, redisKey).Result()
switch {
case err == nil:
if stored == "processing" {
c.JSON(http.StatusConflict, gin.H{
"error": "a request with this idempotency key is still in progress",
"code": "IDEMPOTENCY_IN_PROGRESS",
})
c.Abort()
return
}
var rec idemRecord
if json.Unmarshal([]byte(stored), &rec) == nil {
c.Header("X-Idempotent-Replay", "true")
c.Data(rec.Status, rec.ContentType, rec.Body)
c.Abort()
return
}
case err != redis.Nil:
// Redis hiccup — degrade to pass-through rather than failing the request.
c.Next()
return
}
// Claim the key for the duration of the request.
if set, serr := rdb.Client.SetNX(ctx, redisKey, "processing", 2*time.Minute).Result(); serr != nil || !set {
c.JSON(http.StatusConflict, gin.H{
"error": "a request with this idempotency key is still in progress",
"code": "IDEMPOTENCY_IN_PROGRESS",
})
c.Abort()
return
}
recorder := &responseRecorder{ResponseWriter: c.Writer, status: http.StatusOK, body: &bytes.Buffer{}}
c.Writer = recorder
c.Next()
// Cache terminal responses only — 5xx is retryable and must not be replayed.
if recorder.status < 500 && recorder.body.Len() <= 1<<20 {
rec := idemRecord{
Status: recorder.status,
ContentType: recorder.Header().Get("Content-Type"),
Body: recorder.body.Bytes(),
}
if rec.ContentType == "" {
rec.ContentType = "application/json; charset=utf-8"
}
if raw, merr := json.Marshal(rec); merr == nil {
_ = rdb.Client.Set(context.Background(), redisKey, raw, 24*time.Hour).Err()
}
} else {
_ = rdb.Client.Del(context.Background(), redisKey).Err()
}
}
}
type idemRecord struct {
Status int `json:"s"`
ContentType string `json:"ct"`
Body []byte `json:"b"`
}
type responseRecorder struct {
gin.ResponseWriter
status int
body *bytes.Buffer
}
func (r *responseRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *responseRecorder) Write(b []byte) (int, error) {
r.body.Write(b)
return r.ResponseWriter.Write(b)
}
@@ -0,0 +1,60 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestIdempotencyPassThroughWithoutKey(t *testing.T) {
gin.SetMode(gin.TestMode)
called := 0
router := gin.New()
router.Use(Idempotency())
router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) })
req := httptest.NewRequest(http.MethodPost, "/x", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if called != 1 || rec.Code != 200 {
t.Fatalf("expected pass-through, called=%d code=%d", called, rec.Code)
}
}
func TestIdempotencyPassThroughWithoutRedis(t *testing.T) {
gin.SetMode(gin.TestMode)
called := 0
router := gin.New()
router.Use(Idempotency())
router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) })
// Key present but no redis in context — must not block the request.
req := httptest.NewRequest(http.MethodPost, "/x", nil)
req.Header.Set("Idempotency-Key", "abc-123")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if called != 1 || rec.Code != 200 {
t.Fatalf("expected pass-through without redis, called=%d code=%d", called, rec.Code)
}
}
func TestIdempotencySkipsNonPOST(t *testing.T) {
gin.SetMode(gin.TestMode)
called := 0
router := gin.New()
router.Use(Idempotency())
router.GET("/x", func(c *gin.Context) { called++; c.String(200, "ok") })
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Idempotency-Key", "abc")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if called != 1 {
t.Fatalf("GET should bypass idempotency, called=%d", called)
}
}
+80 -6
View File
@@ -2,7 +2,10 @@ package middleware
import ( import (
"containr/internal/database" "containr/internal/database"
sqlcdb "containr/internal/database/sqlcdb"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -75,6 +78,14 @@ func RequestID() gin.HandlerFunc {
func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) { func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) {
tokenString, tokenErr, hasToken := extractJWTToken(c) tokenString, tokenErr, hasToken := extractJWTToken(c)
if tokenString != "" { if tokenString != "" {
// Personal access tokens (cnp_…) are not JWTs — they resolve
// against the user_tokens table by sha256 hash.
if strings.HasPrefix(tokenString, patPrefix) {
if id, mail, verified := resolvePersonalAccessToken(c, tokenString); verified {
return id, mail, "", hasToken, true
}
return "", "", "Invalid token", hasToken, false
}
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid { if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
userIDClaim, exists := claims["user_id"] userIDClaim, exists := claims["user_id"]
if exists { if exists {
@@ -101,12 +112,67 @@ func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *bette
return "", "", tokenErr, hasToken, false return "", "", tokenErr, hasToken, false
} }
const patPrefix = "cnp_"
// resolvePersonalAccessToken authenticates a `cnp_` bearer token against the
// user_tokens table (sha256 hash match, not revoked, not expired). On success
// it stamps last_used_at, records the token scope on the context, and
// returns the owner's id and email.
func resolvePersonalAccessToken(c *gin.Context, token string) (string, string, bool) {
dbValue, exists := c.Get("db")
if !exists {
return "", "", false
}
db, ok := dbValue.(*database.DB)
if !ok || db == nil || db.DB == nil {
return "", "", false
}
sum := sha256.Sum256([]byte(token))
queries := sqlcdb.New(db.DB)
row, err := queries.GetUserTokenByHash(c.Request.Context(), hex.EncodeToString(sum[:]))
if err != nil {
return "", "", false
}
var email string
if err := db.QueryRowContext(c.Request.Context(),
`SELECT email FROM users WHERE id = $1`, row.UserID).Scan(&email); err != nil {
return "", "", false
}
_ = queries.TouchUserToken(c.Request.Context(), row.ID)
c.Set("pat_scope", row.Scope)
return row.UserID.String(), email, true
}
// patScopeAllows reports whether a PAT-authenticated request may proceed.
// read scope permits only safe methods; write and admin are unrestricted at
// this layer (admin routes still require the admin scope AND a platform
// admin account — enforced via is_admin in setAuthenticatedContext).
func patScopeAllows(c *gin.Context) bool {
if c.GetString("pat_scope") != "read" {
return true
}
switch c.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
return false
}
// setAuthenticatedContext stores the resolved identity plus the admin flag on // setAuthenticatedContext stores the resolved identity plus the admin flag on
// the gin context so handlers and RequireAdmin can rely on it. // the gin context so handlers and RequireAdmin can rely on it.
func setAuthenticatedContext(c *gin.Context, userID, email string) { func setAuthenticatedContext(c *gin.Context, userID, email string) {
c.Set("user_id", userID) c.Set("user_id", userID)
c.Set("email", email) c.Set("email", email)
c.Set("is_admin", loadIsAdmin(c, userID)) isAdmin := loadIsAdmin(c, userID)
// A PAT can never outrank its scope: non-admin scopes cap the token at
// non-admin even when the owning user is a platform admin.
if scope := c.GetString("pat_scope"); scope != "" && scope != "admin" {
isAdmin = false
}
c.Set("is_admin", isAdmin)
} }
func loadIsAdmin(c *gin.Context, userID string) bool { func loadIsAdmin(c *gin.Context, userID string) bool {
@@ -133,20 +199,28 @@ func Auth(jwtSecret string) gin.HandlerFunc {
userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier) userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
if ok { if ok {
setAuthenticatedContext(c, userID, email) setAuthenticatedContext(c, userID, email)
if !patScopeAllows(c) {
c.JSON(http.StatusForbidden, gin.H{
"error": "Token scope is read-only",
"code": "SCOPE_INSUFFICIENT",
})
c.Abort()
return
}
c.Next() c.Next()
return return
} }
if tokenErr != "" { if tokenErr != "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr}) c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr, "code": "INVALID_TOKEN"})
c.Abort() c.Abort()
return return
} }
if hasToken { if hasToken {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token", "code": "INVALID_TOKEN"})
} else { } else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"})
} }
c.Abort() c.Abort()
} }
@@ -171,12 +245,12 @@ func OptionalAuth(jwtSecret string) gin.HandlerFunc {
func RequireAdmin() gin.HandlerFunc { func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if _, exists := c.Get("user_id"); !exists { if _, exists := c.Get("user_id"); !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"})
c.Abort() c.Abort()
return return
} }
if !c.GetBool("is_admin") { if !c.GetBool("is_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required", "code": "ADMIN_REQUIRED"})
c.Abort() c.Abort()
return return
} }
@@ -131,6 +131,61 @@ func TestAuthStoresStringUserIDForValidClaims(t *testing.T) {
} }
} }
func TestAuthRejectsPATWithoutDatabase(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(Auth("secret"))
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
// A cnp_-prefixed token takes the PAT path, not JWT parsing — without a
// db handle it must fail closed with 401.
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer cnp_"+strings.Repeat("ab", 24))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
}
}
func TestPATScopeAllows(t *testing.T) {
gin.SetMode(gin.TestMode)
cases := []struct {
name string
scope string
method string
want bool
}{
{"read allows GET", "read", http.MethodGet, true},
{"read allows HEAD", "read", http.MethodHead, true},
{"read allows OPTIONS", "read", http.MethodOptions, true},
{"read blocks POST", "read", http.MethodPost, false},
{"read blocks DELETE", "read", http.MethodDelete, false},
{"write allows POST", "write", http.MethodPost, true},
{"write allows DELETE", "write", http.MethodDelete, true},
{"admin allows PATCH", "admin", http.MethodPatch, true},
{"no scope (session/JWT) unrestricted", "", http.MethodPost, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(tc.method, "/x", nil)
if tc.scope != "" {
c.Set("pat_scope", tc.scope)
}
if got := patScopeAllows(c); got != tc.want {
t.Fatalf("patScopeAllows(scope=%q, method=%s) = %v, want %v", tc.scope, tc.method, got, tc.want)
}
})
}
}
func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string { func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string {
t.Helper() t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+98
View File
@@ -0,0 +1,98 @@
package secrets
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"log"
"strings"
"sync"
)
// Prefix marks AES-GCM-encrypted values in environment_variables.value.
// Plaintext rows keep working — Decrypt returns them unchanged, so the
// rollout is transparent encrypt-on-write / decrypt-on-read.
const Prefix = "enc:v1:"
var (
keyOnce sync.Once
key []byte
enabled bool
)
// Configure derives the AES-256 key. SECRETS_KEY wins; JWT_SECRET is the
// fallback so existing installs need no new config. With neither, encrypt
// degrades to plaintext with a single warning.
func Configure(secretsKey, jwtSecret string) {
keyOnce.Do(func() {
seed := secretsKey
if seed == "" {
seed = jwtSecret
}
if seed == "" {
log.Printf("secrets: no SECRETS_KEY or JWT_SECRET — secret variables stay plaintext")
return
}
sum := sha256.Sum256([]byte("containr-env-secrets:" + seed))
key = sum[:]
enabled = true
})
}
func IsEncrypted(value string) bool {
return strings.HasPrefix(value, Prefix)
}
// Encrypt returns value unchanged if disabled or already encrypted.
func Encrypt(value string) string {
if !enabled || IsEncrypted(value) {
return value
}
block, err := aes.NewCipher(key)
if err != nil {
return value
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return value
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return value
}
sealed := gcm.Seal(nonce, nonce, []byte(value), nil)
return Prefix + base64.StdEncoding.EncodeToString(sealed)
}
// Decrypt returns plaintext for enc:v1 values, the input otherwise.
// Corrupt ciphertext fails soft to the stored value rather than erroring
// the deploy path — an unreadable secret is no worse than a plaintext one.
func Decrypt(value string) string {
if !enabled || !IsEncrypted(value) {
return value
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, Prefix))
if err != nil {
return value
}
block, err := aes.NewCipher(key)
if err != nil {
return value
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return value
}
if len(raw) < gcm.NonceSize() {
return value
}
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
if err != nil {
log.Printf("secrets: decrypt failed (%v) — check SECRETS_KEY/JWT_SECRET rotation", fmt.Errorf("%w", err))
return value
}
return string(plain)
}
@@ -0,0 +1,63 @@
package secrets
import (
"sync"
"testing"
)
// resetForTest restores the package state so Configure can be re-run.
func resetForTest() {
keyOnce = sync.Once{}
key = nil
enabled = false
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
resetForTest()
Configure("test-secrets-key", "")
enc := Encrypt("super-secret-value")
if !IsEncrypted(enc) {
t.Fatalf("expected encrypted prefix, got %q", enc)
}
if enc == "super-secret-value" {
t.Fatal("value stored as plaintext")
}
if got := Decrypt(enc); got != "super-secret-value" {
t.Fatalf("decrypt = %q", got)
}
}
func TestPlaintextPassThrough(t *testing.T) {
resetForTest()
Configure("test-secrets-key", "")
if got := Decrypt("legacy-plaintext"); got != "legacy-plaintext" {
t.Fatalf("legacy plaintext changed: %q", got)
}
}
func TestEncryptIsIdempotent(t *testing.T) {
resetForTest()
Configure("test-secrets-key", "")
enc := Encrypt("v")
if got := Encrypt(enc); got != enc {
t.Fatal("double-encrypt changed the value")
}
}
func TestDecryptFailsSoftOnCorruption(t *testing.T) {
resetForTest()
Configure("test-secrets-key", "")
corrupt := Prefix + "not-valid-base64!!!"
if got := Decrypt(corrupt); got != corrupt {
t.Fatal("corrupt ciphertext should pass through unchanged")
}
}
func TestDisabledWithoutKey(t *testing.T) {
resetForTest()
Configure("", "")
if got := Encrypt("v"); got != "v" {
t.Fatal("encrypt should pass through when no key configured")
}
}
+5
View File
@@ -28,6 +28,11 @@ type BuildRequest struct {
TriggeredBy string `json:"triggered_by"` // Who triggered the build TriggeredBy string `json:"triggered_by"` // Who triggered the build
Branch string `json:"branch"` // Git branch Branch string `json:"branch"` // Git branch
Commit string `json:"commit"` // Git commit SHA Commit string `json:"commit"` // Git commit SHA
NoCache bool `json:"no_cache"` // Disable Docker layer cache
// Registry credentials for pulling a private prebuilt image.
PullUsername string `json:"pull_username,omitempty"`
PullPassword string `json:"pull_password,omitempty"`
} }
// BuildResponse represents the response from a build operation // BuildResponse represents the response from a build operation
@@ -0,0 +1,11 @@
-- +goose Up
-- Railway-style template publishing: owners can mark a user template public so
-- it appears in the anonymous catalog (GET /api/v1/templates) and on the
-- landing page. Official templates stay official; is_public only matters for
-- user templates.
ALTER TABLE public.service_templates ADD COLUMN IF NOT EXISTS is_public boolean NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS idx_service_templates_public ON public.service_templates(is_public) WHERE is_public;
-- +goose Down
DROP INDEX IF EXISTS public.idx_service_templates_public;
ALTER TABLE public.service_templates DROP COLUMN IF EXISTS is_public;
@@ -0,0 +1,8 @@
-- +goose Up
-- Stable public host port for service replica 0. 0 means "not yet assigned";
-- the runtime writes the ephemeral port it picked back here so restarts and
-- redeploys keep the same public endpoint instead of churning.
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS published_port integer NOT NULL DEFAULT 0;
-- +goose Down
ALTER TABLE public.services DROP COLUMN IF EXISTS published_port;
@@ -0,0 +1,18 @@
-- +goose Up
-- Persisted HA health checks. Previously checks lived only in memory, so a
-- backend restart silently deleted every configured check while services kept
-- running unmonitored.
CREATE TABLE IF NOT EXISTS ha_health_checks (
id uuid PRIMARY KEY,
service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE,
node_id varchar(255) NOT NULL DEFAULT '',
type varchar(20) NOT NULL,
config jsonb NOT NULL DEFAULT '{}'::jsonb,
status varchar(20) NOT NULL DEFAULT 'unknown',
last_check timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE IF EXISTS ha_health_checks;
@@ -0,0 +1,25 @@
-- +goose Up
-- Personal access tokens for CLI/MCP/agent authentication.
-- Only sha256 hashes are stored — raw tokens (cnp_…) are shown once at
-- creation and never persisted. Scope gates what the token may do:
-- read = GET/HEAD only, write = all non-admin routes, admin = everything
-- (admin still requires the owning user to be a platform admin).
CREATE TABLE IF NOT EXISTS public.user_tokens (
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
name character varying(255) NOT NULL,
key_prefix character varying(16) NOT NULL,
token_hash text NOT NULL UNIQUE,
scope character varying(20) NOT NULL DEFAULT 'write'
CHECK (scope IN ('read', 'write', 'admin')),
expires_at timestamp with time zone,
last_used_at timestamp with time zone,
revoked_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_tokens_user_id ON public.user_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_user_tokens_token_hash ON public.user_tokens(token_hash);
-- +goose Down
DROP TABLE IF EXISTS public.user_tokens;
@@ -0,0 +1,13 @@
-- +goose Up
-- Volume mounts on services: [{type: "volume"|"bind", source, target,
-- read_only}]. Applied to every replica's container config at deploy and
-- reconcile time. The baseline schema declared the column nullable with
-- no default — this normalizes it.
ALTER TABLE services ADD COLUMN IF NOT EXISTS volumes JSONB DEFAULT '[]'::jsonb;
UPDATE services SET volumes = '[]'::jsonb WHERE volumes IS NULL;
ALTER TABLE services ALTER COLUMN volumes SET DEFAULT '[]'::jsonb;
ALTER TABLE services ALTER COLUMN volumes SET NOT NULL;
-- +goose Down
ALTER TABLE services ALTER COLUMN volumes DROP NOT NULL;
ALTER TABLE services ALTER COLUMN volumes DROP DEFAULT;
@@ -0,0 +1,30 @@
-- +goose Up
-- Multi-domain support. services.domain stays as the derived default for
-- compatibility; service_domains is the source of truth for the full
-- hostname set. Backfills existing services.domain rows as defaults.
CREATE TABLE IF NOT EXISTS service_domains (
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE,
domain varchar(255) NOT NULL,
is_default boolean NOT NULL DEFAULT false,
cert_type varchar(32) NOT NULL DEFAULT 'letsencrypt',
cert_status varchar(32) NOT NULL DEFAULT 'pending',
last_checked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (service_id, domain)
);
CREATE INDEX IF NOT EXISTS idx_service_domains_service ON service_domains(service_id);
INSERT INTO service_domains (service_id, domain, is_default)
SELECT id, domain, true FROM services WHERE domain <> '' AND domain IS NOT NULL
ON CONFLICT (service_id, domain) DO NOTHING;
-- Access control: maintenance mode redirects to a platform page; basic auth
-- gates the route behind htpasswd-format user:hash pairs (Traefik middleware).
ALTER TABLE services ADD COLUMN IF NOT EXISTS maintenance_mode boolean NOT NULL DEFAULT false;
ALTER TABLE services ADD COLUMN IF NOT EXISTS basic_auth_users text NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE services DROP COLUMN IF EXISTS maintenance_mode;
ALTER TABLE services DROP COLUMN IF EXISTS basic_auth_users;
DROP TABLE IF EXISTS service_domains;
@@ -0,0 +1,17 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE services ADD COLUMN IF NOT EXISTS builder varchar(32) NOT NULL DEFAULT 'auto';
ALTER TABLE services ADD COLUMN IF NOT EXISTS cpu_reserve varchar(20) NOT NULL DEFAULT '';
ALTER TABLE services ADD COLUMN IF NOT EXISTS memory_reserve varchar(20) NOT NULL DEFAULT '';
ALTER TABLE services ADD COLUMN IF NOT EXISTS static_build_cmd varchar(255) NOT NULL DEFAULT '';
ALTER TABLE services ADD COLUMN IF NOT EXISTS static_dir varchar(255) NOT NULL DEFAULT '';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE services DROP COLUMN IF EXISTS static_dir;
ALTER TABLE services DROP COLUMN IF EXISTS static_build_cmd;
ALTER TABLE services DROP COLUMN IF EXISTS memory_reserve;
ALTER TABLE services DROP COLUMN IF EXISTS cpu_reserve;
ALTER TABLE services DROP COLUMN IF EXISTS builder;
-- +goose StatementEnd
@@ -0,0 +1,20 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS registries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
host VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (owner_id, host)
);
CREATE INDEX IF NOT EXISTS idx_registries_owner ON registries(owner_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS registries;
-- +goose StatementEnd
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff. Show more