Compare commits

...
65 Commits
Author SHA1 Message Date
Tomáš Dvořák 64cbf82dd8 Merge pull request #36 from Dvorinka/feat/compose-import-project
feat: direct docker-compose import into projects
2026-10-09 09:43:15 +02:00
Tomas Dvorak 5c2f2861bd feat: direct docker-compose import into projects
POST /projects/:id/import-compose deploys a compose file straight into a
project — no template row required. Well-known DB images become managed
databases attached to the project network under their service name as DNS
alias; other services deploy in dependency order via deployTemplateGraph.

- api: extract composeToSpecs converter (shared by the template
  import-compose preview and the new project endpoint); fix dropped env
  warnings (pointer receiver); replace insertion sort with sort.Strings
- cascade: database_services.project_id links graph-provisioned DBs to
  their project so project delete tears down runtime + row (previously
  leaked container + network)
- cli: new `containr import` group, `import compose <project> <file|->`
- mcp: containr_projects_import_compose tool
- openapi + regenerated frontend types; docs snapshot + CLI.md
- frontend: compose import modal on the project workspace toolbar
- tests: composeToSpecs conversion, ordering, db-image map, warnings
2026-10-09 09:33:38 +02:00
Tomáš Dvořák dca8aaa7b3 Merge pull request #35 from Dvorinka/fix/mobile-auth-gate
fix(mobile): gate authed routes — cold-start redbox fix
2026-10-09 05:49:29 +02:00
Tomas DvorakandDevin 6946357a7b fix(mobile): gate authed routes behind Stack.Protected — cold-start redbox
'/' redirected to /(tabs) unconditionally; tab screens call useApi()
which throws 'not signed in' during render — before AuthGate's effect
redirect could run. Sign-out hit the same throw.

- index redirects based on session state (api ? tabs : login)
- authed screens wrapped in Stack.Protected guard={!!api}
- found by HP-WIN11 emulator visual pass

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 05:45:44 +02:00
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
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 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 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
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 f22a464ad0 Merge remote-tracking branch 'origin/main' into feat/db-public-port 2026-10-09 01:04:41 +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 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
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
50 changed files with 6889 additions and 74 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.
+12 -3
View File
@@ -13,6 +13,7 @@ require (
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/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
@@ -23,6 +24,7 @@ require (
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 golang.org/x/term v0.46.0
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
@@ -37,23 +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/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
@@ -63,8 +70,10 @@ 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/asm v1.2.0 // indirect
github.com/segmentio/encoding v0.5.4 // indirect github.com/segmentio/encoding v0.5.4 // indirect
@@ -74,6 +83,7 @@ require (
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 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
@@ -97,7 +107,6 @@ require (
golang.org/x/time v0.15.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
) )
+21 -5
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,8 +65,8 @@ 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.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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=
@@ -82,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=
@@ -99,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=
@@ -130,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=
@@ -144,6 +157,8 @@ 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=
@@ -182,6 +197,8 @@ 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=
@@ -227,7 +244,6 @@ golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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=
+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)
}
}
+145 -8
View File
@@ -3,6 +3,7 @@ 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" "containr/internal/secrets"
"context" "context"
@@ -22,6 +23,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/google/uuid"
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/network"
@@ -53,6 +56,8 @@ type DatabaseService struct {
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 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 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"`
@@ -105,6 +110,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
@@ -121,6 +127,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
@@ -128,6 +135,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
@@ -295,6 +304,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 {
@@ -322,7 +332,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) {
@@ -364,6 +380,13 @@ func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID stri
now := time.Now() now := time.Now()
version := h.getDefaultVersion(dbType) version := h.getDefaultVersion(dbType)
var projectID uuid.NullUUID
if req.ProjectID != "" {
if parsed, parseErr := uuid.Parse(req.ProjectID); parseErr == nil {
projectID = uuid.NullUUID{UUID: parsed, Valid: true}
}
}
err = h.queries.CreateDatabaseService(ctx, sqlcdb.CreateDatabaseServiceParams{ err = h.queries.CreateDatabaseService(ctx, sqlcdb.CreateDatabaseServiceParams{
ID: databaseID, ID: databaseID,
UserID: userID, UserID: userID,
@@ -373,6 +396,8 @@ func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID stri
Version: version, Version: version,
Plan: plan, Plan: plan,
Region: region, Region: region,
PublicPort: req.PublicPort,
ProjectID: projectID,
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},
}) })
@@ -389,10 +414,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)
@@ -411,11 +443,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
@@ -956,6 +1041,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
@@ -1108,6 +1194,8 @@ func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
BackupSchedule: databaseNullString(row.BackupSchedule), BackupSchedule: databaseNullString(row.BackupSchedule),
NextBackupAt: databaseNullTimePtr(row.NextBackupAt), NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
Provider: row.Provider, 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),
@@ -1285,10 +1373,21 @@ 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()
@@ -1297,7 +1396,7 @@ func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseNam
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) 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{})
@@ -1319,7 +1418,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
@@ -1344,8 +1443,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{}{}}
@@ -1403,6 +1506,17 @@ 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
@@ -1764,10 +1878,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()
@@ -1776,6 +1897,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)
+23
View File
@@ -7,6 +7,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"log"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -297,6 +298,28 @@ func handleDeleteProject(c *gin.Context) {
return return
} }
// Managed databases provisioned into this project (graph templates,
// compose imports) carry project_id and must be torn down first — their
// containers are not labelled containr.project and hold references to the
// project network.
if dbHandlerValue, exists := c.Get("database_handler"); exists && dbHandlerValue != nil {
if dbHandler, ok := dbHandlerValue.(*DatabaseHandler); ok {
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
projectDBs, listErr := dbHandler.queries.ListDatabaseServicesByProject(ctx, uuid.NullUUID{UUID: projectID, Valid: true})
if listErr == nil {
for _, projectDB := range projectDBs {
if projectDB.Provider != "external" {
if err := dbHandler.deleteManagedDatabaseRuntime(projectDB.ID); err != nil {
log.Printf("delete project %s: database %s runtime removal failed: %v", projectID, projectDB.ID, err)
}
}
_ = dbHandler.queries.DeleteDatabaseServiceByID(ctx, projectDB.ID)
}
}
cancel()
}
}
// Remove live containers and the project network before dropping rows. // Remove live containers and the project network before dropping rows.
// Best-effort: Docker may be unavailable; DB cleanup proceeds regardless. // Best-effort: Docker may be unavailable; DB cleanup proceeds regardless.
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil { if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
+23
View File
@@ -98,6 +98,9 @@ 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)
@@ -158,6 +161,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
c.Set("deployment_engine", deploymentEngine) c.Set("deployment_engine", deploymentEngine)
} }
c.Set("deploy_queue", deployQueue) 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)
@@ -249,6 +253,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.POST("/auth/login", handleLogin) public.POST("/auth/login", handleLogin)
public.POST("/auth/register", handleRegister) public.POST("/auth/register", handleRegister)
public.GET("/maintenance", handleMaintenancePage) 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
@@ -271,6 +279,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
read.GET("/services/:id/deployments", handleGetDeployments) read.GET("/services/:id/deployments", handleGetDeployments)
read.GET("/services/:id/domains", handleListServiceDomains) read.GET("/services/:id/domains", handleListServiceDomains)
read.GET("/services/:id/domains/check", handleCheckServiceDomains) 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)
@@ -324,6 +333,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// publishes them. // publishes them.
authed.POST("/projects", handleCreateProject) authed.POST("/projects", handleCreateProject)
authed.POST("/projects/:id/services", handleCreateService) authed.POST("/projects/:id/services", handleCreateService)
authed.POST("/projects/:id/import-compose", handleImportProjectCompose)
authed.PUT("/projects/:id", handleUpdateProject) authed.PUT("/projects/:id", handleUpdateProject)
authed.DELETE("/projects/:id", handleDeleteProject) authed.DELETE("/projects/:id", handleDeleteProject)
@@ -335,6 +345,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
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/clone", handleCloneService)
authed.POST("/services/:id/move", handleMoveService) authed.POST("/services/:id/move", handleMoveService)
@@ -358,6 +370,13 @@ 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. // Private-image registries — owner-scoped, credentials encrypted at rest.
authed.GET("/registries", handleListRegistries) authed.GET("/registries", handleListRegistries)
authed.POST("/registries", handleCreateRegistry) authed.POST("/registries", handleCreateRegistry)
@@ -395,6 +414,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
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)
@@ -413,6 +433,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)
+109
View File
@@ -641,3 +641,112 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
_, _ = 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,
})
}
+33 -1
View File
@@ -46,10 +46,24 @@ type Service struct {
MemoryReserve string `json:"memory_reserve,omitempty" db:"memory_reserve"` MemoryReserve string `json:"memory_reserve,omitempty" db:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd,omitempty" db:"static_build_cmd"` StaticBuildCmd string `json:"static_build_cmd,omitempty" db:"static_build_cmd"`
StaticDir string `json:"static_dir,omitempty" db:"static_dir"` 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. // ServiceVolume is a volume/bind mount applied to every replica.
type ServiceVolume struct { type ServiceVolume struct {
Type string `json:"type"` // volume | bind (empty = volume) Type string `json:"type"` // volume | bind (empty = volume)
@@ -154,6 +168,8 @@ type UpdateServiceRequest struct {
MemoryReserve *string `json:"memory_reserve"` MemoryReserve *string `json:"memory_reserve"`
StaticBuildCmd *string `json:"static_build_cmd"` StaticBuildCmd *string `json:"static_build_cmd"`
StaticDir *string `json:"static_dir"` 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
@@ -201,6 +217,7 @@ func handleGetServices(c *gin.Context) {
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''), COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''),
COALESCE(memory_reserve, ''), COALESCE(static_build_cmd, ''), COALESCE(memory_reserve, ''), COALESCE(static_build_cmd, ''),
COALESCE(static_dir, ''), 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
@@ -223,6 +240,7 @@ func handleGetServices(c *gin.Context) {
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve, &service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir, &service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.SleepEnabled, &service.SleepIdleMinutes,
&service.CreatedAt, &service.UpdatedAt, &service.CreatedAt, &service.UpdatedAt,
) )
if err != nil { if err != nil {
@@ -524,6 +542,7 @@ func handleGetService(c *gin.Context) {
}) })
} }
service.Domains = loadServiceDomains(db.(*database.DB), service.ID) service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
loadServiceSleep(db.(*database.DB), &service)
maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID) maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
service.MaintenanceMode = maintenance service.MaintenanceMode = maintenance
service.BasicAuth = basicAuthUsernames(basicAuth) service.BasicAuth = basicAuthUsernames(basicAuth)
@@ -665,6 +684,17 @@ func handleUpdateService(c *gin.Context) {
if existingService.Builder == "static" && existingService.StaticDir == "" { if existingService.Builder == "static" && existingService.StaticDir == "" {
existingService.StaticDir = "dist" 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{ for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec, "cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec, "memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
@@ -701,7 +731,8 @@ func handleUpdateService(c *gin.Context) {
replicas = $11, port = $12, domain = $13, healthcheck_path = $14, replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16, restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
builder = $19, cpu_reserve = $20, memory_reserve = $21, builder = $19, cpu_reserve = $20, memory_reserve = $21,
static_build_cmd = $22, static_dir = $23 static_build_cmd = $22, static_dir = $23,
sleep_enabled = $24, sleep_idle_minutes = $25
WHERE id = $18`, 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,
@@ -711,6 +742,7 @@ func handleUpdateService(c *gin.Context) {
existingService.UpdatedAt, volumesArg, existingService.ID, existingService.UpdatedAt, volumesArg, existingService.ID,
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve, existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
existingService.StaticBuildCmd, existingService.StaticDir, existingService.StaticBuildCmd, existingService.StaticDir,
existingService.SleepEnabled, existingService.SleepIdleMinutes,
) )
if err != nil { if err != nil {
+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")
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,126 @@
package api
import (
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func composeToSpecsForTest(t *testing.T, y string) ([]TemplateServiceSpec, []string, error) {
t.Helper()
var file composeFileDef
if err := yaml.Unmarshal([]byte(y), &file); err != nil {
t.Fatalf("yaml: %v", err)
}
return composeToSpecs(&file)
}
func TestComposeToSpecsConvertsStack(t *testing.T) {
specs, warnings, err := composeToSpecsForTest(t, `services:
web:
image: traefik/whoami:latest
ports:
- "80"
environment:
GREETING: hello-compose
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: pgsecret123
POSTGRES_DB: appdb
`)
if err != nil {
t.Fatal(err)
}
if len(specs) != 2 {
t.Fatalf("want 2 specs, got %d", len(specs))
}
db, web := specs[0], specs[1]
if db.Key != "db" || db.Type != "database" || db.Runtime != "postgresql" {
t.Errorf("db spec wrong: %+v", db)
}
if db.Environment["POSTGRES_PASSWORD"] != "pgsecret123" {
t.Errorf("db env lost: %v", db.Environment)
}
if web.Key != "web" || web.Type != "web" || web.Runtime != "traefik/whoami:latest" {
t.Errorf("web spec wrong: %+v", web)
}
if web.Port != 80 {
t.Errorf("web port = %d, want 80", web.Port)
}
if len(web.DependsOn) != 1 || web.DependsOn[0] != "db" {
t.Errorf("web depends_on = %v, want [db]", web.DependsOn)
}
if len(warnings) != 0 {
t.Errorf("unexpected warnings: %v", warnings)
}
}
func TestComposeToSpecsDeterministicOrder(t *testing.T) {
y := `services:
zebra:
image: alpine
alpha:
image: alpine
`
for i := 0; i < 5; i++ {
specs, _, err := composeToSpecsForTest(t, y)
if err != nil {
t.Fatal(err)
}
if specs[0].Key != "alpha" || specs[1].Key != "zebra" {
t.Fatalf("unstable order: %v", specs)
}
}
}
func TestComposeToSpecsDatabaseImages(t *testing.T) {
cases := map[string]string{
"postgres:16": "postgresql",
"redis:7": "redis",
"mysql:8": "mysql",
"mariadb:11": "mariadb",
"mongo:7": "mongodb",
"clickhouse:24": "clickhouse",
}
for image, want := range cases {
specs, _, err := composeToSpecsForTest(t, "services:\n db:\n image: "+image+"\n")
if err != nil {
t.Fatalf("%s: %v", image, err)
}
if specs[0].Type != "database" || specs[0].Runtime != want {
t.Errorf("%s → %+v, want database/%s", image, specs[0], want)
}
}
}
func TestComposeToSpecsNoServices(t *testing.T) {
_, _, err := composeToSpecsForTest(t, "version: '3'\n")
if err == nil || !strings.Contains(err.Error(), "no services") {
t.Errorf("want no-services error, got %v", err)
}
}
func TestComposeToSpecsWarnings(t *testing.T) {
_, warnings, err := composeToSpecsForTest(t, `services:
app:
build: .
orphan:
image: alpine
environment:
- UNSET_VAR
`)
if err != nil {
t.Fatal(err)
}
joined := strings.Join(warnings, "\n")
if !strings.Contains(joined, "build contexts are not supported") {
t.Errorf("missing build warning: %v", warnings)
}
if !strings.Contains(joined, "UNSET_VAR") {
t.Errorf("missing env warning: %v", warnings)
}
}
@@ -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)
}
}
}
+153 -6
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"
@@ -34,6 +36,7 @@ type ServiceTemplate struct {
} }
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"`
@@ -43,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 {
@@ -147,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"`
@@ -160,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 {
@@ -213,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()})
@@ -351,11 +398,15 @@ func handleCreateFromTemplate(c *gin.Context) {
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},
@@ -927,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
} }
@@ -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)
}
@@ -156,6 +156,48 @@ func (c *Client) Do(method, path string, body interface{}) ([]byte, error) {
return data, nil 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. // DoJSON performs a request and unmarshals the response into out.
func (c *Client) DoJSON(method, path string, body interface{}, out interface{}) error { func (c *Client) DoJSON(method, path string, body interface{}, out interface{}) error {
data, err := c.Do(method, path, body) data, err := c.Do(method, path, body)
+77 -2
View File
@@ -2,6 +2,7 @@ package commands
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -79,6 +80,9 @@ var dbCreateCmd = &cobra.Command{
body[key] = v body[key] = v
} }
} }
if v, _ := cmd.Flags().GetBool("public"); v {
body["public_port"] = true
}
data, err := c.Do("POST", "/databases", body) data, err := c.Do("POST", "/databases", body)
if err != nil { if err != nil {
return err return err
@@ -88,6 +92,43 @@ var dbCreateCmd = &cobra.Command{
}, },
} }
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{ var dbDeleteCmd = &cobra.Command{
Use: "delete <id>", Use: "delete <id>",
Short: "Delete a database and its data", Short: "Delete a database and its data",
@@ -159,7 +200,36 @@ var dbRestoreCmd = &cobra.Command{
if !Confirm(fmt.Sprintf("Restore database %s from backup %s? Current data will be overwritten.", args[0], args[1])) { 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} 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]}) 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 { if err != nil {
return err return err
} }
@@ -278,6 +348,11 @@ func init() {
dbCreateCmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse") dbCreateCmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
dbCreateCmd.Flags().String("version", "", "engine version tag") dbCreateCmd.Flags().String("version", "", "engine version tag")
dbCreateCmd.Flags().String("storage-size", "", "volume size (e.g. 10Gi)") 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} { for _, cmd := range []*cobra.Command{dbRegisterCmd, dbTestConnCmd} {
cmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse") cmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
cmd.Flags().String("host", "", "database host") cmd.Flags().String("host", "", "database host")
@@ -287,5 +362,5 @@ func init() {
cmd.Flags().String("password", "", "database password (stored encrypted on register)") cmd.Flags().String("password", "", "database password (stored encrypted on register)")
cmd.Flags().Bool("ssl", false, "require TLS") cmd.Flags().Bool("ssl", false, "require TLS")
} }
DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbRegisterCmd, dbTestConnCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbDownloadCmd) DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbUpdateCmd, dbRegisterCmd, dbTestConnCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbImportBackupCmd, dbDownloadCmd)
} }
@@ -0,0 +1,57 @@
package commands
import (
"fmt"
"io"
"os"
"github.com/spf13/cobra"
)
var importVars []string
var ImportCmd = &cobra.Command{
Use: "import",
Short: "Import external definitions into Containr",
}
var importComposeCmd = &cobra.Command{
Use: "compose <project-id> <compose.yml|->",
Short: "Deploy a docker-compose file straight into a project (no 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 compose file: %w", err)
}
vars, err := parseKVFlags(importVars)
if err != nil {
return err
}
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{"compose_yaml": string(raw)}
if len(vars) > 0 {
body["variables"] = vars
}
data, err := c.Do("POST", "/projects/"+args[0]+"/import-compose", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() {
importComposeCmd.Flags().StringArrayVar(&importVars, "var", nil, "Variable override (KEY=VALUE, repeatable)")
ImportCmd.AddCommand(importComposeCmd)
}
+45 -2
View File
@@ -195,6 +195,14 @@ var servicesUpdateCmd = &cobra.Command{
} }
body["volumes"] = volumes 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") { if cmd.Flags().Changed("maintenance") {
v, _ := cmd.Flags().GetString("maintenance") v, _ := cmd.Flags().GetString("maintenance")
body["maintenance_mode"] = v == "on" || v == "true" body["maintenance_mode"] = v == "on" || v == "true"
@@ -504,8 +512,10 @@ func init() {
uf.String("memory-reserve", "", "soft memory 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-cmd", "", "build command for the static builder (empty clears)")
uf.String("static-dir", "", "output dir 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"} { for _, a := range []string{"start", "stop", "restart", "redeploy", "sleep", "wake"} {
verb := a verb := a
ServicesCmd.AddCommand(&cobra.Command{ ServicesCmd.AddCommand(&cobra.Command{
Use: verb + " <id>", Use: verb + " <id>",
@@ -520,5 +530,38 @@ func init() {
domainsAddCmd.Flags().Bool("default", false, "set as the default domain") domainsAddCmd.Flags().Bool("default", false, "set as the default domain")
servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd) servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd)
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd) 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}
},
} }
+136 -2
View File
@@ -1,8 +1,11 @@
package commands package commands
import ( import (
"encoding/json"
"fmt" "fmt"
"io"
"os" "os"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -104,16 +107,31 @@ var templatesDeleteCmd = &cobra.Command{
}, },
} }
var tplDeployVars []string
var tplDeployName string
var templatesDeployCmd = &cobra.Command{ var templatesDeployCmd = &cobra.Command{
Use: "deploy <template-id> <project-id>", Use: "deploy <template-id> <project-id>",
Short: "Deploy a template into a project", 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), Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := client() c, err := client()
if err != nil { if err != nil {
return err return err
} }
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", map[string]string{"project_id": args[1]}) 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 { if err != nil {
return err return err
} }
@@ -122,6 +140,122 @@ var templatesDeployCmd = &cobra.Command{
}, },
} }
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() { func init() {
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd) 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)
} }
+2
View File
@@ -75,9 +75,11 @@ func init() {
rootCmd.AddCommand(commands.VariablesCmd) rootCmd.AddCommand(commands.VariablesCmd)
rootCmd.AddCommand(commands.VolumesCmd) rootCmd.AddCommand(commands.VolumesCmd)
rootCmd.AddCommand(commands.RegistriesCmd) rootCmd.AddCommand(commands.RegistriesCmd)
rootCmd.AddCommand(commands.BackupTargetsCmd)
rootCmd.AddCommand(commands.DatabasesCmd) rootCmd.AddCommand(commands.DatabasesCmd)
rootCmd.AddCommand(commands.CronCmd) rootCmd.AddCommand(commands.CronCmd)
rootCmd.AddCommand(commands.TemplatesCmd) rootCmd.AddCommand(commands.TemplatesCmd)
rootCmd.AddCommand(commands.ImportCmd)
rootCmd.AddCommand(commands.NodesCmd) rootCmd.AddCommand(commands.NodesCmd)
rootCmd.AddCommand(commands.ScalingCmd) rootCmd.AddCommand(commands.ScalingCmd)
rootCmd.AddCommand(commands.HACmd) rootCmd.AddCommand(commands.HACmd)
@@ -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
}
@@ -8,6 +8,8 @@ package sqlcdb
import ( import (
"context" "context"
"database/sql" "database/sql"
"github.com/google/uuid"
) )
const countDatabaseServicesByUserAndName = `-- name: CountDatabaseServicesByUserAndName :one const countDatabaseServicesByUserAndName = `-- name: CountDatabaseServicesByUserAndName :one
@@ -55,8 +57,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, project_id, 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, $12)
` `
type CreateDatabaseServiceParams struct { type CreateDatabaseServiceParams struct {
@@ -68,6 +70,8 @@ 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"`
ProjectID uuid.NullUUID `json:"project_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"`
} }
@@ -82,6 +86,8 @@ func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseS
arg.Version, arg.Version,
arg.Plan, arg.Plan,
arg.Region, arg.Region,
arg.PublicPort,
arg.ProjectID,
arg.CreatedAt, arg.CreatedAt,
arg.UpdatedAt, arg.UpdatedAt,
) )
@@ -152,6 +158,16 @@ func (q *Queries) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg Data
return exists, err return exists, err
} }
const deleteDatabaseServiceByID = `-- name: DeleteDatabaseServiceByID :exec
DELETE FROM database_services
WHERE id = $1
`
func (q *Queries) DeleteDatabaseServiceByID(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteDatabaseServiceByID, id)
return err
}
const deleteDatabaseServiceByIDAndUser = `-- name: DeleteDatabaseServiceByIDAndUser :exec const deleteDatabaseServiceByIDAndUser = `-- name: DeleteDatabaseServiceByIDAndUser :exec
DELETE FROM database_services DELETE FROM database_services
WHERE id = $1 AND user_id = $2 WHERE id = $1 AND user_id = $2
@@ -168,7 +184,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
@@ -189,6 +205,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,
) )
@@ -196,7 +213,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
} }
const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
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.created_at, database_services.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.project_id, database_services.created_at, database_services.updated_at
FROM database_services FROM database_services
WHERE id = $1 WHERE id = $1
` `
@@ -223,6 +240,9 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
&i.ExternalUsername, &i.ExternalUsername,
&i.ExternalPassword, &i.ExternalPassword,
&i.ExternalSsl, &i.ExternalSsl,
&i.PublicPort,
&i.BackupTargetID,
&i.ProjectID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -230,7 +250,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
} }
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
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.created_at, database_services.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.project_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
` `
@@ -262,6 +282,9 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
&i.ExternalUsername, &i.ExternalUsername,
&i.ExternalPassword, &i.ExternalPassword,
&i.ExternalSsl, &i.ExternalSsl,
&i.PublicPort,
&i.BackupTargetID,
&i.ProjectID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -282,7 +305,7 @@ func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (str
} }
const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
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.created_at, database_services.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.project_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
` `
@@ -315,6 +338,9 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic
&i.ExternalUsername, &i.ExternalUsername,
&i.ExternalPassword, &i.ExternalPassword,
&i.ExternalSsl, &i.ExternalSsl,
&i.PublicPort,
&i.BackupTargetID,
&i.ProjectID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@@ -332,7 +358,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
@@ -361,6 +387,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 {
@@ -377,8 +404,62 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
return items, nil return items, nil
} }
const listDatabaseServicesByProject = `-- name: ListDatabaseServicesByProject :many
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.project_id, database_services.created_at, database_services.updated_at
FROM database_services
WHERE project_id = $1
ORDER BY created_at DESC
`
func (q *Queries) ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error) {
rows, err := q.db.QueryContext(ctx, listDatabaseServicesByProject, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []DatabaseService{}
for rows.Next() {
var i DatabaseService
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Type,
&i.Status,
&i.Version,
&i.Plan,
&i.Region,
&i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.Provider,
&i.ExternalHost,
&i.ExternalPort,
&i.ExternalName,
&i.ExternalUsername,
&i.ExternalPassword,
&i.ExternalSsl,
&i.PublicPort,
&i.BackupTargetID,
&i.ProjectID,
&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 listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
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.created_at, database_services.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.project_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
@@ -412,6 +493,9 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
&i.ExternalUsername, &i.ExternalUsername,
&i.ExternalPassword, &i.ExternalPassword,
&i.ExternalSsl, &i.ExternalSsl,
&i.PublicPort,
&i.BackupTargetID,
&i.ProjectID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@@ -527,6 +611,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"`
} }
@@ -98,6 +114,9 @@ type DatabaseService struct {
ExternalUsername sql.NullString `json:"external_username"` ExternalUsername sql.NullString `json:"external_username"`
ExternalPassword string `json:"external_password"` ExternalPassword string `json:"external_password"`
ExternalSsl bool `json:"external_ssl"` ExternalSsl bool `json:"external_ssl"`
PublicPort bool `json:"public_port"`
BackupTargetID sql.NullString `json:"backup_target_id"`
ProjectID uuid.NullUUID `json:"project_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"`
} }
@@ -6,6 +6,7 @@ package sqlcdb
import ( import (
"context" "context"
"database/sql"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -13,11 +14,13 @@ 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
@@ -30,12 +33,16 @@ type Querier interface {
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, 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
DeleteDatabaseServiceByID(ctx context.Context, id string) 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)
@@ -55,9 +62,11 @@ type Querier interface {
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)
ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error)
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error) ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error) ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
@@ -74,9 +83,12 @@ type Querier interface {
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) 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
@@ -85,6 +97,7 @@ type Querier interface {
TouchUserToken(ctx context.Context, id uuid.UUID) 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
+20
View File
@@ -238,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)
@@ -366,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)
+17 -2
View File
@@ -58,12 +58,15 @@ func init() {
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false}) 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_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_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_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_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_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_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_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).", "PUT", "/services/{id}", []string{"id"}, nil, 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 // Domains
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false}) register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
@@ -100,7 +103,8 @@ func init() {
// Databases // Databases
register(spec{"containr_databases_list", "List managed databases", "GET", "/databases", nil, nil, false}) 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_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).", "POST", "/databases", nil, 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_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", "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_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})
@@ -109,6 +113,13 @@ func init() {
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_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}) 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 // Cron
register(spec{"containr_cron_list", "List cron jobs", "GET", "/cron-jobs", nil, nil, false}) 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_create", "Create a cron job. Args: service_id, schedule (5-field), command, name, timezone.", "POST", "/cron-jobs", nil, nil, false})
@@ -120,6 +131,10 @@ func init() {
register(spec{"containr_templates_list", "List templates", "GET", "/templates", nil, nil, false}) 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_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_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})
register(spec{"containr_projects_import_compose", "Deploy a docker-compose file straight into a project. Args: id (project), compose_yaml, variables, plan, region.", "POST", "/projects/{id}/import-compose", []string{"id"}, []string{"compose_yaml", "variables", "plan", "region"}, false})
// Notifications // Notifications
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false}) register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
@@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE database_services
ADD COLUMN IF NOT EXISTS public_port BOOLEAN NOT NULL DEFAULT false;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE database_services
DROP COLUMN IF EXISTS public_port;
-- +goose StatementEnd
@@ -0,0 +1,34 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS backup_targets (
id VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
endpoint VARCHAR(512) NOT NULL,
bucket VARCHAR(255) NOT NULL,
region VARCHAR(100) NOT NULL DEFAULT '',
prefix VARCHAR(255) NOT NULL DEFAULT '',
access_key TEXT NOT NULL DEFAULT '',
secret_key TEXT NOT NULL DEFAULT '',
use_tls BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
ALTER TABLE database_services
ADD COLUMN IF NOT EXISTS backup_target_id VARCHAR(255);
ALTER TABLE database_backups
ADD COLUMN IF NOT EXISTS remote_key VARCHAR(512);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE database_backups
DROP COLUMN IF EXISTS remote_key;
ALTER TABLE database_services
DROP COLUMN IF EXISTS backup_target_id;
DROP TABLE IF EXISTS backup_targets;
-- +goose StatementEnd
@@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE services ADD COLUMN IF NOT EXISTS sleep_enabled BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE services ADD COLUMN IF NOT EXISTS sleep_idle_minutes INTEGER NOT NULL DEFAULT 15;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE services DROP COLUMN IF EXISTS sleep_enabled;
ALTER TABLE services DROP COLUMN IF EXISTS sleep_idle_minutes;
-- +goose StatementEnd
@@ -0,0 +1,12 @@
-- +goose Up
-- Link managed databases provisioned by graph template deploys / compose
-- imports to their project so deleting the project cleans them up too.
ALTER TABLE database_services
ADD COLUMN IF NOT EXISTS project_id uuid NULL REFERENCES projects(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_database_services_project_id
ON database_services(project_id);
-- +goose Down
DROP INDEX IF EXISTS idx_database_services_project_id;
ALTER TABLE database_services DROP COLUMN IF EXISTS project_id;
@@ -0,0 +1,43 @@
-- name: ListBackupTargetsByUser :many
SELECT backup_targets.*
FROM backup_targets
WHERE user_id = sqlc.arg(user_id)
ORDER BY created_at DESC;
-- name: GetBackupTargetByIDAndUser :one
SELECT backup_targets.*
FROM backup_targets
WHERE id = sqlc.arg(id) AND user_id = sqlc.arg(user_id);
-- name: GetBackupTargetByID :one
SELECT backup_targets.*
FROM backup_targets
WHERE id = sqlc.arg(id);
-- 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);
-- 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;
-- name: DeleteBackupTargetByIDAndUser :exec
DELETE FROM backup_targets
WHERE id = $1 AND user_id = $2;
-- name: SetDatabaseBackupTargetByIDAndUser :exec
UPDATE database_services
SET backup_target_id = $1, updated_at = $2
WHERE id = $3 AND user_id = $4;
-- name: SetDatabaseBackupRemoteKeyByID :exec
UPDATE database_backups
SET remote_key = $1
WHERE id = $2;
-- name: CountDatabasesUsingBackupTarget :one
SELECT COUNT(*)
FROM database_services
WHERE backup_target_id = sqlc.arg(target_id);
+19 -4
View File
@@ -35,8 +35,23 @@ FROM database_services
WHERE user_id = sqlc.arg(user_id) AND LOWER(name) = LOWER(sqlc.arg(name)); WHERE user_id = sqlc.arg(user_id) AND LOWER(name) = LOWER(sqlc.arg(name));
-- name: CreateDatabaseService :exec -- 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, project_id, 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, $12);
-- name: ListDatabaseServicesByProject :many
SELECT database_services.*
FROM database_services
WHERE project_id = $1
ORDER BY created_at DESC;
-- name: DeleteDatabaseServiceByID :exec
DELETE FROM database_services
WHERE id = $1;
-- name: SetDatabaseServicePublicPortByIDAndUser :exec
UPDATE database_services
SET public_port = $1, updated_at = $2
WHERE id = $3 AND user_id = $4;
-- name: UpdateDatabaseServiceNameAndPlanByIDAndUser :exec -- name: UpdateDatabaseServiceNameAndPlanByIDAndUser :exec
UPDATE database_services UPDATE database_services
@@ -73,7 +88,7 @@ SET status = $1, connection_url = $2, updated_at = $3
WHERE id = $4; WHERE id = $4;
-- name: ListDatabaseBackupsByDatabaseAndUser :many -- 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
@@ -81,7 +96,7 @@ ORDER BY b.created_at DESC
LIMIT $3; LIMIT $3;
-- name: GetDatabaseBackupByIDAndDatabaseAndUser :one -- 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;
+19
View File
@@ -146,6 +146,9 @@ CREATE TABLE database_services (
external_username VARCHAR(255), external_username VARCHAR(255),
external_password TEXT NOT NULL DEFAULT '', external_password TEXT NOT NULL DEFAULT '',
external_ssl BOOLEAN NOT NULL DEFAULT false, external_ssl BOOLEAN NOT NULL DEFAULT false,
public_port BOOLEAN NOT NULL DEFAULT false,
backup_target_id VARCHAR(255),
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
@@ -156,10 +159,26 @@ CREATE TABLE database_backups (
size VARCHAR(50) NOT NULL, size VARCHAR(50) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'in_progress', status VARCHAR(50) NOT NULL DEFAULT 'in_progress',
backup_path TEXT, backup_path TEXT,
remote_key VARCHAR(512),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE completed_at TIMESTAMP WITH TIME ZONE
); );
CREATE TABLE backup_targets (
id VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
endpoint VARCHAR(512) NOT NULL,
bucket VARCHAR(255) NOT NULL,
region VARCHAR(100) NOT NULL DEFAULT '',
prefix VARCHAR(255) NOT NULL DEFAULT '',
access_key TEXT NOT NULL DEFAULT '',
secret_key TEXT NOT NULL DEFAULT '',
use_tls BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE node_agents ( CREATE TABLE node_agents (
id VARCHAR(255) PRIMARY KEY, id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
+215 -2
View File
@@ -31,6 +31,12 @@ import {
restoreDatabaseBackup, restoreDatabaseBackup,
createManagedDatabase, createManagedDatabase,
registerExternalDatabase, registerExternalDatabase,
importDatabaseBackup,
updateDatabase,
listBackupTargets,
createBackupTarget,
deleteBackupTarget,
testBackupTarget,
testDatabaseConnection, testDatabaseConnection,
testDatabaseConnectionByID, testDatabaseConnectionByID,
type ExternalDatabaseInput, type ExternalDatabaseInput,
@@ -125,6 +131,7 @@ import {
HeartPulse, HeartPulse,
Cloud, Cloud,
ExternalLink, ExternalLink,
Upload,
} from 'lucide-react'; } from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) { function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
@@ -1567,6 +1574,139 @@ function RegistriesSection() {
); );
} }
function BackupTargetsSection() {
const queryClient = useQueryClient();
const targetsQuery = useQuery({
queryKey: ['backup-targets'],
queryFn: listBackupTargets,
});
const [form, setForm] = useState({ name: '', endpoint: '', bucket: '', region: '', prefix: '', accessKey: '', secretKey: '', noTls: false });
const [testResult, setTestResult] = useState<Record<string, { ok: boolean; latency_ms?: number; error?: string }>>({});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['backup-targets'] });
const createMutation = useMutation({
mutationFn: () =>
createBackupTarget({
name: form.name.trim(),
endpoint: form.endpoint.trim(),
bucket: form.bucket.trim(),
region: form.region.trim() || undefined,
prefix: form.prefix.trim() || undefined,
access_key: form.accessKey || undefined,
secret_key: form.secretKey || undefined,
use_tls: !form.noTls,
}),
onSuccess: () => {
setForm({ name: '', endpoint: '', bucket: '', region: '', prefix: '', accessKey: '', secretKey: '', noTls: false });
invalidate();
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteBackupTarget(id),
onSuccess: invalidate,
});
const testMutation = useMutation({
mutationFn: (id: string) => testBackupTarget(id),
onSuccess: (result, id) => setTestResult((prev) => ({ ...prev, [id]: result })),
onError: (error, id) =>
setTestResult((prev) => ({ ...prev, [id]: { ok: false, error: error instanceof Error ? error.message : 'probe failed' } })),
});
const targets = targetsQuery.data ?? [];
const fieldCls = 'h-9 flex-1 min-w-[130px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all';
return (
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<HardDrive size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Backup targets</h2>
<p className="text-xs text-[var(--text-tertiary)]">S3-compatible destinations for database archives — keys stored encrypted</p>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Name (e.g. garage)" className={fieldCls} />
<input value={form.endpoint} onChange={(e) => setForm((f) => ({ ...f, endpoint: e.target.value }))} placeholder="Endpoint host:port" className={fieldCls} />
<input value={form.bucket} onChange={(e) => setForm((f) => ({ ...f, bucket: e.target.value }))} placeholder="Bucket" className="h-9 w-36 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all" />
<input value={form.accessKey} onChange={(e) => setForm((f) => ({ ...f, accessKey: e.target.value }))} placeholder="Access key" className={fieldCls} />
<input type="password" value={form.secretKey} onChange={(e) => setForm((f) => ({ ...f, secretKey: e.target.value }))} placeholder="Secret key" className={fieldCls} />
<label className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
<input type="checkbox" checked={form.noTls} onChange={(e) => setForm((f) => ({ ...f, noTls: e.target.checked }))} className="accent-[var(--accent-primary)]" />
No TLS
</label>
<button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !form.name.trim() || !form.endpoint.trim() || !form.bucket.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: 'var(--accent-primary)' }}
>
{createMutation.isPending ? 'Checking…' : 'Add target'}
</button>
</div>
{createMutation.isError ? (
<p className="text-xs text-[var(--error)]">
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add target'}
</p>
) : null}
{targetsQuery.isLoading ? (
<div className="py-4 text-center">
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : targets.length === 0 ? (
<p className="text-xs text-[var(--text-tertiary)]">
No backup targets. Database archives stay in the local backups volume until a target is configured.
</p>
) : (
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
{targets.map((t) => (
<div key={t.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0">
<span className="text-[var(--text-primary)]">{t.name}</span>
<span className="ml-2 mono text-[var(--text-muted)]">{t.endpoint}/{t.bucket}{t.prefix ? `/${t.prefix}` : ''}</span>
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
{t.has_credentials ? 'keys set' : 'anonymous'}{t.use_tls ? '' : ' · no TLS'}
</span>
{testResult[t.id ?? ''] && (
<span className={`ml-2 ${testResult[t.id ?? '']?.ok ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
{testResult[t.id ?? '']?.ok ? `reachable (${testResult[t.id ?? '']?.latency_ms ?? 0}ms)` : `unreachable — ${testResult[t.id ?? '']?.error ?? 'probe failed'}`}
</span>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
<button
onClick={() => testMutation.mutate(t.id ?? '')}
disabled={testMutation.isPending}
className="text-[var(--accent-primary)] hover:underline disabled:opacity-50"
>
Test
</button>
<button
onClick={() => {
if (window.confirm(`Delete backup target ${t.name}?`)) {
deleteMutation.mutate(t.id ?? '');
}
}}
disabled={deleteMutation.isPending}
className="text-[var(--error)] hover:underline disabled:opacity-50"
>
Remove
</button>
</div>
</div>
))}
</div>
)}
</div>
</section>
);
}
export function SettingsPage() { export function SettingsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -1741,6 +1881,7 @@ export function SettingsPage() {
{/* Personal Access Tokens */} {/* Personal Access Tokens */}
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} /> <UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
<RegistriesSection /> <RegistriesSection />
<BackupTargetsSection />
{/* Platform Settings - owner only */} {/* Platform Settings - owner only */}
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null} {profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
@@ -1906,7 +2047,7 @@ export function DatabasesPage() {
const [bindDb, setBindDb] = useState<DatabaseEntity | null>(null); const [bindDb, setBindDb] = useState<DatabaseEntity | null>(null);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createMode, setCreateMode] = useState<'managed' | 'external'>('managed'); const [createMode, setCreateMode] = useState<'managed' | 'external'>('managed');
const [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' }); const [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local', public: false });
const [externalForm, setExternalForm] = useState({ name: '', type: 'postgresql', host: '', port: '', database: '', username: '', password: '', ssl: false }); const [externalForm, setExternalForm] = useState({ name: '', type: 'postgresql', host: '', port: '', database: '', username: '', password: '', ssl: false });
const [externalProbe, setExternalProbe] = useState<{ ok: boolean; latency_ms?: number; error?: string } | null>(null); const [externalProbe, setExternalProbe] = useState<{ ok: boolean; latency_ms?: number; error?: string } | null>(null);
const [externalTestResult, setExternalTestResult] = useState<Record<string, { ok: boolean; latency_ms?: number; error?: string }>>({}); const [externalTestResult, setExternalTestResult] = useState<Record<string, { ok: boolean; latency_ms?: number; error?: string }>>({});
@@ -1917,6 +2058,11 @@ export function DatabasesPage() {
refetchInterval: 15_000, refetchInterval: 15_000,
enabled: !isDemoMode, enabled: !isDemoMode,
}); });
const backupTargetsQuery = useQuery({
queryKey: ['backup-targets'],
queryFn: listBackupTargets,
enabled: !isDemoMode,
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['databases'] }); const invalidate = () => queryClient.invalidateQueries({ queryKey: ['databases'] });
const createMutation = useMutation({ const createMutation = useMutation({
@@ -1926,10 +2072,11 @@ export function DatabasesPage() {
type: createForm.type as CreateDatabaseInput['type'], type: createForm.type as CreateDatabaseInput['type'],
plan: createForm.plan as CreateDatabaseInput['plan'], plan: createForm.plan as CreateDatabaseInput['plan'],
region: createForm.region.trim() || 'local', region: createForm.region.trim() || 'local',
public_port: createForm.public,
}), }),
onSuccess: () => { onSuccess: () => {
setCreateOpen(false); setCreateOpen(false);
setCreateForm({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' }); setCreateForm({ name: '', type: 'postgresql', plan: 'hobby', region: 'local', public: false });
setCreateError(null); setCreateError(null);
invalidate(); invalidate();
}, },
@@ -1965,6 +2112,20 @@ export function DatabasesPage() {
onSuccess: (result) => setExternalProbe(result), onSuccess: (result) => setExternalProbe(result),
onError: (error) => setExternalProbe({ ok: false, error: error instanceof Error ? error.message : 'probe failed' }), onError: (error) => setExternalProbe({ ok: false, error: error instanceof Error ? error.message : 'probe failed' }),
}); });
const publicPortMutation = useMutation({
mutationFn: ({ id, publicPort }: { id: string; publicPort: boolean }) =>
updateDatabase(id, { public_port: publicPort }),
onSuccess: () => window.setTimeout(invalidate, 1500),
});
const backupTargetMutation = useMutation({
mutationFn: ({ id, targetId }: { id: string; targetId: string }) =>
updateDatabase(id, { backup_target_id: targetId }),
onSuccess: invalidate,
});
const importBackupMutation = useMutation({
mutationFn: ({ id, file }: { id: string; file: File }) => importDatabaseBackup(id, file),
onSuccess: invalidate,
});
const testByIdMutation = useMutation({ const testByIdMutation = useMutation({
mutationFn: (id: string) => testDatabaseConnectionByID(id), mutationFn: (id: string) => testDatabaseConnectionByID(id),
onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })), onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })),
@@ -2132,6 +2293,13 @@ export function DatabasesPage() {
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
> >
<RefreshCw size={11} /> Restart <RefreshCw size={11} /> Restart
</button>
<button
onClick={() => { if (window.confirm(`${db.public_port ? 'Stop exposing' : 'Expose'} ${db.name} on all interfaces? The container will be recreated; data persists.`)) publicPortMutation.mutate({ id: db.id ?? '', publicPort: !db.public_port }) }}
disabled={publicPortMutation.isPending}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border text-xs font-medium transition-colors disabled:opacity-40 ${db.public_port ? 'border-[var(--warning)] text-[var(--warning)]' : 'border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--border-default)]'}`}
>
<Link2 size={11} /> {db.public_port ? 'Public ✓' : 'Local only'}
</button> </button>
</> </>
)} )}
@@ -2182,6 +2350,38 @@ export function DatabasesPage() {
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p> <p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p>
{signedIn ? ( {signedIn ? (
<div className="flex items-center gap-2">
{(backupTargetsQuery.data?.length ?? 0) > 0 && (
<select
value={db.backup_target_id ?? ''}
onChange={(e) => backupTargetMutation.mutate({ id: db.id ?? '', targetId: e.target.value })}
title="Offsite archive destination"
className="h-7 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-xs text-[var(--text-secondary)]"
>
<option value="">local only</option>
{(backupTargetsQuery.data ?? []).map((t) => (
<option key={t.id} value={t.id}>{t.name} ({t.bucket})</option>
))}
</select>
)}
<label
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium border border-[var(--border-subtle)] text-[var(--text-secondary)] transition-all ${importBackupMutation.isPending ? 'opacity-50' : 'cursor-pointer hover:text-[var(--text-primary)]'}`}
title="Import a .tar.gz archive as a restore point"
>
<Upload size={11} />
{importBackupMutation.isPending ? 'Importing…' : 'Import'}
<input
type="file"
accept=".tar.gz,application/gzip"
className="hidden"
disabled={importBackupMutation.isPending}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) importBackupMutation.mutate({ id: db.id ?? '', file: f });
e.target.value = '';
}}
/>
</label>
<button <button
onClick={() => backupMutation.mutate(db.id ?? '')} onClick={() => backupMutation.mutate(db.id ?? '')}
disabled={!running || backupMutation.isPending} disabled={!running || backupMutation.isPending}
@@ -2191,6 +2391,7 @@ export function DatabasesPage() {
<Archive size={11} /> <Archive size={11} />
{backupMutation.isPending ? 'Starting…' : 'New backup'} {backupMutation.isPending ? 'Starting…' : 'New backup'}
</button> </button>
</div>
) : null} ) : null}
</div> </div>
<BackupScheduleRow <BackupScheduleRow
@@ -2207,6 +2408,7 @@ export function DatabasesPage() {
<span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span> <span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span>
<span className="mono text-[var(--text-muted)]">{b.size}</span> <span className="mono text-[var(--text-muted)]">{b.size}</span>
<span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span> <span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span>
{b.remote && <span className="rounded bg-[var(--surface-card)] px-1.5 py-0.5 text-[var(--accent-primary)]" title="Also shipped to the configured backup target">offsite</span>}
{b.status === 'completed' && ( {b.status === 'completed' && (
<span className="ml-auto flex items-center gap-3"> <span className="ml-auto flex items-center gap-3">
<a <a
@@ -2368,6 +2570,7 @@ export function DatabasesPage() {
</div> </div>
</> </>
) : ( ) : (
<>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div> <div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</label> <label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</label>
@@ -2403,6 +2606,16 @@ export function DatabasesPage() {
/> />
</div> </div>
</div> </div>
<label className="flex items-center gap-2 text-xs text-[var(--text-secondary)]">
<input
type="checkbox"
checked={createForm.public}
onChange={(e) => setCreateForm((f) => ({ ...f, public: e.target.checked }))}
className="accent-[var(--accent-primary)]"
/>
Expose port on all interfaces (default: localhost only)
</label>
</>
)} )}
{createError ? ( {createError ? (
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-3.5 py-2.5 text-xs text-[var(--error)]">{createError}</div> <div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-3.5 py-2.5 text-xs text-[var(--error)]">{createError}</div>
@@ -33,7 +33,10 @@ import {
updateService, updateService,
restartService, restartService,
stopService, stopService,
sleepService,
wakeService,
getServiceRuntime, getServiceRuntime,
getServiceEnvCheck,
type CronJobEntity, type CronJobEntity,
type ServiceVolume, type ServiceVolume,
type ServiceDomain, type ServiceDomain,
@@ -187,6 +190,12 @@ export function ServiceDetailPage() {
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'variables', enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'variables',
}); });
const envCheckQuery = useQuery({
queryKey: ['service-env-check', serviceId],
queryFn: () => getServiceEnvCheck(serviceId),
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'variables',
});
const saveVariablesMutation = useMutation({ const saveVariablesMutation = useMutation({
mutationFn: (rows: VariableDraft[]) => mutationFn: (rows: VariableDraft[]) =>
updateServiceVariables( updateServiceVariables(
@@ -459,10 +468,19 @@ export function ServiceDetailPage() {
mutationFn: () => stopService(serviceId), mutationFn: () => stopService(serviceId),
onSuccess: invalidateService, onSuccess: invalidateService,
}); });
const sleepMutation = useMutation({
mutationFn: () => sleepService(serviceId),
onSuccess: invalidateService,
});
const wakeMutation = useMutation({
mutationFn: () => wakeService(serviceId),
onSuccess: invalidateService,
});
const updateServiceMutation = useMutation({ const updateServiceMutation = useMutation({
mutationFn: (input: Parameters<typeof updateService>[1]) => updateService(serviceId, input), mutationFn: (input: Parameters<typeof updateService>[1]) => updateService(serviceId, input),
onSuccess: () => { onSuccess: () => {
setNetworkForm(null); setNetworkForm(null);
setSleepDraft(null);
invalidateService(); invalidateService();
}, },
}); });
@@ -481,6 +499,7 @@ export function ServiceDetailPage() {
const [domainInput, setDomainInput] = useState(''); const [domainInput, setDomainInput] = useState('');
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null); const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null); const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
const [sleepDraft, setSleepDraft] = useState<{ id: string; enabled: boolean; minutes: string } | null>(null);
const [buildForm, setBuildForm] = useState<{ const [buildForm, setBuildForm] = useState<{
builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string; builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string;
} | null>(null); } | null>(null);
@@ -501,6 +520,9 @@ export function ServiceDetailPage() {
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data; const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data; const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
const sleepDraftCurrent = sleepDraft?.id === serviceId ? sleepDraft : null;
const sleepEnabled = sleepDraftCurrent?.enabled ?? Boolean(service?.sleepEnabled);
const sleepMinutes = sleepDraftCurrent?.minutes ?? String(service?.sleepIdleMinutes ?? 15);
const varRows: VariableDraft[] = useMemo( const varRows: VariableDraft[] = useMemo(
() => () =>
@@ -689,7 +711,7 @@ export function ServiceDetailPage() {
className={`badge-${service.status === 'running' ? 'active' : service.status === 'degraded' ? 'degraded' : 'stopped'}`} className={`badge-${service.status === 'running' ? 'active' : service.status === 'degraded' ? 'degraded' : 'stopped'}`}
> >
{service.status === 'running' && <span className="live-dot" />} {service.status === 'running' && <span className="live-dot" />}
{service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : 'Stopped'} {service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : service.status === 'sleeping' ? 'Sleeping' : 'Stopped'}
</span> </span>
</div> </div>
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}> <div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
@@ -1288,6 +1310,23 @@ export function ServiceDetailPage() {
</div> </div>
</div> </div>
{envCheckQuery.data && !envCheckQuery.data.ok && (
<div className="mb-4 rounded-[var(--radius-md)] border border-[var(--warning)]/40 bg-[var(--warning-soft)] p-4">
<p className="text-xs font-semibold text-[var(--warning)] mb-2">Environment issues detected</p>
<ul className="space-y-1 text-xs text-[var(--text-secondary)]">
{(envCheckQuery.data.unresolved ?? []).map((u) => (
<li key={u} className="mono">Unresolved reference: {u}</li>
))}
{(envCheckQuery.data.empty ?? []).map((k) => (
<li key={k} className="mono">Empty value: {k}</li>
))}
{(envCheckQuery.data.unreadable ?? []).map((k) => (
<li key={k} className="mono">Cannot decrypt secret (key rotated?): {k}</li>
))}
</ul>
</div>
)}
{bulkOpen && ( {bulkOpen && (
<div className="mb-4 panel-soft p-4"> <div className="mb-4 panel-soft p-4">
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
@@ -2340,6 +2379,80 @@ export function ServiceDetailPage() {
</div> </div>
)} )}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium text-[var(--text-primary)]">Sleep</h3>
<p className="text-xs text-[var(--text-tertiary)] mt-0.5">
Scale to zero after idle minutes without traffic. Requests resume the service automatically.
</p>
</div>
{service.status === 'sleeping' ? (
<button
type="button"
disabled={wakeMutation.isPending}
onClick={() => wakeMutation.mutate()}
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-50"
>
{wakeMutation.isPending ? 'Waking…' : 'Wake now'}
</button>
) : (
<button
type="button"
disabled={sleepMutation.isPending || service.status !== 'running'}
onClick={() => sleepMutation.mutate()}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors disabled:opacity-50"
>
{sleepMutation.isPending ? 'Sleeping…' : 'Sleep now'}
</button>
)}
</div>
<div className="flex items-center gap-4 text-xs">
<label className="flex items-center gap-2 text-[var(--text-primary)]">
<input
type="checkbox"
checked={sleepEnabled}
onChange={(e) =>
setSleepDraft({ id: serviceId, enabled: e.target.checked, minutes: sleepMinutes })
}
/>
Sleep on idle
</label>
<label className="flex items-center gap-2 text-[var(--text-tertiary)]">
after
<input
type="number"
min={1}
max={1440}
value={sleepMinutes}
onChange={(e) =>
setSleepDraft({ id: serviceId, enabled: sleepEnabled, minutes: e.target.value })
}
className="w-20 px-2 py-1 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
/>
min
</label>
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() =>
updateServiceMutation.mutate({
sleep_enabled: sleepEnabled,
sleep_idle_minutes: Math.max(1, Math.min(1440, Number(sleepMinutes) || 15)),
})
}
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-50"
>
Save
</button>
{sleepDraftCurrent && (
<span className="text-[var(--text-tertiary)]">unsaved</span>
)}
</div>
</div>
)}
{!isDemoMode && ( {!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]"> <div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -7,6 +7,7 @@ import {
deployTemplate, deployTemplate,
getCurrentUserProfile, getCurrentUserProfile,
getTemplateById, getTemplateById,
importComposeTemplate,
listProjects, listProjects,
listTemplates, listTemplates,
updateTemplate, updateTemplate,
@@ -36,6 +37,8 @@ import {
Trash2, Trash2,
Upload, Upload,
X, X,
FileCode,
Network,
} from 'lucide-react'; } from 'lucide-react';
function toServiceName(value: string): string { function toServiceName(value: string): string {
@@ -147,10 +150,16 @@ export function TemplatesPage() {
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState< const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
Record<string, Record<string, string>> Record<string, Record<string, string>>
>({}); >({});
const [composeOpen, setComposeOpen] = useState(false);
const [composeYaml, setComposeYaml] = useState('');
const [composeWarnings, setComposeWarnings] = useState<string[]>([]);
const [composeError, setComposeError] = useState<string | null>(null);
const [lastDeployment, setLastDeployment] = useState<{ const [lastDeployment, setLastDeployment] = useState<{
projectId: string; projectId: string;
serviceId: string; serviceId?: string;
serviceName: string; serviceName: string;
created?: { key: string; kind: string; id: string; name: string; deploymentId?: string }[];
} | null>(null); } | null>(null);
const templatesQuery = useQuery({ const templatesQuery = useQuery({
@@ -281,6 +290,12 @@ export function TemplatesPage() {
}); });
}, [selectedDetail, variableValues]); }, [selectedDetail, variableValues]);
const graphMembers = useMemo(
() => selectedDetail?.config.services ?? [],
[selectedDetail],
);
const isGraphTemplate = graphMembers.length > 0;
const deployMutation = useMutation({ const deployMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!selectedTemplateId) { if (!selectedTemplateId) {
@@ -296,17 +311,21 @@ export function TemplatesPage() {
return deployTemplate(selectedTemplateId, { return deployTemplate(selectedTemplateId, {
projectId: deployProjectId, projectId: deployProjectId,
name: deployName.trim(), name: isGraphTemplate ? undefined : deployName.trim(),
variables, variables,
}); });
}, },
onSuccess: (result) => { onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['projects'] }); queryClient.invalidateQueries({ queryKey: ['projects'] });
queryClient.invalidateQueries({ queryKey: ['project-services'] }); queryClient.invalidateQueries({ queryKey: ['project-services'] });
queryClient.invalidateQueries({ queryKey: ['databases'] });
setLastDeployment({ setLastDeployment({
projectId: deployProjectId, projectId: deployProjectId,
serviceId: result.serviceId, serviceId: result.serviceId,
serviceName: deployName.trim(), serviceName: isGraphTemplate
? `${result.created?.length ?? 0} resources`
: deployName.trim(),
created: result.created,
}); });
}, },
}); });
@@ -314,7 +333,7 @@ export function TemplatesPage() {
const isDeployDisabled = const isDeployDisabled =
!selectedTemplateId || !selectedTemplateId ||
!deployProjectId || !deployProjectId ||
deployName.trim().length === 0 || (!isGraphTemplate && deployName.trim().length === 0) ||
missingRequiredVariables.length > 0 || missingRequiredVariables.length > 0 ||
deployMutation.isPending; deployMutation.isPending;
@@ -344,6 +363,37 @@ export function TemplatesPage() {
}, },
}); });
const composeImportMutation = useMutation({
mutationFn: (yaml: string) => importComposeTemplate(yaml),
onSuccess: (result) => {
setComposeWarnings(result.warnings);
setComposeError(null);
setComposeOpen(false);
// Load the converted graph into the template editor for review/save.
setEditingTemplate(null);
setEditorIsPublic(false);
setEditorError(null);
setEditorJson(
JSON.stringify(
{
name: 'compose-stack',
description: 'Imported from docker-compose',
category: 'stack',
logo: '',
config: result.config,
variables: [],
},
null,
2,
),
);
setEditorOpen(true);
},
onError: (error) => {
setComposeError(error instanceof Error ? error.message : 'Failed to convert compose file');
},
});
const openCreateEditor = () => { const openCreateEditor = () => {
setEditingTemplate(null); setEditingTemplate(null);
setEditorJson(EMPTY_TEMPLATE_JSON); setEditorJson(EMPTY_TEMPLATE_JSON);
@@ -438,6 +488,19 @@ export function TemplatesPage() {
<Layers size={16} /> <Layers size={16} />
<span>{filteredTemplates.length} templates</span> <span>{filteredTemplates.length} templates</span>
{signedIn && !isDemoMode ? ( {signedIn && !isDemoMode ? (
<>
<button
type="button"
onClick={() => {
setComposeYaml('');
setComposeWarnings([]);
setComposeError(null);
setComposeOpen(true);
}}
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3.5 text-[12.5px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
<FileCode size={14} /> Import Compose
</button>
<button <button
type="button" type="button"
onClick={openCreateEditor} onClick={openCreateEditor}
@@ -445,6 +508,7 @@ export function TemplatesPage() {
> >
<Plus size={14} /> New template <Plus size={14} /> New template
</button> </button>
</>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -725,6 +789,42 @@ export function TemplatesPage() {
)} )}
</div> </div>
{isGraphTemplate ? (
<div className="panel-soft p-4 mb-6">
<div className="flex items-center gap-2 mb-3">
<Network size={14} className="text-[var(--accent-primary)]" />
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Stack — {graphMembers.length} members
</p>
</div>
<div className="space-y-2">
{graphMembers.map((member) => (
<div key={member.key} className="flex items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-2 min-w-0">
{member.type === 'database' ? (
<Database size={13} className="text-[var(--info)] shrink-0" />
) : (
<Box size={13} className="text-[var(--text-tertiary)] shrink-0" />
)}
<span className="mono text-[var(--text-primary)] truncate">{member.name || member.key}</span>
{member.dependsOn && member.dependsOn.length > 0 ? (
<span className="text-[10px] text-[var(--text-tertiary)]">
after {member.dependsOn.join(', ')}
</span>
) : null}
</div>
<span className="mono text-[11px] text-[var(--text-tertiary)] truncate">
{member.type === 'database' ? `managed ${member.runtime}` : member.runtime || member.repo || '—'}
</span>
</div>
))}
</div>
<p className="text-xs text-[var(--text-tertiary)] mt-3">
Members join a private project network; references like {'{{service.db.url}}'} resolve to the member key.
</p>
</div>
) : null}
{/* Deploy Section */} {/* Deploy Section */}
<div className="border-t border-[var(--border-subtle)] pt-6"> <div className="border-t border-[var(--border-subtle)] pt-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
@@ -733,7 +833,9 @@ export function TemplatesPage() {
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3> <h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3>
<p className="text-sm text-[var(--text-secondary)]">Configure and create a new service</p> <p className="text-sm text-[var(--text-secondary)]">
{isGraphTemplate ? 'Deploy all members into a project' : 'Configure and create a new service'}
</p>
</div> </div>
</div> </div>
@@ -767,6 +869,7 @@ export function TemplatesPage() {
))} ))}
</select> </select>
</div> </div>
{!isGraphTemplate ? (
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Service Name Service Name
@@ -784,6 +887,7 @@ export function TemplatesPage() {
placeholder="service-name" placeholder="service-name"
/> />
</div> </div>
) : null}
</div> </div>
{selectedDetail.variables.length > 0 && ( {selectedDetail.variables.length > 0 && (
@@ -846,15 +950,30 @@ export function TemplatesPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Check size={16} /> <Check size={16} />
<span> <span>
Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created {lastDeployment.created
? `Stack deployed — ${lastDeployment.created.length} resources`
: <>Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created</>}
</span> </span>
<Link <Link
to={`/projects/${lastDeployment.projectId}/services/${lastDeployment.serviceId}${isDemoMode ? '?demo=1' : ''}`} to={lastDeployment.created
? `/projects/${lastDeployment.projectId}${isDemoMode ? '?demo=1' : ''}`
: `/projects/${lastDeployment.projectId}/services/${lastDeployment.serviceId}${isDemoMode ? '?demo=1' : ''}`}
className="flex items-center gap-1 ml-2 underline underline-offset-2 hover:no-underline" className="flex items-center gap-1 ml-2 underline underline-offset-2 hover:no-underline"
> >
View <ArrowRight size={12} /> View <ArrowRight size={12} />
</Link> </Link>
</div> </div>
{lastDeployment.created ? (
<div className="mt-2 space-y-1">
{lastDeployment.created.map((m) => (
<div key={m.id} className="flex items-center gap-2 text-xs">
{m.kind === 'database' ? <Database size={11} /> : <Box size={11} />}
<span className="mono">{m.name}</span>
<span className="opacity-70">({m.kind})</span>
</div>
))}
</div>
) : null}
</div> </div>
)} )}
@@ -868,12 +987,12 @@ export function TemplatesPage() {
{deployMutation.isPending ? ( {deployMutation.isPending ? (
<> <>
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin" />
Creating... Deploying...
</> </>
) : ( ) : (
<> <>
<Play size={16} /> <Play size={16} />
Create Service {isGraphTemplate ? 'Deploy Stack' : 'Create Service'}
</> </>
)} )}
</button> </button>
@@ -929,6 +1048,14 @@ export function TemplatesPage() {
spellCheck={false} spellCheck={false}
className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none" className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/> />
{composeWarnings.length > 0 ? (
<div className="border-t border-[var(--border-subtle)] bg-[var(--warning-soft)]/60 px-5 py-2.5 text-xs text-[var(--warning)]">
<p className="font-medium mb-1">Compose import notes</p>
{composeWarnings.map((w) => (
<p key={w}>• {w}</p>
))}
</div>
) : null}
{editorError ? ( {editorError ? (
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]"> <div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
{editorError} {editorError}
@@ -966,6 +1093,59 @@ export function TemplatesPage() {
</div> </div>
</div> </div>
) : null} ) : null}
{/* Compose import */}
{composeOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
<div className="flex items-center justify-between border-b border-[var(--border-subtle)] px-5 py-4">
<div>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Import docker-compose</h2>
<p className="text-xs text-[var(--text-tertiary)]">
Paste a compose file — known database images become managed databases, everything else becomes services.
</p>
</div>
<button
type="button"
onClick={() => setComposeOpen(false)}
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
>
<X size={15} />
</button>
</div>
<textarea
value={composeYaml}
onChange={(e) => setComposeYaml(e.target.value)}
spellCheck={false}
placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'}
className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/>
{composeError ? (
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
{composeError}
</div>
) : null}
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
<button
type="button"
onClick={() => setComposeOpen(false)}
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Cancel
</button>
<button
type="button"
onClick={() => composeImportMutation.mutate(composeYaml)}
disabled={!composeYaml.trim() || composeImportMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />}
Convert to template
</button>
</div>
</div>
</div>
) : null}
</div> </div>
); );
} }
@@ -8,6 +8,7 @@ import {
createManagedDatabase, createManagedDatabase,
createService, createService,
getProjectById, getProjectById,
importComposeIntoProject,
listConnectedGitRepositories, listConnectedGitRepositories,
listGitBranches, listGitBranches,
listGitProviders, listGitProviders,
@@ -45,6 +46,7 @@ import {
Search, Search,
GitBranch, GitBranch,
Container, Container,
FileUp,
} from 'lucide-react'; } from 'lucide-react';
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings'; type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
@@ -473,6 +475,8 @@ export function ProjectWorkspacePage() {
const signedIn = isDemoMode || Boolean(sessionQuery.data); const signedIn = isDemoMode || Boolean(sessionQuery.data);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [composeYaml, setComposeYaml] = useState('');
const openAddService = () => { const openAddService = () => {
if (!signedIn) { if (!signedIn) {
@@ -556,6 +560,25 @@ export function ProjectWorkspacePage() {
}, },
}); });
const composeImportMutation = useMutation({
mutationFn: (yaml: string) => importComposeIntoProject(projectId, yaml),
onSuccess: (result) => {
setImportOpen(false);
setComposeYaml('');
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
const count = result.created.length;
const warn = result.warnings.length > 0 ? ` (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})` : '';
toast.showToast(`Imported ${count} resource${count === 1 ? '' : 's'} from compose${warn}`, 'success');
for (const warning of result.warnings) {
toast.showToast(warning, 'warning');
}
},
onError: (err) => {
toast.showToast(err instanceof Error ? err.message : 'Compose import failed', 'error');
},
});
const project = useMemo( const project = useMemo(
() => (isDemoMode ? getDemoProjectById(projectId) : projectQuery.data), () => (isDemoMode ? getDemoProjectById(projectId) : projectQuery.data),
[isDemoMode, projectId, projectQuery.data], [isDemoMode, projectId, projectQuery.data],
@@ -721,6 +744,15 @@ export function ProjectWorkspacePage() {
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd> <kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
</button> </button>
{!isDemoMode && signedIn && ( {!isDemoMode && signedIn && (
<>
<button
onClick={() => setImportOpen(true)}
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] hover:text-[var(--text-primary)] transition-colors"
title="Deploy a docker-compose file into this project"
>
<FileUp size={14} />
<span className="hidden sm:inline">Compose</span>
</button>
<button <button
onClick={openAddService} onClick={openAddService}
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all" className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
@@ -729,6 +761,7 @@ export function ProjectWorkspacePage() {
<Plus size={14} /> <Plus size={14} />
<span className="hidden sm:inline">Add Service</span> <span className="hidden sm:inline">Add Service</span>
</button> </button>
</>
)} )}
</div> </div>
</div> </div>
@@ -973,6 +1006,49 @@ export function ProjectWorkspacePage() {
/> />
)} )}
{/* Compose Import */}
{importOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setImportOpen(false)} />
<div className="relative w-full max-w-2xl panel p-6">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<FileUp size={20} className="text-[var(--accent-primary)]" />
</div>
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Import docker-compose</h3>
</div>
<p className="text-sm text-[var(--text-secondary)]">
Paste a docker-compose.yml. Services deploy in dependency order; postgres, mysql, redis, mongo,
mariadb, clickhouse and dragonfly images become managed databases reachable by service name.
</p>
<textarea
value={composeYaml}
onChange={(e) => setComposeYaml(e.target.value)}
spellCheck={false}
className="mt-5 w-full h-72 px-4 py-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm resize-none"
placeholder={'services:\n web:\n image: nginx:alpine\n ports:\n - "80"\n db:\n image: postgres:16-alpine\n environment:\n POSTGRES_PASSWORD: secret'}
/>
<div className="mt-5 flex justify-end gap-2">
<button
onClick={() => setImportOpen(false)}
className="h-10 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Cancel
</button>
<button
onClick={() => composeImportMutation.mutate(composeYaml)}
disabled={composeImportMutation.isPending || composeYaml.trim().length === 0}
className="h-10 px-5 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50 transition-all flex items-center gap-2"
style={{ background: 'var(--accent-primary)' }}
>
{composeImportMutation.isPending && <Loader2 size={14} className="animate-spin" />}
Deploy stack
</button>
</div>
</div>
</div>
)}
{/* Command Palette */} {/* Command Palette */}
<CommandPalette <CommandPalette
open={commandPaletteOpen} open={commandPaletteOpen}
+815 -1
View File
@@ -932,6 +932,176 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/services/{id}/sleep": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Sleep service
* @description Scale the service to zero immediately. When it has public domains a wake placeholder holds the route and resumes the service on the next request.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Sleep initiated */
202: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/wake": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Wake service
* @description Reconcile a sleeping service back to running on demand
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Wake initiated */
202: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/env-check": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Environment diagnostics
* @description Reports empty variables, unresolved ${{service.KEY}} references, and secrets whose stored ciphertext fails to decrypt. Values are never returned.
*/
get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Diagnostic result */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
ok?: boolean;
unresolved?: string[];
empty?: string[];
unreadable?: string[];
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/internal/wake/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Wake callback (internal)
* @description Unauthenticated wake endpoint hit by the busybox wake placeholder while a slept service resumes. Idempotent and non-destructive.
*/
get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Wake accepted */
202: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Service not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/redeploy": { "/services/{id}/redeploy": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1241,6 +1411,204 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/backup-targets": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List backup targets
* @description S3-compatible archive destinations for the caller. Secret keys are never returned — `has_credentials` signals presence.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Backup target list */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
backup_targets?: components["schemas"]["BackupTarget"][];
};
};
};
};
};
put?: never;
/**
* Register a backup target
* @description The bucket is probed (BucketExists) before storing — typos and bad credentials fail at registration. Keys are stored encrypted at rest.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["BackupTargetRequest"];
};
};
responses: {
/** @description Backup target created */
201: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation error or connection check failed */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/backup-targets/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Update a backup target
* @description Empty access_key/secret_key keep the stored pair — delete and recreate to remove credentials. Re-probed before saving.
*/
put: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["BackupTargetRequest"];
};
};
responses: {
/** @description Backup target updated */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Backup target not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
post?: never;
/**
* Delete a backup target
* @description Fails with 409 while databases still point at the target.
*/
delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Backup target deleted */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Databases still reference this target */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/backup-targets/{id}/test": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Re-probe a backup target
* @description Checks bucket access with the stored credentials. Always returns a probe result — `ok` false means unreachable or unauthorized.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Probe result */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
ok?: boolean;
latency_ms?: number;
error?: string;
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/domains": { "/services/{id}/domains": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2603,6 +2971,10 @@ export interface paths {
* Create resource from template * Create resource from template
* @description Create a new resource in a project using a selected template. * @description Create a new resource in a project using a selected template.
* For `database` templates, this creates a managed database service. * For `database` templates, this creates a managed database service.
* For v2 graph templates (config.services non-empty), this deploys
* the entire service graph — databases first, then services in
* dependency order — and returns `resource: stack` with a `created`
* list. Deployments are enqueued for each service member.
*/ */
post: { post: {
parameters: { parameters: {
@@ -2637,6 +3009,271 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/templates/{id}/plan": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Dry-resolve a template
* @description Resolves a template without creating resources: variable merging,
* `{{secret}}`/`{{random:N}}`/`{{VAR}}`/`{{VAR:-default}}` expression
* expansion and `{{service.<key>.<prop>}}` references. Generated
* secrets are returned masked — a real deploy regenerates them.
* Returns `members` (with resolved environment), `missing_variables`
* and `unresolved` reference names.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Template ID */
id: string;
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
project_id?: string;
variables?: {
[key: string]: string;
};
};
};
};
responses: {
/** @description Resolved plan */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
members?: components["schemas"]["TemplatePlanMember"][];
missing_variables?: string[];
unresolved?: string[];
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/templates/import/compose": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Convert docker-compose YAML to a template graph
* @description Parses a docker-compose YAML document and returns a v2 template
* config (`version: 2`, `services[]`). Well-known database images
* (postgres, redis, mongo, mysql, mariadb, clickhouse, dragonfly)
* become managed-database members; other images become web services.
* `build:` contexts are flagged in `warnings` — Containr deploys
* images or git repos, not local build contexts. The returned config
* can be saved via POST /templates or deployed directly via
* POST /templates/deploy.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description Raw docker-compose YAML (max 512 KiB) */
compose_yaml: string;
};
};
};
responses: {
/** @description Converted config */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
config?: components["schemas"]["TemplateConfig"];
variables?: Record<string, never>[];
warnings?: string[];
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/templates/deploy": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Deploy an ad-hoc service graph
* @description Deploys a v2 graph config (config.services[]) without a stored
* template — the companion to POST /templates/import/compose.
* Members are created in dependency order; `database` members become
* managed databases attached to the project network under their key
* as DNS alias, and deployments are enqueued for service members.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
project_id: string;
config: components["schemas"]["TemplateConfig"];
variables?: {
[key: string]: string;
};
plan?: string;
region?: string;
};
};
};
responses: {
/** @description Graph deployed */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
resource?: string;
created?: {
key?: string;
kind?: string;
id?: string;
name?: string;
deployment_id?: string;
}[];
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{id}/import-compose": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Deploy a docker-compose file into a project
* @description Parses a docker-compose YAML document and deploys it directly into
* the project — no template row. Well-known database images
* (postgres, mysql, redis, mongo, mariadb, clickhouse, dragonfly)
* become managed databases attached to the project network under
* their service name as DNS alias; other services are created and
* deployed in dependency order. Returns the created resources plus
* any import warnings (unsupported keys, missing values).
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description Full docker-compose YAML document (max 512 KiB) */
compose_yaml: string;
variables?: {
[key: string]: string;
};
plan?: string;
region?: string;
};
};
};
responses: {
/** @description Stack deployed */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
resource?: string;
created?: Record<string, never>[];
warnings?: string[];
};
};
};
/** @description Invalid compose YAML or unresolved references */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Access denied */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Project not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/agent-tokens": { "/agent-tokens": {
parameters: { parameters: {
query?: never; query?: never;
@@ -3414,6 +4051,78 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/databases/{id}/backups/import": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Import a backup archive
* @description Upload a `.tar.gz` archive (multipart field `file` or a raw application/gzip body) to create a new restore point. The archive lands in the backup volume and, when a backup target is assigned, is also shipped offsite. Managed databases only; 2 GiB ceiling.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Database ID */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": {
/** Format: binary */
file?: string;
};
"application/gzip": string;
};
};
responses: {
/** @description Backup imported */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
backup_id?: string;
size?: string;
status?: string;
};
};
};
/** @description Invalid archive */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Archive too large */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/databases/{id}/backups/{bid}/download": { "/databases/{id}/backups/{bid}/download": {
parameters: { parameters: {
query?: never; query?: never;
@@ -8573,6 +9282,10 @@ export interface components {
static_build_cmd?: string; static_build_cmd?: string;
/** @description Output directory for the static builder (default dist) */ /** @description Output directory for the static builder (default dist) */
static_dir?: string; static_dir?: string;
/** @description Scale to zero after sleep_idle_minutes without inbound traffic; a wake placeholder answers requests while the service resumes */
sleep_enabled?: boolean;
/** @description Idle timeout in minutes before sleeping (1-1440, default 15) */
sleep_idle_minutes?: number;
/** @description Computed public URL (published port or domain) */ /** @description Computed public URL (published port or domain) */
public_url?: string; public_url?: string;
/** /**
@@ -8700,6 +9413,10 @@ export interface components {
static_build_cmd?: string; static_build_cmd?: string;
/** @description Output directory for the static builder (empty string clears) */ /** @description Output directory for the static builder (empty string clears) */
static_dir?: string; static_dir?: string;
/** @description Scale to zero after sleep_idle_minutes without inbound traffic; a wake placeholder answers requests while resuming */
sleep_enabled?: boolean;
/** @description Idle timeout in minutes before sleeping (1-1440) */
sleep_idle_minutes?: number;
}; };
ServiceDomain: { ServiceDomain: {
/** Format: uuid */ /** Format: uuid */
@@ -9035,6 +9752,8 @@ export interface components {
is_public?: boolean; is_public?: boolean;
}; };
TemplateConfig: { TemplateConfig: {
/** @description Config version — 2 when services[] is used */
version?: number;
/** @description Service type for created service */ /** @description Service type for created service */
type?: string; type?: string;
/** @description Runtime or base image */ /** @description Runtime or base image */
@@ -9057,6 +9776,52 @@ export interface components {
nixpacks_config?: { nixpacks_config?: {
[key: string]: string; [key: string]: string;
}; };
/**
* @description v2 graph members. When non-empty, the flat fields above are
* ignored and deploy creates the whole graph in dependency order.
*/
services?: components["schemas"]["TemplateServiceSpec"][];
};
TemplateServiceSpec: {
/** @description Member key — DNS alias on the project network and the target of {{service.<key>.*}} refs */
key: string;
/** @description Display/deployed name (defaults to key) */
name?: string;
/** @enum {string} */
type: "web" | "worker" | "cron" | "database";
/** @description Image reference (web/worker/cron) or managed engine (database) */
runtime?: string;
/** @description Git repository URL — when set, the member builds from source */
repo?: string;
branch?: string;
build_command?: string;
start_command?: string;
port?: number;
health_check?: string;
/** @enum {string} */
builder?: "auto" | "nixpacks" | "dockerfile" | "static";
/**
* @description Env vars. Values support {{secret}}, {{secret(N)}}, {{random:N}},
* {{VAR}}, {{VAR:-default}} and {{service.<key>.<prop>}} where prop
* is host, port, url, user, password or database.
*/
environment?: {
[key: string]: string;
};
volumes?: components["schemas"]["ServiceVolume"][];
/** @description Member keys that must be created first */
depends_on?: string[];
};
TemplatePlanMember: {
key?: string;
name?: string;
type?: string;
runtime?: string;
port?: number;
depends_on?: string[];
environment?: {
[key: string]: string;
};
}; };
TemplateVariable: { TemplateVariable: {
/** @description Variable key */ /** @description Variable key */
@@ -9099,11 +9864,20 @@ export interface components {
* @description Type of resource created by template deployment * @description Type of resource created by template deployment
* @enum {string} * @enum {string}
*/ */
resource?: "service" | "database"; resource?: "service" | "database" | "stack";
/** @description Newly created service ID (set for non-database templates) */ /** @description Newly created service ID (set for non-database templates) */
service_id?: string; service_id?: string;
/** @description Newly created managed database ID (set for database templates) */ /** @description Newly created managed database ID (set for database templates) */
database_id?: string; database_id?: string;
/** @description Created resources (set for graph templates — resource=stack) */
created?: {
key?: string;
/** @enum {string} */
kind?: "service" | "database";
id?: string;
name?: string;
deployment_id?: string;
}[];
/** @description Deployment result message */ /** @description Deployment result message */
message?: string; message?: string;
}; };
@@ -9185,6 +9959,10 @@ export interface components {
* @enum {string} * @enum {string}
*/ */
provider?: "managed" | "external"; provider?: "managed" | "external";
/** @description managed only — bind the database port on all interfaces instead of 127.0.0.1; toggling recreates the container */
public_port?: boolean;
/** @description S3-compatible backup target receiving this database's archives, empty when local-only */
backup_target_id?: string;
external?: components["schemas"]["DatabaseExternal"]; external?: components["schemas"]["DatabaseExternal"];
/** /**
* Format: date-time * Format: date-time
@@ -9220,6 +9998,8 @@ export interface components {
plan: "hobby" | "starter" | "standard" | "business"; plan: "hobby" | "starter" | "standard" | "business";
/** @description Database region */ /** @description Database region */
region: string; region: string;
/** @description Bind the database port on all interfaces (default 127.0.0.1) */
public_port?: boolean;
}; };
DatabaseExternal: { DatabaseExternal: {
host?: string; host?: string;
@@ -9253,6 +10033,10 @@ export interface components {
plan?: "hobby" | "starter" | "standard" | "business"; plan?: "hobby" | "starter" | "standard" | "business";
/** @description Cron expression for automatic backups; empty string clears the schedule */ /** @description Cron expression for automatic backups; empty string clears the schedule */
backup_schedule?: string | null; backup_schedule?: string | null;
/** @description Toggle the published port between 127.0.0.1 and all interfaces; recreates the managed container */
public_port?: boolean;
/** @description Assign a backup target for offsite archives; empty string clears */
backup_target_id?: string | null;
}; };
DatabaseActionRequest: { DatabaseActionRequest: {
/** /**
@@ -9303,6 +10087,36 @@ export interface components {
size?: string; size?: string;
/** @enum {string} */ /** @enum {string} */
status?: "completed" | "failed" | "in_progress"; status?: "completed" | "failed" | "in_progress";
/** @description Archive also shipped to the database's configured backup target */
remote?: boolean;
};
BackupTarget: {
id?: string;
name?: string;
/** @description Bare host[:port] — scheme comes from use_tls */
endpoint?: string;
bucket?: string;
region?: string;
prefix?: string;
has_credentials?: boolean;
use_tls?: boolean;
/** Format: date-time */
created_at?: string;
/** Format: date-time */
updated_at?: string;
};
BackupTargetRequest: {
name?: string;
endpoint: string;
bucket: string;
region?: string;
prefix?: string;
/** @description Stored encrypted; never returned. Empty on update keeps stored credentials. */
access_key?: string;
/** @description Stored encrypted; never returned. Empty on update keeps stored credentials. */
secret_key?: string;
/** @default true */
use_tls: boolean;
}; };
DatabaseBackupConfig: { DatabaseBackupConfig: {
enabled?: boolean; enabled?: boolean;
+1 -1
View File
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/CLI.md", "path": "guides/CLI.md",
"section": "guides", "section": "guides",
"title": "Containr CLI", "title": "Containr CLI",
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add <id> app.example.com --default\ncontainr services domains check <id> # DNS preflight per domain\ncontainr services domains remove <id> <domain-id>\n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update <id> --maintenance on\ncontainr services update <id> --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate eLine truncated "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `-Line truncated
}, },
{ {
"path": "guides/CLOUDFLARE_SETUP.md", "path": "guides/CLOUDFLARE_SETUP.md",
+4
View File
@@ -398,6 +398,10 @@ textarea {
background: var(--text-tertiary); background: var(--text-tertiary);
} }
.status-sleeping {
background: var(--info, #6366f1);
}
@keyframes pulse-ring { @keyframes pulse-ring {
0% { transform: scale(1); opacity: 0.7; } 0% { transform: scale(1); opacity: 0.7; }
70% { transform: scale(2); opacity: 0; } 70% { transform: scale(2); opacity: 0; }
+220 -10
View File
@@ -1,6 +1,6 @@
import type { components, paths } from '@/generated/api-types'; import type { components, paths } from '@/generated/api-types';
export type ServiceStatus = 'running' | 'degraded' | 'stopped' | 'building' | 'failed' | 'unknown'; export type ServiceStatus = 'running' | 'degraded' | 'stopped' | 'building' | 'failed' | 'sleeping' | 'waking' | 'unknown';
export type ProjectStats = components['schemas']['ProjectStats']; export type ProjectStats = components['schemas']['ProjectStats'];
@@ -63,6 +63,8 @@ export type ServiceEntity = {
memoryReserve?: string; memoryReserve?: string;
staticBuildCmd?: string; staticBuildCmd?: string;
staticDir?: string; staticDir?: string;
sleepEnabled?: boolean;
sleepIdleMinutes?: number;
publicUrl?: string; publicUrl?: string;
}; };
@@ -164,7 +166,20 @@ export type TemplateEntity = {
updatedAt?: string; updatedAt?: string;
}; };
export type TemplateServiceSpecEntity = {
key: string;
name?: string;
type: string;
runtime?: string;
repo?: string;
branch?: string;
port?: number;
dependsOn?: string[];
environment: Record<string, string>;
};
export type TemplateConfigEntity = { export type TemplateConfigEntity = {
version?: number;
type: string; type: string;
runtime: string; runtime: string;
buildCommand: string; buildCommand: string;
@@ -174,6 +189,7 @@ export type TemplateConfigEntity = {
environment: Record<string, string>; environment: Record<string, string>;
dockerfile?: string; dockerfile?: string;
nixpacksConfig: Record<string, string>; nixpacksConfig: Record<string, string>;
services?: TemplateServiceSpecEntity[];
}; };
export type TemplateVariableEntity = { export type TemplateVariableEntity = {
@@ -197,12 +213,15 @@ export type ListTemplatesInput = {
export type DeployTemplateInput = { export type DeployTemplateInput = {
projectId: string; projectId: string;
name: string; name?: string;
variables?: Record<string, string>; variables?: Record<string, string>;
}; };
export type DeployTemplateResult = { export type DeployTemplateResult = {
serviceId: string; resource: string;
serviceId?: string;
databaseId?: string;
created?: { key: string; kind: string; id: string; name: string; deploymentId?: string }[];
message: string; message: string;
}; };
@@ -594,6 +613,8 @@ function normalizeService(service: RawService): ServiceEntity | null {
memoryReserve: service.memory_reserve, memoryReserve: service.memory_reserve,
staticBuildCmd: service.static_build_cmd, staticBuildCmd: service.static_build_cmd,
staticDir: service.static_dir, staticDir: service.static_dir,
sleepEnabled: service.sleep_enabled,
sleepIdleMinutes: service.sleep_idle_minutes,
publicUrl: service.public_url, publicUrl: service.public_url,
}; };
} }
@@ -696,7 +717,21 @@ function normalizeStringRecord(raw: unknown): Record<string, string> {
} }
function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEntity { function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEntity {
const services = Array.isArray(config?.services)
? config.services.map((s) => ({
key: s?.key ?? '',
name: s?.name ?? undefined,
type: s?.type ?? 'web',
runtime: s?.runtime ?? undefined,
repo: s?.repo ?? undefined,
branch: s?.branch ?? undefined,
port: s?.port ?? undefined,
dependsOn: Array.isArray(s?.depends_on) ? s.depends_on.filter((d): d is string => typeof d === 'string') : undefined,
environment: normalizeStringRecord(s?.environment),
}))
: undefined;
return { return {
version: config?.version ?? undefined,
type: config?.type ?? '', type: config?.type ?? '',
runtime: config?.runtime ?? '', runtime: config?.runtime ?? '',
buildCommand: config?.build_command ?? '', buildCommand: config?.build_command ?? '',
@@ -706,6 +741,7 @@ function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEnti
environment: normalizeStringRecord(config?.environment), environment: normalizeStringRecord(config?.environment),
dockerfile: config?.dockerfile ?? undefined, dockerfile: config?.dockerfile ?? undefined,
nixpacksConfig: normalizeStringRecord(config?.nixpacks_config), nixpacksConfig: normalizeStringRecord(config?.nixpacks_config),
services,
}; };
} }
@@ -1258,13 +1294,26 @@ export async function getServiceRuntime(serviceId: string): Promise<ServiceRunti
return normalizeRuntime(payload.runtime); return normalizeRuntime(payload.runtime);
} }
async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart'): Promise<void> { async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart' | 'sleep' | 'wake'): Promise<void> {
await requestJson<{ message?: string }>(`/services/${serviceId}/${action}`, { method: 'POST' }); await requestJson<{ message?: string }>(`/services/${serviceId}/${action}`, { method: 'POST' });
} }
export const startService = (serviceId: string) => serviceAction(serviceId, 'start'); export const startService = (serviceId: string) => serviceAction(serviceId, 'start');
export const stopService = (serviceId: string) => serviceAction(serviceId, 'stop'); export const stopService = (serviceId: string) => serviceAction(serviceId, 'stop');
export const restartService = (serviceId: string) => serviceAction(serviceId, 'restart'); export const restartService = (serviceId: string) => serviceAction(serviceId, 'restart');
export const sleepService = (serviceId: string) => serviceAction(serviceId, 'sleep');
export const wakeService = (serviceId: string) => serviceAction(serviceId, 'wake');
export type EnvCheckResult = {
ok: boolean;
unresolved?: string[];
empty?: string[];
unreadable?: string[];
};
export async function getServiceEnvCheck(serviceId: string): Promise<EnvCheckResult> {
return requestJson<EnvCheckResult>(`/services/${serviceId}/env-check`);
}
export async function redeployService(serviceId: string): Promise<ServiceRuntime> { export async function redeployService(serviceId: string): Promise<ServiceRuntime> {
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, { const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, {
@@ -1602,21 +1651,122 @@ export async function deployTemplate(
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
project_id: input.projectId, project_id: input.projectId,
name: input.name, name: input.name ?? '',
variables: input.variables ?? {}, variables: input.variables ?? {},
}), }),
}); });
if (!payload.service_id) { const created = Array.isArray(payload.created)
throw new ApiError('Template deployment response is invalid', 500); ? payload.created
} .filter((m) => m && typeof m.id === 'string')
.map((m) => ({
key: m.key ?? '',
kind: m.kind ?? '',
id: m.id ?? '',
name: m.name ?? '',
deploymentId: m.deployment_id ?? undefined,
}))
: undefined;
return { return {
serviceId: payload.service_id, resource: payload.resource ?? 'service',
message: payload.message ?? 'Service created from template', serviceId: payload.service_id ?? undefined,
databaseId: payload.database_id ?? undefined,
created,
message: payload.message ?? 'Template deployed',
}; };
} }
export type TemplatePlanMember = {
key: string;
name: string;
type: string;
runtime?: string;
port?: number;
dependsOn?: string[];
environment: Record<string, string>;
};
export type TemplatePlanResult = {
members: TemplatePlanMember[];
missingVariables: string[];
unresolved: string[];
};
export async function planTemplate(
templateId: string,
input: { projectId?: string; variables?: Record<string, string> },
): Promise<TemplatePlanResult> {
const payload = await requestJson<{
members?: {
key?: string;
name?: string;
type?: string;
runtime?: string;
port?: number;
depends_on?: string[];
environment?: Record<string, string>;
}[];
missing_variables?: string[];
unresolved?: string[];
}>(`/templates/${templateId}/plan`, {
method: 'POST',
body: JSON.stringify({
project_id: input.projectId ?? '',
variables: input.variables ?? {},
}),
});
return {
members: (payload.members ?? []).map((m) => ({
key: m.key ?? '',
name: m.name ?? '',
type: m.type ?? '',
runtime: m.runtime ?? undefined,
port: m.port ?? undefined,
dependsOn: m.depends_on ?? undefined,
environment: normalizeStringRecord(m.environment),
})),
missingVariables: payload.missing_variables ?? [],
unresolved: payload.unresolved ?? [],
};
}
export type ComposeImportResult = {
config: Record<string, unknown>;
warnings: string[];
};
export async function importComposeTemplate(composeYaml: string): Promise<ComposeImportResult> {
const payload = await requestJson<{ config?: Record<string, unknown>; warnings?: string[] }>(
'/templates/import/compose',
{ method: 'POST', body: JSON.stringify({ compose_yaml: composeYaml }) },
);
if (!payload.config || typeof payload.config !== 'object') {
throw new ApiError('Compose import response is invalid', 500);
}
return { config: payload.config, warnings: payload.warnings ?? [] };
}
export type ProjectComposeImportResult = {
created: Array<{ key: string; kind: string; id: string; name: string; deployment_id?: string }>;
warnings: string[];
};
export async function importComposeIntoProject(
projectId: string,
composeYaml: string,
): Promise<ProjectComposeImportResult> {
const payload = await requestJson<{
created?: ProjectComposeImportResult['created'];
warnings?: string[];
}>(`/projects/${projectId}/import-compose`, {
method: 'POST',
body: JSON.stringify({ compose_yaml: composeYaml }),
});
return { created: payload.created ?? [], warnings: payload.warnings ?? [] };
}
export function serviceStatusClass(status: ServiceStatus): string { export function serviceStatusClass(status: ServiceStatus): string {
switch (status) { switch (status) {
case 'running': case 'running':
@@ -1627,6 +1777,10 @@ export function serviceStatusClass(status: ServiceStatus): string {
return 'status-building'; return 'status-building';
case 'failed': case 'failed':
return 'status-failed'; return 'status-failed';
case 'sleeping':
return 'status-sleeping';
case 'waking':
return 'status-building';
case 'stopped': case 'stopped':
return 'status-stopped'; return 'status-stopped';
default: default:
@@ -1708,6 +1862,13 @@ export async function getDatabase(id: string): Promise<DatabaseEntity> {
return requestJson<DatabaseEntity>(`/databases/${encodeURIComponent(id)}`); return requestJson<DatabaseEntity>(`/databases/${encodeURIComponent(id)}`);
} }
export async function updateDatabase(id: string, input: components['schemas']['UpdateDatabaseRequest']): Promise<void> {
await requestJson(`/databases/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(input),
});
}
export async function databaseAction(id: string, action: 'start' | 'stop' | 'restart'): Promise<void> { export async function databaseAction(id: string, action: 'start' | 'stop' | 'restart'): Promise<void> {
await requestJson(`/databases/${encodeURIComponent(id)}/action`, { await requestJson(`/databases/${encodeURIComponent(id)}/action`, {
method: 'POST', method: 'POST',
@@ -1736,6 +1897,21 @@ export async function restoreDatabaseBackup(id: string, backupId: string): Promi
}); });
} }
export async function importDatabaseBackup(id: string, file: File): Promise<{ backup_id: string; size: string }> {
const form = new FormData();
form.append('file', file);
const resp = await fetch(`${getApiBaseUrl()}/databases/${encodeURIComponent(id)}/backups/import`, {
method: 'POST',
headers: authHeaders(),
body: form,
});
const payload = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new ApiError(payload.error || 'Import failed', resp.status);
}
return payload;
}
export async function createManagedDatabase( export async function createManagedDatabase(
input: CreateDatabaseInput, input: CreateDatabaseInput,
): Promise<{ id: string; status: string }> { ): Promise<{ id: string; status: string }> {
@@ -1782,6 +1958,40 @@ export async function testDatabaseConnectionByID(id: string): Promise<{ ok: bool
return requestJson(`/databases/${encodeURIComponent(id)}/test-connection`, { method: 'POST' }); return requestJson(`/databases/${encodeURIComponent(id)}/test-connection`, { method: 'POST' });
} }
export type BackupTarget = components['schemas']['BackupTarget'];
export type BackupTargetInput = components['schemas']['BackupTargetRequest'];
export async function listBackupTargets(): Promise<BackupTarget[]> {
const payload = await requestJson<{ backup_targets?: BackupTarget[] }>('/backup-targets');
return payload.backup_targets ?? [];
}
export async function createBackupTarget(input: BackupTargetInput): Promise<BackupTarget> {
const payload = await requestJson<{ backup_target?: BackupTarget }>('/backup-targets', {
method: 'POST',
body: JSON.stringify(input),
});
if (!payload.backup_target) {
throw new ApiError('Backup target response is invalid', 500);
}
return payload.backup_target;
}
export async function updateBackupTarget(id: string, input: BackupTargetInput): Promise<void> {
await requestJson(`/backup-targets/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(input),
});
}
export async function deleteBackupTarget(id: string): Promise<void> {
await requestJson(`/backup-targets/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
export async function testBackupTarget(id: string): Promise<{ ok: boolean; latency_ms?: number; error?: string }> {
return requestJson(`/backup-targets/${encodeURIComponent(id)}/test`, { method: 'POST' });
}
export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoRequest']; export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoRequest'];
export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest']; export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest'];
export type GitWebhookEntity = components['schemas']['GitWebhook']; export type GitWebhookEntity = components['schemas']['GitWebhook'];
+6 -3
View File
@@ -19,10 +19,11 @@ function AuthGate() {
useEffect(() => { useEffect(() => {
if (!ready) return; if (!ready) return;
const inLogin = segments[0] === 'login'; const inLogin = segments[0] === 'login';
if (!api && !inLogin) router.replace('/login'); if (api && inLogin) router.replace('/');
else if (api && inLogin) router.replace('/');
}, [ready, api, segments, router]); }, [ready, api, segments, router]);
if (!ready) return null;
return ( return (
<Stack <Stack
screenOptions={{ screenOptions={{
@@ -31,11 +32,13 @@ function AuthGate() {
contentStyle: { backgroundColor: colors.bg }, contentStyle: { backgroundColor: colors.bg },
}} }}
> >
<Stack.Screen name="login" options={{ headerShown: false }} /> <Stack.Protected guard={!!api}>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="project/[id]" options={{ title: 'Project' }} /> <Stack.Screen name="project/[id]" options={{ title: 'Project' }} />
<Stack.Screen name="service/[id]" options={{ title: 'Service' }} /> <Stack.Screen name="service/[id]" options={{ title: 'Service' }} />
<Stack.Screen name="service/logs" options={{ title: 'Logs' }} /> <Stack.Screen name="service/logs" options={{ title: 'Logs' }} />
</Stack.Protected>
<Stack.Screen name="login" options={{ headerShown: false }} />
</Stack> </Stack>
); );
} }
+4 -1
View File
@@ -1,5 +1,8 @@
import { Redirect } from 'expo-router'; import { Redirect } from 'expo-router';
import { useSession } from '../session';
export default function Index() { export default function Index() {
return <Redirect href="/(tabs)" />; const { ready, api } = useSession();
if (!ready) return null;
return <Redirect href={api ? '/(tabs)' : '/login'} />;
} }
+682 -1
View File
@@ -649,6 +649,95 @@ paths:
'200': '200':
description: Service restarted description: Service restarted
/services/{id}/sleep:
post:
tags:
- Services
summary: Sleep service
description: Scale the service to zero immediately. When it has public domains a wake placeholder holds the route and resumes the service on the next request.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'202':
description: Sleep initiated
/services/{id}/wake:
post:
tags:
- Services
summary: Wake service
description: Reconcile a sleeping service back to running on demand
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'202':
description: Wake initiated
/services/{id}/env-check:
get:
tags:
- Services
summary: Environment diagnostics
description: Reports empty variables, unresolved ${{service.KEY}} references, and secrets whose stored ciphertext fails to decrypt. Values are never returned.
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Diagnostic result
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
unresolved:
type: array
items:
type: string
empty:
type: array
items:
type: string
unreadable:
type: array
items:
type: string
/internal/wake/{id}:
get:
tags:
- Services
summary: Wake callback (internal)
description: Unauthenticated wake endpoint hit by the busybox wake placeholder while a slept service resumes. Idempotent and non-destructive.
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'202':
description: Wake accepted
'404':
description: Service not found
/services/{id}/redeploy: /services/{id}/redeploy:
post: post:
tags: tags:
@@ -828,6 +917,118 @@ paths:
'404': '404':
description: Registry not found description: Registry not found
/backup-targets:
get:
tags:
- BackupTargets
summary: List backup targets
description: S3-compatible archive destinations for the caller. Secret keys are never returned — `has_credentials` signals presence.
security:
- bearerAuth: []
responses:
'200':
description: Backup target list
content:
application/json:
schema:
type: object
properties:
backup_targets:
type: array
items:
$ref: '#/components/schemas/BackupTarget'
post:
tags:
- BackupTargets
summary: Register a backup target
description: The bucket is probed (BucketExists) before storing — typos and bad credentials fail at registration. Keys are stored encrypted at rest.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BackupTargetRequest'
responses:
'201':
description: Backup target created
'400':
description: Validation error or connection check failed
/backup-targets/{id}:
put:
tags:
- BackupTargets
summary: Update a backup target
description: Empty access_key/secret_key keep the stored pair — delete and recreate to remove credentials. Re-probed before saving.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BackupTargetRequest'
responses:
'200':
description: Backup target updated
'404':
description: Backup target not found
delete:
tags:
- BackupTargets
summary: Delete a backup target
description: Fails with 409 while databases still point at the target.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Backup target deleted
'409':
description: Databases still reference this target
/backup-targets/{id}/test:
post:
tags:
- BackupTargets
summary: Re-probe a backup target
description: Checks bucket access with the stored credentials. Always returns a probe result — `ok` false means unreachable or unauthorized.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Probe result
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
latency_ms:
type: integer
error:
type: string
/services/{id}/domains: /services/{id}/domains:
get: get:
tags: tags:
@@ -1758,6 +1959,10 @@ paths:
description: | description: |
Create a new resource in a project using a selected template. Create a new resource in a project using a selected template.
For `database` templates, this creates a managed database service. For `database` templates, this creates a managed database service.
For v2 graph templates (config.services non-empty), this deploys
the entire service graph — databases first, then services in
dependency order — and returns `resource: stack` with a `created`
list. Deployments are enqueued for each service member.
security: security:
- bearerAuth: [] - bearerAuth: []
parameters: parameters:
@@ -1781,6 +1986,235 @@ paths:
schema: schema:
$ref: '#/components/schemas/DeployTemplateResponse' $ref: '#/components/schemas/DeployTemplateResponse'
/templates/{id}/plan:
post:
tags:
- Templates
summary: Dry-resolve a template
description: |
Resolves a template without creating resources: variable merging,
`{{secret}}`/`{{random:N}}`/`{{VAR}}`/`{{VAR:-default}}` expression
expansion and `{{service.<key>.<prop>}}` references. Generated
secrets are returned masked — a real deploy regenerates them.
Returns `members` (with resolved environment), `missing_variables`
and `unresolved` reference names.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
description: Template ID
schema:
type: string
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
project_id:
type: string
variables:
type: object
additionalProperties:
type: string
responses:
'200':
description: Resolved plan
content:
application/json:
schema:
type: object
properties:
members:
type: array
items:
$ref: '#/components/schemas/TemplatePlanMember'
missing_variables:
type: array
items:
type: string
unresolved:
type: array
items:
type: string
/templates/import/compose:
post:
tags:
- Templates
summary: Convert docker-compose YAML to a template graph
description: |
Parses a docker-compose YAML document and returns a v2 template
config (`version: 2`, `services[]`). Well-known database images
(postgres, redis, mongo, mysql, mariadb, clickhouse, dragonfly)
become managed-database members; other images become web services.
`build:` contexts are flagged in `warnings` — Containr deploys
images or git repos, not local build contexts. The returned config
can be saved via POST /templates or deployed directly via
POST /templates/deploy.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- compose_yaml
properties:
compose_yaml:
type: string
description: Raw docker-compose YAML (max 512 KiB)
responses:
'200':
description: Converted config
content:
application/json:
schema:
type: object
properties:
config:
$ref: '#/components/schemas/TemplateConfig'
variables:
type: array
items:
type: object
warnings:
type: array
items:
type: string
/templates/deploy:
post:
tags:
- Templates
summary: Deploy an ad-hoc service graph
description: |
Deploys a v2 graph config (config.services[]) without a stored
template — the companion to POST /templates/import/compose.
Members are created in dependency order; `database` members become
managed databases attached to the project network under their key
as DNS alias, and deployments are enqueued for service members.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- project_id
- config
properties:
project_id:
type: string
config:
$ref: '#/components/schemas/TemplateConfig'
variables:
type: object
additionalProperties:
type: string
plan:
type: string
region:
type: string
responses:
'201':
description: Graph deployed
content:
application/json:
schema:
type: object
properties:
resource:
type: string
created:
type: array
items:
type: object
properties:
key:
type: string
kind:
type: string
id:
type: string
name:
type: string
deployment_id:
type: string
/projects/{id}/import-compose:
post:
tags:
- Projects
summary: Deploy a docker-compose file into a project
description: |
Parses a docker-compose YAML document and deploys it directly into
the project — no template row. Well-known database images
(postgres, mysql, redis, mongo, mariadb, clickhouse, dragonfly)
become managed databases attached to the project network under
their service name as DNS alias; other services are created and
deployed in dependency order. Returns the created resources plus
any import warnings (unsupported keys, missing values).
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- compose_yaml
properties:
compose_yaml:
type: string
description: Full docker-compose YAML document (max 512 KiB)
variables:
type: object
additionalProperties:
type: string
plan:
type: string
region:
type: string
responses:
'201':
description: Stack deployed
content:
application/json:
schema:
type: object
properties:
resource:
type: string
created:
type: array
items:
type: object
warnings:
type: array
items:
type: string
'400':
description: Invalid compose YAML or unresolved references
'403':
description: Access denied
'404':
description: Project not found
# Agent Onboarding Tokens # Agent Onboarding Tokens
/agent-tokens: /agent-tokens:
post: post:
@@ -2276,6 +2710,66 @@ paths:
schema: schema:
$ref: '#/components/schemas/MessageResponse' $ref: '#/components/schemas/MessageResponse'
/databases/{id}/backups/import:
post:
tags:
- Databases
summary: Import a backup archive
description: >-
Upload a `.tar.gz` archive (multipart field `file` or a raw
application/gzip body) to create a new restore point. The archive
lands in the backup volume and, when a backup target is assigned,
is also shipped offsite. Managed databases only; 2 GiB ceiling.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
description: Database ID
schema:
type: string
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
application/gzip:
schema:
type: string
format: binary
responses:
'201':
description: Backup imported
content:
application/json:
schema:
type: object
properties:
backup_id:
type: string
size:
type: string
status:
type: string
'400':
description: Invalid archive
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'413':
description: Archive too large
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/databases/{id}/backups/{bid}/download: /databases/{id}/backups/{bid}/download:
get: get:
tags: tags:
@@ -5496,6 +5990,12 @@ components:
static_dir: static_dir:
type: string type: string
description: Output directory for the static builder (default dist) description: Output directory for the static builder (default dist)
sleep_enabled:
type: boolean
description: Scale to zero after sleep_idle_minutes without inbound traffic; a wake placeholder answers requests while the service resumes
sleep_idle_minutes:
type: integer
description: Idle timeout in minutes before sleeping (1-1440, default 15)
public_url: public_url:
type: string type: string
description: Computed public URL (published port or domain) description: Computed public URL (published port or domain)
@@ -5667,6 +6167,12 @@ components:
static_dir: static_dir:
type: string type: string
description: Output directory for the static builder (empty string clears) description: Output directory for the static builder (empty string clears)
sleep_enabled:
type: boolean
description: Scale to zero after sleep_idle_minutes without inbound traffic; a wake placeholder answers requests while resuming
sleep_idle_minutes:
type: integer
description: Idle timeout in minutes before sleeping (1-1440)
ServiceDomain: ServiceDomain:
type: object type: object
@@ -6169,6 +6675,9 @@ components:
TemplateConfig: TemplateConfig:
type: object type: object
properties: properties:
version:
type: integer
description: Config version — 2 when services[] is used
type: type:
type: string type: string
description: Service type for created service description: Service type for created service
@@ -6200,6 +6709,87 @@ components:
additionalProperties: additionalProperties:
type: string type: string
description: Optional nixpacks-specific config description: Optional nixpacks-specific config
services:
type: array
description: |
v2 graph members. When non-empty, the flat fields above are
ignored and deploy creates the whole graph in dependency order.
items:
$ref: '#/components/schemas/TemplateServiceSpec'
TemplateServiceSpec:
type: object
required:
- key
- type
properties:
key:
type: string
description: Member key — DNS alias on the project network and the target of {{service.<key>.*}} refs
name:
type: string
description: Display/deployed name (defaults to key)
type:
type: string
enum: [web, worker, cron, database]
runtime:
type: string
description: Image reference (web/worker/cron) or managed engine (database)
repo:
type: string
description: Git repository URL — when set, the member builds from source
branch:
type: string
build_command:
type: string
start_command:
type: string
port:
type: integer
health_check:
type: string
builder:
type: string
enum: [auto, nixpacks, dockerfile, static]
environment:
type: object
additionalProperties:
type: string
description: |
Env vars. Values support {{secret}}, {{secret(N)}}, {{random:N}},
{{VAR}}, {{VAR:-default}} and {{service.<key>.<prop>}} where prop
is host, port, url, user, password or database.
volumes:
type: array
items:
$ref: '#/components/schemas/ServiceVolume'
depends_on:
type: array
items:
type: string
description: Member keys that must be created first
TemplatePlanMember:
type: object
properties:
key:
type: string
name:
type: string
type:
type: string
runtime:
type: string
port:
type: integer
depends_on:
type: array
items:
type: string
environment:
type: object
additionalProperties:
type: string
TemplateVariable: TemplateVariable:
type: object type: object
@@ -6265,7 +6855,7 @@ components:
properties: properties:
resource: resource:
type: string type: string
enum: [service, database] enum: [service, database, stack]
description: Type of resource created by template deployment description: Type of resource created by template deployment
service_id: service_id:
type: string type: string
@@ -6273,6 +6863,23 @@ components:
database_id: database_id:
type: string type: string
description: Newly created managed database ID (set for database templates) description: Newly created managed database ID (set for database templates)
created:
type: array
description: Created resources (set for graph templates — resource=stack)
items:
type: object
properties:
key:
type: string
kind:
type: string
enum: [service, database]
id:
type: string
name:
type: string
deployment_id:
type: string
message: message:
type: string type: string
description: Deployment result message description: Deployment result message
@@ -6391,6 +6998,12 @@ components:
type: string type: string
enum: [managed, external] enum: [managed, external]
description: managed = Containr-provisioned container; external = registered connection to a database hosted elsewhere description: managed = Containr-provisioned container; external = registered connection to a database hosted elsewhere
public_port:
type: boolean
description: managed only — bind the database port on all interfaces instead of 127.0.0.1; toggling recreates the container
backup_target_id:
type: string
description: S3-compatible backup target receiving this database's archives, empty when local-only
external: external:
$ref: '#/components/schemas/DatabaseExternal' $ref: '#/components/schemas/DatabaseExternal'
next_backup_at: next_backup_at:
@@ -6434,6 +7047,9 @@ components:
region: region:
type: string type: string
description: Database region description: Database region
public_port:
type: boolean
description: Bind the database port on all interfaces (default 127.0.0.1)
DatabaseExternal: DatabaseExternal:
type: object type: object
@@ -6492,6 +7108,13 @@ components:
type: string type: string
nullable: true nullable: true
description: Cron expression for automatic backups; empty string clears the schedule description: Cron expression for automatic backups; empty string clears the schedule
public_port:
type: boolean
description: Toggle the published port between 127.0.0.1 and all interfaces; recreates the managed container
backup_target_id:
type: string
nullable: true
description: Assign a backup target for offsite archives; empty string clears
DatabaseActionRequest: DatabaseActionRequest:
type: object type: object
@@ -6585,6 +7208,62 @@ components:
status: status:
type: string type: string
enum: [completed, failed, in_progress] enum: [completed, failed, in_progress]
remote:
type: boolean
description: Archive also shipped to the database's configured backup target
BackupTarget:
type: object
properties:
id:
type: string
name:
type: string
endpoint:
type: string
description: Bare host[:port] — scheme comes from use_tls
bucket:
type: string
region:
type: string
prefix:
type: string
has_credentials:
type: boolean
use_tls:
type: boolean
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
BackupTargetRequest:
type: object
required:
- endpoint
- bucket
properties:
name:
type: string
endpoint:
type: string
bucket:
type: string
region:
type: string
prefix:
type: string
access_key:
type: string
description: Stored encrypted; never returned. Empty on update keeps stored credentials.
secret_key:
type: string
description: Stored encrypted; never returned. Empty on update keeps stored credentials.
use_tls:
type: boolean
default: true
DatabaseBackupConfig: DatabaseBackupConfig:
type: object type: object
@@ -8128,5 +8807,7 @@ tags:
description: Scheduled jobs description: Scheduled jobs
- name: Registries - name: Registries
description: Private-image pull credentials description: Private-image pull credentials
- name: BackupTargets
description: S3-compatible backup destinations for managed databases
- name: Gateway - name: Gateway
description: API gateway — upstream services, keys, analytics description: API gateway — upstream services, keys, analytics
+40 -2
View File
@@ -48,11 +48,29 @@ logs <service-id> [--follow] runtime logs (SSE follow)
exec <service-id> -- <cmd> one-off container command exec <service-id> -- <cmd> one-off container command
variables list|set|unset env vars (secrets masked; --redeploy applies them) variables list|set|unset env vars (secrets masked; --redeploy applies them)
variables shared list|set|unset project shared vars — referenced as ${{shared.KEY}} variables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}
databases list|get|create|delete|action|backup|restore|download-backup databases list|get|create|update|delete|action|backup|restore|download-backup|import-backup
import-backup <id> <file.tar.gz|-> uploads an archive as a restore
point; ships offsite too when a backup target is assigned
databases list|get|create|update|delete|action|backup|restore|download-backup
create --public exposes the port on all interfaces;
update <id> --public/--public=false toggles it (container recreates, data persists)
databases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing) databases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)
databases test-connection probe a connection — by id, or ad-hoc flags databases test-connection probe a connection — by id, or ad-hoc flags
backup-targets list|add|update|remove|test
S3-compatible archive destinations; add/update probe the bucket
first, empty --access-key/--secret-key on update keep stored keys;
update <db-id> --backup-target assigns a target ("" clears it)
cron list|get|create|delete|trigger|executions cron list|get|create|delete|trigger|executions
templates list|get|create|delete|deploy templates list|get|create|delete|deploy|plan|import-compose|deploy-graph
deploy --name --var K=V; plan dry-resolves expressions;
import-compose <compose.yml|-> converts a compose file into
a v2 graph config; deploy-graph <project-id> <config.json|->
deploys an ad-hoc service graph
import compose <project-id> <compose.yml|->
deploys a docker-compose file straight into a project —
no template row. DB images become managed databases
attached under their service name; --var K=V overrides
template variables
nodes list|get|delete|tokens node agents + onboarding tokens (admin) nodes list|get|delete|tokens node agents + onboarding tokens (admin)
scaling status|policies|services|scale scaling status|policies|services|scale
ha status|alerts|health|policies|enable|disable|failover ha status|alerts|health|policies|enable|disable|failover
@@ -148,6 +166,26 @@ instance-level defaults used at service creation;
`app_settings.capacity_policy=block` fails deployments whose requested `app_settings.capacity_policy=block` fails deployments whose requested
memory exceeds node capacity (default `warn` logs only). memory exceeds node capacity (default `warn` logs only).
## Sleep mode (scale-to-zero)
Services can scale to zero after an idle window and resume on traffic:
```bash
containr services update <id> --sleep --sleep-idle 15 # enable, 15 min idle timeout
containr services update <id> --sleep=false # disable
containr services sleep <id> # sleep now
containr services wake <id> # resume now
containr services env-check <id> # flag empty vars + unresolved ${{refs}}
```
Idleness is measured as flat container network I/O over the configured
window (swept every 30s). Sleeping removes the workload containers and —
when the service has domains — installs a busybox placeholder that holds
the Traefik route at higher priority, serves a reloading "waking" page,
and pings `GET /api/v1/internal/wake/:id` to reconcile the service back.
The API must be reachable from containers for the callback (containerized
deploys work via the shared network; bare-metal needs `HOST=0.0.0.0`).
## Clone, move, registries ## Clone, move, registries
`services clone` duplicates config, volumes, domains, access gates, `services clone` duplicates config, volumes, domains, access gates,
+1
View File
@@ -73,6 +73,7 @@ else
PG_PASS="$(gen)"; REDIS_PASS="$(gen)" PG_PASS="$(gen)"; REDIS_PASS="$(gen)"
setvar ENVIRONMENT "production" setvar ENVIRONMENT "production"
setvar JWT_SECRET "$(gen)" setvar JWT_SECRET "$(gen)"
setvar SECRETS_KEY "$(gen)"
setvar BETTER_AUTH_SECRET "$(gen)" setvar BETTER_AUTH_SECRET "$(gen)"
setvar BETTER_AUTH_INTERNAL_TOKEN "$(gen)" setvar BETTER_AUTH_INTERNAL_TOKEN "$(gen)"
setvar CONTAINR_AGENT_AUTH_TOKEN "$(gen)" setvar CONTAINR_AGENT_AUTH_TOKEN "$(gen)"
+5
View File
@@ -23,6 +23,11 @@ Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
- **Clone & move**: `POST /services/{id}/clone` `{name?, project_id?, environment?}` copies config, volumes, domains, access gates, and variables (secret ciphertext verbatim; clone starts stopped). `POST /services/{id}/move` `{project_id}` re-parents a service — both require ownership of source and target; name conflicts → `CONFLICT`. Cloned domains are verbatim copies: the same hostname on two services makes routing ambiguous. - **Clone & move**: `POST /services/{id}/clone` `{name?, project_id?, environment?}` copies config, volumes, domains, access gates, and variables (secret ciphertext verbatim; clone starts stopped). `POST /services/{id}/move` `{project_id}` re-parents a service — both require ownership of source and target; name conflicts → `CONFLICT`. Cloned domains are verbatim copies: the same hostname on two services makes routing ambiguous.
- **Registries**: `GET|POST /registries`, `PUT|DELETE /registries/{id}` — owner-scoped private-image pull credentials, encrypted at rest, never returned (`has_password` signals presence). `host` matched against the image reference on image deploys (`docker.io` for Docker Hub; scheme/case normalized). Empty `password` on update keeps the stored credential. - **Registries**: `GET|POST /registries`, `PUT|DELETE /registries/{id}` — owner-scoped private-image pull credentials, encrypted at rest, never returned (`has_password` signals presence). `host` matched against the image reference on image deploys (`docker.io` for Docker Hub; scheme/case normalized). Empty `password` on update keeps the stored credential.
- **External databases**: `POST /databases/register-external` (probes first; `{name,type,host,port,database,username,password,ssl}`), `POST /databases/test-connection` (ad-hoc probe → `{ok,latency_ms,error}`), `POST /databases/{id}/test-connection` (re-probe with stored credentials). Rows carry `provider:"external"` + `external{host,port,database,username,has_password,ssl}`; passwords encrypted at rest, never returned. Lifecycle actions and backups reject external rows — no container exists. - **External databases**: `POST /databases/register-external` (probes first; `{name,type,host,port,database,username,password,ssl}`), `POST /databases/test-connection` (ad-hoc probe → `{ok,latency_ms,error}`), `POST /databases/{id}/test-connection` (re-probe with stored credentials). Rows carry `provider:"external"` + `external{host,port,database,username,has_password,ssl}`; passwords encrypted at rest, never returned. Lifecycle actions and backups reject external rows — no container exists.
- **Public DB port**: managed databases bind `127.0.0.1` by default; `public_port:true` on create or `PUT /databases/{id} {public_port}` binds `0.0.0.0`. Toggling recreates the container — the data volume persists.
- **Backup targets**: S3-compatible destinations (`/backup-targets` CRUD + `POST /backup-targets/{id}/test`). Endpoints/buckets are probed before storing; keys encrypted at rest (`has_credentials` only). Assign via `PUT /databases/{id} {backup_target_id}` — empty string clears; external databases reject assignment. Managed backups stay local; a copy ships to `[prefix/]databases/<db-id>/<archive>` and rows expose `remote:true`. Restore prefers the local archive and falls back to the remote object. Deleting a target is refused (409) while databases reference it.
- **Backup import**: `POST /databases/{id}/backups/import` — upload a `.tar.gz` (multipart `file` or raw `application/gzip` body, ≤2 GiB) to create a restore point; also ships offsite when a target is assigned. CLI: `databases import-backup <id> <file|->`.
- **Sleep mode**: `PUT /services/{id} {sleep_enabled, sleep_idle_minutes}` scales a service to zero after N idle minutes (network-byte deltas, 30s sweep). Status becomes `sleeping`. A busybox wake container then holds the service's Traefik host rule at priority 100, serves a reloading "waking" page, and hits `GET /api/v1/internal/wake/{id}` (unauthenticated, idempotent) which reconciles the service back. `POST /services/{id}/sleep|wake` for manual control; `GET /services/{id}/env-check` reports empty vars, unresolved `${{svc.KEY}}` refs, and undecryptable secrets (values never exposed). Requires the API reachable from containers (containerized deploy or `HOST=0.0.0.0`).
- **Template graphs (v2)**: a template `config` with `services[]` deploys a multi-service stack. Members declare `key`, `type` (`web|worker|cron|database`), `runtime`, `environment`, `depends_on`. Env values support expressions: `{{secret}}`, `{{secret(N)}}`, `{{random:N}}`, `{{VAR}}`, `{{VAR:-default}}`, `{{service.<key>.<prop>}}` (props: `host`, `port`, `url`, `user`, `password`, `database`). `database` members become managed databases attached to the project network under their key — siblings resolve them by name. `POST /templates/{id}/plan` dry-resolves; `POST /templates/deploy` deploys an inline graph; `POST /templates/import/compose` converts docker-compose YAML (known db images → managed databases). Service↔service refs `${{svc.KEY}}` also resolve at runtime (HOST/PORT or env vars).
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens. - **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
## Core objects ## Core objects