Compare commits

...
Author SHA1 Message Date
Tomáš Dvořák 4734be0d9d Merge pull request #33 from Dvorinka/feat/release-mcp
ci: MCP release binaries + docs-only path gate
2026-10-09 05:11:14 +02:00
Tomáš Dvořák 4e8d364911 Merge pull request #32 from Dvorinka/feat/shared-vars
feat(vars): shared project variables — ${{shared.KEY}} refs
2026-10-09 05:11:08 +02:00
Tomáš Dvořák f53695be4f Merge pull request #23 from Dvorinka/feat/clone-move-registries
feat: service clone/move + private registry credentials (C4)
2026-10-09 05:10:27 +02:00
Tomas DvorakandDevin 57f8b4f56e ci: release MCP binaries + path-gate heavyweight jobs
- build-cli.sh also builds containr-mcp-<os>-<arch> per platform; the
  existing containr-* asset glob in the release job picks them up
- paths-filter 'changes' job gates backend/frontend/smoke/docker on PRs —
  docs-only PRs skip ~10min of compute; skipped checks satisfy protection
- docker job requires success() explicitly (skipped parents would pass
  '!= failure'); release requires docker success to never release stale images

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 01:07:18 +02:00
Tomas Dvorak 58424ed0ab Merge remote-tracking branch 'origin/main' into feat/clone-move-registries 2026-10-09 01:04:41 +02:00
Tomas DvorakandDevin 25fa8c47e6 feat(vars): project shared variables — ${{shared.KEY}} refs, CRUD, UI
- project_variables table (secret entries encrypted at rest, masked in API)
- resolveServiceEnv resolves shared.* before service lookup
- GET|PUT /projects/{id}/variables; CLI 'variables shared list|set|unset';
  MCP containr_shared_variables_{list,set}
- Settings tab → Shared Variables editor; env help text mentions shared refs

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 01:04:19 +02:00
Tomáš Dvořák b3da014d26 Merge pull request #22 from Dvorinka/feat/builder-resources
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
C3: builder selection, resource reservations, static builder
2026-10-09 01:03:19 +02:00
Tomas Dvorak 2049087ccd Merge remote-tracking branch 'origin/main' into feat/clone-move-registries
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:06 +02:00
Tomas Dvorak 01edb677e5 Merge remote-tracking branch 'origin/main' into feat/builder-resources
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:49:31 +02:00
Tomáš Dvořák b22ac401c8 Merge pull request #21 from Dvorinka/feat/service-domains
C2: multi-domain, DNS preflight, maintenance + basic-auth gates
2026-10-09 00:48:49 +02:00
Tomáš Dvořák ea96929435 Merge pull request #20 from Dvorinka/feat/service-volumes
C1: service volume mounts + admin volume inventory
2026-10-09 00:48:43 +02:00
Tomáš Dvořák 971773f80c Merge pull request #19 from Dvorinka/feat/deploy-queue
feat(deploy): pipeline maturity — queue, cancel, no-cache, secrets at rest
2026-10-09 00:48:37 +02:00
Tomáš Dvořák 7e99e41f8d Merge pull request #18 from Dvorinka/feat/agent-docs
docs: AGENTS.md + llms.txt
2026-10-09 00:48:32 +02:00
Tomáš Dvořák 6c5ff7be41 Merge pull request #17 from Dvorinka/feat/api-ergonomics
feat(api): error codes, idempotency keys, cursor pagination
2026-10-09 00:48:27 +02:00
Tomáš Dvořák 7b93506e2d Merge pull request #16 from Dvorinka/feat/mcp-server
feat(mcp): stdio MCP server exposing the full API as tools
2026-10-09 00:48:22 +02:00
Tomáš Dvořák f34fba165c Merge pull request #15 from Dvorinka/feat/cli-v2
feat(cli): v2 — full API coverage, profiles, --json/--yes, containr up
2026-10-09 00:48:16 +02:00
Tomáš Dvořák 6949c01464 Merge pull request #14 from Dvorinka/feat/personal-access-tokens
feat: personal access tokens with scoped bearer auth
2026-10-09 00:41:09 +02:00
Tomas Dvorak f22ae22375 Merge branch 'feat/service-volumes' into feat/service-domains 2026-10-09 00:40:13 +02:00
Tomas DvorakandDevin a4b6ea4bd2 fix(ui): volumes save — fill type/read_only defaults for generated type
Generated with [Devin](https://devin.ai)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-04 09:37:47 +02:00
95 changed files with 11762 additions and 624 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
github: Dvorinka
+29 -1
View File
@@ -17,6 +17,27 @@ permissions:
contents: read
jobs:
# Cheap gate so docs-only/roadmap PRs skip the heavyweight jobs. Skipped
# required checks still satisfy branch protection ("skipped" = success).
changes:
name: Changed paths
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
code:
- 'app/**'
- 'infra/**'
- 'docker-compose.yml'
- 'install.sh'
- '.github/workflows/**'
- 'docs/api/**'
infra:
name: Infra (Compose + Scripts)
runs-on: ubuntu-latest
@@ -48,6 +69,8 @@ jobs:
backend:
name: Backend (Go)
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
defaults:
run:
working-directory: app/backend
@@ -70,6 +93,8 @@ jobs:
frontend:
name: Frontend (React)
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
defaults:
run:
working-directory: app/frontend
@@ -100,6 +125,8 @@ jobs:
name: Compose boot smoke
runs-on: ubuntu-latest
timeout-minutes: 20
needs: changes
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -137,6 +164,7 @@ jobs:
name: Build & publish images
runs-on: ubuntu-latest
needs: [backend, frontend, smoke]
if: needs.backend.result == 'success' && needs.frontend.result == 'success' && needs.smoke.result == 'success'
permissions:
contents: read
packages: write
@@ -227,7 +255,7 @@ jobs:
name: Release
runs-on: ubuntu-latest
needs: [docker]
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' && needs.docker.result == 'success'
permissions:
contents: write
packages: write
+37
View File
@@ -0,0 +1,37 @@
# AGENTS.md — Containr
Containr is a self-hosted Docker PaaS (Railway-style). Backend: Go + Gin + PostgreSQL (sqlc) + Redis-compatible cache (Dragonfly) + Docker API. Frontend: React + Vite + TypeScript + React Query. API contract: `docs/api/openapi.yaml` (generated types: `app/frontend/src/generated/api-types.ts`).
This file is for **coding agents working on the repo**. For operating Containr as a deployed product, see `llms.txt`.
## Layout
- `app/backend` — Go monolith. Entrypoints: `cmd/server` (API), `cmd/cli` (`containr` CLI), `cmd/mcp` (`containr-mcp` MCP server).
- `app/backend/internal/api` — Gin handlers + route groups (`routes.go`: public / `read` OptionalAuth / `authed` / `admin`).
- `app/backend/internal/middleware` — `Auth` (JWT + Better Auth session + PAT), `RequireAdmin`, `Idempotency`.
- `app/backend/internal/cli/commands` — all CLI commands; shared `Client`, `Output`, `confirm` helpers live here.
- `app/backend/internal/mcpserver` — declarative MCP tool registry mirroring the API.
- `app/backend/sqlc` — `schema.sql` + `queries/*.sql`; generate via `sqlc generate` into `internal/database/sqlcdb`.
- `app/backend/migrations_goose` — goose migrations (`YYYYMMDDHHMMSS_name.sql`).
- `app/frontend` — React app. Pages under `src/features/`, API calls via `src/lib/api-client.ts`.
## Build / test / verify
```sh
cd app/backend && go build ./... && go vet ./... && go test ./...
cd app/frontend && npx tsc --noEmit && npm run lint && npm run build
cd app/frontend && npm run generate:api # after any openapi.yaml change
```
Dev stack: Postgres on `:15432` (`containr-postgres-1`), Dragonfly on `:16379` (`containr-dragonfly-1`). Server needs `DATABASE_URL`, `REDIS_URL`, `JWT_SECRET`, `PORT=8080`.
## Conventions that matter
- New endpoints go in the right route group; mutations on `authed`/`admin` get idempotency for free.
- Deploy work goes through `deploy_queue` (`internal/deployqueue`) — per-service FIFO; enqueue jobs instead of `go`-ing deployment/reconcile goroutines so they never race a build.
- New list endpoints use `pageWindow`/`paginationMeta` (`internal/api/pagination.go`) — cursor + page, `next_cursor` in the envelope.
- New errors use stable codes via `respondError(c, status, code, msg)` in `internal/api/errors.go`.
- New CLI command → also register an MCP tool in `internal/mcpserver/server.go` and document in OpenAPI. All three surfaces ship together.
- Destructive CLI commands must gate on `--yes`/`confirm` via `commands.confirm`; destructive MCP tools require `confirm: true`.
- sqlc: edit `schema.sql` + `queries/*.sql`, run `sqlc generate`, add the goose migration.
- Don't commit secrets. PATs (`cnp_…`) and agent tokens (`cagt_…`) are sha256-hashed at rest; raw values are shown once.
+2 -1
View File
@@ -56,7 +56,8 @@ dedicated console.
- **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
- **HA & scaling** — failover policies, node agents, autoscaling rules
- **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vulnerability scans
- **Auth & security** — Better Auth sessions (email/password self-hosted), personal access tokens with `read`/`write`/`admin` scopes for CLI and agents, audit logs, vulnerability scans
- **CLI** — `containr` covers the full API: projects, services, deployments, logs, exec, variables, databases, cron, templates, nodes, scaling, HA, security, gateway, admin — with `--json`, `--yes`, named profiles, and `containr up` for current-directory deploys
- **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
+396
View File
@@ -0,0 +1,396 @@
# Containr — Roadmap 2: The dflow Convergence
Last verified: **2026-10-08** — deep pass over the full dflow codebase
(`~/Desktop/PROG+HTML/dflow`, community fork of dflow-sh/dflow, frozen for
preservation) against Containr `main` (`15b5b0e`).
Goal: make Containr the superior successor — absorb everything worth having
from dflow, then go past it and past Railway. Same power for humans and
agents: the UI is the friendly face, the CLI and MCP are the machine face,
all three sit on the same API. **Branding stays Containr/Vertice** —
white-labeling is an admin option, not an identity change.
---
## 1. What dflow actually is (verified)
Next.js 15 + PayloadCMS 3 + MongoDB + Redis/BullMQ. Runtime model: every
host is a remote server driven **over SSH** with Dokku auto-installed as the
per-app container manager. Async work = 43 BullMQ queue processors
(`src/queues/**`), live logs via Redis pub/sub → SSE endpoint, persisted to
deployment records. ~30 collections, ~180 server actions, 11 dashboard
routes per service (general, deployments, logs, environment, domains,
volumes, proxy, scaling, backups, settings).
### Full capability matrix — dflow → Containr
Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable (dokku/SSH-specific)
#### Deployment pipeline
| dflow capability | Containr status | Action |
|---|---|---|
| Queued deployments, serialized per server, cancellable while queued | ❌ goroutine fire-and-forget; `CancelDeployment` exists in engine but is **never routed** | Add deploy queue + cancel route |
| Deployment states `queued → building → success/failed`, live SSE logs + persisted record | 🟡 states exist (`building→running`); WS streams builds; no queued state | Extend status + stream |
| Redeploy cache modes (`ps:rebuild` vs fresh pull); webhook pushes force `no-cache` | ❌ always rebuilds | Add `cache` flag on deploy/redeploy |
| Cancel queued deployment from UI | ❌ (engine fn unrouted) | Route it |
| Per-service job ordering | ❌ concurrent deploys of one service can race | Serialize per service |
#### Service configuration
| dflow capability | Containr status | Action |
|---|---|---|
| Multi-domain per service + default flag + cert type | ❌ single `services.domain` | `service_domains` table |
| Cert types: letsencrypt / none / Cloudflare Origin / **CF-for-SaaS custom hostnames** | 🟡 Traefik ACME only | LE default; CF custom hostnames deferred (only needed if hosting tenant domains) |
| DNS preflight check (`checkDNSConfigAction`) | ❌ | `GET /services/:id/domains/check` |
| Volumes per service + dangling-volume browser/attach/delete | ❌ engine supports `VolumeMounts`; no API/UI | `services.volumes` jsonb + Volumes tab |
| Resource **limits and reservations**, per-server defaults | 🟡 `cpu_limit`/`memory_limit` int columns (unclear units) | explicit string units + reserves + instance defaults |
| Pre-deploy capacity check (dokku binary interceptor) | ❌ | Node capacity check pre-deploy/scale |
| Maintenance mode toggle | ❌ | Traefik middleware label |
| Basic-auth gate per service (`toggleHttpAuth`) | ❌ | Traefik basic-auth middleware |
| Raw proxy config override (`setServiceNginxConfig`) | ❌ | Allowlisted `traefik_labels` jsonb |
| Service clone (full config copy) | ❌ | `POST /services/:id/clone` |
| Move service between projects (`SwitchServiceProjectDialog`) | ❌ | `PUT /services/:id {project_id}` |
| Env var save → hot-apply + optional restart (`noRestart`) | ❌ rows rewritten; containers keep stale env until manual redeploy | Apply→redeploy flow |
| Static-site builder; Heroku buildpacks | 🟡 railpack/nixpacks/dockerfile auto-detect, no user override | `services.builder` enum + static builder |
| Docker registry accounts (private registries) | ❌ | `registry_credentials` per service/instance |
#### Databases
| dflow capability | Containr status | Action |
|---|---|---|
| Managed DBs (pg/mysql/mariadb/mongo/redis/clickhouse via dokku plugins) | ✅ plus dragonfly | — |
| **External DB registration** (Neon/Atlas/RDS conn details + test) | ❌ | `provider=external` + test endpoint |
| Internal backups + restore (same-type enforced) | ✅ | — |
| Scheduled backups | ✅ cron field | — |
| **External S3 backups** (auth/schedule/unschedule ops) | ❌ | `backup_targets` + shipper |
| **Cross-server DB migration** (export → sftp hop → import) | ❌ | `POST /databases/:id/migrate` |
| Public DB port expose/unexpose | ❌ loopback only | opt-in TCP expose |
| DB → service env linking (`dokku link`) | ✅ Bind action injects `DATABASE_URL` | — |
#### Servers / infrastructure
| dflow capability | Containr status | Action |
|---|---|---|
| Attach server = paste IP + SSH key; Dokku auto-installs | 🟡 agents exist, manual install | **SSH bootstrap**: one SSH session installs agent, never needed again |
| Tailscale/Headscale, NetBird, ZeroTier enroll + mesh connect | ❌ | Agent mesh-IP detection first; auth-key enroll optional |
| AWS EC2 + Hetzner provisioning w/ cloud-init | ❌ | IaC (ROADMAP §2.2): Terraform Proxmox/AWS + Hetzner module |
| Security groups (AWS SG rules, sync) | ❌ | Under IaC provider modules |
| Server-level global/wildcard domain (`updateServerDomain`) | ❌ | `nodes.default_domain` |
| Scheduled server cleanup (dokku cleanup → docker prune) | ❌ | Agent `prune` command + schedule |
| Queue flush/inspect per server (`flushServerQueues`, `getServerQueues`) | ➖ no queue yet | Ops page shows jobs once queue exists |
| Arbitrary SSH command on server (`executeCommand`) | ➖ exec is container-scoped | Node terminal via agent (PTY) |
| Server onboarding wizard, reset, cleanup-on-delete | 🟡 token issue/revoke only | Onboarding flow + `nodes add` UX |
| Ansible playbooks (stored, runnable per server, exec history) | ➖ | **Defer** — security surface; SSH bootstrap covers provisioning |
| Per-node resource dashboards | 🟡 heartbeat telemetry exists | Node detail page |
#### Platform / org
| dflow capability | Containr status | Action |
|---|---|---|
| Multi-tenant organisations + custom RBAC (per-resource CRUD, create-limits, read-scope) | 🟡 `is_admin` + `project_members` | Defer full RBAC; design PAT scopes role-compatible |
| Team invite links/emails | 🟡 manual user create only | `POST /invites` → link join |
| User API keys → full REST API | ❌ **the big gap** — session/JWT only | PATs (Phase A) |
| Outbound webhooks (collection events, HMAC, custom headers) | ❌ | `webhooks` table + delivery log |
| Banners (global + per-tenant, dismissible) | ❌ | `banners` table + top bar |
| White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` — **Containr remains the default brand** |
| Activity feed (icon/severity/category/metadata, 90-day TTL job) | 🟡 `audit_logs` coarser | Enrich + project feed UI |
| Admin metrics dashboard (users/servers/queued/failed counts) | 🟡 `/admin` exists | Extend |
| **Impersonate user** | ❌ | Admin support tool |
| In-app docs (content-collections markdown) | ✅ `/docs` | — |
| Dynamic client-side filter framework (535-line `filter.utils`) | 🟡 audit page filters only | Reuse pattern for tables |
| Ops "bubble" (queue monitor, terminal, sync) | ❌ | Operations page |
#### Agent surface
| dflow capability | Containr status | Action |
|---|---|---|
| Payload REST/GraphQL auto-API + per-user API keys | ❌ | PATs + OpenAPI (better: typed, documented) |
| CLI | ❌ | none in dflow — **Containr CLI is a genuine leap past it** |
| MCP | ❌ | none — same leap |
| Railway migrator script | ❌ | `containr migrate railway` |
| GitHub App **manifest flow** (instance self-provisions the App) | 🟡 env-configured App | `POST /git/github-app/manifest` flow |
| fork-sync webhook deploy trigger | ❌ | ~20 lines on receiver |
| Azure DevOps provider | ❌ | Optional (dflow does token-clone only) |
---
## 2. The spine: agent-first platform
*Agent and user on the same level.* Concretely:
- Every operation reachable by a **personal access token**, not just
sessions.
- Every operation in the **CLI** with `--json`, stable exit codes, `--yes`.
- Every operation in an **MCP server** (stdio first) so Devin/Claude Code/
Cursor/Codex drive Containr natively.
- `openapi.yaml` stays the single source of truth; UI, CLI, MCP are thin
clients.
### Phase A — Agent surface (do this FIRST; everything after inherits it)
- [ ] **Personal access tokens.** `user_tokens` table — `id`, `user_id`,
`name`, `key_prefix` (`cnp_…`), `key_hash` (sha256), `scopes`
(`read`/`write`/`admin`, later resource-scoped), `expires_at`,
`last_used_at`. Model on `agent_auth_tokens` (raw shown once, hash stored).
`POST/GET/DELETE /api/v1/user/tokens`; `middleware.Auth` accepts
`Bearer cnp_…`; Settings → Access tokens UI with one-time display +
copyable CLI/MCP config snippets.
- [ ] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client:
- `auth login [--token] [--url] [--profile]` (multi-instance profiles).
- Resources: `projects services deploy logs exec variables databases
cron templates nodes scaling ha security gateway notifications
webhooks domains volumes backups admin`.
- `containr up` / `deploy` — cwd→service create-or-update→watch
(the `railway up` equivalent; single most important command).
- `containr link` (bind cwd→service), `containr open`, `containr run`,
`containr shell` (PTY once exec upgrade lands), `completion`.
- Universal `--json`, `-o yaml`, `--watch`, `--yes`; exit codes
0/1/2/3/4 (ok/err/not-found/unauthorized/conflict).
- GoReleaser → GH releases + `install.sh`; `version` command.
- Conventions from docsync/envdiff/cix/repolint.
- [ ] **MCP server** (`cmd/mcp`, same module, stdio). Tools mirror the
CLI: `list_projects create_service deploy get_logs set_variable
restart rollback create_database trigger_cron deploy_template exec
get_metrics security_scan …`. Auth: `CONTAINR_URL` + `CONTAINR_TOKEN`
or shared `~/.containr/config`. `containr mcp serve` + install snippets
for Devin/Claude/Cursor.
- [ ] **API agent ergonomics.** `Idempotency-Key` on mutating endpoints;
uniform `{error, code, details}` envelope (audit bare-string returns);
cursor pagination on growing lists; name-or-id lookup everywhere.
- [ ] **Agent docs.** Root `AGENTS.md`, `/docs` serves `llms.txt` +
`llms-full.txt`, MCP tool list generated from OpenAPI where possible.
**Gate**: an agent holding only a PAT registers a project, deploys from a
git repo, streams logs, sets vars, rolls back — zero UI. Same via CLI
for a human.
---
## 3. Phase B — Deployment pipeline maturity
dflow's one real architectural edge. Containr deploys are fire-and-forget
goroutines; this fixes correctness *and* unlocks the ops surface.
- [ ] **Deploy job queue.** Lightweight internal queue (Postgres
`deployment_queue` table + single worker loop — no new infra; Redis
exists but don't add BullMQ-style sprawl). Serialize per service;
optional per-node serialization. Status gains `queued`.
- [ ] **Cancel deployments.** Route `POST /deployments/:id/cancel` →
engine `CancelDeployment` (exists, unrouted). Queued jobs cancel
cleanly; building jobs cancel context where safe.
- [ ] **Cache modes.** `?cache=full` on redeploy → rebuild from existing
image layers vs clean pull/checkout. Webhook-triggered deploys default
to no-cache (dflow convention — prevents stale-layer surprises).
- [ ] **Queued→building→running status + persisted step logs.** Already
mostly there; add `queued`, stream over existing `/ws`.
- [ ] **Env var apply → redeploy.** Saving variables asks "restart to
apply?" (or `--apply` flag in CLI); backend triggers redeploy honoring
running state. Currently rows update but containers keep stale env —
silent-drift bug class, not just UX.
- [ ] **Encrypt variables at rest.** `environment_variables.value` is
plaintext today (verified). Envelope-encrypt `is_secret` values
(AES-GCM, key from `JWT_SECRET` or dedicated `SECRETS_KEY`); dflow
encrypts every var, we encrypt secrets — migration = transparent
encrypt-on-write + decrypt-on-read.
## 4. Phase C — Service configuration parity
- [ ] **Volumes on services** — `services.volumes` jsonb
`[{volume|host_path, container_path, read_only}]`; engine accepts
`VolumeMounts` already. Volumes tab + orphan-volume listing
(`docker volume ls -f dangling`) with delete/attach.
- [ ] **Multi-domain** — `service_domains(service_id, domain, is_default,
cert_type, status, last_checked_at)`. Domains tab: add/remove/
set-default/DNS-status badge. Keep `services.domain` as derived
default for compat.
- [ ] **DNS preflight** — `GET /services/:id/domains/check` resolves and
compares vs edge IP → `ok|wrong-target|pending`.
- [ ] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
no proxy code needed.
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with
allowlist validation (middlewares, headers, redirects).
- [ ] **Builder override** — `services.builder` enum
`auto|railpack|nixpacks|dockerfile|static`; fix hardcoded `nixpacks`
at `deployments.go:406`. Static builder = build + nginx sidecar.
- [ ] **Resource limits + reserves** — adopt dflow-style explicit units
(`"0.5"`/`"512m"`); add `cpu_reserve`/`memory_reserve` →
`NanoCPUs`/`Memory`/`MemoryReservation`; instance defaults in
`app_settings`.
- [ ] **Pre-deploy capacity check** — sum reservations vs node capacity
(agent telemetry); warn or block per policy flag.
- [ ] **Service clone** + **move between projects**.
- [ ] **Private registries** — `registry_credentials`
(server/username/secret, encrypted); `docker login` at pull time;
per-service registry link.
- [ ] **Service detail tabs to match dflow's surface**: add Domains,
Volumes, Proxy/access-controls, Backups sections to the service page
(Cron/Previews/Scaling already exist).
## 5. Phase D — Data layer maturity
- [ ] **External DB registration** — `provider='managed'|'external'`,
conn fields, `POST /databases/test-connection`, same bind flow.
- [ ] **Public DB port expose** — opt-in Traefik TCP route or host port;
default stays loopback.
- [ ] **Offsite backups** — `backup_targets` (S3-compatible:
endpoint/bucket/keys encrypted); per-DB destination; ship archives via
utility container + s3 SDK; restore-from-S3.
- [ ] **Cross-node DB migration** — `POST /databases/:id/migrate
{target_node_id}`: dump → stream transfer → provision → restore →
re-point bound services. Needs ≥2 nodes or local↔node.
- [ ] **Backup import** — upload archive → new restore point (pairs with
migrators).
## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates)
- [ ] **Template = ordered service graph** — `services[]` entries with
`{name, type, source, build, ports, volumes, variables, depends_on}`;
deploy order from dependency refs.
- [ ] **Expressions** — `${{service.KEY}}` (already the runtime ref
syntax — reuse), `{{secret(len,"charset")}}`, `{{random:int}}`,
`{{VAR:-default}}`; `POST /templates/:id/plan` returns the resolved
graph without deploying (agents love this).
- [ ] **Compose import** — `POST /templates/import/compose` (compose-go
parser → graph), `POST /templates/import/github` (compose file from
repo path). Implements `docs/superpowers/specs/2026-04-14-*`.
- [ ] **Graph deploy pipeline** — creates project + services in
dependency order, resolves vars, triggers builds; defined
partial-failure policy.
- [ ] **Visual template builder** — canvas mode: drop git/image/db
nodes, wire refs, set order, save/publish. Reuses existing canvas
primitives (dflow's `templates/compose` is exactly this).
- [ ] **Catalog growth** — convert `templates/*.md` (20 app guides) into
real graph templates; screenshots + badges per the old spec.
**Gate**: paste n8n compose → 3-service project with generated secrets
and wired refs, one click or one CLI call.
## 7. Phase F — Nodes & infrastructure
- [ ] **SSH bootstrap install** — `containr nodes add --ssh user@host` +
UI flow: one SSH session runs agent install (binary + systemd +
enroll token), SSH closed forever. dflow onboarding UX, agent-model
security.
- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/
zt0 IPs at register; backend stores + displays. Optional
`TAILSCALE_AUTH_KEY` enroll-at-provision later.
- [ ] **Node detail page** — placed services, telemetry history, agent
version + self-upgrade, **drain/cordon**, remove.
- [ ] **Node housekeeping** — agent `prune` command (docker system
prune, bounded) + optional schedule (`setServerAutoCleanup` analog —
disk pressure is the #1 small-VPS killer).
- [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists):
pin/spread/affinity, prefer-lowest-usage; deploy engine routes to
`services.node_id` via agent commands (agent already runs containers).
- [ ] **Build-on-node** — agents currently `docker run` prebuilt images
only; extend agent to pull registry images (build stays central) —
private registry creds flow through Phase C.
- [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/
OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module
(dflow's second provider), cloud-init → auto-enroll.
- [ ] **Global/wildcard domain per node** (`nodes.default_domain`) —
auto-domains for services on that node.
## 8. Phase G — Platform polish
- [ ] **Outbound webhooks** — `webhooks(url, secret, events[], headers,
enabled)` + `webhook_deliveries` log; HMAC `X-Containr-Signature`;
events: `deployment.* service.* database.* security.* agent.*`.
- [ ] **Activity feed** — enrich `audit_logs` with `severity category
icon label metadata`; per-project timeline + global feed; TTL job.
- [ ] **Banners** — `banners(title, body, level, scope, active)`; top
bar in UI + surfaced to CLI on `auth status`.
- [ ] **White-label** — `app_settings.branding`: product name, logos,
favicon, accent override, docs/support links. `GET /api/v1/branding`
public + cached. **Containr/Vertice remains default;** this is for
self-hosters who rebrand their instance.
- [ ] **Operations page** — unified view of running/queued/failed jobs
(deploys, builds, backups, cron, scans, agent commands) — dflow's
bubble panel as a real page; flush/stuck-job tools once queue exists.
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`);
`nodes/:id/terminal` via agent (dflow's wetty without the extra
container).
- [ ] **Team invites** — `POST /invites` → signed link → join flow;
replaces manual-only user creation.
- [ ] **Impersonate user** — admin support tool, audit-logged.
- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest`
returns manifest + redirect; instance self-provisions its App
(dflow pattern — removes the env-setup barrier).
- [ ] **fork-sync trigger** — accept repo-sync events on webhook
receiver.
- [ ] **Dynamic filter framework** — port dflow's `filter.utils`
pattern (typed filter configs → URL state → client filtering) for
audit logs, deployments, notifications tables.
- [ ] **Notification producers round-out** — backups, agent offline,
upgrade available, security findings.
- [ ] **Custom roles/RBAC** — defer until demanded; PAT scopes designed
forward-compatible now.
## 9. Phase H — Migration & ecosystem
- [ ] **Railway migrator** — `containr migrate railway --token --project
--dry-run`: Railway GraphQL → services/vars/domains → recreate via
PAT. Landing-page pitch: "off Railway in one command."
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST
export or Mongo dump: servers→nodes, projects, services(+vars/
domains/volumes), databases, templates. Onboards dflow refugees.
- [ ] **`containr import compose`** — Phase-E parser straight to a
project (no template intermediate).
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
fetch/publish; bundled catalog works offline (dflow's self-contained
principle — nothing phones home).
## 10. Carried-over debt (ROADMAP.md items still open)
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit →
`gateway/proxy.go` → metrics. Management API exists; proxy unwired.
- [ ] **Preview environments runtime** — real build+deploy+subdomain+TTL
(bookkeeping only now). Pairs with clone + multi-domain.
- [ ] **Environments** — env switcher, per-env vars/domains, promote.
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`.
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
webhook table tests, Playwright e2e golden path.
- [ ] **First-run wizard** — admin → GitHub App (manifest flow from
Phase G makes this self-serve) → Tunnel → done.
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release.
## 11. Deliberately not imported from dflow
- Dokku-over-SSH runtime — our Docker API + agent model is strictly
better.
- Per-server-named BullMQ queues — our queue stays a single ordered
table.
- Payload/Mongo/Next.js stack, multi-tenant plugin machinery.
- Ansible playbook runner — arbitrary code-on-node is a security
surface; SSH bootstrap + agent commands cover the real needs.
Revisit only with a concrete demand.
- `isAdmin`-only collection access — our owner/admin/public model is
richer already.
- Netdata install flow — our agent telemetry + optional Beszel covers
monitoring; don't ship a third dashboard stack.
## 12. Recommended order
```
A (agent surface) ──► B (deploy pipeline) ──► C/D (service+data depth)
│ │
▼ ▼
CLI/MCP dogfood every feature E (templates v2)
│
F (nodes/infra) ──► H (migrators)
│
G (polish) + debt
```
Why this order: PATs+CLI+MCP first = every later feature ships
user-and-agent-visible at once. The queue (B) is prerequisite for
the ops surface and correct concurrency. Templates v2 needs volumes,
multi-domain, clone. Migrators last — they're launch marketing, not
foundation.
## 13. Design rules
- **API is the contract.** UI, CLI, MCP are thin clients over
`openapi.yaml`. A feature isn't done until all three reach it.
- **Everything scriptable.** JSON in/out, `--json`/`--yes`, uniform
pagination, name-or-id addressing.
- **Same powers, same position.** Nothing user-facing that isn't
API-facing; agent-visible state (tokens, jobs) visible to users too.
- **dflow is a donor, not a dependency.** Port semantics, not code.
- **Containr branding is the default**; white-label is instance config.
- **Self-hosted first** — no required external service, no phone-home.
+1 -4
View File
@@ -15,8 +15,5 @@ var (
func main() {
cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime))
if err := cli.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
os.Exit(cli.Execute())
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"containr/internal/mcpserver"
)
// Injected at build time via -ldflags.
var Version = "dev"
// containr-mcp exposes the whole Containr API as MCP tools over stdio.
// Configure it in an MCP client with:
//
// command: containr-mcp
// env: CONTAINR_API_URL, CONTAINR_TOKEN (a cnp_ personal access token)
func main() {
apiURL := os.Getenv("CONTAINR_API_URL")
if apiURL == "" {
apiURL = "http://localhost:8080/api/v1"
}
apiURL = strings.TrimSuffix(apiURL, "/")
if !strings.HasSuffix(apiURL, "/api/v1") {
apiURL += "/api/v1"
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := mcpserver.Run(ctx, apiURL, os.Getenv("CONTAINR_TOKEN"), Version); err != nil {
fmt.Fprintf(os.Stderr, "containr-mcp: %v\n", err)
os.Exit(1)
}
}
+9 -2
View File
@@ -8,11 +8,12 @@ require (
github.com/gin-gonic/gin v1.9.1
github.com/go-playground/validator/v10 v10.30.1
github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/lib/pq v1.10.9
github.com/miekg/dns v1.1.72
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/pressly/goose/v3 v3.24.1
github.com/robfig/cron/v3 v3.0.1
github.com/rs/cors v1.10.1
@@ -21,6 +22,7 @@ require (
github.com/sqlc-dev/pqtype v0.3.0
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.57.0
golang.org/x/term v0.46.0
)
require (
@@ -45,6 +47,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
@@ -63,6 +66,8 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
@@ -71,6 +76,7 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
@@ -84,10 +90,11 @@ require (
golang.org/x/arch v0.3.0 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.49.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+18 -4
View File
@@ -65,11 +65,13 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
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.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -105,6 +107,8 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -143,6 +147,10 @@ github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU
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/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
@@ -178,6 +186,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
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/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
@@ -213,16 +223,20 @@ golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/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.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
+248 -38
View File
@@ -3,10 +3,13 @@ package api
import (
"containr/internal/database"
"containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/docker"
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
@@ -35,6 +38,7 @@ type CreateDeploymentRequest struct {
Branch string `json:"branch"`
Trigger string `json:"trigger"`
EnvVars map[string]string `json:"env_vars"`
NoCache bool `json:"no_cache"`
}
type DeploymentResponse struct {
@@ -74,14 +78,21 @@ func handleGetDeployments(c *gin.Context) {
return
}
offset, limit := pageWindow(c, 50, 100)
var total int
_ = db.(*database.DB).QueryRow(
`SELECT COUNT(*) FROM deployments WHERE service_id = $1`, serviceID,
).Scan(&total)
rows, err := db.(*database.DB).Query(
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
FROM deployments
WHERE service_id = $1
ORDER BY created_at DESC
LIMIT 50`,
serviceID,
LIMIT $2 OFFSET $3`,
serviceID, limit, offset,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
@@ -104,7 +115,10 @@ func handleGetDeployments(c *gin.Context) {
deployments = append(deployments, d)
}
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
c.JSON(http.StatusOK, gin.H{
"deployments": deployments,
"pagination": paginationMeta(offset, limit, total),
})
}
// RecentDeployment is a deployment row joined with its service and project names.
@@ -132,11 +146,19 @@ func handleGetRecentDeployments(c *gin.Context) {
userID := optionalUserUUID(c)
isAdmin := contextIsAdmin(c)
limit := 10
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
limit = n
}
offset, limit := pageWindow(c, 10, 50)
var total int
if err := db.(*database.DB).QueryRow(
`SELECT COUNT(*)
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`,
userID, isAdmin,
).Scan(&total); err != nil {
total = 0
}
rows, err := db.(*database.DB).Query(
@@ -148,8 +170,8 @@ func handleGetRecentDeployments(c *gin.Context) {
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
ORDER BY d.created_at DESC
LIMIT $3`,
userID, isAdmin, limit,
LIMIT $3 OFFSET $4`,
userID, isAdmin, limit, offset,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
@@ -168,7 +190,10 @@ func handleGetRecentDeployments(c *gin.Context) {
deployments = append(deployments, d)
}
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
c.JSON(http.StatusOK, gin.H{
"deployments": deployments,
"pagination": paginationMeta(offset, limit, total),
})
}
func handleCreateDeployment(c *gin.Context) {
@@ -209,6 +234,9 @@ func handleCreateDeployment(c *gin.Context) {
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, p.owner_id
FROM services s
JOIN projects p ON s.project_id = p.id
@@ -219,7 +247,9 @@ func handleCreateDeployment(c *gin.Context) {
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectOwner,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
)
if err != nil {
@@ -289,7 +319,23 @@ func handleCreateDeployment(c *gin.Context) {
}
engineInstance := engine.(*deployment.DeploymentEngine)
go runDeploymentAndSync(context.Background(), db.(*database.DB), engineInstance, &d, service, req, userID.(string))
pos := getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
DeploymentID: d.ID,
Run: func(jctx context.Context) {
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, service, req, userID.(string))
},
})
if pos > 0 {
d.Status = "queued"
_, _ = db.(*database.DB).Exec(
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), d.ID,
)
_, _ = db.(*database.DB).Exec(
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
)
}
}
c.JSON(http.StatusCreated, DeploymentResponse{
@@ -362,6 +408,19 @@ func runDeploymentAndSyncWithImage(
// ephemeral ports.
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
publicPort := int32(service.Port)
if service.Builder == "static" && publicPort == 0 {
// The static builder's image is nginx — route to its default port
// unless the user picked another.
publicPort = 80
}
maintenanceMode, basicAuthUsers := serviceAccess(db, service.ID)
maintURL := ""
if maintenanceMode {
maintURL = maintenanceURL(db)
}
deployReq := &deployment.DeploymentRequest{
ProjectID: service.ProjectID.String(),
ServiceID: service.ID.String(),
@@ -372,15 +431,22 @@ func runDeploymentAndSyncWithImage(
Command: command,
Environment: env,
Replicas: replicas,
PublicPort: int32(service.Port),
PublicPort: publicPort,
PublishedPort: publishedPort,
Domain: service.Domain,
HealthPath: service.HealthCheckPath,
RestartPolicy: service.RestartPolicy,
VolumeMounts: loadServiceVolumes(db, service.ID),
Domains: serviceDomainNames(db, service.ID, service.Domain),
Resources: deployment.ResourceLimits{
MemoryBytes: parseMemoryLimit(service.Memory),
CPUQuota: parseCPULimit(service.CPU),
MemoryBytes: parseMemoryLimit(service.Memory),
MemoryReservation: parseMemoryLimit(service.MemoryReserve),
CPUQuota: parseCPULimit(service.CPU),
CPUShares: parseCPUReserveShares(service.CPUReserve),
},
Maintenance: maintenanceMode,
MaintenanceURL: maintURL,
BasicAuthUsers: basicAuthUsers,
},
Trigger: deployment.TriggerConfig{
Type: req.Trigger,
@@ -390,24 +456,50 @@ func runDeploymentAndSyncWithImage(
},
}
if imageOverride != "" {
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "prebuilt",
PrebuiltImage: imageOverride,
if imageOverride != "" || service.GitRepo == "" {
// Prebuilt image deploy (rollback, redeploy, or image-sourced
// service): resolve private-registry credentials by image host.
image := imageOverride
if image == "" {
image = service.Image
}
} else if service.GitRepo == "" {
// Image-sourced service: pull the image instead of building.
var ownerID string
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
auth := registryAuthFor(db, ownerID, image)
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "prebuilt",
PrebuiltImage: service.Image,
PrebuiltImage: image,
PullUsername: auth.Username,
PullPassword: auth.Password,
}
} else {
// "auto" leaves BuildType empty so the manager detects it; the static
// builder additionally feeds its generated Dockerfile the service's
// build command and output dir.
buildType := ""
if service.Builder != "" && service.Builder != "auto" {
buildType = service.Builder
}
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "nixpacks",
BuildType: buildType,
SourcePath: sourcePath,
Branch: req.Branch,
Commit: req.CommitHash,
NoCache: req.NoCache,
}
if buildType == "static" {
deployReq.BuildConfig.BuildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build")
deployReq.BuildConfig.BuildArgs = map[string]string{"STATIC_DIR": firstNonEmpty(service.StaticDir, "dist")}
}
}
if msg := capacityCheck(parentCtx, db, engine.DockerClient(), service, replicas); msg != "" {
failedAt := time.Now()
_, _ = db.Exec(
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
msg, failedAt, dbDeployment.ID,
)
return
}
engineDeployment, err := engine.Deploy(ctx, deployReq)
@@ -434,16 +526,19 @@ func runDeploymentAndSyncWithImage(
select {
case <-ctx.Done():
failedAt := time.Now()
timeoutErr := "Deployment timed out before completion"
finalStatus, finalErr := "failed", "Deployment timed out before completion"
if errors.Is(ctx.Err(), context.Canceled) {
finalStatus, finalErr = "cancelled", "Deployment cancelled"
}
_, _ = db.Exec(
`UPDATE deployments
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
WHERE id = $3`,
timeoutErr, failedAt, dbDeployment.ID,
SET status = $1, error = $2, completed_at = $3, updated_at = $3
WHERE id = $4`,
finalStatus, finalErr, failedAt, dbDeployment.ID,
)
_, _ = db.Exec(
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
failedAt, service.ID,
`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`,
finalStatus, failedAt, service.ID,
)
return
case <-syncTicker.C:
@@ -510,13 +605,86 @@ func runDeploymentAndSyncWithImage(
}
func mapEngineStatusToDBStatus(status string) string {
switch status {
case "running":
if status == "running" {
return "deployed"
case "cancelled":
return "failed"
}
return status
}
// getDeployQueue returns the per-service deploy queue. SetupRoutes always
// sets it; the fallback keeps handler tests that skip SetupRoutes working.
func getDeployQueue(c *gin.Context) *deployqueue.Queue {
if v, exists := c.Get("deploy_queue"); exists {
if q, ok := v.(*deployqueue.Queue); ok && q != nil {
return q
}
}
return deployqueue.New()
}
// handleCancelDeployment cancels a queued or in-flight deployment. Queued
// jobs are dropped and marked cancelled immediately; active jobs get their
// context cancelled so the build/reconcile aborts and the sync loop writes
// the terminal 'cancelled' status.
func handleCancelDeployment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available", "code": "DEPENDENCY_UNAVAILABLE"})
return
}
deploymentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID", "code": "VALIDATION"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated", "code": "UNAUTHENTICATED"})
return
}
var status, owner string
var serviceID uuid.UUID
err = db.(*database.DB).QueryRow(
`SELECT d.status, d.service_id, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(&status, &serviceID, &owner)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found", "code": "NOT_FOUND"})
return
}
if owner != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied", "code": "FORBIDDEN"})
return
}
switch status {
case "deployed", "failed", "cancelled":
c.JSON(http.StatusConflict, gin.H{"error": "Deployment already finished", "code": "NOT_CANCELLABLE"})
return
}
switch getDeployQueue(c).Cancel(serviceID, deploymentID) {
case deployqueue.WasQueued:
now := time.Now()
_, _ = db.(*database.DB).Exec(
`UPDATE deployments SET status = 'cancelled', error = 'Deployment cancelled', completed_at = $1, updated_at = $1 WHERE id = $2`,
now, deploymentID,
)
_, _ = db.(*database.DB).Exec(
`UPDATE services SET status = 'cancelled', updated_at = $1 WHERE id = $2`,
now, serviceID,
)
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
case deployqueue.WasActive:
c.JSON(http.StatusAccepted, gin.H{"status": "cancelling"})
default:
return status
c.JSON(http.StatusConflict, gin.H{"error": "Deployment is not running on this node", "code": "NOT_CANCELLABLE"})
}
}
@@ -695,6 +863,9 @@ func handleRollbackDeployment(c *gin.Context) {
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`, serviceID,
).Scan(
@@ -702,7 +873,9 @@ func handleRollbackDeployment(c *gin.Context) {
&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.CreatedAt, &service.UpdatedAt,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt,
)
rollbackReq := CreateDeploymentRequest{
@@ -715,7 +888,12 @@ func handleRollbackDeployment(c *gin.Context) {
Trigger: "rollback",
}
// Reuse the stored image directly — no rebuild.
go runDeploymentAndSyncWithImage(context.Background(), db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
DeploymentID: rollback.ID,
Run: func(jctx context.Context) {
runDeploymentAndSyncWithImage(jctx, db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
},
})
}
c.JSON(http.StatusCreated, gin.H{
@@ -731,3 +909,35 @@ func handleRollbackDeployment(c *gin.Context) {
"message": "Rollback initiated",
})
}
// capacityCheck compares the requested per-replica memory (limit, falling
// back to reservation) against node capacity. app_settings.capacity_policy
// = "block" fails the deployment; the default "warn" only logs. CPU is not
// gated — shares are a weight, not a reservation.
func capacityCheck(ctx context.Context, db *database.DB, client *docker.Client, service Service, replicas int) string {
if client == nil {
return ""
}
requested := parseMemoryLimit(service.Memory)
if requested == 0 {
requested = parseMemoryLimit(service.MemoryReserve)
}
if requested == 0 {
return ""
}
info, err := client.GetSystemInfo(ctx)
if err != nil || info.MemTotal <= 0 {
return ""
}
need := requested * int64(replicas)
if need <= info.MemTotal {
return ""
}
msg := fmt.Sprintf("requested %d bytes x %d replicas exceeds node memory (%d bytes)",
requested, replicas, info.MemTotal)
if strings.ToLower(settingValue(db, "capacity_policy", "", "warn")) == "block" {
return msg
}
slog.Warn("capacity check exceeded", "service_id", service.ID, "detail", msg)
return ""
}
+375
View File
@@ -0,0 +1,375 @@
package api
import (
"context"
"database/sql"
"net"
"net/http"
"regexp"
"strings"
"time"
"containr/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// ServiceDomain is one hostname routed to a service. services.domain stays
// the derived default; this table is the source of truth for the full set.
type ServiceDomain struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Domain string `json:"domain"`
IsDefault bool `json:"is_default"`
CertType string `json:"cert_type"`
CertStatus string `json:"cert_status"`
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// BasicAuthCred is a plaintext credential the server encodes to htpasswd.
type BasicAuthCred struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
var hostnameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`)
func validHostname(d string) bool {
return len(d) <= 253 && hostnameRE.MatchString(d)
}
func loadServiceDomains(db *database.DB, serviceID uuid.UUID) []ServiceDomain {
rows, err := db.Query(
`SELECT id, service_id, domain, is_default, cert_type, cert_status, last_checked_at, created_at
FROM service_domains WHERE service_id = $1 ORDER BY is_default DESC, created_at ASC`, serviceID)
if err != nil {
return nil
}
defer rows.Close()
out := []ServiceDomain{}
for rows.Next() {
var d ServiceDomain
if err := rows.Scan(&d.ID, &d.ServiceID, &d.Domain, &d.IsDefault, &d.CertType, &d.CertStatus, &d.LastCheckedAt, &d.CreatedAt); err == nil {
out = append(out, d)
}
}
return out
}
func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) []string {
domains := loadServiceDomains(db, serviceID)
out := make([]string, 0, len(domains))
for _, d := range domains {
out = append(out, d.Domain)
}
if len(out) == 0 && fallback != "" {
out = []string{fallback}
}
return out
}
// syncDefaultDomain writes the is_default row back to services.domain so
// legacy reads (CLI tables, public_url) keep working.
func syncDefaultDomain(db *database.DB, serviceID uuid.UUID) {
var domain string
err := db.QueryRow(
`SELECT domain FROM service_domains WHERE service_id = $1 AND is_default`, serviceID).Scan(&domain)
if err == sql.ErrNoRows {
err = db.QueryRow(
`SELECT domain FROM service_domains WHERE service_id = $1 ORDER BY created_at ASC LIMIT 1`, serviceID).Scan(&domain)
}
if err == sql.ErrNoRows {
domain = ""
}
_, _ = db.Exec(`UPDATE services SET domain = $1 WHERE id = $2`, domain, serviceID)
}
// serviceAccess loads the maintenance/basic-auth columns for the runtime spec.
func serviceAccess(db *database.DB, serviceID uuid.UUID) (maintenance bool, basicAuth string) {
_ = db.QueryRow(`SELECT maintenance_mode, basic_auth_users FROM services WHERE id = $1`, serviceID).
Scan(&maintenance, &basicAuth)
return maintenance, basicAuth
}
// maintenanceURL is the redirect target for maintenance mode — the public
// base URL of this API plus the maintenance page path.
func maintenanceURL(db *database.DB) string {
base := strings.TrimRight(settingValue(db, "base_url", "BASE_URL", ""), "/")
if base == "" {
return ""
}
return base + "/api/v1/maintenance"
}
// encodeBasicAuth turns plaintext creds into Traefik's htpasswd format
// (user:bcrypt hash, comma-separated). Bcrypt is what Traefik expects.
func encodeBasicAuth(creds []BasicAuthCred) (string, error) {
pairs := make([]string, 0, len(creds))
for _, cred := range creds {
if cred.Username == "" || cred.Password == "" {
continue
}
hash, err := bcrypt.GenerateFromPassword([]byte(cred.Password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
pairs = append(pairs, cred.Username+":"+string(hash))
}
return strings.Join(pairs, ","), nil
}
// basicAuthUsernames returns just the usernames for API responses — never
// the hashes.
func basicAuthUsernames(encoded string) []string {
var out []string
for _, pair := range strings.Split(encoded, ",") {
if u, _, ok := strings.Cut(pair, ":"); ok && u != "" {
out = append(out, u)
}
}
return out
}
// serviceWriteAccess mirrors the service update handler's access check.
func serviceWriteAccess(c *gin.Context, db *database.DB, serviceID uuid.UUID) bool {
userID, _ := c.Get("user_id")
var ok bool
err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool))`,
serviceID, userID, contextIsAdmin(c)).Scan(&ok)
return err == nil && ok
}
func handleListServiceDomains(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
c.JSON(http.StatusOK, gin.H{"domains": loadServiceDomains(db.(*database.DB), serviceID)})
}
func handleAddServiceDomain(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
return
}
var req struct {
Domain string `json:"domain" binding:"required"`
IsDefault bool `json:"is_default"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
domain := strings.ToLower(strings.TrimSpace(req.Domain))
if !validHostname(domain) {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid hostname")
return
}
// First domain on a service becomes the default automatically.
var count int
_ = db.(*database.DB).QueryRow(`SELECT COUNT(*) FROM service_domains WHERE service_id = $1`, serviceID).Scan(&count)
makeDefault := req.IsDefault || count == 0
tx, err := db.(*database.DB).Begin()
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
defer tx.Rollback()
if makeDefault {
if _, err := tx.Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
}
var inserted ServiceDomain
err = tx.QueryRow(
`INSERT INTO service_domains (service_id, domain, is_default)
VALUES ($1, $2, $3)
RETURNING id, service_id, domain, is_default, cert_type, cert_status, created_at`,
serviceID, domain, makeDefault).
Scan(&inserted.ID, &inserted.ServiceID, &inserted.Domain, &inserted.IsDefault, &inserted.CertType, &inserted.CertStatus, &inserted.CreatedAt)
if err != nil {
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
respondError(c, http.StatusConflict, "CONFLICT", "Domain already attached to this service")
return
}
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
if err := tx.Commit(); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusCreated, gin.H{"domain": inserted})
}
func handleDeleteServiceDomain(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
return
}
domainID, err := uuid.Parse(c.Param("domain_id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
return
}
res, err := db.(*database.DB).Exec(
`DELETE FROM service_domains WHERE id = $1 AND service_id = $2`, domainID, serviceID)
affected, _ := res.RowsAffected()
if err != nil || affected == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusOK, gin.H{"deleted": domainID})
}
func handleSetDefaultDomain(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
return
}
domainID, err := uuid.Parse(c.Param("domain_id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
return
}
res, err := db.(*database.DB).Exec(
`UPDATE service_domains SET is_default = (id = $1) WHERE service_id = $2`,
domainID, serviceID)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to set default domain")
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusOK, gin.H{"domains": loadServiceDomains(db.(*database.DB), serviceID)})
}
// handleCheckServiceDomains resolves each domain's DNS and compares it to
// the configured expected target (app_settings.public_ip / PUBLIC_IP, or the
// base_url host). Statuses: ok | wrong-target | pending.
func handleCheckServiceDomains(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
dbh := db.(*database.DB)
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
expectedIP := settingValue(dbh, "public_ip", "PUBLIC_IP", "")
expectedHost := ""
if base := settingValue(dbh, "base_url", "BASE_URL", ""); base != "" {
if u := strings.TrimPrefix(strings.TrimPrefix(base, "https://"), "http://"); u != "" {
expectedHost, _, _ = strings.Cut(u, "/")
expectedHost, _, _ = strings.Cut(expectedHost, ":")
}
}
type result struct {
Domain string `json:"domain"`
Status string `json:"status"` // ok | wrong-target | pending
Resolved []string `json:"resolved"`
}
results := []result{}
resolver := net.DefaultResolver
for _, d := range loadServiceDomains(dbh, serviceID) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 4*time.Second)
addrs, _ := resolver.LookupHost(ctx, d.Domain)
cname, _ := resolver.LookupCNAME(ctx, d.Domain)
cancel()
r := result{Domain: d.Domain, Resolved: addrs}
cname = strings.TrimSuffix(cname, ".")
switch {
case len(addrs) == 0:
r.Status = "pending"
case expectedIP != "":
r.Status = "wrong-target"
for _, a := range addrs {
if a == expectedIP {
r.Status = "ok"
}
}
case expectedHost != "":
r.Status = "ok" // resolves; CNAME match is advisory only
if cname != "" && cname != d.Domain && cname != expectedHost {
r.Status = "wrong-target"
}
default:
r.Status = "ok"
}
results = append(results, r)
_, _ = dbh.Exec(`UPDATE service_domains SET last_checked_at = NOW() WHERE id = $1`, d.ID)
}
c.JSON(http.StatusOK, gin.H{"domains": results})
}
// handleMaintenancePage serves the maintenance-mode landing page. Public:
// browsers hit it via the Traefik redirect before any auth.
func handleMaintenancePage(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Maintenance</title><style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0b0e14;color:#e6e8ee;font-family:system-ui,sans-serif}
.card{text-align:center;padding:2rem}.title{font-size:1.4rem;font-weight:600;margin-bottom:.5rem}
.sub{color:#9aa3b2;font-size:.95rem}.brand{margin-top:2rem;color:#5b6472;font-size:.75rem;letter-spacing:.12em;text-transform:uppercase}
</style></head><body><div class="card">
<div class="title">This service is under maintenance</div>
<div class="sub">Please check back shortly.</div>
<div class="brand">Containr</div>
</div></body></html>`))
}
+29
View File
@@ -0,0 +1,29 @@
package api
import (
"github.com/gin-gonic/gin"
)
// Stable machine-readable error codes for agent clients. The "error" field
// stays human-readable; "code" is the contract the CLI/MCP rely on.
const (
CodeUnauthorized = "UNAUTHENTICATED"
CodeInvalidToken = "INVALID_TOKEN"
CodeForbidden = "FORBIDDEN"
CodeAdminRequired = "ADMIN_REQUIRED"
CodeScopeInsufficient = "SCOPE_INSUFFICIENT"
CodeNotFound = "NOT_FOUND"
CodeValidation = "VALIDATION"
CodeConflict = "CONFLICT"
CodeInternal = "INTERNAL"
CodeUnavailable = "DEPENDENCY_UNAVAILABLE"
CodeIdempotencyRunning = "IDEMPOTENCY_IN_PROGRESS"
)
// respondError emits the standard error envelope. Existing handlers return
// bare {"error": msg} — this adds the stable code for new and
// agent-facing paths.
func respondError(c *gin.Context, status int, code, message string) {
c.JSON(status, gin.H{"error": message, "code": code})
c.Abort()
}
+19 -5
View File
@@ -14,6 +14,7 @@ import (
"containr/internal/database"
"containr/internal/deployment"
"containr/internal/deployqueue"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -178,11 +179,24 @@ func handleGitWebhookPush(c *gin.Context) {
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
go runDeploymentAndSync(
context.Background(), db, engine, &d, service,
CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook"},
repoUserID,
)
// Webhook pushes default to a clean build — stale layers are the
// classic silent-rollback failure (dflow convention).
webhookReq := CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook", NoCache: true}
if pos := getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
DeploymentID: d.ID,
Run: func(jctx context.Context) {
runDeploymentAndSync(jctx, db, engine, &d, service, webhookReq, repoUserID)
},
}); pos > 0 {
_, _ = db.Exec(
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), d.ID,
)
_, _ = db.Exec(
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
}
enqueued++
}
+52
View File
@@ -0,0 +1,52 @@
package api
import (
"encoding/base64"
"fmt"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
// pageWindow parses pagination from either ?page=&limit= (legacy) or
// ?cursor=&limit= (agent-friendly). The cursor is an opaque base64 offset —
// cheap to implement over the existing offset queries while the public
// contract stays cursor-shaped for clients.
func pageWindow(c *gin.Context, defaultLimit, maxLimit int) (offset, limit int) {
limit = defaultLimit
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= maxLimit {
limit = n
}
}
if cur := c.Query("cursor"); cur != "" {
if raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(cur, "o_")); err == nil {
if n, err := strconv.Atoi(string(raw)); err == nil && n >= 0 {
return n, limit
}
}
return 0, limit // invalid cursor → first page, fail soft
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
return (page - 1) * limit, limit
}
// paginationMeta renders the standard envelope: legacy page fields plus
// next_cursor when another page exists.
func paginationMeta(offset, limit, total int) gin.H {
page := offset/limit + 1
meta := gin.H{
"page": page,
"limit": limit,
"total": total,
"pages": (total + limit - 1) / limit,
}
if offset+limit < total {
meta["next_cursor"] = "o_" + base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprint(offset+limit)))
}
return meta
}
@@ -0,0 +1,55 @@
package api
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func ctxWithQuery(q string) *gin.Context {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/x?"+q, nil)
return c
}
func TestPageWindowDefaults(t *testing.T) {
offset, limit := pageWindow(ctxWithQuery(""), 10, 100)
if offset != 0 || limit != 10 {
t.Fatalf("expected 0/10, got %d/%d", offset, limit)
}
}
func TestPageWindowLegacyPage(t *testing.T) {
offset, limit := pageWindow(ctxWithQuery("page=3&limit=20"), 10, 100)
if offset != 40 || limit != 20 {
t.Fatalf("expected 40/20, got %d/%d", offset, limit)
}
}
func TestPageWindowCursor(t *testing.T) {
// cursor for offset 50 produced by paginationMeta
meta := paginationMeta(0, 50, 200)
cursor := meta["next_cursor"].(string)
offset, limit := pageWindow(ctxWithQuery("cursor="+cursor+"&limit=50"), 10, 100)
if offset != 50 || limit != 50 {
t.Fatalf("expected 50/50, got %d/%d", offset, limit)
}
}
func TestPageWindowInvalidCursorFailsSoft(t *testing.T) {
offset, _ := pageWindow(ctxWithQuery("cursor=garbage"), 10, 100)
if offset != 0 {
t.Fatalf("expected offset 0 on invalid cursor, got %d", offset)
}
}
func TestPaginationMetaNextCursorOnlyWhenMore(t *testing.T) {
if _, ok := paginationMeta(0, 10, 10)["next_cursor"]; ok {
t.Fatal("no next page should omit next_cursor")
}
if _, ok := paginationMeta(0, 10, 25)["next_cursor"]; !ok {
t.Fatal("expected next_cursor when more pages exist")
}
}
+3 -19
View File
@@ -8,7 +8,6 @@ import (
"database/sql"
"errors"
"net/http"
"strconv"
"strings"
"time"
@@ -55,18 +54,9 @@ func handleGetProjects(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
offset, limit := pageWindow(c, 10, 100)
search := strings.TrimSpace(c.DefaultQuery("search", ""))
if page < 1 {
page = 1
}
if limit > 100 || limit < 1 {
limit = 10
}
offset := (page - 1) * limit
searchParam := sql.NullString{}
if search != "" {
searchParam = sql.NullString{String: search, Valid: true}
@@ -99,15 +89,9 @@ func handleGetProjects(c *gin.Context) {
return
}
totalCount := int(total)
c.JSON(http.StatusOK, gin.H{
"projects": projects,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": totalCount,
"pages": (totalCount + limit - 1) / limit,
},
"projects": projects,
"pagination": paginationMeta(offset, limit, int(total)),
})
}
+180
View File
@@ -0,0 +1,180 @@
package api
import (
"net/http"
"time"
"containr/internal/database"
"containr/internal/secrets"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Project-level shared variables — referenced from service env values as
// ${{shared.KEY}} and resolved at runtime in resolveServiceEnv.
type ProjectVariable struct {
ID uuid.UUID `json:"id" db:"id"`
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
Key string `json:"key" db:"key"`
Value string `json:"value" db:"value"`
IsSecret bool `json:"is_secret" db:"is_secret"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
func handleGetProjectVariables(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
rows, err := db.(*database.DB).Query(
`SELECT id, project_id, key, value, is_secret, created_at, updated_at
FROM project_variables WHERE project_id = $1 ORDER BY key ASC`,
projectID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
return
}
defer rows.Close()
variables := []ProjectVariable{}
for rows.Next() {
var v ProjectVariable
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan variable"})
return
}
if v.IsSecret {
v.Value = maskedSecretValue
}
variables = append(variables, v)
}
c.JSON(http.StatusOK, gin.H{"variables": variables})
}
func handleUpdateProjectVariables(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var req UpdateVariablesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
storedSecrets := map[string]string{}
secretRows, err := db.(*database.DB).Query(
`SELECT key, value FROM project_variables WHERE project_id = $1 AND is_secret = TRUE`,
projectID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load existing variables"})
return
}
for secretRows.Next() {
var k, v string
if err := secretRows.Scan(&k, &v); err == nil {
storedSecrets[k] = v
}
}
secretRows.Close()
tx, err := db.(*database.DB).Begin()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
return
}
defer tx.Rollback()
if _, err = tx.Exec(`DELETE FROM project_variables WHERE project_id = $1`, projectID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
return
}
now := time.Now()
for _, v := range req.Variables {
value := v.Value
if v.IsSecret && value == maskedSecretValue {
if stored, ok := storedSecrets[v.Key]; ok {
value = stored
}
}
if v.IsSecret {
value = secrets.Encrypt(value)
}
if _, err = tx.Exec(
`INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
uuid.New(), projectID, v.Key, value, v.IsSecret, now, now,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
return
}
}
if err = tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
return
}
rows, err := db.(*database.DB).Query(
`SELECT id, project_id, key, value, is_secret, created_at, updated_at
FROM project_variables WHERE project_id = $1 ORDER BY key ASC`,
projectID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
return
}
defer rows.Close()
variables := []ProjectVariable{}
for rows.Next() {
var v ProjectVariable
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt); err != nil {
continue
}
if v.IsSecret {
v.Value = maskedSecretValue
}
variables = append(variables, v)
}
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Project variables updated successfully"})
}
// resolveSharedVar returns the decrypted value of a project variable.
func resolveSharedVar(db *database.DB, projectID uuid.UUID, key string) (string, bool) {
var value string
err := db.QueryRow(
`SELECT value FROM project_variables WHERE project_id = $1 AND key = $2`,
projectID, key,
).Scan(&value)
if err != nil {
return "", false
}
return secrets.Decrypt(value), true
}
+193
View File
@@ -0,0 +1,193 @@
package api
import (
"net/http"
"strings"
"time"
"containr/internal/database"
"containr/internal/secrets"
"github.com/docker/docker/api/types/registry"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Registry is a stored credential for pulling private images. Password is
// encrypted at rest and never returned — `has_password` signals presence.
type Registry struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Host string `json:"host"` // e.g. ghcr.io, registry.example.com; docker.io for Docker Hub
Username string `json:"username"`
HasPassword bool `json:"has_password"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type registryRequest struct {
Name string `json:"name" binding:"required,min=1,max=255"`
Host string `json:"host" binding:"required,min=1,max=255"`
Username string `json:"username"`
Password string `json:"password"`
}
func handleListRegistries(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
rows, err := db.(*database.DB).Query(
`SELECT id, name, host, username, password <> '', created_at, updated_at
FROM registries WHERE owner_id = $1 ORDER BY created_at DESC`,
userID.(string),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list registries"})
return
}
defer rows.Close()
registries := []Registry{}
for rows.Next() {
var r Registry
if err := rows.Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt); err == nil {
registries = append(registries, r)
}
}
c.JSON(http.StatusOK, gin.H{"registries": registries})
}
func handleCreateRegistry(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
var req registryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
req.Host = strings.TrimSuffix(req.Host, "/")
// Encrypt only a real credential — encrypting "" would still satisfy
// `password <> ''` and report has_password for anonymous registries.
password := ""
if req.Password != "" {
password = secrets.Encrypt(req.Password)
}
var r Registry
err := db.(*database.DB).QueryRow(
`INSERT INTO registries (owner_id, name, host, username, password)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
userID.(string), req.Name, req.Host, req.Username, password,
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
if err != nil {
if strings.Contains(err.Error(), "unique") {
c.JSON(http.StatusConflict, gin.H{"error": "A registry for this host already exists", "code": "CONFLICT"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create registry"})
return
}
c.JSON(http.StatusCreated, gin.H{"registry": r})
}
func handleUpdateRegistry(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
registryID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
return
}
var req registryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
req.Host = strings.TrimSuffix(req.Host, "/")
// Empty password keeps the stored credential — there is no "clear"
// operation; delete + recreate to remove a password.
var passwordArg interface{}
if req.Password != "" {
passwordArg = secrets.Encrypt(req.Password)
}
var r Registry
err = db.(*database.DB).QueryRow(
`UPDATE registries SET name = $1, host = $2, username = $3,
password = COALESCE($4, password), updated_at = now()
WHERE id = $5 AND owner_id = $6
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
req.Name, req.Host, req.Username, passwordArg, registryID, userID.(string),
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
return
}
c.JSON(http.StatusOK, gin.H{"registry": r})
}
func handleDeleteRegistry(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
registryID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
return
}
res, err := db.(*database.DB).Exec(
`DELETE FROM registries WHERE id = $1 AND owner_id = $2`,
registryID, userID.(string),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete registry"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Registry deleted"})
}
// registryAuthFor resolves pull credentials for an image reference: the
// caller's registry whose host matches, else anonymous. docker.io covers
// bare image names (nginx:latest).
func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig {
host := registryHost(imageRef)
var username, password string
err := db.QueryRow(
`SELECT username, password FROM registries WHERE owner_id = $1 AND host = $2`,
ownerID, host,
).Scan(&username, &password)
if err != nil {
return registry.AuthConfig{}
}
return registry.AuthConfig{
Username: username,
Password: secrets.Decrypt(password),
ServerAddress: host,
}
}
@@ -0,0 +1,48 @@
package api
import "testing"
func TestParseCPUReserveShares(t *testing.T) {
cases := map[string]int64{
"": 0,
"0": 0,
"-1": 0,
"abc": 0,
"0.25": 256,
"1": 1024,
"2.5": 2560,
" 0.5 ": 512,
}
for in, want := range cases {
if got := parseCPUReserveShares(in); got != want {
t.Errorf("parseCPUReserveShares(%q) = %d, want %d", in, got, want)
}
}
}
func TestValidateResourceSpecs(t *testing.T) {
if err := validateCPUSpec(""); err != nil {
t.Error("empty cpu should pass")
}
if err := validateCPUSpec("0.5"); err != nil {
t.Error("0.5 should be valid cpu")
}
if err := validateCPUSpec("512mb"); err == nil {
t.Error("512mb should fail cpu validation")
}
if err := validateMemorySpec(""); err != nil {
t.Error("empty memory should pass")
}
if err := validateMemorySpec("512Mi"); err != nil {
t.Error("512Mi should be valid memory")
}
if err := validateMemorySpec("1g"); err != nil {
t.Error("1g should be valid memory")
}
if err := validateMemorySpec("0.5"); err == nil {
t.Error("0.5 should fail memory validation (parses to <1 byte)")
}
if err := validateMemorySpec("lots"); err == nil {
t.Error("'lots' should fail memory validation")
}
}
+98
View File
@@ -4,17 +4,20 @@ import (
"context"
"log"
"net/http"
"os"
"time"
"containr/internal/build"
"containr/internal/config"
"containr/internal/database"
"containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/docker"
"containr/internal/ha"
"containr/internal/metrics"
"containr/internal/middleware"
"containr/internal/scaling"
"containr/internal/secrets"
"github.com/gin-gonic/gin"
)
@@ -57,6 +60,33 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
}
}
haManager := ha.NewHighAvailabilityManager(scheduler, metricsCollector)
if db != nil && db.DB != nil {
haManager.WithPersistence(db.DB)
if err := haManager.LoadHealthChecks(context.Background()); err != nil {
log.Printf("Failed to restore health checks: %v", err)
}
}
if dockerClient != nil && db != nil {
// Service-bound checks probe the service DNS name on the project
// network — the backend container joins it on demand. The old
// 127.0.0.1 fallback stays for node-bound checks.
docker := dockerClient
haManager.WithServiceResolver(func(ctx context.Context, serviceID string) (string, int, error) {
var name, projectID string
var port int
err := db.QueryRowContext(ctx,
`SELECT name, project_id::text, COALESCE(port, 0) FROM services WHERE id = $1`,
serviceID,
).Scan(&name, &projectID, &port)
if err != nil {
return "", 0, err
}
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(projectID)); err != nil {
return "", 0, err
}
return name, port, nil
})
}
haAPIManager := NewHAManager(haManager)
// Initialize scaling handler
@@ -71,6 +101,45 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Initialize database handler
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
// Per-service serialized deploy queue — one active deployment per
// service, the rest wait as 'queued'.
deployQueue := deployqueue.New()
// Encrypt-at-rest for secret env vars. SECRETS_KEY wins, JWT_SECRET is
// the fallback so existing installs need no new config.
secrets.Configure(os.Getenv("SECRETS_KEY"), cfg.JWTSecret)
// Boot sweep: in-memory engine state dies on restart, so anything that
// looked in-flight is actually orphaned — mark it failed rather than
// leaving phantom 'building'/'queued' rows.
_, _ = db.Exec(
`UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW()
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back')`,
)
// Boot sweep: encrypt any plaintext secret values left over from before
// encryption-at-rest landed. Transparent and idempotent.
if encRows, err := db.Query(`SELECT id, value FROM environment_variables WHERE is_secret = TRUE`); err == nil {
type encRow struct {
id string
value string
}
var toEncrypt []encRow
for encRows.Next() {
var r encRow
if err := encRows.Scan(&r.id, &r.value); err == nil && !secrets.IsEncrypted(r.value) {
toEncrypt = append(toEncrypt, r)
}
}
encRows.Close()
for _, r := range toEncrypt {
_, _ = db.Exec(`UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2`, secrets.Encrypt(r.value), r.id)
}
if len(toEncrypt) > 0 {
log.Printf("secrets: encrypted %d plaintext secret variable(s)", len(toEncrypt))
}
}
// Initialize security handler
securityHandler := NewSecurityHandler(db, cfg.JWTSecret)
@@ -88,6 +157,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
if deploymentEngine != nil {
c.Set("deployment_engine", deploymentEngine)
}
c.Set("deploy_queue", deployQueue)
c.Set("scheduler", scheduler)
c.Set("metrics_collector", metricsCollector)
c.Set("auto_scaler", autoScaler)
@@ -178,6 +248,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.GET("/auth/bootstrap", handleAuthBootstrap)
public.POST("/auth/login", handleLogin)
public.POST("/auth/register", handleRegister)
public.GET("/maintenance", handleMaintenancePage)
}
// Read routes — public browsing. OptionalAuth resolves a session when
@@ -198,6 +269,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
read.GET("/services/:id/metrics", handleGetServiceMetrics)
read.GET("/services/:id/runtime", handleGetServiceRuntime)
read.GET("/services/:id/deployments", handleGetDeployments)
read.GET("/services/:id/domains", handleListServiceDomains)
read.GET("/services/:id/domains/check", handleCheckServiceDomains)
read.GET("/deployments", handleGetRecentDeployments)
read.GET("/deployments/:id", handleGetDeployment)
@@ -229,10 +302,16 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// enforce ownership; platform admins additionally pass every check.
authed := v1.Group("/")
authed.Use(middleware.Auth(cfg.JWTSecret))
authed.Use(middleware.Idempotency())
{
authed.GET("/user/profile", handleGetProfile)
authed.PUT("/user/profile", handleUpdateProfile)
// Personal access tokens for CLI/MCP/agent auth.
authed.GET("/user/tokens", handleListUserTokens)
authed.POST("/user/tokens", handleCreateUserToken)
authed.DELETE("/user/tokens/:id", handleDeleteUserToken)
authed.GET("/notifications", handleListNotifications)
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
@@ -250,17 +329,27 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.PUT("/services/:id", handleUpdateService)
authed.DELETE("/services/:id", handleDeleteService)
authed.POST("/services/:id/domains", handleAddServiceDomain)
authed.DELETE("/services/:id/domains/:domain_id", handleDeleteServiceDomain)
authed.POST("/services/:id/domains/:domain_id/default", handleSetDefaultDomain)
authed.POST("/services/:id/start", handleServiceStart)
authed.POST("/services/:id/stop", handleServiceStop)
authed.POST("/services/:id/restart", handleServiceRestart)
authed.POST("/services/:id/redeploy", handleServiceRedeploy)
authed.POST("/services/:id/clone", handleCloneService)
authed.POST("/services/:id/move", handleMoveService)
authed.POST("/services/:id/deployments", handleCreateDeployment)
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
authed.POST("/deployments/:id/cancel", handleCancelDeployment)
// Environment variables — secrets, owner/admin only.
authed.GET("/services/:id/variables", handleGetVariables)
authed.PUT("/services/:id/variables", handleUpdateVariables)
// Project-level shared variables — ${{shared.KEY}} refs.
authed.GET("/projects/:id/variables", handleGetProjectVariables)
authed.PUT("/projects/:id/variables", handleUpdateProjectVariables)
// Runtime logs — container stdout routinely echoes secrets,
// so these stay behind authentication like build logs.
authed.GET("/services/:id/logs", handleGetLogs)
@@ -269,6 +358,12 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// One-off exec console (docker exec, 30s ceiling)
authed.POST("/services/:id/exec", handleExecInService)
// Private-image registries — owner-scoped, credentials encrypted at rest.
authed.GET("/registries", handleListRegistries)
authed.POST("/registries", handleCreateRegistry)
authed.PUT("/registries/:id", handleUpdateRegistry)
authed.DELETE("/registries/:id", handleDeleteRegistry)
// Git integration — providers carry per-user credentials.
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
authed.POST("/git/github-app/connect", handleConnectGitHubApp)
@@ -325,6 +420,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Admin routes — platform-wide controls and cross-user management.
admin := v1.Group("/")
admin.Use(middleware.Auth(cfg.JWTSecret))
admin.Use(middleware.Idempotency())
admin.Use(middleware.RequireAdmin())
{
admin.GET("/admin/overview", handleAdminOverview)
@@ -345,6 +441,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
scalingHandler.RegisterAdminRoutes(admin)
agentHandler.SetupAdminRoutes(admin)
admin.GET("/admin/volumes", handleAdminListVolumes)
admin.DELETE("/admin/volumes/:name", handleAdminDeleteVolume)
admin.GET("/audit-logs", handleGetAuditLogs)
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
+137 -6
View File
@@ -11,6 +11,8 @@ import (
"containr/internal/database"
"containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/secrets"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -51,6 +53,19 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
}
spec.NanoCPUs = parseCPULimit(service.CPU)
spec.MemoryBytes = parseMemoryLimit(service.Memory)
spec.MemoryReserve = parseMemoryLimit(service.MemoryReserve)
spec.CPUShares = parseCPUReserveShares(service.CPUReserve)
if service.Builder == "static" && spec.Port == 0 {
// The static builder's image is nginx; expose its default port so
// Traefik has something to route to without extra config.
spec.Port = 80
}
spec.Volumes = loadServiceVolumes(db, service.ID)
spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
if spec.Maintenance {
spec.MaintenanceURL = maintenanceURL(db)
}
if spec.Port > 0 {
env["PORT"] = strconv.Itoa(service.Port)
}
@@ -78,7 +93,7 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
env[k] = v
env[k] = secrets.Decrypt(v)
}
var refServices []struct{ ID, Name string }
@@ -104,6 +119,9 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
}
resolve := func(serviceName, key string) (string, bool) {
if strings.EqualFold(serviceName, "shared") {
return resolveSharedVar(db, service.ProjectID, key)
}
id, ok := byName[strings.ToLower(serviceName)]
if !ok {
return "", false
@@ -126,7 +144,7 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
if err != nil {
return "", false
}
return value, true
return secrets.Decrypt(value), true
}
for k, v := range env {
@@ -152,6 +170,32 @@ func parseCPULimit(cpu string) int64 {
return int64(v * 1e9)
}
// parseCPUReserveShares maps a cpu fraction to Docker's relative share
// weight: 1024 shares ≈ one CPU.
func parseCPUReserveShares(cpu string) int64 {
v, err := strconv.ParseFloat(strings.TrimSpace(cpu), 64)
if err != nil || v <= 0 {
return 0
}
return int64(v * 1024)
}
// validateCPUSpec and validateMemorySpec reject values that silently parse
// to zero — a typo like "512mb" must fail loudly instead of dropping the limit.
func validateCPUSpec(v string) error {
if s := strings.TrimSpace(v); s != "" && parseCPULimit(s) <= 0 {
return fmt.Errorf("must be a positive cpu fraction (e.g. 0.5, 2)")
}
return nil
}
func validateMemorySpec(v string) error {
if s := strings.TrimSpace(v); s != "" && parseMemoryLimit(s) <= 0 {
return fmt.Errorf("must be bytes with unit (e.g. 256Mi, 1g)")
}
return nil
}
var memUnits = map[string]int64{"k": 1e3, "m": 1e6, "g": 1e9, "ki": 1 << 10, "mi": 1 << 20, "gi": 1 << 30}
func parseMemoryLimit(mem string) int64 {
@@ -207,6 +251,9 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
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, p.owner_id
FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
@@ -216,7 +263,9 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
&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.CreatedAt, &service.UpdatedAt, &owner,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &owner,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
@@ -250,6 +299,9 @@ func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
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, s.project_id
FROM services s
WHERE s.id = $1`,
@@ -259,7 +311,9 @@ func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
&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.CreatedAt, &service.UpdatedAt, &projectID,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &projectID,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
@@ -381,6 +435,7 @@ func handleServiceRestart(c *gin.Context) {
// handleServiceRedeploy re-applies the current spec: pulls the image for
// image-sourced services and reconciles containers (env, ports, replicas).
// Runs through the per-service deploy queue so it never overlaps a build.
func handleServiceRedeploy(c *gin.Context) {
engine, db, ok := getRuntimeEngine(c)
if !ok {
@@ -406,7 +461,78 @@ func handleServiceRedeploy(c *gin.Context) {
}
}
service.Image = image
reconcileNow(c, engine, db, service)
finished := make(chan struct{})
var recErr error
var state *deployment.RuntimeState
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{Run: func(jctx context.Context) {
defer close(finished)
state, recErr = reconcileServiceJob(jctx, db, engine, service)
}})
select {
case <-finished:
if recErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": recErr.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"runtime": state})
case <-c.Request.Context().Done():
c.JSON(http.StatusAccepted, gin.H{"status": "queued"})
}
}
// reconcileServiceJob is the shared queue-job body for redeploy paths:
// build the spec from current DB state, reconcile containers, persist the
// resulting status and published port.
func reconcileServiceJob(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, service Service) (*deployment.RuntimeState, error) {
spec, err := serviceRuntimeSpec(db, service)
if err != nil {
return nil, err
}
state, err := engine.ReconcileService(ctx, spec)
if err != nil {
return nil, err
}
if state != nil {
persistPublishedPort(db, service.ID, state.Ports)
status := "stopped"
if state.Status != "" {
status = state.Status
}
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
}
return state, nil
}
// enqueueServiceRedeploy resolves a deployable image (service.Image, else
// the last successful deployment's tag) and queues a reconcile. Returns
// false when no image is available or the engine is missing.
func enqueueServiceRedeploy(c *gin.Context, db *database.DB, service Service) bool {
engineValue, exists := c.Get("deployment_engine")
if !exists || engineValue == nil {
return false
}
engine, ok := engineValue.(*deployment.DeploymentEngine)
if !ok {
return false
}
image := service.Image
if image == "" {
if err := db.QueryRow(
`SELECT image_name || ':' || image_tag FROM deployments
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
ORDER BY created_at DESC LIMIT 1`,
service.ID,
).Scan(&image); err != nil {
return false
}
}
service.Image = image
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{Run: func(jctx context.Context) {
_, _ = reconcileServiceJob(jctx, db, engine, service)
}})
return true
}
// persistPublishedPort records the host port Docker actually bound so later
@@ -469,6 +595,9 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
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, p.owner_id
FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`, serviceID,
@@ -477,7 +606,9 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
&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.CreatedAt, &service.UpdatedAt, &owner,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &owner,
)
if err != nil {
return err
+226
View File
@@ -0,0 +1,226 @@
package api
import (
"net/http"
"strings"
"time"
"containr/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type CloneServiceRequest struct {
Name string `json:"name"`
ProjectID uuid.UUID `json:"project_id"` // empty = clone within the same project
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
}
// handleCloneService duplicates a service's full configuration — image/git
// source, volumes, domains, access gates, builder settings, and variables —
// into the same or another project. Containers are not cloned; the copy
// starts stopped until its first deploy.
func handleCloneService(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
var req CloneServiceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
source, ok := loadOwnedService(c, db.(*database.DB))
if !ok {
return
}
targetProjectID := source.ProjectID
if req.ProjectID != uuid.Nil {
targetProjectID = req.ProjectID
}
if !ownsProject(db.(*database.DB), targetProjectID, userID.(string), contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = source.Name + "-copy"
}
var count int
_ = db.(*database.DB).QueryRow(
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
targetProjectID, name,
).Scan(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in target project", "code": "CONFLICT"})
return
}
environment := source.Environment
if req.Environment != "" {
environment = req.Environment
}
if environment == "" {
environment = "production"
}
environmentID, err := getProjectEnvironmentID(db.(*database.DB), targetProjectID, environment)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
return
}
maintenance, basicAuth := serviceAccess(db.(*database.DB), source.ID)
newID := uuid.New()
now := time.Now()
// string() so pq sends text — []byte would go as bytea and fail ::jsonb.
volumesJSON := string(loadServiceVolumesJSON(db.(*database.DB), source.ID))
_, err = db.(*database.DB).Exec(
`INSERT INTO services
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
build_command, start_command, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, volumes, maintenance_mode, basic_auth_users,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
created_at, updated_at)
SELECT $1, $2, $3, $4, service_type, source_type, source_url, image_name,
build_command, start_command, type, 'stopped', image, command, $5,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, $6::jsonb, $7, $8,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
$9, $9
FROM services WHERE id = $10`,
newID, targetProjectID, name, environmentID, environment, volumesJSON,
maintenance, basicAuth, now, source.ID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
return
}
if _, err := db.(*database.DB).Exec(
`INSERT INTO service_domains (service_id, domain, is_default, cert_type)
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
newID, source.ID,
); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service domains"})
return
}
// Variable values are copied as stored — ciphertext stays ciphertext.
if _, err := db.(*database.DB).Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
newID, source.ID,
); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service variables"})
return
}
LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
}
type MoveServiceRequest struct {
ProjectID uuid.UUID `json:"project_id" binding:"required"`
}
// handleMoveService re-parents a service to another project owned by the
// caller. Variables, domains, and volumes follow via the service id;
// running containers keep running (they join the new project's network on
// the next deploy).
func handleMoveService(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID", "code": "VALIDATION"})
return
}
var req MoveServiceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
service, ok := loadOwnedService(c, db.(*database.DB))
if !ok {
return
}
if req.ProjectID == service.ProjectID {
c.JSON(http.StatusBadRequest, gin.H{"error": "Service is already in that project", "code": "VALIDATION"})
return
}
if !ownsProject(db.(*database.DB), req.ProjectID, userID.(string), contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
return
}
var count int
_ = db.(*database.DB).QueryRow(
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
req.ProjectID, service.Name,
).Scan(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "A service with this name already exists in the target project", "code": "CONFLICT"})
return
}
envName := service.Environment
if envName == "" {
envName = "production"
}
environmentID, err := getProjectEnvironmentID(db.(*database.DB), req.ProjectID, envName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
return
}
res, err := db.(*database.DB).Exec(
`UPDATE services SET project_id = $1, environment_id = $2, updated_at = $3 WHERE id = $4`,
req.ProjectID, environmentID, time.Now(), serviceID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to move service"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found", "code": "NOT_FOUND"})
return
}
LogAuditWithRequest(c, "service", serviceID.String(), "service.move", map[string]interface{}{"to_project": req.ProjectID.String()})
c.JSON(http.StatusOK, gin.H{"message": "Service moved", "project_id": req.ProjectID})
}
// ownsProject is true when the user owns the project or is an admin.
func ownsProject(db *database.DB, projectID uuid.UUID, userID string, isAdmin bool) bool {
if isAdmin {
return true
}
var owner string
err := db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, projectID).Scan(&owner)
return err == nil && owner == userID
}
// loadServiceVolumesJSON returns the raw stored mounts for a verbatim copy.
func loadServiceVolumesJSON(db *database.DB, serviceID uuid.UUID) []byte {
var raw []byte
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil || len(raw) == 0 {
return []byte("[]")
}
return raw
}
+310 -57
View File
@@ -3,8 +3,12 @@ package api
import (
"containr/internal/database"
"containr/internal/deployment"
"containr/internal/deployqueue"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -27,53 +31,129 @@ type Service struct {
CPU string `json:"cpu" db:"cpu"`
Memory string `json:"memory" db:"memory"`
// Runtime spec
Replicas int `json:"replicas" db:"replicas"`
Port int `json:"port" db:"port"` // container port to expose
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
Replicas int `json:"replicas" db:"replicas"`
Port int `json:"port" db:"port"` // container port to expose
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB
Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
MaintenanceMode bool `json:"maintenance_mode" db:"-"`
BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes
Builder string `json:"builder" db:"builder"` // auto|railpack|nixpacks|dockerfile|static
CPUReserve string `json:"cpu_reserve,omitempty" db:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve,omitempty" db:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd,omitempty" db:"static_build_cmd"`
StaticDir string `json:"static_dir,omitempty" db:"static_dir"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ServiceVolume is a volume/bind mount applied to every replica.
type ServiceVolume struct {
Type string `json:"type"` // volume | bind (empty = volume)
Source string `json:"source"` // volume name or host path
Target string `json:"target"` // container path (absolute)
ReadOnly bool `json:"read_only"`
}
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades
// to no mounts rather than failing the request.
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
var raw []byte
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
return nil
}
var stored []ServiceVolume
if err := json.Unmarshal(raw, &stored); err != nil {
return nil
}
out := make([]deployment.VolumeMount, 0, len(stored))
for _, v := range stored {
out = append(out, deployment.VolumeMount{
Type: v.Type,
Source: v.Source,
Destination: v.Target,
ReadOnly: v.ReadOnly,
})
}
return out
}
// validateServiceVolumes enforces the mount contract — bad paths fail
// fast at write time instead of surfacing as docker create errors later.
func validateServiceVolumes(vols []ServiceVolume) error {
for i, v := range vols {
if v.Type == "" {
v.Type = "volume"
}
if v.Type != "volume" && v.Type != "bind" {
return fmt.Errorf("volumes[%d].type must be volume or bind", i)
}
if strings.TrimSpace(v.Source) == "" {
return fmt.Errorf("volumes[%d].source is required", i)
}
if !strings.HasPrefix(v.Target, "/") {
return fmt.Errorf("volumes[%d].target must be an absolute container path", i)
}
}
return nil
}
// CreateServiceRequest represents a request to create a service
type CreateServiceRequest struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name" binding:"required,min=1,max=255"`
Type string `json:"type" binding:"required,oneof=web worker database cron"`
Image string `json:"image"`
Command string `json:"command"`
Environment string `json:"environment" binding:"required,oneof=production preview development"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
BuildPath string `json:"build_path"`
CPU string `json:"cpu"`
Memory string `json:"memory"`
Replicas int `json:"replicas"`
Port int `json:"port"`
Domain string `json:"domain"`
HealthCheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name" binding:"required,min=1,max=255"`
Type string `json:"type" binding:"required,oneof=web worker database cron"`
Image string `json:"image"`
Command string `json:"command"`
Environment string `json:"environment" binding:"required,oneof=production preview development"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
BuildPath string `json:"build_path"`
CPU string `json:"cpu"`
Memory string `json:"memory"`
Replicas int `json:"replicas"`
Port int `json:"port"`
Domain string `json:"domain"`
HealthCheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
Volumes []ServiceVolume `json:"volumes"`
MaintenanceMode bool `json:"maintenance_mode"`
BasicAuth []BasicAuthCred `json:"basic_auth"`
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
CPUReserve string `json:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd"`
StaticDir string `json:"static_dir"`
}
// UpdateServiceRequest represents a request to update a service
type UpdateServiceRequest struct {
Name string `json:"name" binding:"omitempty,min=1,max=255"`
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
Image string `json:"image"`
Command string `json:"command"`
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
BuildPath string `json:"build_path"`
CPU string `json:"cpu"`
Memory string `json:"memory"`
Replicas *int `json:"replicas"`
Port *int `json:"port"`
Domain *string `json:"domain"`
HealthCheckPath *string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
Name string `json:"name" binding:"omitempty,min=1,max=255"`
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
Image string `json:"image"`
Command string `json:"command"`
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
BuildPath string `json:"build_path"`
CPU string `json:"cpu"`
Memory string `json:"memory"`
Replicas *int `json:"replicas"`
Port *int `json:"port"`
Domain *string `json:"domain"`
HealthCheckPath *string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
Volumes *[]ServiceVolume `json:"volumes"`
MaintenanceMode *bool `json:"maintenance_mode"`
BasicAuth *[]BasicAuthCred `json:"basic_auth"`
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
CPUReserve *string `json:"cpu_reserve"`
MemoryReserve *string `json:"memory_reserve"`
StaticBuildCmd *string `json:"static_build_cmd"`
StaticDir *string `json:"static_dir"`
}
// handleGetServices retrieves all services for a project
@@ -118,6 +198,9 @@ func handleGetServices(c *gin.Context) {
COALESCE(replicas, 1), COALESCE(port, 0),
COALESCE(domain, ''), COALESCE(healthcheck_path, ''),
COALESCE(restart_policy, 'unless-stopped'),
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''),
COALESCE(memory_reserve, ''), COALESCE(static_build_cmd, ''),
COALESCE(static_dir, ''),
created_at, updated_at
FROM services
WHERE project_id = $1
@@ -138,7 +221,9 @@ func handleGetServices(c *gin.Context) {
&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.CreatedAt, &service.UpdatedAt,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
@@ -249,12 +334,38 @@ func handleCreateService(c *gin.Context) {
UpdatedAt: time.Now(),
}
// Set default values if not provided
// Set default values if not provided; app_settings.default_cpu /
// default_memory override the hardcoded fallbacks.
if service.CPU == "" {
service.CPU = "0.5"
service.CPU = firstNonEmpty(settingValue(db.(*database.DB), "default_cpu", "", ""), "0.5")
}
if service.Memory == "" {
service.Memory = "512Mi"
service.Memory = firstNonEmpty(settingValue(db.(*database.DB), "default_memory", "", ""), "512Mi")
}
if req.Builder == "" {
service.Builder = "auto"
} else {
service.Builder = req.Builder
}
service.CPUReserve = req.CPUReserve
service.MemoryReserve = req.MemoryReserve
service.StaticBuildCmd = req.StaticBuildCmd
service.StaticDir = req.StaticDir
if service.Builder == "static" && service.StaticDir == "" {
service.StaticDir = "dist"
}
for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
} {
v := map[string]string{
"cpu": service.CPU, "cpu_reserve": service.CPUReserve,
"memory": service.Memory, "memory_reserve": service.MemoryReserve,
}[label]
if err := check(v); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
return
}
}
if service.Replicas < 1 {
service.Replicas = 1
@@ -262,6 +373,15 @@ func handleCreateService(c *gin.Context) {
if service.RestartPolicy == "" {
service.RestartPolicy = "unless-stopped"
}
if err := validateServiceVolumes(req.Volumes); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
service.Volumes = req.Volumes
volumesJSON, _ := json.Marshal(req.Volumes)
if len(req.Volumes) == 0 {
volumesJSON = []byte("[]")
}
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
if err != nil {
@@ -277,14 +397,18 @@ func handleCreateService(c *gin.Context) {
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
build_command, start_command, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27)`,
healthcheck_path, restart_policy, volumes,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
$27, $28, $29, $30, $31, $32, $33)`,
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
"", service.Command, 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.CreatedAt, service.UpdatedAt,
volumesJSON, service.Builder, service.CPUReserve, service.MemoryReserve,
service.StaticBuildCmd, service.StaticDir, service.CreatedAt, service.UpdatedAt,
)
if err != nil {
@@ -292,6 +416,26 @@ func handleCreateService(c *gin.Context) {
return
}
// Domains live in service_domains; services.domain is the derived default.
if req.Domain != "" && validHostname(strings.ToLower(req.Domain)) {
_, _ = db.(*database.DB).Exec(
`INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true)
ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`,
service.ID, strings.ToLower(req.Domain))
}
if req.MaintenanceMode || len(req.BasicAuth) > 0 {
basicAuthUsers := ""
if encoded, encErr := encodeBasicAuth(req.BasicAuth); encErr == nil {
basicAuthUsers = encoded
}
_, _ = db.(*database.DB).Exec(
`UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`,
req.MaintenanceMode, basicAuthUsers, service.ID)
service.MaintenanceMode = req.MaintenanceMode
service.BasicAuth = basicAuthUsernames(basicAuthUsers)
}
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
c.JSON(http.StatusCreated, gin.H{"service": service})
}
@@ -349,6 +493,9 @@ func handleGetService(c *gin.Context) {
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
JOIN projects p ON s.project_id = p.id
@@ -360,7 +507,9 @@ func handleGetService(c *gin.Context) {
&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.CreatedAt, &service.UpdatedAt,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt,
)
if err != nil {
@@ -369,6 +518,15 @@ func handleGetService(c *gin.Context) {
}
liveServiceStatus(c, db.(*database.DB), &service)
for _, v := range loadServiceVolumes(db.(*database.DB), service.ID) {
service.Volumes = append(service.Volumes, ServiceVolume{
Type: v.Type, Source: v.Source, Target: v.Destination, ReadOnly: v.ReadOnly,
})
}
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
service.MaintenanceMode = maintenance
service.BasicAuth = basicAuthUsernames(basicAuth)
c.JSON(http.StatusOK, gin.H{"service": service})
}
@@ -417,6 +575,9 @@ func handleUpdateService(c *gin.Context) {
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
JOIN projects p ON s.project_id = p.id
@@ -429,6 +590,9 @@ func handleUpdateService(c *gin.Context) {
&existingService.BuildPath, &existingService.CPU, &existingService.Memory,
&existingService.Replicas, &existingService.Port, &existingService.Domain,
&existingService.HealthCheckPath, &existingService.RestartPolicy,
&existingService.Builder, &existingService.CPUReserve,
&existingService.MemoryReserve, &existingService.StaticBuildCmd,
&existingService.StaticDir,
&existingService.CreatedAt, &existingService.UpdatedAt,
)
@@ -483,6 +647,49 @@ func handleUpdateService(c *gin.Context) {
if req.RestartPolicy != "" {
existingService.RestartPolicy = req.RestartPolicy
}
if req.Builder != "" {
existingService.Builder = req.Builder
}
if req.CPUReserve != nil {
existingService.CPUReserve = *req.CPUReserve
}
if req.MemoryReserve != nil {
existingService.MemoryReserve = *req.MemoryReserve
}
if req.StaticBuildCmd != nil {
existingService.StaticBuildCmd = *req.StaticBuildCmd
}
if req.StaticDir != nil {
existingService.StaticDir = *req.StaticDir
}
if existingService.Builder == "static" && existingService.StaticDir == "" {
existingService.StaticDir = "dist"
}
for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
} {
v := map[string]string{
"cpu": existingService.CPU, "cpu_reserve": existingService.CPUReserve,
"memory": existingService.Memory, "memory_reserve": existingService.MemoryReserve,
}[label]
if err := check(v); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
return
}
}
// lib/pq sends a nil []byte as an empty bytea literal rather than NULL,
// so the COALESCE fallback needs an untyped nil when volumes aren't sent.
var volumesArg interface{}
if req.Volumes != nil {
if err := validateServiceVolumes(*req.Volumes); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
existingService.Volumes = *req.Volumes
volumesJSON, _ := json.Marshal(*req.Volumes)
volumesArg = volumesJSON
}
existingService.UpdatedAt = time.Now()
@@ -492,14 +699,18 @@ func handleUpdateService(c *gin.Context) {
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
restart_policy = $15, updated_at = $16
WHERE id = $17`,
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
builder = $19, cpu_reserve = $20, memory_reserve = $21,
static_build_cmd = $22, static_dir = $23
WHERE id = $18`,
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
existingService.BuildPath, existingService.CPU, existingService.Memory,
existingService.Replicas, existingService.Port, existingService.Domain,
existingService.HealthCheckPath, existingService.RestartPolicy,
existingService.UpdatedAt, existingService.ID,
existingService.UpdatedAt, volumesArg, existingService.ID,
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
existingService.StaticBuildCmd, existingService.StaticDir,
)
if err != nil {
@@ -507,23 +718,65 @@ func handleUpdateService(c *gin.Context) {
return
}
// Domain writes route through service_domains — services.domain is the
// derived default and gets rewritten by syncDefaultDomain.
if req.Domain != nil {
domain := strings.ToLower(strings.TrimSpace(*req.Domain))
if domain == "" {
_, _ = db.(*database.DB).Exec(`DELETE FROM service_domains WHERE service_id = $1`, serviceID)
} else if validHostname(domain) {
_, _ = db.(*database.DB).Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID)
_, _ = db.(*database.DB).Exec(
`INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true)
ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`,
serviceID, domain)
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid hostname", "code": "VALIDATION"})
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
_ = db.(*database.DB).QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&existingService.Domain)
}
if req.MaintenanceMode != nil || req.BasicAuth != nil {
maintenance, basicAuth := serviceAccess(db.(*database.DB), serviceID)
if req.MaintenanceMode != nil {
maintenance = *req.MaintenanceMode
}
if req.BasicAuth != nil {
encoded, encErr := encodeBasicAuth(*req.BasicAuth)
if encErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encode basic auth", "code": "INTERNAL"})
return
}
basicAuth = encoded
}
_, _ = db.(*database.DB).Exec(
`UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`,
maintenance, basicAuth, serviceID)
existingService.MaintenanceMode = maintenance
}
existingService.Domains = loadServiceDomains(db.(*database.DB), serviceID)
_, storedAuth := serviceAccess(db.(*database.DB), serviceID)
existingService.BasicAuth = basicAuthUsernames(storedAuth)
// A replica change applies immediately when the service has live
// containers; other spec fields take effect on the next deploy.
// Runs through the deploy queue so it can't overlap a build.
if req.Replicas != nil && existingService.Status == "running" {
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
engine := engineValue.(*deployment.DeploymentEngine)
serviceCopy := existingService
go func() {
spec, err := serviceRuntimeSpec(db.(*database.DB), serviceCopy)
dbCopy := db.(*database.DB)
getDeployQueue(c).Enqueue(serviceCopy.ID, deployqueue.Job{Run: func(jctx context.Context) {
spec, err := serviceRuntimeSpec(dbCopy, serviceCopy)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if state, err := engine.ReconcileService(ctx, spec); err == nil && state != nil {
persistPublishedPort(db.(*database.DB), serviceCopy.ID, state.Ports)
if state, err := engine.ReconcileService(jctx, spec); err == nil && state != nil {
persistPublishedPort(dbCopy, serviceCopy.ID, state.Ports)
}
}()
}})
}
}
+2 -2
View File
@@ -42,8 +42,8 @@ type hostMonitoringResponse struct {
Docker map[string]any `json:"docker,omitempty"`
// Scope names what the telemetry actually measures: the physical host for
// native dockerd, or the Docker Desktop VM when the daemon runs in one.
Scope string `json:"scope"`
CollectedAt string `json:"collected_at"`
Scope string `json:"scope"`
CollectedAt string `json:"collected_at"`
}
type hostCPU struct {
+194
View File
@@ -0,0 +1,194 @@
package api
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"net/http"
"strings"
"time"
"containr/internal/database"
sqlcdb "containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Personal access tokens let the CLI, MCP server, and third-party agents
// authenticate without a browser session. The raw token (cnp_…) is
// returned exactly once at creation; only its sha256 hash persists.
var userTokenScopes = map[string]bool{"read": true, "write": true, "admin": true}
func generateUserToken() (token string, prefix string, hash string, err error) {
buf := make([]byte, 24)
if _, err = rand.Read(buf); err != nil {
return "", "", "", err
}
token = "cnp_" + hex.EncodeToString(buf)
prefix = token[:12] // cnp_ + 8 hex chars, enough to identify in a list
sum := sha256.Sum256([]byte(token))
return token, prefix, hex.EncodeToString(sum[:]), nil
}
func userTokenJSON(t sqlcdb.UserToken) gin.H {
row := gin.H{
"id": t.ID.String(),
"name": t.Name,
"key_prefix": t.KeyPrefix,
"scope": t.Scope,
}
if t.CreatedAt.Valid {
row["created_at"] = t.CreatedAt.Time
}
if t.LastUsedAt.Valid {
row["last_used_at"] = t.LastUsedAt.Time
}
if t.ExpiresAt.Valid {
row["expires_at"] = t.ExpiresAt.Time
}
return row
}
func handleCreateUserToken(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
var req struct {
Name string `json:"name"`
Scope string `json:"scope"`
ExpiresInDays int `json:"expires_in_days"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"})
return
}
if req.Scope == "" {
req.Scope = "write"
}
if !userTokenScopes[req.Scope] {
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be one of: read, write, admin", "code": "VALIDATION"})
return
}
// A token can never outrank its owner: non-admin users cannot mint
// admin-scope tokens even for themselves.
if req.Scope == "admin" && !c.GetBool("is_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "admin scope requires a platform admin account", "code": "FORBIDDEN"})
return
}
if req.ExpiresInDays < 0 || req.ExpiresInDays > 3650 {
c.JSON(http.StatusBadRequest, gin.H{"error": "expires_in_days must be between 0 (never) and 3650", "code": "VALIDATION"})
return
}
token, prefix, hash, err := generateUserToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token", "code": "INTERNAL"})
return
}
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
return
}
expiresAt := sql.NullTime{}
if req.ExpiresInDays > 0 {
expiresAt = sql.NullTime{Time: time.Now().Add(time.Duration(req.ExpiresInDays) * 24 * time.Hour), Valid: true}
}
row, err := queries.CreateUserToken(c.Request.Context(), sqlcdb.CreateUserTokenParams{
UserID: ownerUUID,
Name: req.Name,
KeyPrefix: prefix,
TokenHash: hash,
Scope: req.Scope,
ExpiresAt: expiresAt,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token", "code": "INTERNAL"})
return
}
LogAudit(userID, "user_token", row.ID.String(), "create", map[string]interface{}{"name": req.Name, "scope": req.Scope})
payload := userTokenJSON(row)
payload["token"] = token
c.JSON(http.StatusCreated, payload)
}
func handleListUserTokens(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
return
}
rows, err := queries.ListUserTokens(c.Request.Context(), ownerUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens", "code": "INTERNAL"})
return
}
out := make([]gin.H, 0, len(rows))
for _, t := range rows {
out = append(out, userTokenJSON(t))
}
c.JSON(http.StatusOK, gin.H{"tokens": out})
}
func handleDeleteUserToken(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
return
}
tokenUUID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID", "code": "VALIDATION"})
return
}
affected, err := queries.RevokeUserToken(c.Request.Context(), sqlcdb.RevokeUserTokenParams{
ID: tokenUUID,
UserID: ownerUUID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token", "code": "INTERNAL"})
return
}
if affected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found", "code": "NOT_FOUND"})
return
}
LogAudit(userID, "user_token", tokenUUID.String(), "revoke", nil)
c.JSON(http.StatusOK, gin.H{"message": "Token revoked"})
}
+25 -1
View File
@@ -2,6 +2,7 @@ package api
import (
"containr/internal/database"
"containr/internal/secrets"
"net/http"
"time"
@@ -25,6 +26,7 @@ type EnvironmentVariable struct {
type UpdateVariablesRequest struct {
Variables []VariableInput `json:"variables" binding:"required"`
Redeploy bool `json:"redeploy"`
}
type VariableInput struct {
@@ -159,6 +161,9 @@ func handleUpdateVariables(c *gin.Context) {
value = stored
}
}
if v.IsSecret {
value = secrets.Encrypt(value)
}
varID := uuid.New()
_, err = tx.Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
@@ -204,5 +209,24 @@ func handleUpdateVariables(c *gin.Context) {
variables = append(variables, v)
}
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Environment variables updated successfully"})
resp := gin.H{"variables": variables, "message": "Environment variables updated successfully"}
if req.Redeploy {
// Load the service spec inputs and queue a reconcile behind any
// in-flight deployment — new env lands in the recreated containers.
var svc Service
svcErr := db.(*database.DB).QueryRow(
`SELECT id, project_id, name, COALESCE(image, ''), COALESCE(command, ''),
COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(domain, ''),
COALESCE(healthcheck_path, ''), COALESCE(restart_policy, 'unless-stopped'),
COALESCE(cpu, ''), COALESCE(memory, ''),
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''), COALESCE(memory_reserve, '')
FROM services WHERE id = $1`,
serviceID,
).Scan(&svc.ID, &svc.ProjectID, &svc.Name, &svc.Image, &svc.Command,
&svc.Replicas, &svc.Port, &svc.Domain, &svc.HealthCheckPath,
&svc.RestartPolicy, &svc.CPU, &svc.Memory,
&svc.Builder, &svc.CPUReserve, &svc.MemoryReserve)
resp["redeploy_queued"] = svcErr == nil && enqueueServiceRedeploy(c, db.(*database.DB), svc)
}
c.JSON(http.StatusOK, resp)
}
+105
View File
@@ -0,0 +1,105 @@
package api
import (
"net/http"
"time"
"containr/internal/docker"
"github.com/gin-gonic/gin"
)
// VolumeInfo is one docker volume plus whether any container mounts it.
type VolumeInfo struct {
Name string `json:"name"`
Driver string `json:"driver"`
Mountpoint string `json:"mountpoint"`
Scope string `json:"scope"`
InUse bool `json:"in_use"`
UsedBy []string `json:"used_by,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
func dockerClientOr500(c *gin.Context) (*docker.Client, bool) {
v, exists := c.Get("docker_client")
if !exists || v == nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Docker client not available")
return nil, false
}
return v.(*docker.Client), true
}
// handleAdminListVolumes returns the node's docker volume inventory with
// in-use flags. Admin-only: mountpoints reveal host filesystem layout.
func handleAdminListVolumes(c *gin.Context) {
client, ok := dockerClientOr500(c)
if !ok {
return
}
ctx := c.Request.Context()
vols, err := client.ListVolumes(ctx)
if err != nil {
respondError(c, http.StatusBadGateway, "DEPENDENCY_UNAVAILABLE", "Failed to list volumes: "+err.Error())
return
}
containers, err := client.ListContainers(ctx, true)
if err != nil {
respondError(c, http.StatusBadGateway, "DEPENDENCY_UNAVAILABLE", "Failed to list containers: "+err.Error())
return
}
inUse := map[string][]string{}
for _, cont := range containers {
for _, m := range cont.Mounts {
if m.Type == "volume" {
name := cont.ID[:12]
if len(cont.Names) > 0 {
name = cont.Names[0]
}
inUse[m.Name] = append(inUse[m.Name], name)
}
}
}
out := make([]VolumeInfo, 0, len(vols.Volumes))
for _, v := range vols.Volumes {
if v == nil {
continue
}
info := VolumeInfo{
Name: v.Name,
Driver: v.Driver,
Mountpoint: v.Mountpoint,
Scope: v.Scope,
Labels: v.Labels,
UsedBy: inUse[v.Name],
}
info.InUse = len(info.UsedBy) > 0
if t, err := time.Parse(time.RFC3339Nano, v.CreatedAt); err == nil {
info.CreatedAt = t
}
out = append(out, info)
}
c.JSON(http.StatusOK, gin.H{"volumes": out})
}
// handleAdminDeleteVolume removes a docker volume. Docker refuses removal
// of in-use volumes — that error is surfaced as a conflict.
func handleAdminDeleteVolume(c *gin.Context) {
client, ok := dockerClientOr500(c)
if !ok {
return
}
name := c.Param("name")
if name == "" {
respondError(c, http.StatusBadRequest, "VALIDATION", "Volume name is required")
return
}
if err := client.RemoveVolume(c.Request.Context(), name, false); err != nil {
respondError(c, http.StatusConflict, "CONFLICT", "Failed to remove volume: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"deleted": name})
}
+7 -2
View File
@@ -211,8 +211,12 @@ func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest)
// Build Docker image
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
// Create build context tar
buildCtx, err := createBuildContext(req.SourcePath, optimizedDockerfile)
// createBuildContext wants the Dockerfile contents, not its path.
dockerfileContent, err := os.ReadFile(optimizedDockerfile)
if err != nil {
return nil, fmt.Errorf("failed to read Dockerfile: %w", err)
}
buildCtx, err := createBuildContext(req.SourcePath, string(dockerfileContent))
if err != nil {
return nil, fmt.Errorf("failed to create build context: %w", err)
}
@@ -222,6 +226,7 @@ func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest)
Dockerfile: "Dockerfile",
Tags: []string{imageName},
BuildArgs: buildArgs,
NoCache: req.NoCache,
Remove: true,
}
+6 -1
View File
@@ -16,6 +16,7 @@ type BuildManager struct {
railpackBuilder *RailpackBuilder
nixpacksBuilder *NixpacksBuilder
dockerfileBuilder *DockerfileBuilder
staticBuilder *StaticBuilder
dockerClient *docker.Client
cacheManager *CacheManager
parallelBuilder *ParallelBuilder
@@ -28,6 +29,7 @@ const (
BuildTypeRailpack BuildType = "railpack"
BuildTypeNixpacks BuildType = "nixpacks"
BuildTypeDockerfile BuildType = "dockerfile"
BuildTypeStatic BuildType = "static"
BuildTypePrebuilt BuildType = "prebuilt"
)
@@ -39,6 +41,7 @@ func NewBuildManager(workDir string, dockerClient *docker.Client) *BuildManager
railpackBuilder: NewRailpackBuilder(workDir, dockerClient),
nixpacksBuilder: NewNixpacksBuilder(workDir, dockerClient),
dockerfileBuilder: NewDockerfileBuilder(workDir, dockerClient),
staticBuilder: NewStaticBuilder(dockerClient),
dockerClient: dockerClient,
cacheManager: cacheManager,
parallelBuilder: parallelBuilder,
@@ -80,6 +83,8 @@ func (bm *BuildManager) Build(ctx context.Context, req *types.BuildRequest) (*ty
return bm.nixpacksBuilder.Build(ctx, req)
case BuildTypeDockerfile:
return bm.dockerfileBuilder.Build(ctx, req)
case BuildTypeStatic:
return bm.staticBuilder.Build(ctx, req)
case BuildTypePrebuilt:
return bm.buildPrebuilt(ctx, req)
default:
@@ -95,7 +100,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
// Pull the prebuilt image — must drain the pull stream or the daemon
// aborts it and the tag below races a missing image.
auth := registry.AuthConfig{}
auth := registry.AuthConfig{Username: req.PullUsername, Password: req.PullPassword}
if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil {
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
}
+2 -1
View File
@@ -498,7 +498,8 @@ func (n *NixpacksBuilder) Build(ctx context.Context, req *types.BuildRequest) (*
BuildArgs: map[string]*string{
"BUILDKIT_INLINE_CACHE": strPtr("1"),
},
Remove: true,
NoCache: req.NoCache,
Remove: true,
}
_, err = n.dockerClient.BuildImage(ctx, buildCtx, buildOptions)
+1
View File
@@ -156,6 +156,7 @@ func (rb *RailpackBuilder) Build(ctx context.Context, req *types.BuildRequest) (
Tags: []string{imageName},
BuildArgs: buildArgs,
Labels: req.Labels,
NoCache: req.NoCache,
Remove: true,
}
+95
View File
@@ -0,0 +1,95 @@
package build
import (
"context"
"fmt"
"containr/internal/docker"
"containr/internal/types"
)
// StaticBuilder produces an nginx-served image from a repo's built assets.
// STATIC_BUILD_CMD runs inside a node build stage; STATIC_DIR names the
// output directory copied into the nginx image. Both are overridable via
// build args so repos with non-node toolchains can swap the build image too.
type StaticBuilder struct {
dockerClient *docker.Client
}
func NewStaticBuilder(dockerClient *docker.Client) *StaticBuilder {
return &StaticBuilder{dockerClient: dockerClient}
}
const staticDockerfile = `ARG BUILD_IMAGE=node:20-alpine
FROM ${BUILD_IMAGE} AS assets
WORKDIR /app
COPY . .
ARG STATIC_BUILD_CMD
RUN sh -c "${STATIC_BUILD_CMD}"
FROM nginx:alpine
ARG STATIC_DIR=dist
COPY --from=assets /app/${STATIC_DIR} /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
`
func (s *StaticBuilder) Build(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
buildCmd := req.BuildCommand
if buildCmd == "" {
buildCmd = "npm ci && npm run build"
}
staticDir := req.BuildArgs["STATIC_DIR"]
if staticDir == "" {
staticDir = "dist"
}
buildArgs := map[string]*string{}
for k, v := range req.BuildArgs {
v := v
buildArgs[k] = &v
}
for k, v := range req.Environment {
v := v
buildArgs[k] = &v
}
buildArgs["STATIC_BUILD_CMD"] = &buildCmd
buildArgs["STATIC_DIR"] = &staticDir
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
buildCtx, err := createBuildContext(req.SourcePath, staticDockerfile)
if err != nil {
return nil, fmt.Errorf("failed to create build context: %w", err)
}
defer buildCtx.Close()
if _, err := s.dockerClient.BuildImage(ctx, buildCtx, docker.BuildOptions{
Dockerfile: "Dockerfile",
Tags: []string{imageName},
BuildArgs: buildArgs,
NoCache: req.NoCache,
Remove: true,
}); err != nil {
return nil, fmt.Errorf("failed to build static image: %w", err)
}
if req.RegistryURL != "" {
full := fmt.Sprintf("%s/%s", req.RegistryURL, imageName)
if err := s.dockerClient.PushImage(ctx, imageName, req.RegistryURL); err != nil {
return nil, fmt.Errorf("failed to push image: %w", err)
}
imageName = full
}
imageInfo, err := s.dockerClient.GetImageInfo(ctx, imageName)
if err != nil {
imageInfo = &docker.ImageInfo{Size: 0}
}
return &types.BuildResponse{
ImageName: imageName,
ImageTag: req.ImageTag,
Size: imageInfo.Size,
Digest: imageInfo.Digest,
}, nil
}
+361
View File
@@ -0,0 +1,361 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// AdminCmd groups platform-admin operations.
var AdminCmd = &cobra.Command{
Use: "admin",
Short: "Platform administration (admin scope required)",
}
var adminOverviewCmd = &cobra.Command{
Use: "overview",
Short: "Platform overview",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/admin/overview", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var adminUsersCmd = &cobra.Command{
Use: "users",
Short: "List all users",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/admin/users", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "users")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, u := range items {
rows = append(rows, []string{
str(u, "id"), str(u, "email"), str(u, "name"), str(u, "is_admin"), relTime(u, "created_at"),
})
}
printRows(data, []string{"ID", "EMAIL", "NAME", "ADMIN", "CREATED"}, rows)
return nil
},
}
var adminSettingsCmd = &cobra.Command{
Use: "settings",
Short: "Show platform settings",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/settings", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var adminAuditCmd = &cobra.Command{
Use: "audit-logs",
Short: "List audit log entries",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/audit-logs", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// GatewayCmd manages the API gateway (admin).
var GatewayCmd = &cobra.Command{
Use: "gateway",
Short: "Manage the API gateway (admin)",
}
var gatewayServicesCmd = &cobra.Command{
Use: "services",
Short: "List gateway services",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/gateway/services", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var gatewayKeysCmd = &cobra.Command{
Use: "keys",
Short: "List gateway API keys",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/gateway/keys", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var gatewayAnalyticsCmd = &cobra.Command{
Use: "analytics",
Short: "Gateway traffic analytics",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
kind, _ := cmd.Flags().GetString("type")
data, err := c.Do("GET", "/gateway/analytics/"+kind, nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// HACmd manages high availability.
var HACmd = &cobra.Command{
Use: "ha",
Short: "High availability controls",
}
var haStatusCmd = &cobra.Command{
Use: "status",
Short: "Show HA status",
RunE: simpleGet("/ha/status"),
}
var haAlertsCmd = &cobra.Command{
Use: "alerts",
Short: "List active HA alerts",
RunE: simpleGet("/ha/alerts/active"),
}
var haHealthCmd = &cobra.Command{
Use: "health",
Short: "List health check results",
RunE: simpleGet("/ha/health/results"),
}
var haPoliciesCmd = &cobra.Command{
Use: "policies",
Short: "List failover policies",
RunE: simpleGet("/ha/failover/policies"),
}
var haEnableCmd = &cobra.Command{
Use: "enable",
Short: "Enable the HA manager",
RunE: simplePost("/ha/enable"),
}
var haDisableCmd = &cobra.Command{
Use: "disable",
Short: "Disable the HA manager",
RunE: func(cmd *cobra.Command, args []string) error {
if !Confirm("Disable HA? Services will no longer be failed over.") {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
return simplePost("/ha/disable")(cmd, args)
},
}
var haFailoverCmd = &cobra.Command{
Use: "failover",
Short: "Trigger a manual failover",
RunE: func(cmd *cobra.Command, args []string) error {
if !Confirm("Trigger a platform failover now? Services may be rescheduled.") {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
return simplePost("/ha/failover")(cmd, args)
},
}
// ScalingCmd manages autoscaling.
var ScalingCmd = &cobra.Command{
Use: "scaling",
Short: "Autoscaling controls",
}
var scalingStatusCmd = &cobra.Command{
Use: "status",
Short: "Show autoscaler status",
RunE: simpleGet("/scaling/status"),
}
var scalingPoliciesCmd = &cobra.Command{
Use: "policies",
Short: "List scaling policies",
RunE: simpleGet("/scaling/policies"),
}
var scalingServicesCmd = &cobra.Command{
Use: "services",
Short: "Show per-service scaling state",
RunE: simpleGet("/scaling/services"),
}
var scalingScaleCmd = &cobra.Command{
Use: "scale <service-id> <replicas>",
Short: "Manually scale a service",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
var replicas int
fmt.Sscanf(args[1], "%d", &replicas)
data, err := c.Do("POST", "/scaling/services/"+args[0]+"/scale", map[string]int{"replicas": replicas})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// SecurityCmd manages security scans and findings.
var SecurityCmd = &cobra.Command{
Use: "security",
Short: "Security scans and vulnerabilities",
}
var securityScanCmd = &cobra.Command{
Use: "scan <project-id>",
Short: "Run a security scan",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
scanType, _ := cmd.Flags().GetString("type")
serviceID, _ := cmd.Flags().GetString("service")
body := map[string]interface{}{"project_id": args[0], "scan_type": scanType}
if serviceID != "" {
body["service_id"] = serviceID
}
data, err := c.Do("POST", "/security/scans", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var securityVulnsCmd = &cobra.Command{
Use: "vulnerabilities <project-id>",
Short: "List a project's vulnerabilities",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/projects/"+args[0]+"/vulnerabilities", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var securityMetricsCmd = &cobra.Command{
Use: "metrics <project-id>",
Short: "Show a project's security metrics",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/projects/"+args[0]+"/security/metrics", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// simpleGet/simplePost cover the many read/trigger endpoints whose full
// JSON response is the useful output.
func simpleGet(path string) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", path, nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
}
}
func simplePost(path string) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", path, map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
}
}
func init() {
gatewayAnalyticsCmd.Flags().String("type", "traffic", "traffic|ops")
securityScanCmd.Flags().String("type", "configuration", "dependency|configuration|comprehensive")
securityScanCmd.Flags().String("service", "", "limit scan to one service")
AdminCmd.AddCommand(adminOverviewCmd, adminUsersCmd, adminSettingsCmd, adminAuditCmd)
GatewayCmd.AddCommand(gatewayServicesCmd, gatewayKeysCmd, gatewayAnalyticsCmd)
HACmd.AddCommand(haStatusCmd, haAlertsCmd, haHealthCmd, haPoliciesCmd, haEnableCmd, haDisableCmd, haFailoverCmd)
ScalingCmd.AddCommand(scalingStatusCmd, scalingPoliciesCmd, scalingServicesCmd, scalingScaleCmd)
SecurityCmd.AddCommand(securityScanCmd, securityVulnsCmd, securityMetricsCmd)
}
+94 -83
View File
@@ -2,139 +2,150 @@ package commands
import (
"fmt"
"net/http"
"strings"
"time"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/term"
)
// AuthCmd represents the auth command
// AuthCmd groups authentication commands.
var AuthCmd = &cobra.Command{
Use: "auth",
Short: "Authenticate with Containr API",
Long: `Manage authentication with the Containr API.
You can login, logout, and check your current authentication status.`,
Short: "Authenticate with a Containr instance",
Long: `Login with a personal access token (cnp_...) created under
Settings → Personal Access Tokens, check status, or switch profiles.`,
}
// loginCmd represents the login command
var loginCmd = &cobra.Command{
Use: "login [token]",
Short: "Login to Containr",
Long: `Login to Containr using your API token.
You can get your token from the Containr web interface.`,
Short: "Store a personal access token",
Long: `Login with a cnp_ token. Reads interactively when no argument is
given. The token is verified against the API before it is stored.`,
Args: cobra.MaximumNArgs(1),
RunE: runLogin,
}
// logoutCmd represents the logout command
var logoutCmd = &cobra.Command{
Use: "logout",
Short: "Logout from Containr",
Long: `Remove stored authentication credentials.`,
Short: "Remove the stored token",
RunE: runLogout,
}
// statusCmd represents the status command
var statusCmd = &cobra.Command{
Use: "status",
Short: "Check authentication status",
Long: `Check if you are currently authenticated with Containr.`,
Short: "Show the active profile and verify the token",
RunE: runStatus,
}
func init() {
AuthCmd.AddCommand(loginCmd)
AuthCmd.AddCommand(logoutCmd)
AuthCmd.AddCommand(statusCmd)
loginCmd.Flags().String("url", "", "API URL to store in the profile (e.g. https://containr.example.com)")
loginCmd.Flags().String("name", "", "profile name to save under (default: active profile)")
AuthCmd.AddCommand(loginCmd, logoutCmd, statusCmd)
}
type profileResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
IsAdmin bool `json:"is_admin"`
}
func runLogin(cmd *cobra.Command, args []string) error {
var token string
if len(args) > 0 {
token = args[0]
} else {
// Prompt for token
fmt.Print("Enter your Containr API token: ")
fmt.Scanln(&token)
} else if isTerminal() {
fmt.Fprint(os.Stderr, "Token: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return err
}
token = string(raw)
}
if token == "" {
return fmt.Errorf("token is required")
return &APIError{Message: "token is required — pass it as an argument or pipe it via stdin", ExitCode: ExitValidation}
}
// Store token in config
viper.Set("token", token)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to save token: %w", err)
// Verify before saving so a mistyped token fails loudly.
urlFlag, _ := cmd.Flags().GetString("url")
probe := &Client{BaseURL: ResolveAPIURL(), Token: token}
if urlFlag != "" {
probe.BaseURL = normalizeBase(urlFlag)
}
var profile profileResponse
if err := probe.DoJSON("GET", "/user/profile", nil, &profile); err != nil {
return fmt.Errorf("token verification failed: %w", err)
}
fmt.Println("✓ Successfully logged in to Containr")
name, _ := cmd.Flags().GetString("name")
if name == "" {
name = ActiveProfile()
}
if err := SaveProfile(name, probe.BaseURL, token); err != nil {
return fmt.Errorf("failed to save profile: %w", err)
}
if JSONMode() {
return PrintJSON(map[string]interface{}{
"profile": name, "api_url": probe.BaseURL,
"user": profile,
})
}
fmt.Printf("Logged in as %s (%s) — profile %q → %s\n", profile.Name, profile.Email, name, probe.BaseURL)
return nil
}
func runLogout(cmd *cobra.Command, args []string) error {
// Remove token from config
viper.Set("token", "")
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to remove token: %w", err)
name := ActiveProfile()
if err := ClearProfileToken(name); err != nil {
return fmt.Errorf("failed to update config: %w", err)
}
if !JSONMode() {
fmt.Printf("Removed token from profile %q\n", name)
} else {
return PrintJSON(map[string]string{"status": "ok", "profile": name})
}
fmt.Println("✓ Successfully logged out from Containr")
return nil
}
func runStatus(cmd *cobra.Command, args []string) error {
token := viper.GetString("token")
if token == "" {
fmt.Println("❌ Not authenticated")
fmt.Println("Run 'containr auth login <token>' to authenticate")
client := NewClient()
profile := ActiveProfile()
if JSONMode() {
out := map[string]interface{}{
"profile": profile,
"api_url": client.BaseURL,
}
if client.Token != "" {
var p profileResponse
if err := client.DoJSON("GET", "/user/profile", nil, &p); err == nil {
out["user"] = p
out["authenticated"] = true
} else {
out["authenticated"] = false
}
} else {
out["authenticated"] = false
}
return PrintJSON(out)
}
fmt.Printf("Profile: %s\n", profile)
fmt.Printf("API URL: %s\n", client.BaseURL)
if client.Token == "" {
fmt.Println("Token: not set — run `containr auth login <token>`")
return nil
}
fmt.Println("✓ Authenticated with Containr")
apiURL := buildAPIURL("/user/profile")
fmt.Printf("API URL: %s\n", strings.TrimSuffix(buildAPIURL(""), "/"))
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return fmt.Errorf("failed to create verification request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("⚠ Token verification unavailable: %v\n", err)
var p profileResponse
if err := client.DoJSON("GET", "/user/profile", nil, &p); err != nil {
fmt.Printf("Token: present but rejected (%v)\n", err)
return nil
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
fmt.Println("✓ Token verified against API")
case http.StatusUnauthorized, http.StatusForbidden:
fmt.Println("❌ Token rejected by API (expired/invalid)")
default:
fmt.Printf("⚠ Token verification returned unexpected status: %s\n", resp.Status)
fmt.Printf("Identity: %s <%s>\n", p.Name, p.Email)
if p.IsAdmin {
fmt.Println("Role: platform admin")
}
return nil
}
func buildAPIURL(endpoint string) string {
baseURL := viper.GetString("api-url")
if baseURL == "" {
baseURL = "http://localhost:8080/api/v1"
}
baseURL = strings.TrimSuffix(baseURL, "/")
if endpoint == "" {
return baseURL
}
if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
return baseURL + endpoint
}
+41 -5
View File
@@ -6,16 +6,52 @@ import (
"github.com/spf13/viper"
)
func TestBuildAPIURLDefaultsAndTrimming(t *testing.T) {
func TestResolveAPIURLDefaultsAndTrimming(t *testing.T) {
viper.Set("api-url", "")
got := buildAPIURL("/user/profile")
if got != "http://localhost:8080/api/v1/user/profile" {
viper.Set("current_profile", "")
viper.Set("profiles.default.api_url", "")
if got := ResolveAPIURL(); got != "http://localhost:8080/api/v1" {
t.Fatalf("unexpected default api url: %s", got)
}
viper.Set("api-url", "https://api.example.com/api/v1/")
got = buildAPIURL("user/profile")
if got != "https://api.example.com/api/v1/user/profile" {
if got := ResolveAPIURL(); got != "https://api.example.com/api/v1" {
t.Fatalf("unexpected trimmed api url: %s", got)
}
// Bare hosts gain the /api/v1 suffix.
viper.Set("api-url", "https://containr.example.com")
if got := ResolveAPIURL(); got != "https://containr.example.com/api/v1" {
t.Fatalf("unexpected suffixed api url: %s", got)
}
viper.Set("api-url", "")
}
func TestResolveTokenProfileAndEnv(t *testing.T) {
viper.Set("token", "")
viper.Set("current_profile", "staging")
viper.Set("profiles.staging.token", "cnp_staging")
if got := ResolveToken(); got != "cnp_staging" {
t.Fatalf("expected profile token, got %s", got)
}
t.Setenv("CONTAINR_TOKEN", "cnp_env")
if got := ResolveToken(); got != "cnp_env" {
t.Fatalf("env should win over profile, got %s", got)
}
viper.Set("token", "cnp_flag")
if got := ResolveToken(); got != "cnp_flag" {
t.Fatalf("flag should win over env, got %s", got)
}
}
func TestExitCodeMapping(t *testing.T) {
if got := exitCodeFor(401); got != ExitAuth {
t.Fatalf("401 → %d, want %d", got, ExitAuth)
}
if got := exitCodeFor(404); got != ExitNotFound {
t.Fatalf("404 → %d, want %d", got, ExitNotFound)
}
if got := exitCodeFor(400); got != ExitValidation {
t.Fatalf("400 → %d, want %d", got, ExitValidation)
}
}
+169
View File
@@ -0,0 +1,169 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/spf13/viper"
)
// Exit codes — stable for scripting and agent use.
const (
ExitOK = 0
ExitError = 1
ExitAuth = 2
ExitNotFound = 3
ExitValidation = 4
ExitUnavailable = 5
)
// APIError is a non-2xx response from the Containr API. Code carries the
// stable exit code so `main` can map errors to exit statuses.
type APIError struct {
Status int
Message string
ExitCode int
}
func (e *APIError) Error() string {
if e.Message != "" {
return e.Message
}
return fmt.Sprintf("request failed with status %d", e.Status)
}
func exitCodeFor(status int) int {
switch status {
case http.StatusUnauthorized, http.StatusForbidden:
return ExitAuth
case http.StatusNotFound:
return ExitNotFound
case http.StatusBadRequest, http.StatusConflict, http.StatusUnprocessableEntity:
return ExitValidation
default:
return ExitError
}
}
// Client is a thin authenticated HTTP client for the Containr API. All CLI
// commands go through it — no command builds its own request.
type Client struct {
BaseURL string
Token string
HTTPClient *http.Client
}
// NewClient resolves the active profile (flag > env > current_profile) and
// returns a client ready to call the API. Call RequireAuth() for commands
// that need a token.
func NewClient() *Client {
return &Client{
BaseURL: ResolveAPIURL(),
Token: ResolveToken(),
HTTPClient: &http.Client{Timeout: 60 * time.Second},
}
}
// RequireAuth returns an error suitable for the CLI when no token is set.
func (c *Client) RequireAuth() error {
if c.Token == "" {
return &APIError{
Status: 0,
Message: "not authenticated — run `containr auth login <token>` or set CONTAINR_TOKEN",
ExitCode: ExitAuth,
}
}
return nil
}
// Do performs an API request. body may be nil, a []byte, or any value that
// marshals to JSON. Returns the raw response body.
func (c *Client) Do(method, path string, body interface{}) ([]byte, error) {
var reader io.Reader
if body != nil {
var data []byte
switch v := body.(type) {
case []byte:
data = v
default:
var err error
data, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to encode request: %w", err)
}
}
reader = bytes.NewReader(data)
}
url := c.BaseURL + path
req, err := http.NewRequest(method, url, reader)
if err != nil {
return nil, err
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if k := viper.GetString("idempotency-key"); k != "" && method == http.MethodPost {
req.Header.Set("Idempotency-Key", k)
}
req.Header.Set("Accept", "application/json")
hc := c.HTTPClient
if hc == nil {
hc = &http.Client{Timeout: 60 * time.Second}
}
resp, err := hc.Do(req)
if err != nil {
return nil, &APIError{
Status: 0,
Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err),
ExitCode: ExitUnavailable,
}
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode >= 400 {
msg := strings.TrimSpace(string(data))
var envelope struct {
Error string `json:"error"`
Code string `json:"code"`
}
if json.Unmarshal(data, &envelope) == nil && envelope.Error != "" {
msg = envelope.Error
if envelope.Code != "" {
msg = envelope.Code + ": " + msg
}
}
return nil, &APIError{
Status: resp.StatusCode,
Message: msg,
ExitCode: exitCodeFor(resp.StatusCode),
}
}
return data, nil
}
// DoJSON performs a request and unmarshals the response into out.
func (c *Client) DoJSON(method, path string, body interface{}, out interface{}) error {
data, err := c.Do(method, path, body)
if err != nil {
return err
}
if out == nil || len(data) == 0 {
return nil
}
return json.Unmarshal(data, out)
}
+106
View File
@@ -0,0 +1,106 @@
package commands
import (
"fmt"
"os"
"strings"
"github.com/spf13/viper"
)
// ActiveProfile resolves which profile applies: --profile flag >
// CONTAINR_PROFILE env > current_profile in config > "default".
func ActiveProfile() string {
if p := viper.GetString("profile"); p != "" {
return p
}
if p := os.Getenv("CONTAINR_PROFILE"); p != "" {
return p
}
if p := viper.GetString("current_profile"); p != "" {
return p
}
return "default"
}
// ResolveAPIURL returns the effective API base URL:
// --api-url flag > CONTAINR_API_URL env > profile.api_url > localhost default.
func ResolveAPIURL() string {
if u := viper.GetString("api-url"); u != "" {
return normalizeBase(u)
}
if u := os.Getenv("CONTAINR_API_URL"); u != "" {
return normalizeBase(u)
}
if u := viper.GetString("profiles." + ActiveProfile() + ".api_url"); u != "" {
return normalizeBase(u)
}
return "http://localhost:8080/api/v1"
}
// ResolveToken returns the effective auth token:
// --token flag > CONTAINR_TOKEN env > profile.token.
func ResolveToken() string {
if t := viper.GetString("token"); t != "" {
return t
}
if t := os.Getenv("CONTAINR_TOKEN"); t != "" {
return t
}
return viper.GetString("profiles." + ActiveProfile() + ".token")
}
func normalizeBase(u string) string {
u = strings.TrimSuffix(strings.TrimSpace(u), "/")
// Accept the bare host too — append the API prefix when missing.
if u != "" && !strings.HasSuffix(u, "/api/v1") {
u += "/api/v1"
}
return u
}
// SaveProfile persists api_url/token under a named profile and makes it
// current. Creates the config file if none exists.
func SaveProfile(name, apiURL, token string) error {
base := "profiles." + name
if apiURL != "" {
viper.Set(base+".api_url", apiURL)
}
if token != "" {
viper.Set(base+".token", token)
}
viper.Set("current_profile", name)
return writeConfig()
}
// ClearProfileToken removes only the token from the active profile.
func ClearProfileToken(name string) error {
viper.Set("profiles."+name+".token", "")
return writeConfig()
}
// ListProfiles returns profile name → api_url for `auth status --all`.
func ListProfiles() map[string]string {
out := map[string]string{}
profiles := viper.GetStringMap("profiles")
for name := range profiles {
if u := viper.GetString("profiles." + name + ".api_url"); u != "" {
out[name] = u
} else {
out[name] = "(no url)"
}
}
return out
}
func writeConfig() error {
if err := viper.WriteConfig(); err != nil {
// No config file yet — create one.
home, herr := os.UserHomeDir()
if herr != nil {
return fmt.Errorf("cannot locate home directory: %w", herr)
}
return viper.WriteConfigAs(home + "/.containr.yaml")
}
return nil
}
+169
View File
@@ -0,0 +1,169 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// CronCmd manages scheduled jobs.
var CronCmd = &cobra.Command{
Use: "cron",
Aliases: []string{"cron-jobs"},
Short: "Manage cron jobs",
}
var cronListCmd = &cobra.Command{
Use: "list",
Short: "List cron jobs",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/cron-jobs", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "cron_jobs", "jobs")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, j := range items {
rows = append(rows, []string{
str(j, "id"), str(j, "name"), str(j, "schedule"), str(j, "service_id"),
str(j, "enabled", "is_active"), relTime(j, "last_run_at"),
})
}
printRows(data, []string{"ID", "NAME", "SCHEDULE", "SERVICE", "ENABLED", "LAST RUN"}, rows)
return nil
},
}
var cronGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a cron job",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/cron-jobs/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var cronCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a cron job (5-field expression)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
for _, f := range []string{"name", "schedule", "command", "service-id", "timezone"} {
v, _ := cmd.Flags().GetString(f)
if v != "" {
key := f
if f == "service-id" {
key = "service_id"
}
body[key] = v
}
}
if body["service_id"] == nil || body["schedule"] == nil || body["command"] == nil {
return &APIError{Message: "--service-id, --schedule, and --command are required", ExitCode: ExitValidation}
}
data, err := c.Do("POST", "/cron-jobs", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var cronDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a cron job",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete cron job %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/cron-jobs/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted cron job %s\n", args[0])
return nil
},
}
var cronTriggerCmd = &cobra.Command{
Use: "trigger <id>",
Short: "Run a cron job now",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/cron-jobs/"+args[0]+"/trigger", map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var cronExecutionsCmd = &cobra.Command{
Use: "executions <id>",
Short: "List a cron job's execution history",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/cron-jobs/"+args[0]+"/executions", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "executions")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, e := range items {
rows = append(rows, []string{
str(e, "id"), str(e, "status", "exit_code"), str(e, "started_at"), str(e, "output", "stdout"),
})
}
printRows(data, []string{"ID", "STATUS", "STARTED", "OUTPUT"}, rows)
return nil
},
}
func init() {
cronCreateCmd.Flags().String("name", "", "job name")
cronCreateCmd.Flags().String("service-id", "", "target service id (required)")
cronCreateCmd.Flags().String("schedule", "", "5-field cron expression (required)")
cronCreateCmd.Flags().String("command", "", "command run inside the container (required)")
cronCreateCmd.Flags().String("timezone", "", "IANA timezone (default UTC)")
CronCmd.AddCommand(cronListCmd, cronGetCmd, cronCreateCmd, cronDeleteCmd, cronTriggerCmd, cronExecutionsCmd)
}
@@ -0,0 +1,204 @@
package commands
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
// DatabasesCmd manages managed databases.
var DatabasesCmd = &cobra.Command{
Use: "databases",
Aliases: []string{"database", "db"},
Short: "Manage databases",
}
var dbListCmd = &cobra.Command{
Use: "list",
Short: "List managed databases",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/databases", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "databases")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, d := range items {
rows = append(rows, []string{
str(d, "id"), str(d, "name"), str(d, "type", "engine"), str(d, "status"),
})
}
printRows(data, []string{"ID", "NAME", "TYPE", "STATUS"}, rows)
return nil
},
}
var dbGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a database (connection URL included)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/databases/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbCreateCmd = &cobra.Command{
Use: "create <name>",
Short: "Provision a managed database",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
dbType, _ := cmd.Flags().GetString("type")
body := map[string]interface{}{"name": args[0], "type": dbType}
for _, f := range []string{"version", "storage-size"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := f
if f == "storage-size" {
key = "storage_size"
}
body[key] = v
}
}
data, err := c.Do("POST", "/databases", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a database and its data",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete database %s and all its data?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/databases/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted database %s\n", args[0])
return nil
},
}
var dbActionCmd = &cobra.Command{
Use: "action <id> <start|stop|restart>",
Short: "Start, stop, or restart a database",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/databases/"+args[0]+"/action", map[string]string{"action": args[1]})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbBackupCmd = &cobra.Command{
Use: "backup <id>",
Short: "Take a manual backup snapshot",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/databases/"+args[0]+"/backup", map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbRestoreCmd = &cobra.Command{
Use: "restore <id> <backup-id>",
Short: "Restore a database from a backup",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Restore database %s from backup %s? Current data will be overwritten.", args[0], args[1])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
data, err := c.Do("POST", "/databases/"+args[0]+"/restore", map[string]string{"backup_id": args[1]})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbDownloadCmd = &cobra.Command{
Use: "download-backup <id> <backup-id> [file]",
Short: "Download a backup archive",
Args: cobra.RangeArgs(2, 3),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", fmt.Sprintf("/databases/%s/backups/%s/download", args[0], args[1]), nil)
if err != nil {
return err
}
out := args[1] + ".tar.gz"
if len(args) > 2 {
out = args[2]
}
if err := os.WriteFile(out, data, 0o600); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"file": out})
}
fmt.Printf("Wrote %s (%d bytes)\n", out, len(data))
return nil
},
}
func init() {
dbCreateCmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
dbCreateCmd.Flags().String("version", "", "engine version tag")
dbCreateCmd.Flags().String("storage-size", "", "volume size (e.g. 10Gi)")
DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbDownloadCmd)
}
+162
View File
@@ -0,0 +1,162 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// DeployCmd triggers a deployment for a service.
var DeployCmd = &cobra.Command{
Use: "deploy <service-id>",
Short: "Trigger a deployment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{"trigger": "cli"}
if nc, _ := cmd.Flags().GetBool("no-cache"); nc {
body["no_cache"] = true
}
for _, f := range []string{"commit-hash", "branch"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := "commit_hash"
if f == "branch" {
key = "branch"
}
body[key] = v
}
}
data, err := c.Do("POST", "/services/"+args[0]+"/deployments", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
d, _ := unwrapObject(data, "deployment")
fmt.Printf("Deployment %s started for service %s (status: %s)\n",
str(d, "id"), args[0], str(d, "status"))
fmt.Println("Follow with: containr deployments logs " + str(d, "id"))
return nil
},
}
// DeploymentsCmd manages deployment history.
var DeploymentsCmd = &cobra.Command{
Use: "deployments",
Aliases: []string{"deployment"},
Short: "Inspect deployments",
}
var deploymentsListCmd = &cobra.Command{
Use: "list [service-id]",
Short: "List deployments (all recent, or for one service)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
path := "/deployments"
if len(args) > 0 {
path = "/services/" + args[0] + "/deployments"
}
data, err := c.Do("GET", path, nil)
if err != nil {
return err
}
items, err := unwrapList(data, "deployments")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, d := range items {
rows = append(rows, []string{
str(d, "id"), str(d, "service_id"), str(d, "status"), str(d, "trigger"), relTime(d, "created_at", "started_at"),
})
}
printRows(data, []string{"ID", "SERVICE", "STATUS", "TRIGGER", "STARTED"}, rows)
return nil
},
}
var deploymentsGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a deployment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/deployments/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var deploymentsLogsCmd = &cobra.Command{
Use: "logs <id>",
Short: "Stream a deployment's build/deploy logs",
Args: cobra.ExactArgs(1),
RunE: runDeploymentLogs,
}
var deploymentsCancelCmd = &cobra.Command{
Use: "cancel <id>",
Short: "Cancel a queued or running deployment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/deployments/"+args[0]+"/cancel", map[string]interface{}{})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
obj, _ := unwrapObject(data)
fmt.Printf("Deployment %s: %s\n", args[0], str(obj, "status"))
return nil
},
}
var deploymentsRollbackCmd = &cobra.Command{
Use: "rollback <id>",
Short: "Roll back to a deployment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/deployments/"+args[0]+"/rollback", map[string]interface{}{})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Rolled back to deployment %s\n", args[0])
return nil
},
}
func init() {
DeployCmd.Flags().String("commit-hash", "", "commit to deploy")
DeployCmd.Flags().String("branch", "", "branch to deploy")
DeployCmd.Flags().Bool("no-cache", false, "build without Docker layer cache")
DeploymentsCmd.AddCommand(deploymentsListCmd, deploymentsGetCmd, deploymentsLogsCmd, deploymentsRollbackCmd, deploymentsCancelCmd)
}
@@ -0,0 +1,113 @@
package commands
import (
"encoding/json"
"fmt"
"time"
)
// client returns an authenticated API client or an ExitAuth error.
func client() (*Client, error) {
c := NewClient()
if err := c.RequireAuth(); err != nil {
return nil, err
}
return c, nil
}
// unwrapList accepts both top-level arrays and {"<key>": [...]} envelopes,
// which the Containr API uses inconsistently between endpoints.
func unwrapList(data []byte, keys ...string) ([]map[string]interface{}, error) {
var arr []map[string]interface{}
if err := json.Unmarshal(data, &arr); err == nil {
return arr, nil
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(data, &obj); err != nil {
return nil, fmt.Errorf("unexpected response shape: %w", err)
}
for _, k := range keys {
if raw, ok := obj[k]; ok {
if err := json.Unmarshal(raw, &arr); err != nil {
return nil, fmt.Errorf("cannot decode %q: %w", k, err)
}
return arr, nil
}
}
return []map[string]interface{}{}, nil
}
func unwrapObject(data []byte, keys ...string) (map[string]interface{}, error) {
var obj map[string]interface{}
if err := json.Unmarshal(data, &obj); err != nil {
return nil, fmt.Errorf("unexpected response shape: %w", err)
}
for _, k := range keys {
if inner, ok := obj[k].(map[string]interface{}); ok {
return inner, nil
}
}
return obj, nil
}
// str extracts a display string from a response map.
func str(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
if v, ok := m[k]; ok && v != nil {
switch t := v.(type) {
case string:
return t
case float64:
if t == float64(int64(t)) {
return fmt.Sprintf("%d", int64(t))
}
return fmt.Sprintf("%v", t)
case bool:
return fmt.Sprintf("%t", t)
}
}
}
return "-"
}
func shortID(m map[string]interface{}) string {
id := str(m, "id")
if len(id) > 8 {
return id[:8]
}
return id
}
func relTime(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
s, ok := m[k].(string)
if !ok || s == "" {
continue
}
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return s
}
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
return "-"
}
// printRows renders a table, or raw JSON for the full response in --json mode.
func printRows(raw []byte, headers []string, rows [][]string) {
if JSONMode() {
PrintRaw(raw)
return
}
PrintTable(headers, rows)
}
+171
View File
@@ -0,0 +1,171 @@
package commands
import (
"bufio"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"github.com/spf13/cobra"
)
// LogsCmd prints service runtime logs.
var LogsCmd = &cobra.Command{
Use: "logs <service-id>",
Short: "Print or follow service logs",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
follow, _ := cmd.Flags().GetBool("follow")
tail, _ := cmd.Flags().GetInt("tail")
path := fmt.Sprintf("/services/%s/logs?tail=%d", args[0], tail)
if follow {
return streamSSE(c, path+"&follow=true")
}
data, err := c.Do("GET", path, nil)
if err != nil {
return err
}
return printLogEntries(data)
},
}
// ExecCmd runs a one-off command inside a service container.
var ExecCmd = &cobra.Command{
Use: "exec <service-id> -- <command>",
Short: "Run a one-off command in the service container (30s ceiling)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
command := strings.Join(args[1:], " ")
if command == "" {
return &APIError{Message: "no command given — usage: containr exec <id> -- <command>", ExitCode: ExitValidation}
}
data, err := c.Do("POST", "/services/"+args[0]+"/exec", map[string]string{"command": command})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
resp, _ := unwrapObject(data)
fmt.Print(str(resp, "output", "stdout"))
if code := str(resp, "exit_code", "exitCode"); code != "-" && code != "0" {
return &APIError{Message: fmt.Sprintf("command exited with %s", code), ExitCode: ExitError}
}
return nil
},
}
type logEntry struct {
Timestamp string `json:"timestamp"`
Message string `json:"message"`
Stream string `json:"stream"`
}
// printLogEntries decodes the {"logs":[...]} envelope both log endpoints use.
func printLogEntries(data []byte) error {
var payload struct {
Logs []logEntry `json:"logs"`
Message string `json:"message"`
}
if err := json.Unmarshal(data, &payload); err != nil {
PrintRaw(data)
return nil
}
if JSONMode() {
PrintRaw(data)
return nil
}
for _, e := range payload.Logs {
fmt.Println(e.Message)
}
if len(payload.Logs) == 0 && payload.Message != "" {
fmt.Fprintln(os.Stderr, payload.Message)
}
return nil
}
// runDeploymentLogs prints persisted build+runtime logs for a deployment.
func runDeploymentLogs(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
logType, _ := cmd.Flags().GetString("type")
data, err := c.Do("GET", "/deployments/"+args[0]+"/logs?type="+logType, nil)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
var payload struct {
BuildLog string `json:"build_log"`
RuntimeLog string `json:"runtime_log"`
}
if json.Unmarshal(data, &payload) == nil && (payload.BuildLog != "" || payload.RuntimeLog != "") {
if payload.BuildLog != "" {
fmt.Print(payload.BuildLog)
}
if payload.RuntimeLog != "" {
fmt.Print(payload.RuntimeLog)
}
return nil
}
return printLogEntries(data)
}
// streamSSE consumes a text/event-stream response and prints each
// `data:` line's message field until the server closes the stream.
func streamSSE(c *Client, path string) error {
req, err := http.NewRequest("GET", c.BaseURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Accept", "text/event-stream")
hc := c.HTTPClient
if hc == nil {
hc = http.DefaultClient
}
resp, err := hc.Do(req)
if err != nil {
return &APIError{Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err), ExitCode: ExitUnavailable}
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return &APIError{Status: resp.StatusCode, ExitCode: exitCodeFor(resp.StatusCode)}
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64<<10), 4<<20)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
var e logEntry
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &e); err == nil {
fmt.Println(e.Message)
} else {
fmt.Println(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
return scanner.Err()
}
func init() {
LogsCmd.Flags().BoolP("follow", "f", false, "stream new log lines (SSE)")
LogsCmd.Flags().Int("tail", 100, "number of lines to return")
deploymentsLogsCmd.Flags().String("type", "all", "log type: all|build|runtime")
}
+150
View File
@@ -0,0 +1,150 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// NodesCmd manages node agents (admin).
var NodesCmd = &cobra.Command{
Use: "nodes",
Aliases: []string{"node", "agents"},
Short: "Manage node agents (admin)",
}
var nodesListCmd = &cobra.Command{
Use: "list",
Short: "List registered node agents",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/agents", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "agents")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, a := range items {
rows = append(rows, []string{
str(a, "id"), str(a, "name", "hostname"), str(a, "status"), str(a, "version"), relTime(a, "last_heartbeat_at"),
})
}
printRows(data, []string{"ID", "NAME", "STATUS", "VERSION", "HEARTBEAT"}, rows)
return nil
},
}
var nodesGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a node agent",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/agents/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodesDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Remove a node agent",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Remove node agent %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/agents/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Removed agent %s\n", args[0])
return nil
},
}
// Agent tokens (cagt_) onboard new nodes.
var nodeTokensCmd = &cobra.Command{
Use: "tokens",
Short: "Manage agent onboarding tokens",
}
var nodeTokensIssueCmd = &cobra.Command{
Use: "issue [label]",
Short: "Issue an onboarding token (shown once)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
if len(args) > 0 {
body["label"] = args[0]
}
data, err := c.Do("POST", "/agent-tokens", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodeTokensListCmd = &cobra.Command{
Use: "list",
Short: "List onboarding tokens",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/agent-tokens", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodeTokensRevokeCmd = &cobra.Command{
Use: "revoke <id>",
Short: "Revoke an onboarding token",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("DELETE", "/agent-tokens/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() {
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesDeleteCmd, nodeTokensCmd)
}
@@ -0,0 +1,65 @@
package commands
import (
"github.com/spf13/cobra"
)
// NotificationsCmd lists and manages notifications.
var NotificationsCmd = &cobra.Command{
Use: "notifications",
Aliases: []string{"notifs"},
Short: "Manage notifications",
}
var notifListCmd = &cobra.Command{
Use: "list",
Short: "List notifications",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/notifications", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "notifications")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, n := range items {
rows = append(rows, []string{
str(n, "id"), str(n, "type"), str(n, "title"), str(n, "read"), relTime(n, "created_at"),
})
}
printRows(data, []string{"ID", "TYPE", "TITLE", "READ", "AGE"}, rows)
return nil
},
}
var notifReadCmd = &cobra.Command{
Use: "read <id>|all",
Short: "Mark a notification (or all) as read",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
path := "/notifications/" + args[0] + "/read"
if args[0] == "all" {
path = "/notifications/read-all"
}
data, err := c.Do("POST", path, map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() {
NotificationsCmd.AddCommand(notifListCmd, notifReadCmd)
}
@@ -0,0 +1,95 @@
package commands
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/viper"
)
// JSONMode reports whether --json was passed. In JSON mode every command
// prints the raw API response and nothing else — safe for agents and pipes.
func JSONMode() bool {
return viper.GetBool("json")
}
// PrintResult renders data: pretty JSON in --json mode, otherwise the
// default JSON rendering is used unless a command prints its own table
// first (commands that render a table should check JSONMode() themselves).
func PrintJSON(v interface{}) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}
// PrintRaw prints raw response bytes as pretty JSON when they decode as
// JSON, else verbatim.
func PrintRaw(data []byte) {
var v interface{}
if json.Unmarshal(data, &v) == nil {
out, _ := json.MarshalIndent(v, "", " ")
fmt.Println(string(out))
return
}
fmt.Println(string(data))
}
// PrintTable renders rows under headers with tabwriter alignment.
// Skipped entirely in JSON mode.
func PrintTable(headers []string, rows [][]string) {
if JSONMode() {
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, strings.Join(headers, "\t"))
for _, row := range rows {
fmt.Fprintln(w, strings.Join(row, "\t"))
}
w.Flush()
}
// Confirm asks the user to confirm a destructive action. Returns true
// immediately when --yes/--force was passed or stdin is not a TTY and
// --yes was given. In non-interactive contexts without --yes it refuses.
func Confirm(prompt string) bool {
if viper.GetBool("yes") {
return true
}
if !isTerminal() {
return false
}
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
reader := bufio.NewReader(os.Stdin)
answer, err := reader.ReadString('\n')
if err != nil {
return false
}
answer = strings.ToLower(strings.TrimSpace(answer))
return answer == "y" || answer == "yes"
}
func isTerminal() bool {
info, err := os.Stdin.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
// ExitCode inspects an error and returns the process exit code.
func ExitCode(err error) int {
if err == nil {
return ExitOK
}
if apiErr, ok := err.(*APIError); ok {
return apiErr.ExitCode
}
return ExitError
}
+101 -197
View File
@@ -1,219 +1,123 @@
package commands
import (
"bytes"
"containr/internal/pkg/utils"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// Project represents a Containr project
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ProjectsCmd represents the projects command
// ProjectsCmd manages projects.
var ProjectsCmd = &cobra.Command{
Use: "projects",
Short: "Manage projects",
Long: `Manage your Containr projects.
You can list, create, update, and delete projects.`,
Use: "projects",
Aliases: []string{"project"},
Short: "Manage projects",
}
// listProjectsCmd represents the list command
var listProjectsCmd = &cobra.Command{
var projectsListCmd = &cobra.Command{
Use: "list",
Short: "List all projects",
Long: `List all your Containr projects.`,
RunE: runListProjects,
Short: "List projects",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/projects", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "projects")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, p := range items {
rows = append(rows, []string{
str(p, "id"), str(p, "name"), str(p, "description"), relTime(p, "created_at"),
})
}
printRows(data, []string{"ID", "NAME", "DESCRIPTION", "CREATED"}, rows)
return nil
},
}
// createProjectCmd represents the create command
var createProjectCmd = &cobra.Command{
Use: "create [name]",
Short: "Create a new project",
Long: `Create a new Containr project.
Provide a name and optional description.`,
Args: cobra.ExactArgs(1),
RunE: runCreateProject,
}
// deleteProjectCmd represents the delete command
var deleteProjectCmd = &cobra.Command{
Use: "delete [project-id]",
Short: "Delete a project",
Long: `Delete a Containr project by ID.`,
var projectsGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a project",
Args: cobra.ExactArgs(1),
RunE: runDeleteProject,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/projects/"+args[0], nil)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
p, _ := unwrapObject(data, "project")
fmt.Printf("ID: %s\n", str(p, "id"))
fmt.Printf("Name: %s\n", str(p, "name"))
fmt.Printf("Description: %s\n", str(p, "description"))
fmt.Printf("Created: %s\n", str(p, "created_at"))
return nil
},
}
var projectDescription string
var projectsCreateCmd = &cobra.Command{
Use: "create <name>",
Short: "Create a project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
desc, _ := cmd.Flags().GetString("description")
body := map[string]interface{}{"name": args[0]}
if desc != "" {
body["description"] = desc
}
data, err := c.Do("POST", "/projects", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
p, _ := unwrapObject(data, "project")
fmt.Printf("Created project %s (%s)\n", str(p, "name"), str(p, "id"))
return nil
},
}
// getAPIURL constructs the full API URL for a given endpoint
func getAPIURL(endpoint string) string {
baseURL := viper.GetString("api-url")
if baseURL == "" {
baseURL = "http://localhost:8080/api/v1" // Default for development
}
// Ensure baseURL doesn't end with / and endpoint starts with /
baseURL = strings.TrimSuffix(baseURL, "/")
if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
return baseURL + endpoint
var projectsDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a project and all its services",
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 project %s and all its services?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/projects/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted project %s\n", args[0])
return nil
},
}
func init() {
ProjectsCmd.AddCommand(listProjectsCmd)
ProjectsCmd.AddCommand(createProjectCmd)
ProjectsCmd.AddCommand(deleteProjectCmd)
// Add flags
createProjectCmd.Flags().StringVarP(&projectDescription, "description", "d", "", "Project description")
}
func runListProjects(cmd *cobra.Command, args []string) error {
apiURL := getAPIURL("/projects")
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
var projects []Project
if err := json.Unmarshal(body, &projects); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if len(projects) == 0 {
fmt.Println("No projects found")
return nil
}
fmt.Println("Your Projects:")
fmt.Println()
for _, project := range projects {
fmt.Printf("📦 %s (%s)\n", project.Name, project.ID)
if project.Description != "" {
fmt.Printf(" %s\n", project.Description)
}
fmt.Printf(" Created: %s\n", utils.FormatTime(project.CreatedAt))
fmt.Println()
}
return nil
}
func runCreateProject(cmd *cobra.Command, args []string) error {
name := args[0]
projectData := map[string]interface{}{
"name": name,
}
if projectDescription != "" {
projectData["description"] = projectDescription
}
jsonData, err := json.Marshal(projectData)
if err != nil {
return fmt.Errorf("failed to marshal project data: %w", err)
}
apiURL := getAPIURL("/projects")
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
var project Project
if err := json.Unmarshal(body, &project); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
fmt.Printf("✓ Project '%s' created successfully!\n", project.Name)
fmt.Printf("ID: %s\n", project.ID)
return nil
}
func runDeleteProject(cmd *cobra.Command, args []string) error {
projectID := args[0]
apiURL := getAPIURL("/projects/" + projectID)
req, err := http.NewRequest("DELETE", apiURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
}
fmt.Printf("✓ Project '%s' deleted successfully!\n", projectID)
return nil
projectsCreateCmd.Flags().StringP("description", "d", "", "project description")
ProjectsCmd.AddCommand(projectsListCmd, projectsGetCmd, projectsCreateCmd, projectsDeleteCmd)
}
@@ -0,0 +1,160 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// RegistriesCmd manages private-image registry credentials.
var RegistriesCmd = &cobra.Command{
Use: "registries",
Aliases: []string{"registry"},
Short: "Manage registry credentials",
}
var registriesListCmd = &cobra.Command{
Use: "list",
Short: "List registry credentials",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/registries", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "registries")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, r := range items {
hasPw := ""
if b, ok := r["has_password"].(bool); ok && b {
hasPw = "yes"
}
rows = append(rows, []string{str(r, "id"), str(r, "name"), str(r, "host"), str(r, "username"), hasPw})
}
printRows(data, []string{"ID", "NAME", "HOST", "USERNAME", "PASSWORD"}, rows)
return nil
},
}
var registriesAddCmd = &cobra.Command{
Use: "add <name> <host>",
Short: "Add a registry credential (e.g. ghcr.io, registry.example.com, docker.io)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{"name": args[0], "host": args[1]}
if v, _ := cmd.Flags().GetString("username"); v != "" {
body["username"] = v
}
if v, _ := cmd.Flags().GetString("password"); v != "" {
body["password"] = v
}
data, err := c.Do("POST", "/registries", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
r, _ := unwrapObject(data, "registry")
fmt.Printf("Added registry %s (%s)\n", str(r, "name"), str(r, "host"))
return nil
},
}
var registriesUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a registry credential (empty --password keeps the stored one)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
for _, f := range []string{"name", "host", "username", "password"} {
if cmd.Flags().Changed(f) {
v, _ := cmd.Flags().GetString(f)
body[f] = v
}
}
if len(body) == 0 {
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
}
// The API requires name+host — backfill them from the stored row
// when the caller didn't supply both.
_, hasName := body["name"]
_, hasHost := body["host"]
if !hasName || !hasHost {
data, err := c.Do("GET", "/registries", nil)
if err != nil {
return err
}
items, _ := unwrapList(data, "registries")
for _, r := range items {
if str(r, "id") == args[0] {
if !hasName {
body["name"] = str(r, "name")
}
if !hasHost {
body["host"] = str(r, "host")
}
}
}
}
data, err := c.Do("PUT", "/registries/"+args[0], body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Updated registry %s\n", args[0])
return nil
},
}
var registriesRemoveCmd = &cobra.Command{
Use: "remove <id>",
Short: "Remove a registry credential",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Remove registry %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/registries/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Removed registry %s\n", args[0])
return nil
},
}
func init() {
registriesAddCmd.Flags().String("username", "", "registry username")
registriesAddCmd.Flags().String("password", "", "registry password/token (stored encrypted)")
registriesUpdateCmd.Flags().String("name", "", "display name")
registriesUpdateCmd.Flags().String("host", "", "registry host")
registriesUpdateCmd.Flags().String("username", "", "registry username")
registriesUpdateCmd.Flags().String("password", "", "registry password/token")
RegistriesCmd.AddCommand(registriesListCmd, registriesAddCmd, registriesUpdateCmd, registriesRemoveCmd)
}
@@ -0,0 +1,524 @@
package commands
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
// ServicesCmd manages services.
var ServicesCmd = &cobra.Command{
Use: "services",
Aliases: []string{"service", "svc"},
Short: "Manage services",
}
var servicesListCmd = &cobra.Command{
Use: "list [project-id]",
Short: "List services in a project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/projects/"+args[0]+"/services", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "services")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, s := range items {
rows = append(rows, []string{
str(s, "id"), str(s, "name"), str(s, "type"), str(s, "status"), str(s, "environment"),
})
}
printRows(data, []string{"ID", "NAME", "TYPE", "STATUS", "ENV"}, rows)
return nil
},
}
var servicesGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a service",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0], nil)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
s, _ := unwrapObject(data, "service")
for _, k := range []string{"id", "name", "type", "status", "environment", "image", "git_repo", "git_branch", "domain", "port"} {
fmt.Printf("%-14s %s\n", k+":", str(s, k))
}
return nil
},
}
var servicesCreateCmd = &cobra.Command{
Use: "create <project-id> <name>",
Short: "Create a service (image- or git-based)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{
"name": args[1],
}
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := f
// JSON uses snake_case keys matching the API schema.
switch f {
case "git-repo":
key = "git_repo"
case "git-branch":
key = "git_branch"
case "build-path":
key = "build_path"
case "restart-policy":
key = "restart_policy"
case "healthcheck-path":
key = "healthcheck_path"
case "cpu-reserve":
key = "cpu_reserve"
case "memory-reserve":
key = "memory_reserve"
case "static-cmd":
key = "static_build_cmd"
case "static-dir":
key = "static_dir"
}
body[key] = v
}
}
if v, _ := cmd.Flags().GetInt("port"); v > 0 {
body["port"] = v
}
if v, _ := cmd.Flags().GetInt("replicas"); v > 0 {
body["replicas"] = v
}
data, err := c.Do("POST", "/projects/"+args[0]+"/services", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
s, _ := unwrapObject(data, "service")
fmt.Printf("Created service %s (%s)\n", str(s, "name"), str(s, "id"))
return nil
},
}
var servicesDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a service",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete service %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/services/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted service %s\n", args[0])
return nil
},
}
var servicesUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a service (volumes, replicas, domain, ...)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
if cmd.Flags().Changed(f) {
key := f
switch f {
case "restart-policy":
key = "restart_policy"
case "healthcheck-path":
key = "healthcheck_path"
case "cpu-reserve":
key = "cpu_reserve"
case "memory-reserve":
key = "memory_reserve"
case "static-cmd":
key = "static_build_cmd"
case "static-dir":
key = "static_dir"
}
v, _ := cmd.Flags().GetString(f)
body[key] = v
}
}
if cmd.Flags().Changed("replicas") {
v, _ := cmd.Flags().GetInt("replicas")
body["replicas"] = v
}
if cmd.Flags().Changed("port") {
v, _ := cmd.Flags().GetInt("port")
body["port"] = v
}
if cmd.Flags().Changed("volume") || cmd.Flags().Changed("bind") || cmd.Flags().Changed("clear-volumes") {
volumes := []map[string]interface{}{}
for _, spec := range volumeFlagValues(cmd, "volume", "bind") {
volumes = append(volumes, spec)
}
body["volumes"] = volumes
}
if cmd.Flags().Changed("maintenance") {
v, _ := cmd.Flags().GetString("maintenance")
body["maintenance_mode"] = v == "on" || v == "true"
}
if cmd.Flags().Changed("basic-auth") {
creds := []map[string]interface{}{}
pairs, _ := cmd.Flags().GetStringArray("basic-auth")
for _, p := range pairs {
user, pass, ok := strings.Cut(p, ":")
if ok {
creds = append(creds, map[string]interface{}{"username": user, "password": pass})
}
}
body["basic_auth"] = creds
}
if len(body) == 0 {
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
}
data, err := c.Do("PUT", "/services/"+args[0], body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Updated service %s\n", args[0])
return nil
},
}
var servicesCloneCmd = &cobra.Command{
Use: "clone <id>",
Short: "Clone a service (config, volumes, domains, variables)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
for _, f := range []string{"name", "project", "environment"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := f
if f == "project" {
key = "project_id"
}
body[key] = v
}
}
data, err := c.Do("POST", "/services/"+args[0]+"/clone", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
m, _ := unwrapObject(data)
fmt.Printf("Cloned to service %s (%s)\n", str(m, "service_id"), str(m, "name"))
return nil
},
}
var servicesMoveCmd = &cobra.Command{
Use: "move <id> <project-id>",
Short: "Move a service to another project",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/services/"+args[0]+"/move", map[string]interface{}{"project_id": args[1]})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Moved service %s to project %s\n", args[0], args[1])
return nil
},
}
// volumeFlagValues parses repeated "src:target[:ro]" flags into API mount
// objects. Bind mounts are type=bind, named volumes type=volume.
func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} {
var out []map[string]interface{}
for _, f := range flags {
vals, _ := cmd.Flags().GetStringArray(f)
for _, v := range vals {
parts := strings.SplitN(v, ":", 3)
if len(parts) < 2 {
continue
}
m := map[string]interface{}{
"type": f,
"source": parts[0],
"target": parts[1],
}
if len(parts) == 3 && parts[2] == "ro" {
m["read_only"] = true
}
out = append(out, m)
}
}
return out
}
var servicesDomainsCmd = &cobra.Command{
Use: "domains",
Short: "Manage service domains",
}
var domainsListCmd = &cobra.Command{
Use: "list <service-id>",
Short: "List domains attached to a service",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0]+"/domains", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "domains")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, d := range items {
def := ""
if b, ok := d["is_default"].(bool); ok && b {
def = "*"
}
rows = append(rows, []string{str(d, "id"), str(d, "domain"), def, str(d, "cert_status")})
}
printRows(data, []string{"ID", "DOMAIN", "DEFAULT", "CERT"}, rows)
return nil
},
}
var domainsAddCmd = &cobra.Command{
Use: "add <service-id> <domain>",
Short: "Attach a domain to a service",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
def, _ := cmd.Flags().GetBool("default")
data, err := c.Do("POST", "/services/"+args[0]+"/domains", map[string]interface{}{
"domain": args[1], "is_default": def,
})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Added domain %s\n", args[1])
return nil
},
}
var domainsRemoveCmd = &cobra.Command{
Use: "remove <service-id> <domain-id>",
Short: "Detach a domain",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if _, err := c.Do("DELETE", fmt.Sprintf("/services/%s/domains/%s", args[0], args[1]), nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[1]})
}
fmt.Printf("Removed domain %s\n", args[1])
return nil
},
}
var domainsDefaultCmd = &cobra.Command{
Use: "default <service-id> <domain-id>",
Short: "Set the default domain",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", fmt.Sprintf("/services/%s/domains/%s/default", args[0], args[1]), map[string]interface{}{})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Default domain updated\n")
return nil
},
}
var domainsCheckCmd = &cobra.Command{
Use: "check <service-id>",
Short: "DNS preflight for all service domains",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0]+"/domains/check", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "domains")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, d := range items {
resolved, _ := d["resolved"].([]interface{})
addrs := make([]string, 0, len(resolved))
for _, a := range resolved {
if s, ok := a.(string); ok {
addrs = append(addrs, s)
}
}
rows = append(rows, []string{str(d, "domain"), str(d, "status"), strings.Join(addrs, ", ")})
}
printRows(data, []string{"DOMAIN", "STATUS", "RESOLVED"}, rows)
return nil
},
}
// serviceAction returns a RunE for the simple POST action endpoints.
func serviceAction(verb string) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", fmt.Sprintf("/services/%s/%s", args[0], verb), map[string]interface{}{})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("%s: %s\n", verb, args[0])
return nil
}
}
func init() {
f := servicesCreateCmd.Flags()
f.String("type", "web", "service type: web|worker|database|cron")
f.String("image", "", "container image (image-based service)")
f.String("git-repo", "", "git repository URL (build-from-source service)")
f.String("git-branch", "main", "git branch")
f.String("build-path", "", "build context subdirectory")
f.String("command", "", "container start command override")
f.String("environment", "production", "production|preview|development")
f.String("domain", "", "public domain")
f.String("restart-policy", "unless-stopped", "docker restart policy")
f.String("healthcheck-path", "", "HTTP health check path")
f.String("cpu", "", "CPU limit (e.g. 0.5)")
f.String("memory", "", "memory limit (e.g. 512m)")
f.Int("port", 0, "container port")
f.Int("replicas", 0, "replica count")
f.String("builder", "", "build strategy: auto|railpack|nixpacks|dockerfile|static")
f.String("cpu-reserve", "", "soft CPU reservation (e.g. 0.25)")
f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)")
f.String("static-cmd", "", "build command for the static builder")
f.String("static-dir", "", "output dir for the static builder (default dist)")
uf := servicesUpdateCmd.Flags()
uf.String("name", "", "service name")
uf.String("image", "", "container image")
uf.String("command", "", "container start command")
uf.String("domain", "", "public domain (empty string clears)")
uf.String("restart-policy", "", "docker restart policy")
uf.String("healthcheck-path", "", "HTTP health check path")
uf.String("cpu", "", "CPU limit (e.g. 0.5)")
uf.String("memory", "", "memory limit (e.g. 512m)")
uf.Int("replicas", 0, "replica count")
uf.Int("port", 0, "container port")
uf.StringArray("volume", nil, "named volume mount name:path[:ro] (repeatable, replaces all mounts)")
uf.StringArray("bind", nil, "bind mount src:path[:ro] (repeatable)")
uf.Bool("clear-volumes", false, "remove all volume mounts")
uf.String("maintenance", "", "maintenance mode: on|off")
uf.StringArray("basic-auth", nil, "basic-auth credential user:password (repeatable, replaces all)")
uf.String("builder", "", "build strategy: auto|railpack|nixpacks|dockerfile|static")
uf.String("cpu-reserve", "", "soft CPU reservation (empty clears)")
uf.String("memory-reserve", "", "soft memory reservation (empty clears)")
uf.String("static-cmd", "", "build command for the static builder (empty clears)")
uf.String("static-dir", "", "output dir for the static builder (empty clears)")
for _, a := range []string{"start", "stop", "restart", "redeploy"} {
verb := a
ServicesCmd.AddCommand(&cobra.Command{
Use: verb + " <id>",
Short: verb + " a service",
Args: cobra.ExactArgs(1),
RunE: serviceAction(verb),
})
}
servicesCloneCmd.Flags().String("name", "", "name for the clone (default <name>-copy)")
servicesCloneCmd.Flags().String("project", "", "target project id (default: same project)")
servicesCloneCmd.Flags().String("environment", "", "target environment (default: source's)")
domainsAddCmd.Flags().Bool("default", false, "set as the default domain")
servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd)
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd)
}
@@ -0,0 +1,127 @@
package commands
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
// TemplatesCmd manages the template catalog.
var TemplatesCmd = &cobra.Command{
Use: "templates",
Aliases: []string{"template"},
Short: "Browse and deploy templates",
}
var templatesListCmd = &cobra.Command{
Use: "list",
Short: "List templates",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/templates", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "templates")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, t := range items {
rows = append(rows, []string{
str(t, "id"), str(t, "name"), str(t, "category"), str(t, "description"),
})
}
printRows(data, []string{"ID", "NAME", "CATEGORY", "DESCRIPTION"}, rows)
return nil
},
}
var templatesGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a template definition",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/templates/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesCreateCmd = &cobra.Command{
Use: "create <file>",
Short: "Create a user template from a JSON file",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
raw, err := os.ReadFile(args[0])
if err != nil {
return fmt.Errorf("cannot read %s: %w", args[0], err)
}
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/templates", raw)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a user template",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete template %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/templates/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted template %s\n", args[0])
return nil
},
}
var templatesDeployCmd = &cobra.Command{
Use: "deploy <template-id> <project-id>",
Short: "Deploy a template into a project",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", map[string]string{"project_id": args[1]})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() {
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd)
}
+101
View File
@@ -0,0 +1,101 @@
package commands
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
// TokensCmd manages personal access tokens (cnp_...).
var TokensCmd = &cobra.Command{
Use: "tokens",
Short: "Manage personal access tokens",
Long: `Create, list, and revoke the cnp_ tokens used by this CLI,
the MCP server, and external agents.`,
}
var tokensListCmd = &cobra.Command{
Use: "list",
Short: "List your tokens (metadata only)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/user/tokens", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "tokens")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, t := range items {
rows = append(rows, []string{
str(t, "id"), str(t, "name"), str(t, "key_prefix"), str(t, "scope"),
relTime(t, "last_used_at"), str(t, "expires_at"),
})
}
printRows(data, []string{"ID", "NAME", "PREFIX", "SCOPE", "LAST USED", "EXPIRES"}, rows)
return nil
},
}
var tokensCreateCmd = &cobra.Command{
Use: "create <name>",
Short: "Create a token (raw value printed once)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
scope, _ := cmd.Flags().GetString("scope")
days, _ := cmd.Flags().GetInt("expires-in-days")
data, err := c.Do("POST", "/user/tokens", map[string]interface{}{
"name": args[0], "scope": scope, "expires_in_days": days,
})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
t, _ := unwrapObject(data)
fmt.Println(str(t, "token"))
fmt.Fprintln(os.Stderr, "Store it now — it is shown only once. Scope: "+str(t, "scope"))
return nil
},
}
var tokensRevokeCmd = &cobra.Command{
Use: "revoke <id>",
Short: "Revoke a token",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Revoke token %s? Clients using it lose access immediately.", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/user/tokens/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "revoked", "id": args[0]})
}
fmt.Printf("Revoked token %s\n", args[0])
return nil
},
}
func init() {
tokensCreateCmd.Flags().String("scope", "write", "read|write|admin (admin requires an admin account)")
tokensCreateCmd.Flags().Int("expires-in-days", 0, "0 = never expires, max 3650")
TokensCmd.AddCommand(tokensListCmd, tokensCreateCmd, tokensRevokeCmd)
}
+145
View File
@@ -0,0 +1,145 @@
package commands
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// UpCmd deploys the current directory: resolves a project, creates (or
// reuses) a service named after the directory, and triggers a deployment.
// The git remote URL and current branch become the build source.
var UpCmd = &cobra.Command{
Use: "up",
Short: "Deploy the current directory (create service + deploy)",
Long: `Deploy the current directory to Containr.
The git remote and current branch are used as the build source. If no
service named after this directory exists in the project, one is
created; an existing service is redeployed.`,
RunE: runUp,
}
func runUp(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
projectID, _ := cmd.Flags().GetString("project")
if projectID == "" {
projectID = viper.GetString("profiles." + ActiveProfile() + ".project_id")
}
if projectID == "" {
return &APIError{
Message: "no project selected — pass --project <id> or set project_id on the active profile",
ExitCode: ExitValidation,
}
}
cwd, err := os.Getwd()
if err != nil {
return err
}
name, _ := cmd.Flags().GetString("name")
if name == "" {
name = filepath.Base(cwd)
}
repo := gitOutput(cwd, "config", "--get", "remote.origin.url")
branch := gitOutput(cwd, "rev-parse", "--abbrev-ref", "HEAD")
commit := gitOutput(cwd, "rev-parse", "HEAD")
image, _ := cmd.Flags().GetString("image")
if repo == "" && image == "" {
return &APIError{
Message: "no git remote and no --image — either init git with an origin remote or pass --image",
ExitCode: ExitValidation,
}
}
// Reuse an existing same-named service when present.
svcID, _ := cmd.Flags().GetString("service")
if svcID == "" {
if data, err := c.Do("GET", "/projects/"+projectID+"/services", nil); err == nil {
if items, err := unwrapList(data, "services"); err == nil {
for _, s := range items {
if str(s, "name") == name {
svcID = str(s, "id")
break
}
}
}
}
}
if svcID == "" {
body := map[string]interface{}{
"name": name,
"type": "web",
"environment": "production",
}
if repo != "" {
body["git_repo"] = repo
if branch != "" {
body["git_branch"] = branch
}
}
if image != "" {
body["image"] = image
}
data, err := c.Do("POST", "/projects/"+projectID+"/services", body)
if err != nil {
return err
}
s, _ := unwrapObject(data, "service")
svcID = str(s, "id")
if svcID == "" || svcID == "-" {
PrintRaw(data)
return &APIError{Message: "service created but id missing from response", ExitCode: ExitError}
}
if !JSONMode() {
fmt.Fprintf(os.Stderr, "Created service %s (%s)\n", name, svcID)
}
}
deployBody := map[string]interface{}{"trigger": "cli-up"}
if commit != "" {
deployBody["commit_hash"] = commit
}
if branch != "" {
deployBody["branch"] = branch
}
data, err := c.Do("POST", "/services/"+svcID+"/deployments", deployBody)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
d, _ := unwrapObject(data, "deployment")
fmt.Printf("Deploying %s → service %s (deployment %s)\n", name, svcID, str(d, "id"))
return nil
}
func gitOutput(dir string, args ...string) string {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func init() {
UpCmd.Flags().String("project", "", "target project id (or set project_id on the profile)")
UpCmd.Flags().String("name", "", "service name (default: directory name)")
UpCmd.Flags().String("service", "", "existing service id to redeploy")
UpCmd.Flags().String("image", "", "deploy this image instead of building from git")
}
@@ -0,0 +1,225 @@
package commands
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
// VariablesCmd manages service environment variables.
var VariablesCmd = &cobra.Command{
Use: "variables",
Aliases: []string{"vars", "env"},
Short: "Manage service environment variables",
}
var varsListCmd = &cobra.Command{
Use: "list <service-id>",
Short: "List variables (secrets shown masked)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0]+"/variables", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "variables")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, v := range items {
rows = append(rows, []string{str(v, "key"), str(v, "value"), str(v, "is_secret")})
}
printRows(data, []string{"KEY", "VALUE", "SECRET"}, rows)
return nil
},
}
// Variables are replaced wholesale server-side — set/unset fetch the
// current list, mutate it, and PUT it back. Masked secret placeholders are
// preserved by the server.
var varsSetCmd = &cobra.Command{
Use: "set <service-id> KEY=VALUE [KEY=VALUE...]",
Short: "Set one or more variables",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
secret, _ := cmd.Flags().GetBool("secret")
redeploy, _ := cmd.Flags().GetBool("redeploy")
return mutateVars(serviceVarsPath(args[0]), args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
for _, pair := range args[1:] {
k, v, found := strings.Cut(pair, "=")
if !found || k == "" {
continue
}
vars = upsertVar(vars, k, v, secret)
}
return vars
})
},
}
var varsUnsetCmd = &cobra.Command{
Use: "unset <service-id> KEY [KEY...]",
Short: "Remove variables",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
drop := map[string]bool{}
for _, k := range args[1:] {
drop[k] = true
}
redeploy, _ := cmd.Flags().GetBool("redeploy")
return mutateVars(serviceVarsPath(args[0]), args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
out := vars[:0]
for _, v := range vars {
if !drop[str(v, "key")] {
out = append(out, v)
}
}
return out
})
},
}
// Shared variables — project scope, referenced as ${{shared.KEY}}.
var varsSharedCmd = &cobra.Command{
Use: "shared",
Short: "Manage project-level shared variables (${{shared.KEY}})",
}
var sharedVarsListCmd = &cobra.Command{
Use: "list <project-id>",
Short: "List shared project variables (secrets shown masked)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", projectVarsPath(args[0]), nil)
if err != nil {
return err
}
items, err := unwrapList(data, "variables")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, v := range items {
rows = append(rows, []string{str(v, "key"), str(v, "value"), str(v, "is_secret")})
}
printRows(data, []string{"KEY", "VALUE", "SECRET"}, rows)
return nil
},
}
var sharedVarsSetCmd = &cobra.Command{
Use: "set <project-id> KEY=VALUE [KEY=VALUE...]",
Short: "Set shared project variables",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
secret, _ := cmd.Flags().GetBool("secret")
return mutateVars(projectVarsPath(args[0]), args[0], false, func(vars []map[string]interface{}) []map[string]interface{} {
for _, pair := range args[1:] {
k, v, found := strings.Cut(pair, "=")
if !found || k == "" {
continue
}
vars = upsertVar(vars, k, v, secret)
}
return vars
})
},
}
var sharedVarsUnsetCmd = &cobra.Command{
Use: "unset <project-id> KEY [KEY...]",
Short: "Remove shared project variables",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
drop := map[string]bool{}
for _, k := range args[1:] {
drop[k] = true
}
return mutateVars(projectVarsPath(args[0]), args[0], false, func(vars []map[string]interface{}) []map[string]interface{} {
out := vars[:0]
for _, v := range vars {
if !drop[str(v, "key")] {
out = append(out, v)
}
}
return out
})
},
}
func serviceVarsPath(serviceID string) string { return "/services/" + serviceID + "/variables" }
func projectVarsPath(projectID string) string { return "/projects/" + projectID + "/variables" }
func mutateVars(path, label string, redeploy bool, mutate func([]map[string]interface{}) []map[string]interface{}) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", path, nil)
if err != nil {
return err
}
vars, err := unwrapList(data, "variables")
if err != nil {
return err
}
vars = mutate(vars)
payload := make([]map[string]interface{}, 0, len(vars))
for _, v := range vars {
payload = append(payload, map[string]interface{}{
"key": str(v, "key"),
"value": str(v, "value"),
"is_secret": v["is_secret"] == true,
})
}
body := map[string]interface{}{"variables": payload}
if redeploy {
body["redeploy"] = true
}
data, err = c.Do("PUT", path, body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
obj, _ := unwrapObject(data)
if obj["redeploy_queued"] == true {
fmt.Printf("Variables updated on %s — redeploy queued\n", label)
} else {
fmt.Printf("Variables updated on %s — redeploy to apply to running containers\n", label)
}
return nil
}
func upsertVar(vars []map[string]interface{}, key, value string, secret bool) []map[string]interface{} {
for _, v := range vars {
if str(v, "key") == key {
v["value"] = value
v["is_secret"] = secret
return vars
}
}
return append(vars, map[string]interface{}{"key": key, "value": value, "is_secret": secret})
}
func init() {
varsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
varsSetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
varsUnsetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
sharedVarsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
varsSharedCmd.AddCommand(sharedVarsListCmd, sharedVarsSetCmd, sharedVarsUnsetCmd)
VariablesCmd.AddCommand(varsListCmd, varsSetCmd, varsUnsetCmd, varsSharedCmd)
}
@@ -0,0 +1,71 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// VolumesCmd manages docker volumes on the node (admin).
var VolumesCmd = &cobra.Command{
Use: "volumes",
Aliases: []string{"volume"},
Short: "Manage docker volumes (admin)",
}
var volumesListCmd = &cobra.Command{
Use: "list",
Short: "List docker volumes with in-use flags",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/admin/volumes", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "volumes")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, v := range items {
inUse := "no"
if b, ok := v["in_use"].(bool); ok && b {
inUse = "yes"
}
rows = append(rows, []string{str(v, "name"), str(v, "driver"), inUse})
}
printRows(data, []string{"NAME", "DRIVER", "IN USE"}, rows)
return nil
},
}
var volumesDeleteCmd = &cobra.Command{
Use: "delete <name>",
Short: "Delete an unused docker volume",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete volume %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/admin/volumes/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "name": args[0]})
}
fmt.Printf("Deleted volume %s\n", args[0])
return nil
},
}
func init() {
VolumesCmd.AddCommand(volumesListCmd, volumesDeleteCmd)
}
+60 -36
View File
@@ -12,23 +12,35 @@ import (
var cfgFile string
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "containr",
Short: "Containr CLI - Manage your self-hosted PaaS",
Long: `Containr CLI is a command-line interface for managing your Containr platform.
You can manage projects, services, deployments, databases, and more from your terminal.`,
Version: "1.0.0",
Short: "Containr CLI - manage your self-hosted PaaS",
Long: `Containr CLI controls every part of a Containr instance: projects,
services, deployments, databases, cron jobs, templates, nodes, scaling,
HA, security, and the API gateway.
Authenticate once with a personal access token (Settings → Personal
Access Tokens in the web UI):
containr auth login cnp_...
Every command supports --json for machine-readable output, and
destructive actions accept --yes to skip confirmation.`,
Version: "2.0.0",
SilenceUsage: true,
SilenceErrors: true,
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
cobra.CheckErr(rootCmd.Execute())
// Execute runs the root command and returns the process exit code.
func Execute() int {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return commands.ExitCode(err)
}
return commands.ExitOK
}
// SetVersion overrides the version reported by `containr --version`.
// Called by main with the value injected via -ldflags at build time.
func SetVersion(v string) {
rootCmd.Version = v
}
@@ -36,46 +48,58 @@ func SetVersion(v string) {
func init() {
cobra.OnInitialize(initConfig)
// Global flags
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.containr.yaml)")
rootCmd.PersistentFlags().String("api-url", "", "Containr API URL (default is https://api.containr.dev)")
rootCmd.PersistentFlags().String("token", "", "Authentication token")
pf := rootCmd.PersistentFlags()
pf.StringVar(&cfgFile, "config", "", "config file (default $HOME/.containr.yaml)")
pf.String("api-url", "", "Containr API URL (overrides profile and CONTAINR_API_URL)")
pf.String("token", "", "auth token (overrides profile and CONTAINR_TOKEN)")
pf.StringP("profile", "p", "", "config profile to use (default current_profile)")
pf.Bool("json", false, "machine-readable JSON output")
pf.BoolP("yes", "y", false, "skip confirmation prompts (required for destructive ops in scripts)")
pf.String("idempotency-key", "", "Idempotency-Key header for POST mutations (safe retries)")
// Bind flags to viper
viper.BindPFlag("api-url", rootCmd.PersistentFlags().Lookup("api-url"))
viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token"))
viper.BindPFlag("api-url", pf.Lookup("api-url"))
viper.BindPFlag("token", pf.Lookup("token"))
viper.BindPFlag("profile", pf.Lookup("profile"))
viper.BindPFlag("json", pf.Lookup("json"))
viper.BindPFlag("yes", pf.Lookup("yes"))
viper.BindPFlag("idempotency-key", pf.Lookup("idempotency-key"))
// Add command groups
rootCmd.AddCommand(commands.AuthCmd)
rootCmd.AddCommand(commands.TokensCmd)
rootCmd.AddCommand(commands.ProjectsCmd)
rootCmd.AddCommand(commands.ServicesCmd)
rootCmd.AddCommand(commands.DeployCmd)
rootCmd.AddCommand(commands.DeploymentsCmd)
rootCmd.AddCommand(commands.LogsCmd)
rootCmd.AddCommand(commands.ExecCmd)
rootCmd.AddCommand(commands.VariablesCmd)
rootCmd.AddCommand(commands.VolumesCmd)
rootCmd.AddCommand(commands.RegistriesCmd)
rootCmd.AddCommand(commands.DatabasesCmd)
rootCmd.AddCommand(commands.CronCmd)
rootCmd.AddCommand(commands.TemplatesCmd)
rootCmd.AddCommand(commands.NodesCmd)
rootCmd.AddCommand(commands.ScalingCmd)
rootCmd.AddCommand(commands.HACmd)
rootCmd.AddCommand(commands.SecurityCmd)
rootCmd.AddCommand(commands.GatewayCmd)
rootCmd.AddCommand(commands.AdminCmd)
rootCmd.AddCommand(commands.NotificationsCmd)
rootCmd.AddCommand(commands.UpCmd)
}
// initConfig reads in config file and ENV variables if set.
func initConfig() {
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
} else {
// Find home directory.
home, err := os.UserHomeDir()
cobra.CheckErr(err)
// Search config in home directory with name ".containr" (without extension).
viper.AddConfigPath(home)
viper.SetConfigType("yaml")
viper.SetConfigName(".containr")
}
viper.AutomaticEnv() // read in environment variables that match
// If a config file is found, read it in.
if err := viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}
// Run executes the CLI
func Run() error {
rootCmd.Execute()
return nil
viper.AutomaticEnv()
// CONTAINR_TOKEN / CONTAINR_API_URL are read explicitly in config.go;
// AutomaticEnv covers them only when bound, so this is belt-and-braces.
viper.ReadInConfig()
}
-25
View File
@@ -1,25 +0,0 @@
package cli
import (
"strings"
"github.com/spf13/viper"
)
// getAPIURL constructs the full API URL for a given endpoint
func getAPIURL(endpoint string) string {
baseURL := viper.GetString("api-url")
if baseURL == "" {
baseURL = "http://localhost:8080/api/v1" // Default for development
}
// Ensure baseURL doesn't end with / and endpoint starts with /
if strings.HasSuffix(baseURL, "/") {
baseURL = baseURL[:len(baseURL)-1]
}
if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
return baseURL + endpoint
}
+60 -27
View File
@@ -168,33 +168,53 @@ type ProjectMember struct {
}
type Service struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description sql.NullString `json:"description"`
ProjectID uuid.UUID `json:"project_id"`
EnvironmentID uuid.UUID `json:"environment_id"`
ServiceType string `json:"service_type"`
SourceType string `json:"source_type"`
SourceUrl sql.NullString `json:"source_url"`
ImageName sql.NullString `json:"image_name"`
BuildCommand sql.NullString `json:"build_command"`
StartCommand sql.NullString `json:"start_command"`
CpuLimit sql.NullInt32 `json:"cpu_limit"`
MemoryLimit sql.NullInt32 `json:"memory_limit"`
PublicUrl sql.NullString `json:"public_url"`
HealthCheckUrl sql.NullString `json:"health_check_url"`
Status sql.NullString `json:"status"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
Type sql.NullString `json:"type"`
Image sql.NullString `json:"image"`
Command sql.NullString `json:"command"`
Environment sql.NullString `json:"environment"`
GitRepo sql.NullString `json:"git_repo"`
GitBranch sql.NullString `json:"git_branch"`
BuildPath sql.NullString `json:"build_path"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullString `json:"memory"`
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description sql.NullString `json:"description"`
ProjectID uuid.UUID `json:"project_id"`
EnvironmentID uuid.UUID `json:"environment_id"`
ServiceType string `json:"service_type"`
SourceType string `json:"source_type"`
SourceUrl sql.NullString `json:"source_url"`
ImageName sql.NullString `json:"image_name"`
BuildCommand sql.NullString `json:"build_command"`
StartCommand sql.NullString `json:"start_command"`
CpuLimit sql.NullInt32 `json:"cpu_limit"`
MemoryLimit sql.NullInt32 `json:"memory_limit"`
PublicUrl sql.NullString `json:"public_url"`
HealthCheckUrl sql.NullString `json:"health_check_url"`
Status sql.NullString `json:"status"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
Type sql.NullString `json:"type"`
Image sql.NullString `json:"image"`
Command sql.NullString `json:"command"`
Environment sql.NullString `json:"environment"`
GitRepo sql.NullString `json:"git_repo"`
GitBranch sql.NullString `json:"git_branch"`
BuildPath sql.NullString `json:"build_path"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullString `json:"memory"`
Replicas int32 `json:"replicas"`
Port int32 `json:"port"`
Domain string `json:"domain"`
HealthcheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
PublishedPort int32 `json:"published_port"`
Volumes json.RawMessage `json:"volumes"`
MaintenanceMode bool `json:"maintenance_mode"`
BasicAuthUsers string `json:"basic_auth_users"`
}
type ServiceDomain struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Domain string `json:"domain"`
IsDefault bool `json:"is_default"`
CertType string `json:"cert_type"`
CertStatus string `json:"cert_status"`
LastCheckedAt sql.NullTime `json:"last_checked_at"`
CreatedAt time.Time `json:"created_at"`
}
type ServiceTemplate struct {
@@ -211,3 +231,16 @@ type ServiceTemplate struct {
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type UserToken struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
TokenHash string `json:"token_hash"`
Scope string `json:"scope"`
ExpiresAt sql.NullTime `json:"expires_at"`
LastUsedAt sql.NullTime `json:"last_used_at"`
RevokedAt sql.NullTime `json:"revoked_at"`
CreatedAt sql.NullTime `json:"created_at"`
}
@@ -26,6 +26,7 @@ type Querier interface {
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
@@ -46,6 +47,7 @@ type Querier interface {
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
@@ -66,9 +68,11 @@ type Querier interface {
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
@@ -77,6 +81,7 @@ type Querier interface {
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
TouchUserToken(ctx context.Context, id uuid.UUID) error
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
@@ -0,0 +1,147 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: user_tokens.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const createUserToken = `-- name: CreateUserToken :one
INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at
`
type CreateUserTokenParams struct {
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
TokenHash string `json:"token_hash"`
Scope string `json:"scope"`
ExpiresAt sql.NullTime `json:"expires_at"`
}
func (q *Queries) CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error) {
row := q.db.QueryRowContext(ctx, createUserToken,
arg.UserID,
arg.Name,
arg.KeyPrefix,
arg.TokenHash,
arg.Scope,
arg.ExpiresAt,
)
var i UserToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyPrefix,
&i.TokenHash,
&i.Scope,
&i.ExpiresAt,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const getUserTokenByHash = `-- name: GetUserTokenByHash :one
SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens
WHERE token_hash = $1
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
LIMIT 1
`
func (q *Queries) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) {
row := q.db.QueryRowContext(ctx, getUserTokenByHash, tokenHash)
var i UserToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyPrefix,
&i.TokenHash,
&i.Scope,
&i.ExpiresAt,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const listUserTokens = `-- name: ListUserTokens :many
SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens
WHERE user_id = $1 AND revoked_at IS NULL
ORDER BY created_at DESC
`
func (q *Queries) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) {
rows, err := q.db.QueryContext(ctx, listUserTokens, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []UserToken{}
for rows.Next() {
var i UserToken
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyPrefix,
&i.TokenHash,
&i.Scope,
&i.ExpiresAt,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const revokeUserToken = `-- name: RevokeUserToken :execrows
UPDATE user_tokens SET revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL
`
type RevokeUserTokenParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) {
result, err := q.db.ExecContext(ctx, revokeUserToken, arg.ID, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const touchUserToken = `-- name: TouchUserToken :exec
UPDATE user_tokens SET last_used_at = NOW()
WHERE id = $1 AND revoked_at IS NULL
`
func (q *Queries) TouchUserToken(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, touchUserToken, id)
return err
}
+48 -22
View File
@@ -57,6 +57,11 @@ type ServiceConfig struct {
PublicPort int32 `json:"public_port,omitempty"`
// Domain is a public hostname routed to PublicPort via Traefik.
Domain string `json:"domain,omitempty"`
// Domains is the full hostname set (includes Domain when set).
Domains []string `json:"domains,omitempty"`
Maintenance bool `json:"maintenance,omitempty"`
MaintenanceURL string `json:"maintenance_url,omitempty"`
BasicAuthUsers string `json:"basic_auth_users,omitempty"`
// HealthPath is an HTTP path probed on PublicPort for container health.
HealthPath string `json:"health_path,omitempty"`
// PublishedPort is the host port bound by the previous deployment; replica 0
@@ -79,10 +84,11 @@ type VolumeMount struct {
}
type ResourceLimits struct {
MemoryBytes int64 `json:"memory_bytes,omitempty"`
CPUQuota int64 `json:"cpu_quota,omitempty"`
CPUPeriod int64 `json:"cpu_period,omitempty"`
CPUShares int64 `json:"cpu_shares,omitempty"`
MemoryBytes int64 `json:"memory_bytes,omitempty"`
MemoryReservation int64 `json:"memory_reservation,omitempty"`
CPUQuota int64 `json:"cpu_quota,omitempty"`
CPUPeriod int64 `json:"cpu_period,omitempty"`
CPUShares int64 `json:"cpu_shares,omitempty"`
}
type HealthCheck struct {
@@ -147,9 +153,14 @@ type BuildConfig struct {
PrebuiltImage string `json:"prebuilt_image"`
BuildCommand string `json:"build_command"`
StartCommand string `json:"start_command"`
BuildArgs map[string]string `json:"build_args,omitempty"`
Environment map[string]string `json:"environment"`
Branch string `json:"branch"`
Commit string `json:"commit"`
// Pull credentials for private prebuilt images.
PullUsername string `json:"pull_username,omitempty"`
PullPassword string `json:"pull_password,omitempty"`
Branch string `json:"branch"`
Commit string `json:"commit"`
NoCache bool `json:"no_cache"`
}
type TriggerConfig struct {
@@ -160,6 +171,12 @@ type TriggerConfig struct {
Timestamp time.Time `json:"timestamp"` // When trigger occurred
}
// DockerClient exposes the engine's docker handle for read-only queries
// (capacity checks, image info).
func (de *DeploymentEngine) DockerClient() *docker.Client {
return de.dockerClient
}
func NewDeploymentEngine(buildManager *build.BuildManager, dockerClient *docker.Client) *DeploymentEngine {
return &DeploymentEngine{
buildManager: buildManager,
@@ -284,13 +301,17 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme
ImageTag: deployment.ID,
BuildCommand: buildConfig.BuildCommand,
StartCommand: buildConfig.StartCommand,
BuildArgs: buildConfig.BuildArgs,
Environment: buildConfig.Environment,
PullUsername: buildConfig.PullUsername,
PullPassword: buildConfig.PullPassword,
ProjectID: deployment.ProjectID,
ServiceID: deployment.ServiceID,
DeploymentID: deployment.ID,
TriggeredBy: "deployment_engine",
Branch: buildConfig.Branch,
Commit: buildConfig.Commit,
NoCache: buildConfig.NoCache,
}
response, err := de.buildManager.Build(ctx, buildReq)
@@ -310,20 +331,27 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
cfg := deployment.Config
spec := RuntimeSpec{
ProjectID: deployment.ProjectID,
ServiceID: deployment.ServiceID,
Name: cfg.Name,
Image: deployment.ImageName,
Command: cfg.Command,
Env: cfg.Environment,
Replicas: cfg.Replicas,
Port: cfg.PublicPort,
PublishedPort: cfg.PublishedPort,
Domain: cfg.Domain,
HealthPath: cfg.HealthPath,
RestartPolicy: cfg.RestartPolicy,
MemoryBytes: cfg.Resources.MemoryBytes,
NanoCPUs: cfg.Resources.CPUQuota,
ProjectID: deployment.ProjectID,
ServiceID: deployment.ServiceID,
Name: cfg.Name,
Image: deployment.ImageName,
Command: cfg.Command,
Env: cfg.Environment,
Replicas: cfg.Replicas,
Port: cfg.PublicPort,
PublishedPort: cfg.PublishedPort,
Domain: cfg.Domain,
HealthPath: cfg.HealthPath,
RestartPolicy: cfg.RestartPolicy,
MemoryBytes: cfg.Resources.MemoryBytes,
MemoryReserve: cfg.Resources.MemoryReservation,
NanoCPUs: cfg.Resources.CPUQuota,
CPUShares: cfg.Resources.CPUShares,
Volumes: cfg.VolumeMounts,
Domains: cfg.Domains,
Maintenance: cfg.Maintenance,
MaintenanceURL: cfg.MaintenanceURL,
BasicAuthUsers: cfg.BasicAuthUsers,
}
for _, pm := range cfg.PortMappings {
if spec.Port == 0 {
@@ -334,8 +362,6 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
spec.RestartPolicy = "unless-stopped"
}
// Volume mounts aren't reconciled replicas-aware yet; still applied on the
// container config of every replica via spec extension later if needed.
state, err := de.ReconcileService(ctx, spec)
if err != nil {
return err
+96 -31
View File
@@ -17,6 +17,7 @@ import (
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/go-connections/nat"
)
@@ -33,20 +34,27 @@ const (
// RuntimeSpec is everything needed to run a service's containers.
type RuntimeSpec struct {
ProjectID string
ServiceID string
Name string // DNS alias on the project network
Image string
Command []string
Env map[string]string // already resolved
Replicas int
Port int32 // container port to expose publicly; 0 = none
PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral
Domain string // public hostname routed via Traefik; empty = none
HealthPath string // http path probed on Port for container healthcheck
RestartPolicy string
MemoryBytes int64
NanoCPUs int64
ProjectID string
ServiceID string
Name string // DNS alias on the project network
Image string
Command []string
Env map[string]string // already resolved
Replicas int
Port int32 // container port to expose publicly; 0 = none
PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral
Domain string // public hostname routed via Traefik; empty = none
HealthPath string // http path probed on Port for container healthcheck
RestartPolicy string
MemoryBytes int64
MemoryReserve int64 // soft reservation; scheduler hint, not a hard cap
NanoCPUs int64
CPUShares int64 // relative weight (1024 ≈ 1 cpu), not a cap
Volumes []VolumeMount // applied to every replica
Domains []string // all public hostnames; Domain is the default
Maintenance bool // redirect traffic to MaintenanceURL
MaintenanceURL string // absolute URL; empty = serve empty response
BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
}
// RuntimeContainer describes one live replica.
@@ -67,7 +75,9 @@ type RuntimeState struct {
Status string `json:"status"` // running, degraded, stopped
}
func projectNetworkName(projectID string) string {
// ProjectNetworkName is the deterministic docker network every service in a
// project joins — containr-proj-<first 12 hex chars of the project uuid>.
func ProjectNetworkName(projectID string) string {
short := strings.ReplaceAll(projectID, "-", "")
if len(short) > 12 {
short = short[:12]
@@ -82,7 +92,7 @@ func serviceNamePrefix(serviceID string) string {
// EnsureProjectNetwork creates the per-project bridge network if missing.
// Containers on it resolve each other by service name (private networking).
func (de *DeploymentEngine) EnsureProjectNetwork(ctx context.Context, projectID string) (string, error) {
name := projectNetworkName(projectID)
name := ProjectNetworkName(projectID)
networks, err := de.dockerClient.ListNetworks(ctx)
if err != nil {
@@ -223,24 +233,72 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
projectNet: {Aliases: []string{spec.Name}},
}
if edgeNet != "" {
router := "svc-" + spec.ServiceID[:8]
domains := spec.Domains
if len(domains) == 0 && spec.Domain != "" {
domains = []string{spec.Domain}
}
sort.Strings(domains)
rules := make([]string, 0, len(domains))
for _, d := range domains {
rules = append(rules, "Host(`"+d+"`)")
}
endpoints[edgeNet] = &network.EndpointSettings{}
labels["traefik.enable"] = "true"
labels["traefik.docker.network"] = edgeNet
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".rule"] = "Host(`" + spec.Domain + "`)"
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".entrypoints"] = "web"
labels["traefik.http.services.svc-"+spec.ServiceID[:8]+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
labels["traefik.http.routers."+router+".rule"] = strings.Join(rules, " || ")
labels["traefik.http.routers."+router+".entrypoints"] = "web"
labels["traefik.http.services."+router+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
var middlewares []string
if spec.Maintenance {
if spec.MaintenanceURL != "" {
mw := router + "-maint"
labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
middlewares = append(middlewares, mw)
} else {
// No redirect target configured — take the site offline with
// empty responses instead of leaking traffic to the app.
labels["traefik.http.routers."+router+".service"] = "noop@internal"
}
}
if spec.BasicAuthUsers != "" {
mw := router + "-auth"
labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
middlewares = append(middlewares, mw)
}
if len(middlewares) > 0 {
labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
}
}
cfg := docker.ContainerConfig{
Name: fmt.Sprintf("%s%d", serviceNamePrefix(spec.ServiceID), index),
Image: spec.Image,
Cmd: spec.Command,
Env: env,
Labels: labels,
RestartPolicy: spec.RestartPolicy,
Memory: spec.MemoryBytes,
NanoCPUs: spec.NanoCPUs,
Networks: endpoints,
Name: fmt.Sprintf("%s%d", serviceNamePrefix(spec.ServiceID), index),
Image: spec.Image,
Cmd: spec.Command,
Env: env,
Labels: labels,
RestartPolicy: spec.RestartPolicy,
Memory: spec.MemoryBytes,
MemoryReservation: spec.MemoryReserve,
NanoCPUs: spec.NanoCPUs,
CPUShares: spec.CPUShares,
Networks: endpoints,
}
for _, v := range spec.Volumes {
mountType := mount.TypeVolume
if v.Type == "bind" {
mountType = mount.TypeBind
}
cfg.Mounts = append(cfg.Mounts, mount.Mount{
Type: mountType,
Source: v.Source,
Target: v.Destination,
ReadOnly: v.ReadOnly,
})
}
var wantPort nat.Port
@@ -364,7 +422,7 @@ func (de *DeploymentEngine) ProbeServiceHealth(ctx context.Context, state *Runti
}
probeURL := ""
if networkName := projectNetworkName(projectID); networkName != "" {
if networkName := ProjectNetworkName(projectID); networkName != "" {
if err := de.dockerClient.ConnectSelfToNetwork(ctx, networkName); err == nil {
probeURL = fmt.Sprintf("http://%s:%d%s", serviceName, port, healthPath)
}
@@ -456,7 +514,7 @@ func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, project
// RemoveProjectNetwork removes the per-project bridge network if present.
func (de *DeploymentEngine) RemoveProjectNetwork(ctx context.Context, projectID string) error {
return de.dockerClient.RemoveNetwork(ctx, projectNetworkName(projectID))
return de.dockerClient.RemoveNetwork(ctx, ProjectNetworkName(projectID))
}
func replicaIndex(c container.Summary) int {
@@ -472,8 +530,15 @@ func specHash(spec RuntimeSpec) string {
Env map[string]string
Port int32
MemoryBytes, NanoCPUs int64
MemoryReserve, CPUShares int64
Volumes []VolumeMount
Domains []string
Maintenance bool
BasicAuth string
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs})
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs,
spec.MemoryReserve, spec.CPUShares, spec.Volumes,
spec.Domains, spec.Maintenance, spec.BasicAuthUsers})
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:8])
}
+146
View File
@@ -0,0 +1,146 @@
package deployqueue
import (
"context"
"sync"
"github.com/google/uuid"
)
// Job is a unit of serialized deployment work. DeploymentID is the
// deployments row it mirrors (uuid.Nil for runtime ops with no row).
// Run is invoked with a cancellable context — cancelled by Cancel for
// active jobs, or the job is dropped before starting when queued.
type Job struct {
DeploymentID uuid.UUID
Run func(ctx context.Context)
}
// Queue serializes deploy work per service: one active job, the rest FIFO.
// A build for service A never races a redeploy or second build of A, while
// services B, C proceed in parallel. Not a global worker pool — per-service
// serialization is what prevents container thrash.
type Queue struct {
mu sync.Mutex
states map[uuid.UUID]*serviceState
cancels map[uuid.UUID]context.CancelFunc
}
type serviceState struct {
running bool
pending []Job
}
func New() *Queue {
return &Queue{
states: make(map[uuid.UUID]*serviceState),
cancels: make(map[uuid.UUID]context.CancelFunc),
}
}
// Enqueue schedules a job. Returns the position: 0 means it starts
// immediately, >0 means that many jobs run first.
func (q *Queue) Enqueue(serviceID uuid.UUID, job Job) int {
q.mu.Lock()
st := q.states[serviceID]
if st == nil {
st = &serviceState{}
q.states[serviceID] = st
}
if st.running {
st.pending = append(st.pending, job)
pos := len(st.pending)
q.mu.Unlock()
return pos
}
st.running = true
q.mu.Unlock()
q.start(serviceID, job)
return 0
}
func (q *Queue) start(serviceID uuid.UUID, job Job) {
ctx, cancel := context.WithCancel(context.Background())
if job.DeploymentID != uuid.Nil {
q.mu.Lock()
q.cancels[job.DeploymentID] = cancel
q.mu.Unlock()
}
go func() {
defer func() {
if job.DeploymentID != uuid.Nil {
q.mu.Lock()
delete(q.cancels, job.DeploymentID)
q.mu.Unlock()
}
cancel()
q.promote(serviceID)
}()
job.Run(ctx)
}()
}
// promote marks the service free and starts the next queued job, if any.
func (q *Queue) promote(serviceID uuid.UUID) {
q.mu.Lock()
st := q.states[serviceID]
if st == nil || len(st.pending) == 0 {
if st != nil {
st.running = false
}
q.mu.Unlock()
return
}
next := st.pending[0]
st.pending = st.pending[1:]
q.mu.Unlock()
q.start(serviceID, next)
}
// CancelState distinguishes what Cancel found.
type CancelState int
const (
NotFound CancelState = iota
WasQueued // removed before it started — caller marks DB cancelled
WasActive // ctx cancelled — the running job marks DB itself
)
// Cancel cancels a job by its deployment row id.
func (q *Queue) Cancel(serviceID, deploymentID uuid.UUID) CancelState {
q.mu.Lock()
if st := q.states[serviceID]; st != nil {
for i, j := range st.pending {
if j.DeploymentID == deploymentID {
st.pending = append(st.pending[:i], st.pending[i+1:]...)
q.mu.Unlock()
return WasQueued
}
}
}
cancel, ok := q.cancels[deploymentID]
q.mu.Unlock()
if ok {
cancel()
return WasActive
}
return NotFound
}
// Do runs fn through the queue and waits for completion — for synchronous
// endpoints (redeploy) that still must not overlap an in-flight deployment.
// ctx bounds the wait, not the job; on timeout the job still runs to
// completion to avoid orphaned reconcile state.
func (q *Queue) Do(ctx context.Context, serviceID uuid.UUID, fn func(ctx context.Context)) {
done := make(chan struct{})
q.Enqueue(serviceID, Job{Run: func(jctx context.Context) {
defer close(done)
fn(jctx)
}})
select {
case <-done:
case <-ctx.Done():
}
}
@@ -0,0 +1,142 @@
package deployqueue
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
)
func TestSerializesPerService(t *testing.T) {
q := New()
svc := uuid.New()
var active, maxActive int32
finish := make(chan struct{})
for i := 0; i < 3; i++ {
q.Enqueue(svc, Job{Run: func(ctx context.Context) {
if n := atomic.AddInt32(&active, 1); n > atomic.LoadInt32(&maxActive) {
atomic.StoreInt32(&maxActive, n)
}
<-finish // hold until released
atomic.AddInt32(&active, -1)
}})
}
deadline := time.After(2 * time.Second)
for atomic.LoadInt32(&active) < 1 {
select {
case <-deadline:
t.Fatal("first job never started")
default:
time.Sleep(time.Millisecond)
}
}
close(finish)
deadline = time.After(2 * time.Second)
for atomic.LoadInt32(&active) > 0 {
select {
case <-deadline:
t.Fatal("jobs did not drain")
default:
time.Sleep(time.Millisecond)
}
}
if got := atomic.LoadInt32(&maxActive); got != 1 {
t.Fatalf("expected max concurrency 1, got %d", got)
}
}
func TestDifferentServicesRunInParallel(t *testing.T) {
q := New()
started := make(chan struct{}, 2)
release := make(chan struct{})
for i := 0; i < 2; i++ {
q.Enqueue(uuid.New(), Job{Run: func(ctx context.Context) {
started <- struct{}{}
<-release
}})
}
for i := 0; i < 2; i++ {
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("parallel services did not both start")
}
}
close(release)
}
func TestCancelQueuedRemovesJob(t *testing.T) {
q := New()
svc := uuid.New()
release := make(chan struct{})
q.Enqueue(svc, Job{DeploymentID: uuid.New(), Run: func(ctx context.Context) { <-release }})
queuedID := uuid.New()
ran := make(chan struct{})
q.Enqueue(svc, Job{DeploymentID: queuedID, Run: func(ctx context.Context) { close(ran) }})
if got := q.Cancel(svc, queuedID); got != WasQueued {
t.Fatalf("expected WasQueued, got %v", got)
}
close(release)
select {
case <-ran:
t.Fatal("cancelled queued job still ran")
case <-time.After(200 * time.Millisecond):
}
}
func TestCancelActiveCancelsContext(t *testing.T) {
q := New()
svc := uuid.New()
id := uuid.New()
ctxDone := make(chan struct{})
q.Enqueue(svc, Job{DeploymentID: id, Run: func(ctx context.Context) {
<-ctx.Done()
close(ctxDone)
}})
deadline := time.After(2 * time.Second)
for {
q.mu.Lock()
_, ok := q.cancels[id]
q.mu.Unlock()
if ok {
break
}
select {
case <-deadline:
t.Fatal("job never registered")
default:
time.Sleep(time.Millisecond)
}
}
if got := q.Cancel(svc, id); got != WasActive {
t.Fatalf("expected WasActive, got %v", got)
}
select {
case <-ctxDone:
case <-time.After(2 * time.Second):
t.Fatal("active job context not cancelled")
}
}
func TestEnqueuePosition(t *testing.T) {
q := New()
svc := uuid.New()
release := make(chan struct{})
if pos := q.Enqueue(svc, Job{Run: func(ctx context.Context) { <-release }}); pos != 0 {
t.Fatalf("first job position = %d, want 0", pos)
}
if pos := q.Enqueue(svc, Job{Run: func(ctx context.Context) {}}); pos != 1 {
t.Fatalf("second job position = %d, want 1", pos)
}
if pos := q.Enqueue(svc, Job{Run: func(ctx context.Context) {}}); pos != 2 {
t.Fatalf("third job position = %d, want 2", pos)
}
close(release)
}
+34 -3
View File
@@ -156,8 +156,10 @@ func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (s
PortBindings: config.PortBindings,
Mounts: config.Mounts,
Resources: container.Resources{
Memory: config.Memory,
NanoCPUs: config.NanoCPUs,
Memory: config.Memory,
MemoryReservation: config.MemoryReservation,
NanoCPUs: config.NanoCPUs,
CPUShares: config.CPUShares,
},
NetworkMode: container.NetworkMode(config.NetworkMode),
}
@@ -291,13 +293,42 @@ func (c *Client) PullImageWait(ctx context.Context, ref string, auth registry.Au
// BuildImage builds an image from a Dockerfile
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
resp, err := c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
Dockerfile: options.Dockerfile,
Tags: options.Tags,
BuildArgs: options.BuildArgs,
Labels: options.Labels,
Remove: options.Remove,
NoCache: options.NoCache,
})
if err != nil {
return resp, err
}
// The daemon aborts the build if the response stream isn't consumed,
// and reports failures only inside that stream.
defer resp.Body.Close()
var buildErr string
dec := json.NewDecoder(resp.Body)
for {
var line struct {
ErrorDetail struct {
Message string `json:"message"`
} `json:"errorDetail"`
Error string `json:"error"`
}
if err := dec.Decode(&line); err != nil {
break
}
if line.ErrorDetail.Message != "" {
buildErr = line.ErrorDetail.Message
} else if line.Error != "" {
buildErr = line.Error
}
}
if buildErr != "" {
return resp, fmt.Errorf("docker build failed: %s", buildErr)
}
return resp, nil
}
// RemoveImage removes an image
+17 -14
View File
@@ -12,20 +12,22 @@ import (
// ContainerConfig represents the configuration for creating a container
type ContainerConfig struct {
Name string
Image string
Cmd []string
Env []string
Labels map[string]string
RestartPolicy string
ExposedPorts nat.PortSet
PortBindings nat.PortMap
Mounts []mount.Mount
Memory int64
NanoCPUs int64
NetworkMode string
Networks map[string]*network.EndpointSettings
Healthcheck *container.HealthConfig
Name string
Image string
Cmd []string
Env []string
Labels map[string]string
RestartPolicy string
ExposedPorts nat.PortSet
PortBindings nat.PortMap
Mounts []mount.Mount
Memory int64
MemoryReservation int64
NanoCPUs int64
CPUShares int64
NetworkMode string
Networks map[string]*network.EndpointSettings
Healthcheck *container.HealthConfig
}
// LogOptions represents options for retrieving container logs
@@ -44,6 +46,7 @@ type BuildOptions struct {
BuildArgs map[string]*string
Labels map[string]string
Remove bool
NoCache bool
}
// NetworkConfig represents the configuration for creating a network
+138 -16
View File
@@ -3,6 +3,7 @@ package ha
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
@@ -75,11 +76,13 @@ type HealthCheckConfig struct {
// HealthChecker performs health checks on services and nodes
type HealthChecker struct {
scheduler *deployment.Scheduler
checks map[string]*HealthCheck
results map[string]*HealthCheckResult
mu sync.RWMutex
checkInterval time.Duration
scheduler *deployment.Scheduler
checks map[string]*HealthCheck
results map[string]*HealthCheckResult
mu sync.RWMutex
checkInterval time.Duration
serviceResolver func(ctx context.Context, serviceID string) (host string, port int, err error)
db *sql.DB
}
// HealthCheck represents a health check configuration
@@ -596,7 +599,11 @@ func (hc *HealthChecker) performHealthChecks(ctx context.Context) error {
result := hc.performHealthCheck(ctx, check)
hc.mu.Lock()
hc.results[check.ID] = result
check.Status = result.Status
check.LastCheck = result.Timestamp
hc.mu.Unlock()
// Persist last-known state so a restart resumes with honest history.
hc.persistCheck(check)
}
return nil
@@ -617,12 +624,20 @@ func (hc *HealthChecker) performHealthCheck(ctx context.Context, check *HealthCh
checkCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var err error
host, port, err := hc.resolveTarget(ctx, check)
if err != nil {
result.Status = HealthStatusUnhealthy
result.Message = err.Error()
result.ErrorCode = "TARGET_UNRESOLVED"
result.Latency = time.Since(start)
return result
}
switch check.Type {
case HealthCheckTypeHTTP:
err = hc.performHTTPCheck(checkCtx, check)
err = hc.performHTTPCheck(checkCtx, check, host, port)
case HealthCheckTypeTCP:
err = hc.performTCPCheck(checkCtx, check)
err = hc.performTCPCheck(checkCtx, check, host, port)
case HealthCheckTypeCommand:
err = hc.performCommandCheck(checkCtx, check)
default:
@@ -642,13 +657,11 @@ func (hc *HealthChecker) performHealthCheck(ctx context.Context, check *HealthCh
return result
}
func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthCheck) error {
host := hc.resolveHealthCheckHost(check)
func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthCheck, host string, port int) error {
protocol := strings.ToLower(strings.TrimSpace(check.Config.Protocol))
if protocol == "" {
protocol = "http"
}
port := check.Config.Port
if port <= 0 {
if protocol == "https" {
port = 443
@@ -684,9 +697,7 @@ func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthChec
return nil
}
func (hc *HealthChecker) performTCPCheck(ctx context.Context, check *HealthCheck) error {
host := hc.resolveHealthCheckHost(check)
port := check.Config.Port
func (hc *HealthChecker) performTCPCheck(ctx context.Context, check *HealthCheck, host string, port int) error {
if port <= 0 {
port = 80
}
@@ -723,6 +734,27 @@ func (hc *HealthChecker) performCommandCheck(ctx context.Context, check *HealthC
return nil
}
// WithServiceResolver lets the API layer plug in service-aware targeting: a
// ServiceID-bound check resolves to the service's DNS name on its project
// network instead of the backend container's loopback, where dialing
// 127.0.0.1:<host port> can never succeed.
func (m *HighAvailabilityManager) WithServiceResolver(fn func(ctx context.Context, serviceID string) (host string, port int, err error)) *HighAvailabilityManager {
m.healthChecker.serviceResolver = fn
return m
}
// WithPersistence attaches Postgres so configured checks survive restarts.
// Call LoadHealthChecks once during startup to hydrate state.
func (m *HighAvailabilityManager) WithPersistence(db *sql.DB) *HighAvailabilityManager {
m.healthChecker.db = db
return m
}
// LoadHealthChecks hydrates persisted health checks during startup.
func (m *HighAvailabilityManager) LoadHealthChecks(ctx context.Context) error {
return m.healthChecker.LoadHealthChecks(ctx)
}
func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string {
if strings.TrimSpace(check.NodeID) != "" && hc.scheduler != nil {
nodes := hc.scheduler.GetNodes()
@@ -739,19 +771,109 @@ func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string {
return "127.0.0.1"
}
// resolveTarget picks the dial target for a check: service-bound checks go to
// the service DNS name on the project network; node-bound and free-standing
// checks keep the old host behavior. A service-bound resolver failure is
// surfaced as an error — falling back to loopback would only produce a
// misleading "service down" result.
func (hc *HealthChecker) resolveTarget(ctx context.Context, check *HealthCheck) (string, int, error) {
port := check.Config.Port
if strings.TrimSpace(check.NodeID) == "" && check.ServiceID != "" && hc.serviceResolver != nil {
resolveCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
h, p, err := hc.serviceResolver(resolveCtx, check.ServiceID)
if err != nil {
return "", 0, fmt.Errorf("resolve service target: %w", err)
}
if h == "" {
return "", 0, fmt.Errorf("resolve service target: service %s not found", check.ServiceID)
}
if port <= 0 {
port = p
}
return h, port, nil
}
return hc.resolveHealthCheckHost(check), port, nil
}
// AddHealthCheck adds a new health check
func (hc *HealthChecker) AddHealthCheck(check *HealthCheck) {
hc.mu.Lock()
defer hc.mu.Unlock()
hc.checks[check.ID] = check
hc.mu.Unlock()
hc.persistCheck(check)
}
// RemoveHealthCheck removes a health check
func (hc *HealthChecker) RemoveHealthCheck(checkID string) {
hc.mu.Lock()
defer hc.mu.Unlock()
delete(hc.checks, checkID)
delete(hc.results, checkID)
hc.mu.Unlock()
if hc.db != nil {
_, _ = hc.db.ExecContext(context.Background(), `DELETE FROM ha_health_checks WHERE id = $1`, checkID)
}
}
// persistCheck upserts one check. Best effort — a write failure never blocks
// the in-memory path, matching the scaling-policy persistence style.
func (hc *HealthChecker) persistCheck(check *HealthCheck) {
if hc.db == nil {
return
}
cfg, _ := json.Marshal(check.Config)
var lastCheck interface{}
if !check.LastCheck.IsZero() {
lastCheck = check.LastCheck
}
_, _ = hc.db.ExecContext(context.Background(),
`INSERT INTO ha_health_checks (id, service_id, node_id, type, config, status, last_check, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
ON CONFLICT (id) DO UPDATE
SET service_id = EXCLUDED.service_id, node_id = EXCLUDED.node_id,
type = EXCLUDED.type, config = EXCLUDED.config,
status = EXCLUDED.status, last_check = EXCLUDED.last_check,
updated_at = now()`,
check.ID, check.ServiceID, check.NodeID, string(check.Type), cfg, string(check.Status), lastCheck,
)
}
// LoadHealthChecks hydrates checks persisted across restarts.
func (hc *HealthChecker) LoadHealthChecks(ctx context.Context) error {
if hc.db == nil {
return nil
}
rows, err := hc.db.QueryContext(ctx,
`SELECT id, service_id, node_id, type, config, status, last_check FROM ha_health_checks`)
if err != nil {
return err
}
defer rows.Close()
loaded := 0
for rows.Next() {
var check HealthCheck
var cfg []byte
var status, checkType string
var lastCheck sql.NullTime
if err := rows.Scan(&check.ID, &check.ServiceID, &check.NodeID, &checkType, &cfg, &status, &lastCheck); err != nil {
continue
}
check.Type = HealthCheckType(checkType)
check.Status = HealthStatus(status)
if lastCheck.Valid {
check.LastCheck = lastCheck.Time
}
_ = json.Unmarshal(cfg, &check.Config)
hc.mu.Lock()
hc.checks[check.ID] = &check
hc.mu.Unlock()
loaded++
}
if loaded > 0 {
log.Printf("Restored %d health checks", loaded)
}
return rows.Err()
}
// GetHealthCheck returns one configured health check.
+301
View File
@@ -0,0 +1,301 @@
// Package mcpserver exposes the Containr API as MCP tools over stdio.
// It reuses the CLI's thin client — same PAT auth, same endpoints —
// so agents reach everything the UI and CLI do.
package mcpserver
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"containr/internal/cli/commands"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// spec describes one MCP tool as an HTTP call. {placeholder} segments in
// Path are filled from arguments; remaining string/number/bool args become
// the JSON body for POST/PUT/PATCH.
type spec struct {
name string
description string
method string
path string
// params are the arguments that fill path placeholders (required).
params []string
// bodyKeys restrict which args go into the request body; when empty all
// non-param args are sent.
bodyKeys []string
// destructive tools refuse unless args["confirm"] == true.
destructive bool
}
var tools []spec
func register(s spec) { tools = append(tools, s) }
func init() {
register(spec{"containr_auth_status", "Show which profile/token is active and the authenticated identity", "GET", "/user/profile", nil, nil, false})
// Personal access tokens
register(spec{"containr_tokens_list", "List personal access tokens (metadata only, never values)", "GET", "/user/tokens", nil, nil, false})
register(spec{"containr_tokens_create", "Create a personal access token. Args: name (required), scope (read|write|admin, default write), expires_in_days (0=never). Returns the raw token once.", "POST", "/user/tokens", nil, []string{"name", "scope", "expires_in_days"}, false})
register(spec{"containr_tokens_revoke", "Revoke a personal access token by id. Requires confirm:true.", "DELETE", "/user/tokens/{id}", []string{"id"}, nil, true})
// Projects
register(spec{"containr_projects_list", "List projects", "GET", "/projects", nil, nil, false})
register(spec{"containr_projects_get", "Get a project by id", "GET", "/projects/{id}", []string{"id"}, nil, false})
register(spec{"containr_projects_create", "Create a project. Args: name (required), description.", "POST", "/projects", nil, []string{"name", "description"}, false})
register(spec{"containr_projects_delete", "Delete a project and all its services. Requires confirm:true.", "DELETE", "/projects/{id}", []string{"id"}, nil, true})
// Services
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir.", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
register(spec{"containr_services_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false})
register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false})
register(spec{"containr_services_clone", "Clone a service (config, volumes, domains, variables) into the same or another project. Args: id, name, project_id, environment.", "POST", "/services/{id}/clone", []string{"id"}, nil, false})
register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false})
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields).", "PUT", "/services/{id}", []string{"id"}, nil, false})
// Domains
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
register(spec{"containr_domains_add", "Attach a domain to a service. Args: id, domain (required), is_default.", "POST", "/services/{id}/domains", []string{"id"}, []string{"domain", "is_default"}, false})
register(spec{"containr_domains_remove", "Detach a domain. Args: id (service), domain_id.", "DELETE", "/services/{id}/domains/{domain_id}", []string{"id", "domain_id"}, nil, false})
register(spec{"containr_domains_default", "Set the default domain. Args: id (service), domain_id.", "POST", "/services/{id}/domains/{domain_id}/default", []string{"id", "domain_id"}, nil, false})
register(spec{"containr_domains_check", "DNS preflight for all service domains — returns ok|wrong-target|pending per domain", "GET", "/services/{id}/domains/check", []string{"id"}, nil, false})
// Registries — private-image pull credentials, owner-scoped.
register(spec{"containr_registries_list", "List registry credentials (passwords never returned)", "GET", "/registries", nil, nil, false})
register(spec{"containr_registries_create", "Add a registry credential. Args: name (required), host (required — e.g. ghcr.io, docker.io), username, password (stored encrypted).", "POST", "/registries", nil, []string{"name", "host"}, false})
register(spec{"containr_registries_update", "Update a registry credential. Args: id, name, host (required), username, password (empty keeps stored).", "PUT", "/registries/{id}", []string{"id"}, []string{"name", "host"}, false})
register(spec{"containr_registries_delete", "Remove a registry credential. Requires confirm:true.", "DELETE", "/registries/{id}", []string{"id"}, nil, true})
// Docker volumes (admin)
register(spec{"containr_volumes_list", "List docker volumes on the node with in-use flags (admin)", "GET", "/admin/volumes", nil, nil, false})
register(spec{"containr_volumes_delete", "Delete an unused docker volume. Requires confirm:true. (admin)", "DELETE", "/admin/volumes/{name}", []string{"name"}, nil, true})
// Variables
register(spec{"containr_variables_list", "List service environment variables (secrets masked)", "GET", "/services/{id}/variables", []string{"id"}, nil, false})
register(spec{"containr_variables_set", "Replace all variables on a service. Args: id, variables = [{key, value, is_secret}], redeploy (queue a redeploy to apply). Fetch the current list first and merge — the server replaces wholesale.", "PUT", "/services/{id}/variables", []string{"id"}, []string{"variables", "redeploy"}, false})
register(spec{"containr_shared_variables_list", "List project-level shared variables, referenced from service env as ${{shared.KEY}} (secrets masked). Args: id (project).", "GET", "/projects/{id}/variables", []string{"id"}, nil, false})
register(spec{"containr_shared_variables_set", "Replace all shared project variables. Args: id (project), variables = [{key, value, is_secret}]. Services reference them as ${{shared.KEY}} — resolved at deploy/refresh, not live-updated.", "PUT", "/projects/{id}/variables", []string{"id"}, []string{"variables"}, false})
// Deployments
register(spec{"containr_deploy", "Trigger a deployment for a service. Args: id (service), commit_hash, branch, no_cache.", "POST", "/services/{id}/deployments", []string{"id"}, []string{"commit_hash", "branch", "trigger", "no_cache"}, false})
register(spec{"containr_deployments_list", "List recent deployments across services", "GET", "/deployments", nil, nil, false})
register(spec{"containr_services_deployments", "List deployments for one service", "GET", "/services/{id}/deployments", []string{"id"}, nil, false})
register(spec{"containr_deployments_get", "Get a deployment", "GET", "/deployments/{id}", []string{"id"}, nil, false})
register(spec{"containr_deployments_logs", "Get persisted build/runtime logs for a deployment. Args: id, type (all|build|runtime).", "GET", "/deployments/{id}/logs", []string{"id"}, nil, false})
register(spec{"containr_deployments_rollback", "Roll back to a deployment. Requires confirm:true.", "POST", "/deployments/{id}/rollback", []string{"id"}, nil, true})
register(spec{"containr_deployments_cancel", "Cancel a queued or running deployment", "POST", "/deployments/{id}/cancel", []string{"id"}, nil, false})
// Databases
register(spec{"containr_databases_list", "List managed databases", "GET", "/databases", nil, nil, false})
register(spec{"containr_databases_get", "Get a database (connection URL included)", "GET", "/databases/{id}", []string{"id"}, nil, false})
register(spec{"containr_databases_create", "Provision a managed database. Args: name, type (postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse).", "POST", "/databases", nil, nil, false})
register(spec{"containr_databases_action", "Start/stop/restart a database. Args: id, action.", "POST", "/databases/{id}/action", []string{"id"}, []string{"action"}, false})
register(spec{"containr_databases_backup", "Take a manual backup snapshot", "POST", "/databases/{id}/backup", []string{"id"}, nil, false})
register(spec{"containr_databases_restore", "Restore a database from a backup — overwrites current data. Requires confirm:true.", "POST", "/databases/{id}/restore", []string{"id"}, []string{"backup_id"}, true})
register(spec{"containr_databases_delete", "Delete a database and its data. Requires confirm:true.", "DELETE", "/databases/{id}", []string{"id"}, nil, true})
// Cron
register(spec{"containr_cron_list", "List cron jobs", "GET", "/cron-jobs", nil, nil, false})
register(spec{"containr_cron_create", "Create a cron job. Args: service_id, schedule (5-field), command, name, timezone.", "POST", "/cron-jobs", nil, nil, false})
register(spec{"containr_cron_trigger", "Run a cron job now", "POST", "/cron-jobs/{id}/trigger", []string{"id"}, nil, false})
register(spec{"containr_cron_executions", "List a cron job's execution history", "GET", "/cron-jobs/{id}/executions", []string{"id"}, nil, false})
register(spec{"containr_cron_delete", "Delete a cron job. Requires confirm:true.", "DELETE", "/cron-jobs/{id}", []string{"id"}, nil, true})
// Templates
register(spec{"containr_templates_list", "List templates", "GET", "/templates", nil, nil, false})
register(spec{"containr_templates_get", "Get a template definition", "GET", "/templates/{id}", []string{"id"}, nil, false})
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
// Notifications
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
// Nodes / agent tokens (admin)
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false})
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
// Scaling / HA / security
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
register(spec{"containr_scaling_policies", "List scaling policies", "GET", "/scaling/policies", nil, nil, false})
register(spec{"containr_scaling_scale", "Manually scale a service. Args: service_id, replicas. (admin)", "POST", "/scaling/services/{service_id}/scale", []string{"service_id"}, []string{"replicas"}, false})
register(spec{"containr_ha_status", "HA manager status", "GET", "/ha/status", nil, nil, false})
register(spec{"containr_ha_alerts", "Active HA alerts", "GET", "/ha/alerts/active", nil, nil, false})
register(spec{"containr_ha_failover", "Trigger a manual failover — reschedules services. Requires confirm:true. (admin)", "POST", "/ha/failover", nil, nil, true})
register(spec{"containr_security_scan", "Run a security scan. Args: project_id (required), scan_type, service_id.", "POST", "/security/scans", nil, nil, false})
register(spec{"containr_security_vulnerabilities", "List a project's vulnerabilities", "GET", "/projects/{project_id}/vulnerabilities", []string{"project_id"}, nil, false})
// Admin
register(spec{"containr_admin_overview", "Platform overview (admin)", "GET", "/admin/overview", nil, nil, false})
register(spec{"containr_admin_users", "List all users (admin)", "GET", "/admin/users", nil, nil, false})
register(spec{"containr_admin_audit_logs", "List audit log entries (admin)", "GET", "/audit-logs", nil, nil, false})
register(spec{"containr_gateway_services", "List gateway services (admin)", "GET", "/gateway/services", nil, nil, false})
register(spec{"containr_gateway_keys", "List gateway API keys (admin)", "GET", "/gateway/keys", nil, nil, false})
}
// Run starts the stdio MCP server. apiURL/token come from env or config —
// the caller resolves them before calling.
func Run(ctx context.Context, apiURL, token, version string) error {
if token == "" {
return fmt.Errorf("CONTAINR_TOKEN is required (create a cnp_ token under Settings → Personal Access Tokens)")
}
client := &commands.Client{BaseURL: apiURL, Token: token}
server := mcp.NewServer(&mcp.Implementation{
Name: "containr",
Version: version,
}, nil)
for _, s := range tools {
s := s
server.AddTool(&mcp.Tool{
Name: s.name,
Description: s.description,
InputSchema: schemaFor(s),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return call(ctx, client, s, req)
})
}
return server.Run(ctx, &mcp.StdioTransport{})
}
// schemaFor builds a JSON Schema for the tool's arguments: path params +
// body keys + confirm for destructive tools. Properties accept any JSON
// type — the API validates values.
func schemaFor(s spec) map[string]interface{} {
props := map[string]interface{}{}
var required []string
for _, p := range s.params {
props[p] = map[string]interface{}{"type": "string", "description": "path parameter"}
required = append(required, p)
}
bodySrc := s.bodyKeys
if len(bodySrc) == 0 && s.method != "GET" && s.method != "DELETE" {
// Unrestricted body: advertise nothing, accept anything.
}
for _, k := range bodySrc {
props[k] = map[string]interface{}{"description": "request body field"}
}
if s.destructive {
props["confirm"] = map[string]interface{}{"type": "boolean", "description": "must be true — this operation is destructive"}
required = append(required, "confirm")
}
schema := map[string]interface{}{
"type": "object",
"properties": props,
"additionalProperties": true,
}
if len(required) > 0 {
schema["required"] = required
}
return schema
}
func call(ctx context.Context, c *commands.Client, s spec, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args map[string]interface{}
if len(req.Params.Arguments) > 0 {
if err := json.Unmarshal(req.Params.Arguments, &args); err != nil {
return errorResult("invalid arguments: " + err.Error()), nil
}
}
if args == nil {
args = map[string]interface{}{}
}
if s.destructive && args["confirm"] != true {
return errorResult("destructive operation — pass confirm:true to proceed"), nil
}
path := s.path
for _, p := range s.params {
v, ok := args[p]
if !ok || fmt.Sprint(v) == "" {
return errorResult(fmt.Sprintf("missing required argument %q", p)), nil
}
path = strings.ReplaceAll(path, "{"+p+"}", fmt.Sprint(v))
}
var body interface{}
var query []string
for k, v := range args {
if k == "confirm" {
continue
}
inParams := false
for _, p := range s.params {
if k == p {
inParams = true
break
}
}
if inParams {
continue
}
if s.method == "GET" || s.method == "DELETE" {
// Non-path args on reads become query parameters.
query = append(query, url.QueryEscape(k)+"="+url.QueryEscape(fmt.Sprint(v)))
continue
}
if len(s.bodyKeys) > 0 {
allowed := false
for _, b := range s.bodyKeys {
if k == b {
allowed = true
break
}
}
if !allowed {
continue
}
}
if body == nil {
body = map[string]interface{}{}
}
body.(map[string]interface{})[k] = v
}
if len(query) > 0 {
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
path += sep + strings.Join(query, "&")
}
data, err := c.Do(s.method, path, body)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(data), nil
}
func textResult(data []byte) *mcp.CallToolResult {
var v interface{}
if json.Unmarshal(data, &v) != nil {
v = string(data)
}
out, _ := json.MarshalIndent(v, "", " ")
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: string(out)}},
}
}
func errorResult(msg string) *mcp.CallToolResult {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{&mcp.TextContent{Text: msg}},
}
}
@@ -0,0 +1,132 @@
package middleware
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"time"
"containr/internal/database"
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
)
// Idempotency replays POST responses for a repeated Idempotency-Key header
// so agents can safely retry mutations. The first request wins; concurrent
// duplicates get 409 IDEMPOTENCY_IN_PROGRESS; completed responses replay
// verbatim for 24h. Keys are scoped to the authenticated identity.
func Idempotency() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method != http.MethodPost {
c.Next()
return
}
key := c.GetHeader("Idempotency-Key")
if key == "" || len(key) > 128 {
c.Next()
return
}
dbValue, exists := c.Get("redis")
if !exists {
c.Next()
return
}
rdb, ok := dbValue.(*database.Redis)
if !ok || rdb == nil || rdb.Client == nil {
c.Next()
return
}
// Scope by caller identity so a stolen key can't replay across users.
actor := c.GetString("user_id")
if actor == "" {
actor = c.GetHeader("Authorization")
}
sum := sha256.Sum256([]byte(actor + ":" + c.Request.URL.Path + ":" + key))
redisKey := "idem:" + hex.EncodeToString(sum[:16])
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
stored, err := rdb.Client.Get(ctx, redisKey).Result()
switch {
case err == nil:
if stored == "processing" {
c.JSON(http.StatusConflict, gin.H{
"error": "a request with this idempotency key is still in progress",
"code": "IDEMPOTENCY_IN_PROGRESS",
})
c.Abort()
return
}
var rec idemRecord
if json.Unmarshal([]byte(stored), &rec) == nil {
c.Header("X-Idempotent-Replay", "true")
c.Data(rec.Status, rec.ContentType, rec.Body)
c.Abort()
return
}
case err != redis.Nil:
// Redis hiccup — degrade to pass-through rather than failing the request.
c.Next()
return
}
// Claim the key for the duration of the request.
if set, serr := rdb.Client.SetNX(ctx, redisKey, "processing", 2*time.Minute).Result(); serr != nil || !set {
c.JSON(http.StatusConflict, gin.H{
"error": "a request with this idempotency key is still in progress",
"code": "IDEMPOTENCY_IN_PROGRESS",
})
c.Abort()
return
}
recorder := &responseRecorder{ResponseWriter: c.Writer, status: http.StatusOK, body: &bytes.Buffer{}}
c.Writer = recorder
c.Next()
// Cache terminal responses only — 5xx is retryable and must not be replayed.
if recorder.status < 500 && recorder.body.Len() <= 1<<20 {
rec := idemRecord{
Status: recorder.status,
ContentType: recorder.Header().Get("Content-Type"),
Body: recorder.body.Bytes(),
}
if rec.ContentType == "" {
rec.ContentType = "application/json; charset=utf-8"
}
if raw, merr := json.Marshal(rec); merr == nil {
_ = rdb.Client.Set(context.Background(), redisKey, raw, 24*time.Hour).Err()
}
} else {
_ = rdb.Client.Del(context.Background(), redisKey).Err()
}
}
}
type idemRecord struct {
Status int `json:"s"`
ContentType string `json:"ct"`
Body []byte `json:"b"`
}
type responseRecorder struct {
gin.ResponseWriter
status int
body *bytes.Buffer
}
func (r *responseRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *responseRecorder) Write(b []byte) (int, error) {
r.body.Write(b)
return r.ResponseWriter.Write(b)
}
@@ -0,0 +1,60 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestIdempotencyPassThroughWithoutKey(t *testing.T) {
gin.SetMode(gin.TestMode)
called := 0
router := gin.New()
router.Use(Idempotency())
router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) })
req := httptest.NewRequest(http.MethodPost, "/x", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if called != 1 || rec.Code != 200 {
t.Fatalf("expected pass-through, called=%d code=%d", called, rec.Code)
}
}
func TestIdempotencyPassThroughWithoutRedis(t *testing.T) {
gin.SetMode(gin.TestMode)
called := 0
router := gin.New()
router.Use(Idempotency())
router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) })
// Key present but no redis in context — must not block the request.
req := httptest.NewRequest(http.MethodPost, "/x", nil)
req.Header.Set("Idempotency-Key", "abc-123")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if called != 1 || rec.Code != 200 {
t.Fatalf("expected pass-through without redis, called=%d code=%d", called, rec.Code)
}
}
func TestIdempotencySkipsNonPOST(t *testing.T) {
gin.SetMode(gin.TestMode)
called := 0
router := gin.New()
router.Use(Idempotency())
router.GET("/x", func(c *gin.Context) { called++; c.String(200, "ok") })
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Idempotency-Key", "abc")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if called != 1 {
t.Fatalf("GET should bypass idempotency, called=%d", called)
}
}
+80 -6
View File
@@ -2,7 +2,10 @@ package middleware
import (
"containr/internal/database"
sqlcdb "containr/internal/database/sqlcdb"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -75,6 +78,14 @@ func RequestID() gin.HandlerFunc {
func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) {
tokenString, tokenErr, hasToken := extractJWTToken(c)
if tokenString != "" {
// Personal access tokens (cnp_…) are not JWTs — they resolve
// against the user_tokens table by sha256 hash.
if strings.HasPrefix(tokenString, patPrefix) {
if id, mail, verified := resolvePersonalAccessToken(c, tokenString); verified {
return id, mail, "", hasToken, true
}
return "", "", "Invalid token", hasToken, false
}
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
userIDClaim, exists := claims["user_id"]
if exists {
@@ -101,12 +112,67 @@ func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *bette
return "", "", tokenErr, hasToken, false
}
const patPrefix = "cnp_"
// resolvePersonalAccessToken authenticates a `cnp_` bearer token against the
// user_tokens table (sha256 hash match, not revoked, not expired). On success
// it stamps last_used_at, records the token scope on the context, and
// returns the owner's id and email.
func resolvePersonalAccessToken(c *gin.Context, token string) (string, string, bool) {
dbValue, exists := c.Get("db")
if !exists {
return "", "", false
}
db, ok := dbValue.(*database.DB)
if !ok || db == nil || db.DB == nil {
return "", "", false
}
sum := sha256.Sum256([]byte(token))
queries := sqlcdb.New(db.DB)
row, err := queries.GetUserTokenByHash(c.Request.Context(), hex.EncodeToString(sum[:]))
if err != nil {
return "", "", false
}
var email string
if err := db.QueryRowContext(c.Request.Context(),
`SELECT email FROM users WHERE id = $1`, row.UserID).Scan(&email); err != nil {
return "", "", false
}
_ = queries.TouchUserToken(c.Request.Context(), row.ID)
c.Set("pat_scope", row.Scope)
return row.UserID.String(), email, true
}
// patScopeAllows reports whether a PAT-authenticated request may proceed.
// read scope permits only safe methods; write and admin are unrestricted at
// this layer (admin routes still require the admin scope AND a platform
// admin account — enforced via is_admin in setAuthenticatedContext).
func patScopeAllows(c *gin.Context) bool {
if c.GetString("pat_scope") != "read" {
return true
}
switch c.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
return false
}
// setAuthenticatedContext stores the resolved identity plus the admin flag on
// the gin context so handlers and RequireAdmin can rely on it.
func setAuthenticatedContext(c *gin.Context, userID, email string) {
c.Set("user_id", userID)
c.Set("email", email)
c.Set("is_admin", loadIsAdmin(c, userID))
isAdmin := loadIsAdmin(c, userID)
// A PAT can never outrank its scope: non-admin scopes cap the token at
// non-admin even when the owning user is a platform admin.
if scope := c.GetString("pat_scope"); scope != "" && scope != "admin" {
isAdmin = false
}
c.Set("is_admin", isAdmin)
}
func loadIsAdmin(c *gin.Context, userID string) bool {
@@ -133,20 +199,28 @@ func Auth(jwtSecret string) gin.HandlerFunc {
userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
if ok {
setAuthenticatedContext(c, userID, email)
if !patScopeAllows(c) {
c.JSON(http.StatusForbidden, gin.H{
"error": "Token scope is read-only",
"code": "SCOPE_INSUFFICIENT",
})
c.Abort()
return
}
c.Next()
return
}
if tokenErr != "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr})
c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr, "code": "INVALID_TOKEN"})
c.Abort()
return
}
if hasToken {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token", "code": "INVALID_TOKEN"})
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"})
}
c.Abort()
}
@@ -171,12 +245,12 @@ func OptionalAuth(jwtSecret string) gin.HandlerFunc {
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
if _, exists := c.Get("user_id"); !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"})
c.Abort()
return
}
if !c.GetBool("is_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required", "code": "ADMIN_REQUIRED"})
c.Abort()
return
}
@@ -131,6 +131,61 @@ func TestAuthStoresStringUserIDForValidClaims(t *testing.T) {
}
}
func TestAuthRejectsPATWithoutDatabase(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(Auth("secret"))
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
// A cnp_-prefixed token takes the PAT path, not JWT parsing — without a
// db handle it must fail closed with 401.
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer cnp_"+strings.Repeat("ab", 24))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
}
}
func TestPATScopeAllows(t *testing.T) {
gin.SetMode(gin.TestMode)
cases := []struct {
name string
scope string
method string
want bool
}{
{"read allows GET", "read", http.MethodGet, true},
{"read allows HEAD", "read", http.MethodHead, true},
{"read allows OPTIONS", "read", http.MethodOptions, true},
{"read blocks POST", "read", http.MethodPost, false},
{"read blocks DELETE", "read", http.MethodDelete, false},
{"write allows POST", "write", http.MethodPost, true},
{"write allows DELETE", "write", http.MethodDelete, true},
{"admin allows PATCH", "admin", http.MethodPatch, true},
{"no scope (session/JWT) unrestricted", "", http.MethodPost, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(tc.method, "/x", nil)
if tc.scope != "" {
c.Set("pat_scope", tc.scope)
}
if got := patScopeAllows(c); got != tc.want {
t.Fatalf("patScopeAllows(scope=%q, method=%s) = %v, want %v", tc.scope, tc.method, got, tc.want)
}
})
}
}
func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+98
View File
@@ -0,0 +1,98 @@
package secrets
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"log"
"strings"
"sync"
)
// Prefix marks AES-GCM-encrypted values in environment_variables.value.
// Plaintext rows keep working — Decrypt returns them unchanged, so the
// rollout is transparent encrypt-on-write / decrypt-on-read.
const Prefix = "enc:v1:"
var (
keyOnce sync.Once
key []byte
enabled bool
)
// Configure derives the AES-256 key. SECRETS_KEY wins; JWT_SECRET is the
// fallback so existing installs need no new config. With neither, encrypt
// degrades to plaintext with a single warning.
func Configure(secretsKey, jwtSecret string) {
keyOnce.Do(func() {
seed := secretsKey
if seed == "" {
seed = jwtSecret
}
if seed == "" {
log.Printf("secrets: no SECRETS_KEY or JWT_SECRET — secret variables stay plaintext")
return
}
sum := sha256.Sum256([]byte("containr-env-secrets:" + seed))
key = sum[:]
enabled = true
})
}
func IsEncrypted(value string) bool {
return strings.HasPrefix(value, Prefix)
}
// Encrypt returns value unchanged if disabled or already encrypted.
func Encrypt(value string) string {
if !enabled || IsEncrypted(value) {
return value
}
block, err := aes.NewCipher(key)
if err != nil {
return value
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return value
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return value
}
sealed := gcm.Seal(nonce, nonce, []byte(value), nil)
return Prefix + base64.StdEncoding.EncodeToString(sealed)
}
// Decrypt returns plaintext for enc:v1 values, the input otherwise.
// Corrupt ciphertext fails soft to the stored value rather than erroring
// the deploy path — an unreadable secret is no worse than a plaintext one.
func Decrypt(value string) string {
if !enabled || !IsEncrypted(value) {
return value
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, Prefix))
if err != nil {
return value
}
block, err := aes.NewCipher(key)
if err != nil {
return value
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return value
}
if len(raw) < gcm.NonceSize() {
return value
}
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
if err != nil {
log.Printf("secrets: decrypt failed (%v) — check SECRETS_KEY/JWT_SECRET rotation", fmt.Errorf("%w", err))
return value
}
return string(plain)
}
@@ -0,0 +1,63 @@
package secrets
import (
"sync"
"testing"
)
// resetForTest restores the package state so Configure can be re-run.
func resetForTest() {
keyOnce = sync.Once{}
key = nil
enabled = false
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
resetForTest()
Configure("test-secrets-key", "")
enc := Encrypt("super-secret-value")
if !IsEncrypted(enc) {
t.Fatalf("expected encrypted prefix, got %q", enc)
}
if enc == "super-secret-value" {
t.Fatal("value stored as plaintext")
}
if got := Decrypt(enc); got != "super-secret-value" {
t.Fatalf("decrypt = %q", got)
}
}
func TestPlaintextPassThrough(t *testing.T) {
resetForTest()
Configure("test-secrets-key", "")
if got := Decrypt("legacy-plaintext"); got != "legacy-plaintext" {
t.Fatalf("legacy plaintext changed: %q", got)
}
}
func TestEncryptIsIdempotent(t *testing.T) {
resetForTest()
Configure("test-secrets-key", "")
enc := Encrypt("v")
if got := Encrypt(enc); got != enc {
t.Fatal("double-encrypt changed the value")
}
}
func TestDecryptFailsSoftOnCorruption(t *testing.T) {
resetForTest()
Configure("test-secrets-key", "")
corrupt := Prefix + "not-valid-base64!!!"
if got := Decrypt(corrupt); got != corrupt {
t.Fatal("corrupt ciphertext should pass through unchanged")
}
}
func TestDisabledWithoutKey(t *testing.T) {
resetForTest()
Configure("", "")
if got := Encrypt("v"); got != "v" {
t.Fatal("encrypt should pass through when no key configured")
}
}
+5
View File
@@ -28,6 +28,11 @@ type BuildRequest struct {
TriggeredBy string `json:"triggered_by"` // Who triggered the build
Branch string `json:"branch"` // Git branch
Commit string `json:"commit"` // Git commit SHA
NoCache bool `json:"no_cache"` // Disable Docker layer cache
// Registry credentials for pulling a private prebuilt image.
PullUsername string `json:"pull_username,omitempty"`
PullPassword string `json:"pull_password,omitempty"`
}
// BuildResponse represents the response from a build operation
@@ -0,0 +1,18 @@
-- +goose Up
-- Persisted HA health checks. Previously checks lived only in memory, so a
-- backend restart silently deleted every configured check while services kept
-- running unmonitored.
CREATE TABLE IF NOT EXISTS ha_health_checks (
id uuid PRIMARY KEY,
service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE,
node_id varchar(255) NOT NULL DEFAULT '',
type varchar(20) NOT NULL,
config jsonb NOT NULL DEFAULT '{}'::jsonb,
status varchar(20) NOT NULL DEFAULT 'unknown',
last_check timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE IF EXISTS ha_health_checks;
@@ -0,0 +1,25 @@
-- +goose Up
-- Personal access tokens for CLI/MCP/agent authentication.
-- Only sha256 hashes are stored — raw tokens (cnp_…) are shown once at
-- creation and never persisted. Scope gates what the token may do:
-- read = GET/HEAD only, write = all non-admin routes, admin = everything
-- (admin still requires the owning user to be a platform admin).
CREATE TABLE IF NOT EXISTS public.user_tokens (
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
name character varying(255) NOT NULL,
key_prefix character varying(16) NOT NULL,
token_hash text NOT NULL UNIQUE,
scope character varying(20) NOT NULL DEFAULT 'write'
CHECK (scope IN ('read', 'write', 'admin')),
expires_at timestamp with time zone,
last_used_at timestamp with time zone,
revoked_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_tokens_user_id ON public.user_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_user_tokens_token_hash ON public.user_tokens(token_hash);
-- +goose Down
DROP TABLE IF EXISTS public.user_tokens;
@@ -0,0 +1,13 @@
-- +goose Up
-- Volume mounts on services: [{type: "volume"|"bind", source, target,
-- read_only}]. Applied to every replica's container config at deploy and
-- reconcile time. The baseline schema declared the column nullable with
-- no default — this normalizes it.
ALTER TABLE services ADD COLUMN IF NOT EXISTS volumes JSONB DEFAULT '[]'::jsonb;
UPDATE services SET volumes = '[]'::jsonb WHERE volumes IS NULL;
ALTER TABLE services ALTER COLUMN volumes SET DEFAULT '[]'::jsonb;
ALTER TABLE services ALTER COLUMN volumes SET NOT NULL;
-- +goose Down
ALTER TABLE services ALTER COLUMN volumes DROP NOT NULL;
ALTER TABLE services ALTER COLUMN volumes DROP DEFAULT;
@@ -0,0 +1,30 @@
-- +goose Up
-- Multi-domain support. services.domain stays as the derived default for
-- compatibility; service_domains is the source of truth for the full
-- hostname set. Backfills existing services.domain rows as defaults.
CREATE TABLE IF NOT EXISTS service_domains (
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE,
domain varchar(255) NOT NULL,
is_default boolean NOT NULL DEFAULT false,
cert_type varchar(32) NOT NULL DEFAULT 'letsencrypt',
cert_status varchar(32) NOT NULL DEFAULT 'pending',
last_checked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (service_id, domain)
);
CREATE INDEX IF NOT EXISTS idx_service_domains_service ON service_domains(service_id);
INSERT INTO service_domains (service_id, domain, is_default)
SELECT id, domain, true FROM services WHERE domain <> '' AND domain IS NOT NULL
ON CONFLICT (service_id, domain) DO NOTHING;
-- Access control: maintenance mode redirects to a platform page; basic auth
-- gates the route behind htpasswd-format user:hash pairs (Traefik middleware).
ALTER TABLE services ADD COLUMN IF NOT EXISTS maintenance_mode boolean NOT NULL DEFAULT false;
ALTER TABLE services ADD COLUMN IF NOT EXISTS basic_auth_users text NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE services DROP COLUMN IF EXISTS maintenance_mode;
ALTER TABLE services DROP COLUMN IF EXISTS basic_auth_users;
DROP TABLE IF EXISTS service_domains;
@@ -0,0 +1,17 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE services ADD COLUMN IF NOT EXISTS builder varchar(32) NOT NULL DEFAULT 'auto';
ALTER TABLE services ADD COLUMN IF NOT EXISTS cpu_reserve varchar(20) NOT NULL DEFAULT '';
ALTER TABLE services ADD COLUMN IF NOT EXISTS memory_reserve varchar(20) NOT NULL DEFAULT '';
ALTER TABLE services ADD COLUMN IF NOT EXISTS static_build_cmd varchar(255) NOT NULL DEFAULT '';
ALTER TABLE services ADD COLUMN IF NOT EXISTS static_dir varchar(255) NOT NULL DEFAULT '';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE services DROP COLUMN IF EXISTS static_dir;
ALTER TABLE services DROP COLUMN IF EXISTS static_build_cmd;
ALTER TABLE services DROP COLUMN IF EXISTS memory_reserve;
ALTER TABLE services DROP COLUMN IF EXISTS cpu_reserve;
ALTER TABLE services DROP COLUMN IF EXISTS builder;
-- +goose StatementEnd
@@ -0,0 +1,20 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS registries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
host VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (owner_id, host)
);
CREATE INDEX IF NOT EXISTS idx_registries_owner ON registries(owner_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS registries;
-- +goose StatementEnd
@@ -0,0 +1,19 @@
-- +goose Up
-- Project-level shared variables (Railway-style). Services reference them via
-- ${{shared.KEY}} in environment values; secret entries are encrypted at rest
-- and masked in API responses exactly like service variables.
CREATE TABLE IF NOT EXISTS project_variables (
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
key varchar(255) NOT NULL,
value text NOT NULL DEFAULT '',
is_secret boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (project_id, key)
);
CREATE INDEX IF NOT EXISTS idx_project_variables_project ON project_variables(project_id);
-- +goose Down
DROP TABLE IF EXISTS project_variables;
+24
View File
@@ -0,0 +1,24 @@
-- name: CreateUserToken :one
INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: ListUserTokens :many
SELECT * FROM user_tokens
WHERE user_id = $1 AND revoked_at IS NULL
ORDER BY created_at DESC;
-- name: GetUserTokenByHash :one
SELECT * FROM user_tokens
WHERE token_hash = $1
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
LIMIT 1;
-- name: TouchUserToken :exec
UPDATE user_tokens SET last_used_at = NOW()
WHERE id = $1 AND revoked_at IS NULL;
-- name: RevokeUserToken :execrows
UPDATE user_tokens SET revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL;
+52 -1
View File
@@ -52,7 +52,45 @@ CREATE TABLE services (
git_branch VARCHAR(100),
build_path VARCHAR(500),
cpu VARCHAR(50),
memory VARCHAR(50)
memory VARCHAR(50),
replicas INTEGER NOT NULL DEFAULT 1,
port INTEGER NOT NULL DEFAULT 0,
domain VARCHAR(255) NOT NULL DEFAULT '',
healthcheck_path VARCHAR(255) NOT NULL DEFAULT '',
restart_policy VARCHAR(50) NOT NULL DEFAULT 'unless-stopped',
published_port INTEGER NOT NULL DEFAULT 0,
volumes JSONB NOT NULL DEFAULT '[]'::jsonb,
maintenance_mode BOOLEAN NOT NULL DEFAULT false,
basic_auth_users TEXT NOT NULL DEFAULT '',
builder VARCHAR(32) NOT NULL DEFAULT 'auto',
cpu_reserve VARCHAR(20) NOT NULL DEFAULT '',
memory_reserve VARCHAR(20) NOT NULL DEFAULT '',
static_build_cmd VARCHAR(255) NOT NULL DEFAULT '',
static_dir VARCHAR(255) NOT NULL DEFAULT ''
);
CREATE TABLE registries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
host VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (owner_id, host)
);
CREATE TABLE service_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
domain VARCHAR(255) NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT false,
cert_type VARCHAR(32) NOT NULL DEFAULT 'letsencrypt',
cert_status VARCHAR(32) NOT NULL DEFAULT 'pending',
last_checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (service_id, domain)
);
CREATE TABLE deployments (
@@ -198,3 +236,16 @@ CREATE TABLE notifications (
read_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE user_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
key_prefix VARCHAR(16) NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
scope VARCHAR(20) NOT NULL DEFAULT 'write',
expires_at TIMESTAMP WITH TIME ZONE,
last_used_at TIMESTAMP WITH TIME ZONE,
revoked_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
@@ -17,6 +17,13 @@ import {
createAgentToken,
listAgentTokens,
revokeAgentToken,
createUserToken,
listUserTokens,
revokeUserToken,
listRegistries,
createRegistry,
deleteRegistry,
type Registry,
listDatabases,
databaseAction,
updateDatabaseBackupSchedule,
@@ -46,6 +53,8 @@ import {
updateVulnerability,
getSecurityMetrics,
type AgentAuthTokenCreated,
type UserTokenCreated,
type UserTokenScope,
type CreateDatabaseInput,
type DatabaseEntity,
type FailoverPolicy,
@@ -1267,6 +1276,293 @@ function PlatformSettingsSection() {
);
}
const PAT_EXPIRY_OPTIONS: { label: string; days: number }[] = [
{ label: 'Never', days: 0 },
{ label: '30 days', days: 30 },
{ label: '90 days', days: 90 },
{ label: '1 year', days: 365 },
];
function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
const queryClient = useQueryClient();
const tokensQuery = useQuery({
queryKey: ['user-tokens'],
queryFn: listUserTokens,
});
const [tokenName, setTokenName] = useState('');
const [tokenScope, setTokenScope] = useState<UserTokenScope>('write');
const [expiryDays, setExpiryDays] = useState(0);
const [issuedToken, setIssuedToken] = useState<UserTokenCreated | null>(null);
const createMutation = useMutation({
mutationFn: () =>
createUserToken({
name: tokenName.trim(),
scope: tokenScope,
expiresInDays: expiryDays,
}),
onSuccess: (data) => {
setIssuedToken(data);
setTokenName('');
queryClient.invalidateQueries({ queryKey: ['user-tokens'] });
},
});
const revokeMutation = useMutation({
mutationFn: (id: string) => revokeUserToken(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['user-tokens'] }),
});
const tokens = tokensQuery.data ?? [];
const scopeOptions: { value: UserTokenScope; label: string }[] = isAdmin
? [
{ value: 'read', label: 'Read - GET requests only' },
{ value: 'write', label: 'Write - read and manage resources' },
{ value: 'admin', label: 'Admin - everything, incl. platform settings' },
]
: [
{ value: 'read', label: 'Read - GET requests only' },
{ value: 'write', label: 'Write - read and manage resources' },
];
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">
<Key size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Personal Access Tokens</h2>
<p className="text-xs text-[var(--text-tertiary)]">Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope</p>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<input
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
placeholder="Token name (e.g. laptop cli)"
className="h-9 flex-1 min-w-[180px] 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"
/>
<select
value={tokenScope}
onChange={(e) => setTokenScope(e.target.value as UserTokenScope)}
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
>
{scopeOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<select
value={expiryDays}
onChange={(e) => setExpiryDays(Number(e.target.value))}
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
>
{PAT_EXPIRY_OPTIONS.map((option) => (
<option key={option.days} value={option.days}>{option.label}</option>
))}
</select>
<button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !tokenName.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 ? 'Creating…' : 'Create token'}
</button>
</div>
{issuedToken ? (
<div className="rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
<div className="flex items-center justify-between gap-2">
<span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
<button
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
>
<Copy size={11} />
Copy
</button>
</div>
<p className="mt-1 text-xs text-[var(--text-tertiary)]">
Shown once - store it now. Use with <span className="mono">containr auth login --token</span> or as a Bearer token.
</p>
</div>
) : null}
{createMutation.isError ? (
<p className="text-xs text-[var(--error)]">
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create token'}
</p>
) : null}
{tokensQuery.isLoading ? (
<div className="py-4 text-center">
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : tokens.length === 0 ? (
<p className="text-xs text-[var(--text-tertiary)]">No tokens yet. Create one to connect the CLI or an agent.</p>
) : (
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
{tokens.map((token) => (
<div key={token.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0">
<span className="text-[var(--text-primary)]">{token.name}</span>
<span className="ml-2 mono text-[var(--text-muted)]">{token.key_prefix}…</span>
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">{token.scope}</span>
<span className="ml-2 text-[var(--text-muted)]">
{token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'}
{token.expires_at ? ` · expires ${formatRelative(token.expires_at)}` : ''}
</span>
</div>
<button
onClick={() => {
if (window.confirm(`Revoke token "${token.name}"? Clients using it lose access immediately.`)) {
revokeMutation.mutate(token.id ?? '');
}
}}
disabled={revokeMutation.isPending}
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
>
Revoke
</button>
</div>
))}
</div>
)}
</div>
</section>
);
}
function RegistriesSection() {
const queryClient = useQueryClient();
const registriesQuery = useQuery({
queryKey: ['registries'],
queryFn: listRegistries,
});
const [name, setName] = useState('');
const [host, setHost] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const createMutation = useMutation({
mutationFn: () =>
createRegistry({
name: name.trim(),
host: host.trim(),
username: username.trim() || undefined,
password: password || undefined,
}),
onSuccess: () => {
setName('');
setHost('');
setUsername('');
setPassword('');
queryClient.invalidateQueries({ queryKey: ['registries'] });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteRegistry(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['registries'] }),
});
const registries = registriesQuery.data ?? [];
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">
<Archive size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Registry credentials</h2>
<p className="text-xs text-[var(--text-tertiary)]">Pull private images on deploy - matched by image host, stored encrypted</p>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name (e.g. ghcr)"
className="h-9 flex-1 min-w-[120px] 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={host}
onChange={(e) => setHost(e.target.value)}
placeholder="Host (ghcr.io, docker.io)"
className="h-9 flex-1 min-w-[160px] 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={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
className="h-9 w-40 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
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password / token"
className="h-9 w-44 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"
/>
<button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !name.trim() || !host.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 ? 'Adding…' : 'Add registry'}
</button>
</div>
{createMutation.isError ? (
<p className="text-xs text-[var(--error)]">
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add registry'}
</p>
) : null}
{registriesQuery.isLoading ? (
<div className="py-4 text-center">
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : registries.length === 0 ? (
<p className="text-xs text-[var(--text-tertiary)]">
No credentials. Image pulls are anonymous unless a registry matching the image host exists here.
</p>
) : (
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
{registries.map((r: Registry) => (
<div key={r.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0">
<span className="text-[var(--text-primary)]">{r.name}</span>
<span className="ml-2 mono text-[var(--text-muted)]">{r.host}</span>
{r.username ? <span className="ml-2 text-[var(--text-secondary)]">{r.username}</span> : null}
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
{r.has_password ? 'password set' : 'anonymous'}
</span>
</div>
<button
onClick={() => {
if (window.confirm(`Remove credentials for ${r.host}?`)) {
deleteMutation.mutate(r.id);
}
}}
disabled={deleteMutation.isPending}
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
>
Remove
</button>
</div>
))}
</div>
)}
</div>
</section>
);
}
export function SettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -1438,6 +1734,10 @@ export function SettingsPage() {
)}
</section>
{/* Personal Access Tokens */}
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
<RegistriesSection />
{/* Platform Settings - owner only */}
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
@@ -28,12 +28,24 @@ import {
manualScaleService,
execInService,
rollbackDeployment,
cancelDeployment,
updateServiceVariables,
updateService,
restartService,
stopService,
getServiceRuntime,
type CronJobEntity,
type ServiceVolume,
type ServiceDomain,
type DomainCheckResult,
listServiceDomains,
addServiceDomain,
removeServiceDomain,
setDefaultServiceDomain,
checkServiceDomains,
cloneService,
moveService,
listProjects,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
import { useDemoMode } from '@/lib/demo-mode';
@@ -66,6 +78,7 @@ import {
Sparkles,
RefreshCw,
Box,
Ban,
Play,
ChevronDown,
GitPullRequest,
@@ -104,7 +117,9 @@ function StatusBadge({ status }: { status: string }) {
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false },
building: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
pending: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
queued: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Clock, animate: false },
rolling_back: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: RefreshCw, animate: true },
cancelled: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Ban, animate: false },
stopped: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false },
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false };
@@ -374,6 +389,14 @@ export function ServiceDetailPage() {
},
});
const cancelDeploymentMutation = useMutation({
mutationFn: (deploymentId: string) => cancelDeployment(deploymentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['service-deployments', serviceId] });
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
},
});
const rollbackMutation = useMutation({
mutationFn: (deploymentId: string) => rollbackDeployment(deploymentId),
onSuccess: () => {
@@ -392,6 +415,36 @@ export function ServiceDetailPage() {
},
});
const projectsQuery = useQuery({
queryKey: ['projects'],
queryFn: () => listProjects({ limit: 100 }),
enabled: !isDemoMode && activeSection === 'settings',
});
const cloneMutation = useMutation({
mutationFn: (input: { name?: string; project_id?: string }) => cloneService(serviceId, input),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['project-services'] });
const targetProject = lifecycleForm?.cloneProject || projectId;
navigate(`/projects/${targetProject}/services/${data.service_id}`);
},
});
const moveMutation = useMutation({
mutationFn: (targetProjectId: string) => moveService(serviceId, targetProjectId),
onSuccess: (_data, targetProjectId) => {
queryClient.invalidateQueries({ queryKey: ['project-services'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
navigate(`/projects/${targetProjectId}/services/${serviceId}`);
},
});
const [lifecycleForm, setLifecycleForm] = useState<{
cloneName: string;
cloneProject: string;
moveTarget: string;
} | null>(null);
const invalidateService = () => {
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
@@ -424,6 +477,27 @@ export function ServiceDetailPage() {
const [networkForm, setNetworkForm] = useState<{
port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string;
} | null>(null);
const [volumesForm, setVolumesForm] = useState<ServiceVolume[] | null>(null);
const [domainInput, setDomainInput] = useState('');
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
const [buildForm, setBuildForm] = useState<{
builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string;
} | null>(null);
const domainsQuery = useQuery({
queryKey: ['service-domains', serviceId],
queryFn: () => listServiceDomains(serviceId),
enabled: Boolean(serviceId) && !isDemoMode,
});
const domainMutation = useMutation({
mutationFn: (fn: () => Promise<unknown>) => fn(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['service-domains', serviceId] });
invalidateService();
},
});
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
@@ -514,7 +588,7 @@ export function ServiceDetailPage() {
const deployments = isDemoMode ? [] : deploymentsQuery.data ?? [];
const total = deployments.length;
const active = deployments.filter((deployment) =>
['pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status),
['queued', 'pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status),
).length;
const failed = deployments.filter((deployment) => deployment.status === 'failed').length;
@@ -1181,8 +1255,9 @@ export function ServiceDetailPage() {
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
<p className="text-sm text-[var(--text-secondary)]">
Runtime values injected at deploy. Reference another service's variable with{' '}
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> — e.g.{' '}
<code className="mono text-[var(--accent-primary)]">{'${{postgres.POSTGRES_PASSWORD}}'}</code>
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> or a project
shared variable with{' '}
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>
</p>
</div>
</div>
@@ -2054,6 +2129,442 @@ export function ServiceDetailPage() {
</div>
)}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Volumes</h3>
{volumesForm === null && (
<button
type="button"
onClick={() => setVolumesForm([...(service.volumes ?? [])])}
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"
>
Edit
</button>
)}
</div>
{volumesForm === null ? (
(service.volumes ?? []).length === 0 ? (
<p className="text-xs text-[var(--text-tertiary)]">No volume mounts configured.</p>
) : (
<div className="space-y-2">
{(service.volumes ?? []).map((v, i) => (
<div key={i} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 py-2 text-xs">
<span className="px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-tertiary)] uppercase tracking-wide">{v.type || 'volume'}</span>
<span className="mono text-[var(--text-primary)]">{v.source}</span>
<span className="text-[var(--text-tertiary)]">→</span>
<span className="mono text-[var(--text-primary)]">{v.target}</span>
{v.read_only && <span className="text-[var(--text-tertiary)]">(read-only)</span>}
</div>
))}
</div>
)
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-3">
{volumesForm.map((v, i) => (
<div key={i} className="grid grid-cols-[80px_1fr_1fr_auto_auto] items-center gap-2">
<select
value={v.type || 'volume'}
onChange={(e) => {
const next = [...volumesForm];
next[i] = { ...v, type: e.target.value as 'volume' | 'bind' };
setVolumesForm(next);
}}
className="px-2 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)]"
>
<option value="volume">volume</option>
<option value="bind">bind</option>
</select>
<input
value={v.source}
onChange={(e) => {
const next = [...volumesForm];
next[i] = { ...v, source: e.target.value };
setVolumesForm(next);
}}
placeholder={v.type === 'bind' ? '/host/path' : 'volume-name'}
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-xs mono text-[var(--text-primary)]"
/>
<input
value={v.target}
onChange={(e) => {
const next = [...volumesForm];
next[i] = { ...v, target: e.target.value };
setVolumesForm(next);
}}
placeholder="/container/path"
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-xs mono text-[var(--text-primary)]"
/>
<label className="flex items-center gap-1 text-[10px] text-[var(--text-tertiary)]">
<input
type="checkbox"
checked={Boolean(v.read_only)}
onChange={(e) => {
const next = [...volumesForm];
next[i] = { ...v, read_only: e.target.checked };
setVolumesForm(next);
}}
/>
ro
</label>
<button
type="button"
onClick={() => setVolumesForm(volumesForm.filter((_, j) => j !== i))}
className="px-2 py-1 text-xs text-[var(--error)]"
>
<X size={12} />
</button>
</div>
))}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setVolumesForm([...volumesForm, { type: 'volume', source: '', target: '', read_only: false }])}
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"
>
Add mount
</button>
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() =>
updateServiceMutation.mutate(
{
volumes: volumesForm
.filter((v) => v.source.trim() && v.target.startsWith('/'))
.map((v) => ({ type: v.type ?? 'volume', source: v.source, target: v.target, read_only: v.read_only ?? false })),
},
{ onSuccess: () => setVolumesForm(null) },
)
}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setVolumesForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
<p className="text-[10px] text-[var(--text-tertiary)]">Replaces all mounts; applies on next deploy or redeploy</p>
</div>
{updateServiceMutation.isError && (
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
)}
</div>
)}
</div>
)}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Domains</h3>
<button
type="button"
disabled={domainMutation.isPending}
onClick={async () => {
const results = await checkServiceDomains(serviceId);
setDomainChecks(Object.fromEntries(results.map((r) => [r.domain, r])));
}}
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"
>
Check DNS
</button>
</div>
<div className="space-y-2 mb-3">
{(domainsQuery.data ?? []).length === 0 && !domainsQuery.isLoading && (
<p className="text-xs text-[var(--text-tertiary)]">No domains attached.</p>
)}
{(domainsQuery.data ?? []).map((d: ServiceDomain) => {
const check = domainChecks?.[d.domain];
return (
<div key={d.id} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 py-2 text-xs">
<span className="mono text-[var(--text-primary)]">{d.domain}</span>
{d.is_default && <span className="px-1.5 py-0.5 rounded bg-[var(--accent-primary)]/15 text-[var(--accent-primary)]">default</span>}
{check && (
<span className={`px-1.5 py-0.5 rounded ${check.status === 'ok' ? 'bg-[var(--success-soft)] text-[var(--success)]' : check.status === 'pending' ? 'bg-[var(--warning-soft)] text-[var(--warning)]' : 'bg-[var(--error-soft)] text-[var(--error)]'}`}>
{check.status}
</span>
)}
<span className="flex-1" />
{!d.is_default && (
<button
type="button"
onClick={() => domainMutation.mutate(() => setDefaultServiceDomain(serviceId, d.id))}
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
>
set default
</button>
)}
<button
type="button"
onClick={() => domainMutation.mutate(() => removeServiceDomain(serviceId, d.id))}
className="text-[var(--error)]"
>
<X size={12} />
</button>
</div>
);
})}
</div>
<div className="flex items-center gap-2">
<input
value={domainInput}
onChange={(e) => setDomainInput(e.target.value)}
placeholder="app.example.com"
className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
<button
type="button"
disabled={domainMutation.isPending || !domainInput.trim()}
onClick={() =>
domainMutation.mutate(() => addServiceDomain(serviceId, domainInput.trim()), {
onSuccess: () => setDomainInput(''),
})
}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
Add
</button>
</div>
{domainMutation.isError && (
<p className="mt-2 text-xs text-[var(--error)]">{(domainMutation.error as Error).message}</p>
)}
<p className="mt-2 text-[10px] text-[var(--text-tertiary)]">Point the domain at this node (A record or CNAME), then Check DNS. Applies on next deploy or redeploy.</p>
</div>
)}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Access</h3>
{accessForm === null && (
<button
type="button"
onClick={() =>
setAccessForm({ maintenance: Boolean(service.maintenanceMode), basicAuth: '' })
}
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"
>
Edit
</button>
)}
</div>
{accessForm === null ? (
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Maintenance mode</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.maintenanceMode ? 'on' : 'off'}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Basic auth</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{(service.basicAuth ?? []).length > 0 ? (service.basicAuth ?? []).join(', ') : 'off'}
</p>
</div>
</div>
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
<input
type="checkbox"
checked={accessForm.maintenance}
onChange={(e) => setAccessForm({ ...accessForm, maintenance: e.target.checked })}
/>
Maintenance mode — redirect all traffic to the maintenance page
</label>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Basic auth credentials (user:password per line — replaces all; empty clears)</label>
<textarea
value={accessForm.basicAuth}
onChange={(e) => setAccessForm({ ...accessForm, basicAuth: e.target.value })}
rows={2}
placeholder="admin:s3cret"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() => {
const creds = accessForm.basicAuth
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
const i = l.indexOf(':');
return i > 0 ? { username: l.slice(0, i), password: l.slice(i + 1) } : null;
})
.filter((x): x is { username: string; password: string } => x !== null);
updateServiceMutation.mutate(
{ maintenance_mode: accessForm.maintenance, basic_auth: creds },
{ onSuccess: () => setAccessForm(null) },
);
}}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setAccessForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
<p className="text-[10px] text-[var(--text-tertiary)]">Applies on next deploy or redeploy</p>
</div>
</div>
)}
</div>
)}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Build &amp; Resources</h3>
{buildForm === null && (
<button
type="button"
onClick={() =>
setBuildForm({
builder: service.builder ?? 'auto',
cpuReserve: service.cpuReserve ?? '',
memoryReserve: service.memoryReserve ?? '',
staticCmd: service.staticBuildCmd ?? '',
staticDir: service.staticDir ?? '',
})
}
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"
>
Edit
</button>
)}
</div>
{buildForm === null ? (
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Builder</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.builder ?? 'auto'}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Reservations</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{[service.cpuReserve && `cpu ${service.cpuReserve}`, service.memoryReserve && `mem ${service.memoryReserve}`]
.filter(Boolean).join(' · ') || 'none'}
</p>
</div>
{service.builder === 'static' && (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3 col-span-2">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Static build</p>
<p className="mt-1 text-sm font-medium mono text-[var(--text-primary)]">
{service.staticBuildCmd || 'npm ci && npm run build'} → {service.staticDir || 'dist'}
</p>
</div>
)}
</div>
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Builder</label>
<select
value={buildForm.builder}
onChange={(e) => setBuildForm({ ...buildForm, builder: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
{['auto', 'railpack', 'nixpacks', 'dockerfile', 'static'].map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">CPU reservation (e.g. 0.25)</label>
<input
value={buildForm.cpuReserve}
onChange={(e) => setBuildForm({ ...buildForm, cpuReserve: e.target.value })}
placeholder="0.25"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Memory reservation (e.g. 128Mi)</label>
<input
value={buildForm.memoryReserve}
onChange={(e) => setBuildForm({ ...buildForm, memoryReserve: e.target.value })}
placeholder="128Mi"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
</div>
{buildForm.builder === 'static' && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Build command</label>
<input
value={buildForm.staticCmd}
onChange={(e) => setBuildForm({ ...buildForm, staticCmd: e.target.value })}
placeholder="npm ci && npm run build"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Output directory</label>
<input
value={buildForm.staticDir}
onChange={(e) => setBuildForm({ ...buildForm, staticDir: e.target.value })}
placeholder="dist"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() => {
updateServiceMutation.mutate(
{
builder: buildForm.builder as 'auto' | 'railpack' | 'nixpacks' | 'dockerfile' | 'static',
cpu_reserve: buildForm.cpuReserve,
memory_reserve: buildForm.memoryReserve,
static_build_cmd: buildForm.staticCmd,
static_dir: buildForm.staticDir,
},
{ onSuccess: () => setBuildForm(null) },
);
}}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setBuildForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
<p className="text-[10px] text-[var(--text-tertiary)]">Builder applies to the next source build; reservations on next deploy</p>
</div>
{updateServiceMutation.isError && (
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
)}
</div>
)}
</div>
)}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
@@ -2121,6 +2632,19 @@ export function ServiceDetailPage() {
>
Logs
</button>
{['queued', 'pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status) && (
<button
onClick={() => {
if (window.confirm(`Cancel deployment ${deployment.id.slice(0, 8)}?`)) {
cancelDeploymentMutation.mutate(deployment.id);
}
}}
disabled={cancelDeploymentMutation.isPending}
className="px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--error)] hover:border-[var(--error)]/50 disabled:opacity-50 transition-colors"
>
{cancelDeploymentMutation.isPending && cancelDeploymentMutation.variables === deployment.id ? '...' : 'Cancel'}
</button>
)}
<button
onClick={() => {
if (window.confirm(`Rollback to deployment ${deployment.id.slice(0, 8)}?`)) {
@@ -2142,6 +2666,135 @@ export function ServiceDetailPage() {
</div>
</div>
)}
{!isDemoMode && signedIn && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Lifecycle</h3>
{lifecycleForm === null && (
<button
type="button"
onClick={() => setLifecycleForm({ cloneName: `${service.name}-copy`, cloneProject: '', moveTarget: '' })}
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"
>
Manage
</button>
)}
</div>
{lifecycleForm === null ? (
<p className="text-xs text-[var(--text-tertiary)]">
Duplicate this service into the same or another project, move it between projects, or delete it.
</p>
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Clone</label>
<div className="flex flex-wrap items-center gap-2">
<input
value={lifecycleForm.cloneName}
onChange={(e) => setLifecycleForm({ ...lifecycleForm, cloneName: e.target.value })}
placeholder={`${service.name}-copy`}
className="w-52 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
<select
value={lifecycleForm.cloneProject}
onChange={(e) => setLifecycleForm({ ...lifecycleForm, cloneProject: e.target.value })}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
<option value="">same project</option>
{(projectsQuery.data ?? [])
.filter((p) => p.id !== projectId)
.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<button
type="button"
disabled={cloneMutation.isPending}
onClick={() =>
cloneMutation.mutate({
name: lifecycleForm.cloneName.trim() || undefined,
project_id: lifecycleForm.cloneProject || undefined,
})
}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{cloneMutation.isPending ? 'Cloning…' : 'Clone'}
</button>
</div>
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
Copies config, volumes, domains, and variables. The clone starts stopped.
</p>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Move to project</label>
<div className="flex flex-wrap items-center gap-2">
<select
value={lifecycleForm.moveTarget}
onChange={(e) => setLifecycleForm({ ...lifecycleForm, moveTarget: e.target.value })}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
<option value="">choose a project…</option>
{(projectsQuery.data ?? [])
.filter((p) => p.id !== projectId)
.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<button
type="button"
disabled={moveMutation.isPending || !lifecycleForm.moveTarget}
onClick={() => {
if (window.confirm(`Move ${service.name} to another project?`)) {
moveMutation.mutate(lifecycleForm.moveTarget);
}
}}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] disabled:opacity-50 transition-colors"
>
{moveMutation.isPending ? 'Moving…' : 'Move'}
</button>
</div>
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
Variables, domains, and volumes move with the service. Running containers keep running.
</p>
</div>
<div className="pt-3 border-t border-[var(--border-subtle)]">
<label className="block text-xs text-[var(--error)] mb-1">Delete service</label>
<button
type="button"
disabled={deleteServiceMutation.isPending}
onClick={() => {
if (window.confirm(`Delete ${service.name}? This removes its containers and cannot be undone.`)) {
deleteServiceMutation.mutate();
}
}}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
>
{deleteServiceMutation.isPending ? 'Deleting…' : 'Delete'}
</button>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setLifecycleForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Close
</button>
{(cloneMutation.isError || moveMutation.isError) && (
<p className="text-xs text-[var(--error)]">
{((cloneMutation.error ?? moveMutation.error) as Error)?.message}
</p>
)}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
@@ -0,0 +1,126 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { listProjectVariables, updateProjectVariables } from '@/lib/api-client';
import { Loader2, Plus, Save, Trash2, KeyRound } from 'lucide-react';
type Row = { key: string; value: string; isSecret: boolean };
// SharedVariablesSection edits project-level variables referenced from
// service env values as ${{shared.KEY}}.
export function SharedVariablesSection({ projectId }: { projectId: string }) {
const queryClient = useQueryClient();
const [drafts, setDrafts] = useState<Row[] | null>(null);
const varsQuery = useQuery({
queryKey: ['project-variables', projectId],
queryFn: () => listProjectVariables(projectId),
});
const saveMutation = useMutation({
mutationFn: (rows: Row[]) =>
updateProjectVariables(
projectId,
rows
.filter((r) => r.key.trim())
.map((r) => ({ key: r.key.trim(), value: r.value, is_secret: r.isSecret })),
),
onSuccess: () => {
setDrafts(null);
queryClient.invalidateQueries({ queryKey: ['project-variables', projectId] });
},
});
const rows: Row[] = drafts ?? (varsQuery.data ?? []).map((v) => ({ key: v.key, value: v.value, isSecret: v.isSecret }));
const dirty = drafts !== null;
const updateRow = (i: number, patch: Partial<Row>) => {
const next = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
setDrafts(next);
};
return (
<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-secondary)]">Shared Variables</h3>
<p className="mt-1 text-xs text-[var(--text-muted)]">
Reference from any service as{' '}
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next
deploy/redeploy — not live-updated.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}
className="flex items-center gap-2 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)] transition-colors"
>
<Plus size={12} />
Add
</button>
<button
onClick={() => saveMutation.mutate(rows)}
disabled={!dirty || saveMutation.isPending}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-50 transition-all"
style={{ background: 'var(--accent-primary)' }}
>
{saveMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
Save
</button>
</div>
</div>
{varsQuery.isLoading ? (
<div className="flex items-center justify-center py-6 text-[var(--text-muted)]">
<Loader2 size={16} className="animate-spin" />
</div>
) : rows.length === 0 ? (
<p className="text-xs text-[var(--text-muted)]">No shared variables.</p>
) : (
<div className="space-y-2">
{rows.map((row, i) => (
<div key={i} className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
<input
value={row.key}
onChange={(e) => updateRow(i, { key: e.target.value })}
placeholder="KEY"
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
<input
value={row.value}
onChange={(e) => updateRow(i, { value: e.target.value })}
type={row.isSecret ? 'password' : 'text'}
placeholder="value"
autoComplete="off"
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
<button
type="button"
title={row.isSecret ? 'Secret — masked on save' : 'Mark as secret'}
onClick={() => updateRow(i, { isSecret: !row.isSecret })}
className={`w-9 h-9 rounded-[var(--radius-md)] border flex items-center justify-center transition-colors ${
row.isSecret
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-default)]'
}`}
>
<KeyRound size={12} />
</button>
<button
type="button"
onClick={() => setDrafts(rows.filter((_, idx) => idx !== i))}
className="w-9 h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors"
>
<Trash2 size={12} />
</button>
</div>
))}
</div>
)}
{saveMutation.isError && (
<p className="mt-2 text-xs text-[var(--error)]">
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save shared variables'}
</p>
)}
</div>
);
}
@@ -26,6 +26,7 @@ import type { ServiceVariable } from '../auto-connections';
import { ProjectCanvas } from '../canvas/ProjectCanvas';
import { canvasStorageKey, clearCanvasMetadata } from '../storage';
import { MetricsDashboard } from '../components/MetricsDashboard';
import { SharedVariablesSection } from '../components/SharedVariablesSection';
import { CommandPalette, StatusBadge, LiveIndicator, useToast } from '@/shared/components';
import {
ArrowLeft,
@@ -935,6 +936,8 @@ export function ProjectWorkspacePage() {
</div>
</div>
{!isDemoMode && signedIn && <SharedVariablesSection projectId={project.id} />}
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3>
<button
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -36,6 +36,12 @@ export const bundledDocs: BundledDoc[] = [
"title": "🚀 Zero-Interaction Docker Deployment System",
"markdown": "# 🚀 Zero-Interaction Docker Deployment System\n\n## 🎯 **Fully Automatic Deployment**\n\nI've created a **completely automated deployment system** that requires **ZERO user interaction**. Just run one command and it does everything!\n\n## ⚡ **One-Click Deployment**\n\n### **Option 1: Quick Start (Recommended)**\n```bash\n# Run the quick deploy script\n./scripts/quick-deploy.sh\n```\n\n**What happens:**\n1. 🎯 **Shows you 6 deployment options**\n2. 🚀 **Deploys your choice automatically**\n3. 🔧 **Handles everything** (secrets, directories, ports)\n4. 🌐 **Shows access URLs** when done\n\n### **Option 2: Direct Command**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh umami\n./scripts/auto-deploy.sh plex\n./scripts/auto-deploy.sh nextcloud\n```\n\n### **Option 3: Deploy Everything**\n```bash\n# Deploy all 20 templates at once\n./scripts/auto-deploy.sh --all\n```\n\n## 🔄 **What Happens Automatically**\n\n### **🔍 Dependency Check**\n```bash\n✅ Checks if Docker is installed\n✅ Installs Docker if missing\n✅ Checks if Docker Compose is installed \n✅ Installs Docker Compose if missing\n```\n\n### **📁 Directory Creation**\n```bash\n✅ Creates deployment directory\n✅ Creates required subdirectories\n✅ Sets proper permissions\n✅ Handles existing deployments\n```\n\n### **🔧 Configuration Setup**\n```bash\n✅ Extracts docker-compose.yml from template\n✅ Creates .env file with all variables\n✅ Auto-generates secrets and passwords\n✅ Fixes port conflicts automatically\n✅ Optimizes volume paths\n```\n\n### **🐳 Docker Deployment**\n```bash\n✅ Pulls all required images\n✅ Starts all services\n✅ Waits for services to be ready\n✅ Checks service health\n✅ Shows deployment status\n```\n\n### **🌐 Access Information**\n```bash\n✅ Shows all access URLs\n✅ Provides management commands\n✅ Shows deployment directory\n✅ Opens browser automatically (optional)\n```\n\n## 🎮 **Try It Now**\n\n### **Easiest Way:**\n```bash\n./scripts/quick-deploy.sh\n```\n\nYou'll see:\n```\n🚀 One-Click Docker Deployment\n============================\n\n🎯 Choose your deployment:\n\n1. 🌐 Deploy Glance Dashboard (Recommended - Simple & Fast)\n2. 📊 Deploy Umami Analytics (Web Analytics)\n3. 📝 Deploy Memos (Note-taking)\n4. 🔍 Deploy MeiliSearch (Search Engine)\n5. 📈 Deploy Uptime Kuma (Monitoring)\n6. 🚀 Deploy ALL Templates (Advanced)\n\nEnter your choice (1-6): 1\n\n🌐 Deploying Glance Dashboard...\n```\n\n### **Direct Way:**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh glance\n```\n\n## 📊 **Example Output**\n\nHere's what you'll see during deployment:\n\n```\n🚀 Auto-Deploying glance\n========================================\n🔄 Checking dependencies...\n✅ Docker is installed\n✅ Docker Compose is installed\n🔄 Auto-deploying glance...\n✅ Created deployment directory: /path/to/deployments/glance\n🔄 Extracting docker-compose.yml...\n✅ docker-compose.yml extracted and optimized\n🔄 Creating .env file...\n✅ .env file created (no variables required)\n🔄 Auto-deploying glance...\n🔄 Pulling Docker images...\n✅ Images pulled\n🔄 Starting services...\n✅ Services started successfully\n\n🎉 Deployment Complete!\n========================================\n✅ Access URLs:\n 🌐 http://localhost:8080 (port: 8080)\n\n✅ Management Commands:\n 📋 View logs: docker-compose logs -f\n 🛑 Stop services: docker-compose down\n 🔄 Restart: docker-compose restart\n 📊 Status: docker-compose ps\n 🗑️ Cleanup: docker-compose down -v\n\n✅ Deployment directory: /path/to/deployments/glance\n🎉 glance deployed successfully!\n```\n\n## 🔧 **Advanced Options**\n\n### **Custom Configuration**\n```bash\n# Deploy without auto-generating secrets\n./scripts/auto-deploy.sh --no-secrets umami\n\n# Deploy without creating directories\n./scripts/auto-deploy.sh --no-dirs plex\n\n# Deploy without auto-starting services\n./scripts/auto-deploy.sh --no-start nextcloud\n\n# Deploy and auto-open browser\n./scripts/auto-deploy.sh --open-browser glance\n```\n\n### **Batch Deployment**\n```bash\n# Deploy multiple templates\nfor template in umami glance memos; do\n ./scripts/auto-deploy.sh \"$template\"\ndone\n\n# Deploy all templates\n./scripts/auto-deploy.sh --all\n```\n\n## 🛡️ **Safety Features**\n\n### **Automatic Conflict Resolution**\n```bash\n✅ Detects port conflicts\n✅ Auto-assigns available ports\n✅ Handles existing deployments\n✅ Cleans up previous installations\n```\n\n### **Security**\n```bash\n✅ Generates strong random secrets\n✅ Uses secure default passwords\n✅ Isolates deployments in separate directories\n✅ Logs all actions for audit trail\n```\n\n### **Error Handling**\n```bash\n✅ Validates template existence\n✅ Checks service health\n✅ Provides detailed error messages\n✅ Offers rollback commands\n```\n\n## 📁 **Di Line truncated
},
{
"path": "guides/CLI.md",
"section": "guides",
"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 domains list|add|remove|default|check multi-domain + DNS preflight\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\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 every request.\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdiLine truncated
},
{
"path": "guides/CLOUDFLARE_SETUP.md",
"section": "guides",
@@ -52,7 +58,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/OPERATIONS.md",
"section": "guides",
"title": "Operations",
"markdown": "# Operations\n\nDay-2 features for running services: scheduled jobs, ad-hoc exec, managed\ndatabases, preview environments, security scans, and high availability.\n\n## Cron jobs\n\nService detail → **Cron**. Standard 5-field cron expressions (`robfig/cron`\nsyntax), per-job timezone, enable/disable, manual trigger, and execution\nhistory with captured stdout/stderr.\n\n- The scheduler ticks every minute and runs due jobs via `docker exec`\n (`sh -c <command>`) inside the service's container.\n- Executions are retained per-job (`retention` count) and visible in the UI.\n- Requires a running container — a stopped service's due job records a\n failure, it does not queue.\n\n```\nGET/POST /api/v1/cron-jobs PUT/DELETE /api/v1/cron-jobs/:id\nPOST /api/v1/cron-jobs/:id/trigger\nGET /api/v1/cron-jobs/:id/executions\n```\n\n## Exec console\n\nService detail → **Console**. One-off `sh -c` commands against the running\ncontainer: 30s ceiling, 64KB output cap, exit code + captured output shown\nper run. Debugging aid — not an interactive shell.\n\n```\nPOST /api/v1/services/:id/exec {command}\n```\n\n## Managed databases\n\n**/databases** page — provision PostgreSQL/Redis/MySQL/MariaDB/MongoDB/\nClickHouse/Dragonfly as managed containers on the `containr` network.\n\n- Start/stop/restart actions; connection URL with copy.\n- **Backups**: manual snapshot now, or set a cron expression\n (`backup_schedule`) — due backups run inside the same scheduler tick as\n cron jobs. Archives live in the `containr-db-backups` volume and are\n downloadable (`GET /databases/:id/backups/:bid/download`) and restorable.\n- **Bind to service**: injects `connection_url` into a service's variables\n as a secret (`DATABASE_URL` by default). Redeploy the service afterward —\n variables are read at deploy time.\n\n## Preview environments\n\nService detail → **Previews**. Create a preview per branch/PR with a TTL,\npromote it to production, or delete it.\n\nCurrent limitation: previews are bookkeeping records — they do not build,\ndeploy, or route a real container. The generated `*.preview.containr.local`\nURL is a placeholder. Real preview runtime is tracked on the roadmap.\n\n## Security scans\n\n**/security** page — pick a project, run a scan (dependency / configuration\n/ comprehensive, optionally scoped to one service).\n\nThe scanner is heuristic config analysis, not Trivy/Grype: it flags\nunpinned image tags, `http://` source/public URLs, `npm install` without\n`npm ci`, `pip install` without hashes, `curl|sh` build steps, missing\nhealth checks, and similar smells. Findings get severity badges and can be\nresolved or ignored; the page also shows a security score, open/resolved\ncounts, scan history, and compliance status.\n\n```\nPOST /api/v1/security/scans {project_id, service_id?, scan_type}\nGET /api/v1/projects/:id/vulnerabilities\nPUT /api/v1/vulnerabilities/:id {status: open|resolved|ignored}\nGET /api/v1/projects/:id/security/metrics\nGET /api/v1/projects/:id/security/history\n```\n\n## High availability\n\n**/ha** page — manager enable/disable, node/health-check/alert status\ncards, manual failover (with confirmation), per-service failover policies\n(project → service picker, strategy, min healthy nodes, max failures),\nactive alerts with resolve, and health check results.\n\nHA state is in-memory like autoscaling — policies and alerts reset on\nbackend restart.\n\n## Node agents\n\n**/usage** page → *Connect node*: issue a per-node token (`cagt_…`, shown\nonce), copy the prefilled install command, revoke tokens when done. Agents\nregister over `POST /api/agents/register` and heartbeat via\n`POST /api/agents/heartbeat`.\n"
"markdown": "# Operations\n\nDay-2 features for running services: scheduled jobs, ad-hoc exec, managed\ndatabases, preview environments, security scans, and high availability.\n\n## Cron jobs\n\nService detail → **Cron**. Standard 5-field cron expressions (`robfig/cron`\nsyntax), per-job timezone, enable/disable, manual trigger, and execution\nhistory with captured stdout/stderr.\n\n- The scheduler ticks every minute and runs due jobs via `docker exec`\n (`sh -c <command>`) inside the service's container.\n- Executions are retained per-job (`retention` count) and visible in the UI.\n- Requires a running container — a stopped service's due job records a\n failure, it does not queue.\n\n```\nGET/POST /api/v1/cron-jobs PUT/DELETE /api/v1/cron-jobs/:id\nPOST /api/v1/cron-jobs/:id/trigger\nGET /api/v1/cron-jobs/:id/executions\n```\n\n## Exec console\n\nService detail → **Console**. One-off `sh -c` commands against the running\ncontainer: 30s ceiling, 64KB output cap, exit code + captured output shown\nper run. Debugging aid — not an interactive shell.\n\n```\nPOST /api/v1/services/:id/exec {command}\n```\n\n## Managed databases\n\n**/databases** page — provision PostgreSQL/Redis/MySQL/MariaDB/MongoDB/\nClickHouse/Dragonfly as managed containers on the `containr` network.\n\n- Start/stop/restart actions; connection URL with copy.\n- **Backups**: manual snapshot now, or set a cron expression\n (`backup_schedule`) — due backups run inside the same scheduler tick as\n cron jobs. Archives live in the `containr-db-backups` volume and are\n downloadable (`GET /databases/:id/backups/:bid/download`) and restorable.\n- **Bind to service**: injects `connection_url` into a service's variables\n as a secret (`DATABASE_URL` by default). Redeploy the service afterward —\n variables are read at deploy time.\n\n## Preview environments\n\nService detail → **Previews**. Create a preview per branch/PR with a TTL,\npromote it to production, or delete it.\n\nCurrent limitation: previews are bookkeeping records — they do not build,\ndeploy, or route a real container. The generated `*.preview.containr.local`\nURL is a placeholder. Real preview runtime is tracked on the roadmap.\n\n## Security scans\n\n**/security** page — pick a project, run a scan (dependency / configuration\n/ comprehensive, optionally scoped to one service).\n\nThe scanner is heuristic config analysis, not Trivy/Grype: it flags\nunpinned image tags, `http://` source/public URLs, `npm install` without\n`npm ci`, `pip install` without hashes, `curl|sh` build steps, missing\nhealth checks, and similar smells. Findings get severity badges and can be\nresolved or ignored; the page also shows a security score, open/resolved\ncounts, scan history, and compliance status.\n\n```\nPOST /api/v1/security/scans {project_id, service_id?, scan_type}\nGET /api/v1/projects/:id/vulnerabilities\nPUT /api/v1/vulnerabilities/:id {status: open|resolved|ignored}\nGET /api/v1/projects/:id/security/metrics\nGET /api/v1/projects/:id/security/history\n```\n\n## High availability\n\n**/ha** page — manager enable/disable, node/health-check/alert status\ncards, manual failover (with confirmation), per-service failover policies\n(project → service picker, strategy, min healthy nodes, max failures),\nactive alerts with resolve, and health check results.\n\nHA state is in-memory like autoscaling — policies and alerts reset on\nbackend restart.\n\n## Node agents\n\n**/usage** page → *Connect node*: issue a per-node token (`cagt_…`, shown\nonce), copy the prefilled install command, revoke tokens when done. Agents\nregister over `POST /api/agents/register` and heartbeat via\n`POST /api/agents/heartbeat`.\n\n## Personal access tokens\n\n**Settings → Personal Access Tokens**: issue `cnp_…` bearer tokens for the\nCLI, MCP server, and scripts — the same privileges as your account, bounded\nby scope. The raw token is shown once at creation; only its SHA-256 hash is\nstored.\n\nScopes:\n\n- `read` — `GET`/`HEAD`/`OPTIONS` only; mutations return `403 SCOPE_INSUFFICIENT`.\n- `write` — everything except admin routes.\n- `admin` — full access; requires a platform admin account, and the token is\n still capped at the owner's privileges.\n\nA token can never outrank its owner: non-admin scopes report\n`is_admin = false` even on admin accounts.\n\n```\nGET /api/v1/user/tokens list (metadata only, never hashes)\nPOST /api/v1/user/tokens {name, scope?, expires_in_days?} → raw token once\nDELETE /api/v1/user/tokens/:id revoke (owner-scoped)\n```\n\nUse as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`.\nRevocation takes effect on the next request; `last_used_at` updates on each\nauthenticated call.\n\n## Deployment queue\n\nDeployments are serialized per service: a deploy while another is active\nlands in `queued` state and promotes when it finishes — deploys, rollbacksLine truncated
},
{
"path": "guides/README_BACKEND.md",
+2
View File
@@ -1523,6 +1523,7 @@ textarea {
background: var(--accent-primary); color: var(--accent-on);
border: none; border-radius: 8px; padding: 9px 17px;
font: 700 12px 'JetBrains Mono', monospace; cursor: pointer;
white-space: nowrap; flex-shrink: 0;
transition: background 0.15s, transform 0.15s;
box-shadow: 0 2px 12px rgba(180, 227, 74, 0.22);
}
@@ -1534,6 +1535,7 @@ textarea {
background: var(--surface-card); color: var(--text-secondary);
border: 1px solid var(--border-subtle); border-radius: 8px; padding: 8px 16px;
font: 600 12px 'JetBrains Mono', monospace; cursor: pointer; transition: 0.15s;
white-space: nowrap; flex-shrink: 0;
}
.v-btn-ghost:hover { color: var(--text-primary); border-color: var(--border-default); }
+184
View File
@@ -54,9 +54,42 @@ export type ServiceEntity = {
domain?: string;
healthcheckPath?: string;
restartPolicy?: string;
volumes?: ServiceVolume[];
domains?: ServiceDomain[];
maintenanceMode?: boolean;
basicAuth?: string[];
builder?: 'auto' | 'railpack' | 'nixpacks' | 'dockerfile' | 'static';
cpuReserve?: string;
memoryReserve?: string;
staticBuildCmd?: string;
staticDir?: string;
publicUrl?: string;
};
export type ServiceDomain = {
id: string;
service_id: string;
domain: string;
is_default: boolean;
cert_type?: string;
cert_status?: string;
last_checked_at?: string;
created_at?: string;
};
export type DomainCheckResult = {
domain: string;
status: 'ok' | 'wrong-target' | 'pending';
resolved?: string[];
};
export type ServiceVolume = {
type?: 'volume' | 'bind';
source: string;
target: string;
read_only?: boolean;
};
export type RuntimeContainer = {
id: string;
name: string;
@@ -552,6 +585,15 @@ function normalizeService(service: RawService): ServiceEntity | null {
domain: service.domain,
healthcheckPath: service.healthcheck_path,
restartPolicy: service.restart_policy,
volumes: service.volumes as ServiceVolume[] | undefined,
domains: service.domains as ServiceDomain[] | undefined,
maintenanceMode: service.maintenance_mode,
basicAuth: service.basic_auth as string[] | undefined,
builder: service.builder as ServiceEntity['builder'],
cpuReserve: service.cpu_reserve,
memoryReserve: service.memory_reserve,
staticBuildCmd: service.static_build_cmd,
staticDir: service.static_dir,
publicUrl: service.public_url,
};
}
@@ -993,6 +1035,34 @@ export async function revokeAgentToken(id: string): Promise<void> {
await requestJson(`/agent-tokens/${id}`, { method: 'DELETE' });
}
export type UserToken = components['schemas']['UserToken'];
export type UserTokenCreated = components['schemas']['UserTokenCreated'];
export type UserTokenScope = 'read' | 'write' | 'admin';
export async function createUserToken(input: {
name: string;
scope?: UserTokenScope;
expiresInDays?: number;
}): Promise<UserTokenCreated> {
return requestJson<UserTokenCreated>(`/user/tokens`, {
method: 'POST',
body: JSON.stringify({
name: input.name,
scope: input.scope ?? 'write',
expires_in_days: input.expiresInDays ?? 0,
}),
});
}
export async function listUserTokens(): Promise<UserToken[]> {
const payload = await requestJson<{ tokens?: UserToken[] }>(`/user/tokens`);
return payload.tokens ?? [];
}
export async function revokeUserToken(id: string): Promise<void> {
await requestJson(`/user/tokens/${id}`, { method: 'DELETE' });
}
export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> {
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, {
method: 'POST',
@@ -1063,6 +1133,36 @@ export async function createService(projectId: string, input: CreateServiceInput
return parsed;
}
export async function listServiceDomains(serviceId: string): Promise<ServiceDomain[]> {
const payload = await requestJson<{ domains?: ServiceDomain[] }>(`/services/${serviceId}/domains`);
return payload.domains ?? [];
}
export async function addServiceDomain(serviceId: string, domain: string, isDefault = false): Promise<ServiceDomain> {
const payload = await requestJson<{ domain: ServiceDomain }>(`/services/${serviceId}/domains`, {
method: 'POST',
body: JSON.stringify({ domain, is_default: isDefault }),
});
return payload.domain;
}
export async function removeServiceDomain(serviceId: string, domainId: string): Promise<void> {
await requestJson(`/services/${serviceId}/domains/${domainId}`, { method: 'DELETE' });
}
export async function setDefaultServiceDomain(serviceId: string, domainId: string): Promise<ServiceDomain[]> {
const payload = await requestJson<{ domains?: ServiceDomain[] }>(
`/services/${serviceId}/domains/${domainId}/default`,
{ method: 'POST' },
);
return payload.domains ?? [];
}
export async function checkServiceDomains(serviceId: string): Promise<DomainCheckResult[]> {
const payload = await requestJson<{ domains?: DomainCheckResult[] }>(`/services/${serviceId}/domains/check`);
return payload.domains ?? [];
}
export async function updateService(serviceId: string, input: UpdateServiceInput): Promise<ServiceEntity> {
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`, {
method: 'PUT',
@@ -1085,6 +1185,68 @@ export async function deleteService(serviceId: string): Promise<void> {
});
}
export type CloneServiceInput = {
name?: string;
project_id?: string;
environment?: string;
};
export async function cloneService(serviceId: string, input: CloneServiceInput): Promise<{ service_id: string; name: string }> {
return requestJson<{ service_id: string; name: string }>(`/services/${serviceId}/clone`, {
method: 'POST',
body: JSON.stringify(input),
});
}
export async function moveService(serviceId: string, projectId: string): Promise<void> {
await requestJson(`/services/${serviceId}/move`, {
method: 'POST',
body: JSON.stringify({ project_id: projectId }),
});
}
export type Registry = {
id: string;
name: string;
host: string;
username?: string;
has_password: boolean;
created_at?: string;
updated_at?: string;
};
export type RegistryInput = {
name: string;
host: string;
username?: string;
password?: string;
};
export async function listRegistries(): Promise<Registry[]> {
const payload = await requestJson<{ registries?: Registry[] }>('/registries');
return payload.registries ?? [];
}
export async function createRegistry(input: RegistryInput): Promise<Registry> {
const payload = await requestJson<{ registry: Registry }>('/registries', {
method: 'POST',
body: JSON.stringify(input),
});
return payload.registry;
}
export async function updateRegistry(id: string, input: RegistryInput): Promise<Registry> {
const payload = await requestJson<{ registry: Registry }>(`/registries/${id}`, {
method: 'PUT',
body: JSON.stringify(input),
});
return payload.registry;
}
export async function deleteRegistry(id: string): Promise<void> {
await requestJson(`/registries/${id}`, { method: 'DELETE' });
}
function normalizeRuntime(runtime?: ServiceRuntime): ServiceRuntime {
const state = runtime ?? { desired: 0, status: 'stopped' as const, urls: [], ports: [], containers: [] };
// The API emits null (not []) for empty collections — coerce at the boundary.
@@ -1148,6 +1310,22 @@ export async function updateServiceVariables(
return normalizeServiceVariables(payload.variables);
}
export async function listProjectVariables(projectId: string): Promise<ServiceVariable[]> {
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(`/projects/${projectId}/variables`);
return normalizeServiceVariables(payload.variables);
}
export async function updateProjectVariables(
projectId: string,
variables: UpdateServiceVariableInput[],
): Promise<ServiceVariable[]> {
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(
`/projects/${projectId}/variables`,
{ method: 'PUT', body: JSON.stringify({ variables }) },
);
return normalizeServiceVariables(payload.variables);
}
export async function listAuditLogs(input: ListAuditLogsInput = {}): Promise<AuditLogEntity[]> {
const searchParams = new URLSearchParams();
if (input.resource) {
@@ -1292,6 +1470,12 @@ export async function rollbackDeployment(deploymentId: string): Promise<Rollback
};
}
export async function cancelDeployment(deploymentId: string): Promise<{ status: string }> {
return requestJson<{ status: string }>(`/deployments/${deploymentId}/cancel`, {
method: 'POST',
});
}
export async function listBuilds(input: ListBuildsInput = {}): Promise<ListBuildsResult> {
const searchParams = new URLSearchParams();
if (input.projectId) {
+904 -5
View File
File diff suppressed because it is too large. Load diff
+202
View File
@@ -0,0 +1,202 @@
# Containr CLI
The CLI controls every part of a Containr instance — the same API surface
the web UI uses. Build it with `scripts/build-cli.sh` or
`go build ./cmd/cli` from `app/backend`.
## Authentication
Create a personal access token in the web UI under
**Settings → Personal Access Tokens** (or `containr tokens create`), then:
```bash
containr auth login cnp_... # verify + store
containr auth login cnp_... --url https://containr.example.com
containr auth login cnp_... --name production # named profile
containr auth status # show active profile + identity
containr auth logout
```
Resolution order for every command: `--token`/`--api-url` flags >
`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >
`http://localhost:8080/api/v1`. The active profile is `--profile` >
`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.
## Output and scripting
- `--json` prints the raw API response — safe to pipe and parse.
- `--yes` / `-y` skips confirmation on destructive commands (delete,
revoke, restore, failover). Non-interactive shells refuse destructive
commands without it.
- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,
`3` not found, `4` validation, `5` unreachable.
## Commands
```
auth login|logout|status token auth and profiles
tokens list|create|revoke personal access tokens
projects list|get|create|delete
services list|get|create|update|delete|start|stop|restart|redeploy
services clone|move duplicate config or re-parent a service
services domains list|add|remove|default|check multi-domain + DNS preflight
registries list|add|update|remove private-image pull credentials
volumes list|delete docker volume inventory + cleanup (admin)
deploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)
deployments list|get|logs|rollback|cancel
logs <service-id> [--follow] runtime logs (SSE follow)
exec <service-id> -- <cmd> one-off container command
variables list|set|unset env vars (secrets masked; --redeploy applies them)
variables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}
databases list|get|create|delete|action|backup|restore|download-backup
cron list|get|create|delete|trigger|executions
templates list|get|create|delete|deploy
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
scaling status|policies|services|scale
ha status|alerts|health|policies|enable|disable|failover
security scan|vulnerabilities|metrics
gateway services|keys|analytics (admin)
admin overview|users|settings|audit-logs
notifications list|read
up deploy the current directory
```
## containr up
Run inside a git checkout: uses `remote.origin.url` + the current branch
as the build source, creates a service named after the directory when none
exists, then triggers a deployment.
```bash
containr up --project <id>
containr up --project <id> --image ghcr.io/example/app:latest # image, no build
```
Set `project_id` on the active profile to omit `--project` (stored in
`~/.containr.yaml` under `profiles.<name>.project_id`).
## Service volumes
`services update` manages mounts; flags replace the whole mount list:
```bash
containr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro
containr services update <id> --clear-volumes
```
`type` is `volume` (docker named volume, auto-created) or `bind` (host
path). `target` must be absolute. Mounts apply on the next deploy or
redeploy — `containr deploy <id>` to apply immediately.
`volumes list` shows the node's docker volume inventory with in-use
flags; `volumes delete <name>` removes an unused volume (admin scope).
## Service domains and access
A service can carry multiple hostnames; the default is mirrored to
`services.domain`:
```bash
containr services domains add <id> app.example.com --default
containr services domains check <id> # DNS preflight per domain
containr services domains remove <id> <domain-id>
```
DNS check compares each domain to the expected target — set
`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is
meaningful; without it any resolvable domain reports `ok`.
Access gates apply via Traefik middlewares (no extra proxy):
```bash
containr services update <id> --maintenance on
containr services update <id> --basic-auth admin:secret --basic-auth ops:pw2
```
Maintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`
— set `app_settings.base_url` or `BASE_URL`; without it the service is
taken offline with empty responses. Basic-auth credentials are stored
bcrypt-hashed (htpasswd) and gate every request.
## Build strategy and resources
Source-built services pick a builder per service:
```bash
containr services update <id> --builder railpack # auto|railpack|nixpacks|dockerfile|static
containr services update <id> --builder static --static-cmd "npm run build" --static-dir dist
```
`auto` detects from repo contents (Dockerfile → railpack → nixpacks).
`static` builds assets in a node stage and serves them from nginx —
`--static-cmd` runs inside the build stage (default
`npm ci && npm run build`), `--static-dir` is copied into the nginx image
(default `dist`, exposed on port 80).
Resource reservations complement the hard `cpu`/`memory` limits:
```bash
containr services update <id> --cpu-reserve 0.25 --memory-reserve 128Mi
```
`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/
`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty
string clears. `app_settings.default_cpu`/`default_memory` change the
instance-level defaults used at service creation;
`app_settings.capacity_policy=block` fails deployments whose requested
memory exceeds node capacity (default `warn` logs only).
## Clone, move, registries
`services clone` duplicates config, volumes, domains, access gates,
builder settings, and variables — ciphertext secret values are copied
verbatim (no decrypt/re-encrypt). The clone starts stopped:
```bash
containr services clone <id> --name staging-api --project <other-project-id> --environment preview
containr services move <id> <project-id> # name conflicts → 409
```
Note: cloned domains are verbatim copies — the same hostname attached to
two services makes Traefik routing ambiguous. Re-domain or undeploy one.
`registries` stores private-image pull credentials, owner-scoped and
encrypted at rest. Matched by image host on every image-sourced deploy:
```bash
containr registries add ghcr ghcr.io --username <user> --password <pat>
containr registries add hub docker.io --username <user> --password <pw> # Docker Hub
containr registries update <id> --username <new-user> # empty --password keeps stored
containr registries remove <id>
```
## MCP server
`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools
over stdio — every command group above maps to a `containr_*` tool
(~60 tools). Destructive tools require the caller to pass
`confirm: true`; results are pretty JSON with `isError` on failures.
Client configuration (Claude Code, Cursor, Devin, any stdio MCP host):
```json
{
"mcpServers": {
"containr": {
"command": "containr-mcp",
"env": {
"CONTAINR_API_URL": "https://containr.example.com",
"CONTAINR_TOKEN": "cnp_..."
}
}
}
}
```
Scope the token deliberately: `read` is enough for inspection agents,
`write` for deploy automation, `admin` only for platform operations.
Deployments are serialized per service: a second `deploy` on a busy
service lands in `queued` state and runs when the active one finishes.
`deployments cancel <id>` drops a queued deployment or aborts a running
one (builds/reconciles stop via context cancellation).
+44
View File
@@ -90,3 +90,47 @@ backend restart.
once), copy the prefilled install command, revoke tokens when done. Agents
register over `POST /api/agents/register` and heartbeat via
`POST /api/agents/heartbeat`.
## Personal access tokens
**Settings → Personal Access Tokens**: issue `cnp_…` bearer tokens for the
CLI, MCP server, and scripts — the same privileges as your account, bounded
by scope. The raw token is shown once at creation; only its SHA-256 hash is
stored.
Scopes:
- `read` — `GET`/`HEAD`/`OPTIONS` only; mutations return `403 SCOPE_INSUFFICIENT`.
- `write` — everything except admin routes.
- `admin` — full access; requires a platform admin account, and the token is
still capped at the owner's privileges.
A token can never outrank its owner: non-admin scopes report
`is_admin = false` even on admin accounts.
```
GET /api/v1/user/tokens list (metadata only, never hashes)
POST /api/v1/user/tokens {name, scope?, expires_in_days?} → raw token once
DELETE /api/v1/user/tokens/:id revoke (owner-scoped)
```
Use as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`.
Revocation takes effect on the next request; `last_used_at` updates on each
authenticated call.
## Deployment queue
Deployments are serialized per service: a deploy while another is active
lands in `queued` state and promotes when it finishes — deploys, rollbacks,
redeploys, and variable-apply reconciles can never race on the same service.
`POST /deployments/:id/cancel` drops queued work or aborts running builds.
Rows left in-flight by a server restart are marked `failed` on boot.
Webhook-triggered deploys build with `no_cache` by default.
## Secret variables
Variables with `is_secret` are AES-GCM-encrypted at rest (`enc:v1:` prefix).
Key derivation: `SECRETS_KEY` env var if set, else `JWT_SECRET`. Existing
plaintext secrets are encrypted on boot; reads for deploy/exec decrypt
transparently. Rotating the key makes stored secrets unreadable — re-save
them after rotation.
+37
View File
@@ -0,0 +1,37 @@
# Containr
> Self-hosted Docker PaaS — deploy and manage containerized apps, databases, cron jobs and preview environments. Operable by humans (web UI) and agents (API / CLI / MCP) through one contract.
Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
## Access (pick one)
- **API**: `Authorization: Bearer <pat>` — create a PAT in Settings → Access Tokens or `containr tokens create`. Scopes: `read` (GET/HEAD/OPTIONS only), `write` (everything but admin routes), `admin` (requires platform-admin owner).
- **CLI**: `containr auth login --api-url <url>` (or `--token cnp_…`), then e.g. `containr projects list --json`, `containr up`. Global flags: `--json`, `--yes`, `--profile`, `--idempotency-key`.
- **MCP**: run `containr-mcp` over stdio with `CONTAINR_API_URL` + `CONTAINR_TOKEN` env (or CLI profile). ~60 tools mirroring the API; destructive tools require `confirm: true`.
## Conventions
- **Errors**: `{"error": "msg", "code": "MACHINE_CODE"}`. Codes: `UNAUTHENTICATED`, `INVALID_TOKEN`, `FORBIDDEN`, `ADMIN_REQUIRED`, `SCOPE_INSUFFICIENT`, `NOT_FOUND`, `VALIDATION`, `CONFLICT`, `INTERNAL`, `DEPENDENCY_UNAVAILABLE`, `IDEMPOTENCY_IN_PROGRESS`, `NOT_CANCELLABLE`.
- **Idempotency**: send `Idempotency-Key: <uuid>` on POSTs — the first response replays for 24h; concurrent duplicates get 409. Always safe to send.
- **Pagination**: `?limit=` + `?cursor=` (pass `pagination.next_cursor` verbatim; legacy `?page=` also works). Invalid cursors fall back to page 1.
- **Deployments**: serialized per service — a deploy on a busy service returns `status: queued` and runs when the active one finishes. `POST /deployments/{id}/cancel` drops queued (`200 cancelled`) or aborts running (`202 cancelling`) work. `no_cache: true` on create disables the Docker layer cache. `PUT /services/{id}/variables` accepts `redeploy: true` to apply env changes to running containers.
- **Variables**: service env values support `${{service.KEY}}` cross-service refs (builtins HOST/PORT) and `${{shared.KEY}}` project shared variables (`GET|PUT /projects/{id}/variables`, CLI `variables shared …`). Resolved at deploy/reconcile, not live-updated.
- **Volumes**: service mounts via `PUT /services/{id}` `volumes: [{type: volume|bind, source, target, read_only}]` — replaces the whole list (`[]` clears), applied on next deploy. `GET /admin/volumes` inventories node volumes with `in_use` flags; `DELETE /admin/volumes/{name}` removes unused ones (admin).
- **Domains**: multiple hostnames per service via `GET|POST /services/{id}/domains`, `DELETE /services/{id}/domains/{domain_id}`, `POST …/{domain_id}/default` (mirrors `services.domain`). `GET /services/{id}/domains/check` returns `ok|wrong-target|pending` per domain vs `app_settings.public_ip`/`PUBLIC_IP`. `PUT` `maintenance_mode` redirects all traffic to `GET /maintenance` (needs `base_url`/`BASE_URL` setting); `basic_auth: [{username,password}]` stores bcrypt htpasswd and gates the route — responses return usernames only.
- **Builders & resources**: service field `builder` = `auto|railpack|nixpacks|dockerfile|static` (auto detects; static builds assets with `static_build_cmd` and serves `static_dir` via nginx:80). `cpu`/`memory` are hard caps; `cpu_reserve`/`memory_reserve` are soft reservations (CPU shares / MemoryReservation) — empty string clears. `app_settings.default_cpu`/`default_memory` set instance defaults; `app_settings.capacity_policy=block` rejects deploys exceeding node memory (default `warn`).
- **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.
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
## Core objects
`projects` → `services` (git/app deploys) → `deployments`; plus `databases`, `templates`, `cron-jobs`, `variables`, `volumes`, `domains`, `registries`, `preview-environments`, `nodes`, `scaling`, `security`, `gateway`, `admin`, `notifications`, `user/tokens`.
## Docs
- `docs/guides/CLI.md` — full CLI reference
- `docs/guides/OPERATIONS.md` — PATs, agent tokens, ops
- `docs/api/openapi.yaml` — request/response contract
- `ROADMAP2.md` — product roadmap
- `AGENTS.md` — repo conventions for coding agents
+10 -3
View File
@@ -21,18 +21,25 @@ for platform in $PLATFORMS; do
GOARCH=${platform#*/}
OUTPUT_NAME="containr-${GOOS}-${GOARCH}"
MCP_NAME="containr-mcp-${GOOS}-${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT_NAME="${OUTPUT_NAME}.exe"
MCP_NAME="${MCP_NAME}.exe"
fi
echo "📦 Building for ${platform}..."
echo "📦 Building CLI for ${platform}..."
GOOS=$GOOS GOARCH=$GOARCH go build \
-ldflags "$LDFLAGS" \
-o "bin/${OUTPUT_NAME}" \
./cmd/cli
echo "✅ Built bin/${OUTPUT_NAME}"
echo "📦 Building MCP server for ${platform}..."
GOOS=$GOOS GOARCH=$GOARCH go build \
-ldflags "$LDFLAGS" \
-o "bin/${MCP_NAME}" \
./cmd/mcp
echo "✅ Built bin/${OUTPUT_NAME} + bin/${MCP_NAME}"
done
echo "🎉 CLI build complete!"