mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0952faf25 | ||
|
|
999d719377 | ||
|
|
8d682583ff | ||
|
|
c80fbc766d | ||
|
|
99ee4e79cf | ||
|
|
f729ef2c82 | ||
|
|
f90fc5f5ad | ||
|
|
f68225a97d | ||
|
|
7536443610 | ||
|
|
68da4236fa | ||
|
|
b9441f3e11 | ||
|
|
605a43455d | ||
|
|
c7256954c2 | ||
|
|
a50260e857 | ||
|
|
3441acbc37 | ||
|
|
c5e0561add | ||
|
|
1ef5ea5f7f | ||
|
|
d26452d97e | ||
|
|
164a39c9c6 | ||
|
|
63f65c78cc | ||
|
|
d5960fdfff | ||
|
|
31c04ad51d | ||
|
|
03f6dcc80d | ||
|
|
1a4ee99f5b | ||
|
|
264f619be5 | ||
|
|
11525194b0 | ||
|
|
9e3994e937 | ||
|
|
355eb2c9ab | ||
|
|
b5a1a8cc21 | ||
|
|
91f40a652d | ||
|
|
2be1715015 | ||
|
|
b32e63368a | ||
|
|
f1a0f2189d | ||
|
|
e3676bd89a | ||
|
|
71c7d2b9f0 | ||
|
|
e67a39436c | ||
|
|
2c3d9fc123 | ||
|
|
4734be0d9d | ||
|
|
4e8d364911 | ||
|
|
f53695be4f | ||
|
|
925199633b | ||
|
|
1054679ca2 | ||
|
|
57f8b4f56e | ||
|
|
ace68da188 | ||
|
|
2d626f8ee8 | ||
|
|
6d3aff6d58 | ||
|
|
c60c31693b | ||
|
|
9fd34482e3 | ||
|
|
541a4719d4 | ||
|
|
dd1bbaf5a2 | ||
|
|
f22a464ad0 | ||
|
|
58424ed0ab | ||
|
|
25fa8c47e6 | ||
|
|
b3da014d26 | ||
|
|
f5f1ff34a0 | ||
|
|
bac317e0e6 | ||
|
|
26d585ffb7 | ||
|
|
f0df61b52e | ||
|
|
ffe66d7b3d | ||
|
|
121b33f36a | ||
|
|
8a8b903cdf | ||
|
|
f83ad81f73 | ||
|
|
2049087ccd | ||
|
|
01edb677e5 | ||
|
|
b22ac401c8 | ||
|
|
ea96929435 | ||
|
|
971773f80c | ||
|
|
7e99e41f8d | ||
|
|
6c5ff7be41 | ||
|
|
7b93506e2d | ||
|
|
f34fba165c | ||
|
|
be83467cc9 | ||
|
|
c99b2df419 | ||
|
|
a26c103eb4 | ||
|
|
f22ae22375 | ||
|
|
a4b6ea4bd2 | ||
|
|
9f8eaed386 | ||
|
|
3360eedc29 | ||
|
|
79fb73afc8 | ||
|
|
99a25cb10e | ||
|
|
d135e5fd4b | ||
|
|
dbc87bf34c | ||
|
|
b4baa853b2 | ||
|
|
15045b8dd3 | ||
|
|
109f79268f | ||
|
|
6f43bbf8f6 | ||
|
|
b7131965b1 | ||
|
|
5dc9b5f115 | ||
|
|
3664eaed1a | ||
|
|
912f6f5142 | ||
|
|
e995c98d05 | ||
|
|
2f8f595d6e | ||
|
|
747b714a02 | ||
|
|
7839dc48a3 | ||
|
|
84d58cf808 | ||
|
|
18f78f00a3 | ||
|
|
485e41a817 | ||
|
|
e51b20b2d4 |
No files matched your search
@@ -39,6 +39,10 @@ REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0
|
||||
# Application Configuration
|
||||
# In production this must be a strong value with at least 32 characters.
|
||||
JWT_SECRET=SuperSecretJWTKey123456789!@#$%^&*()
|
||||
# Encrypts secret env vars at rest. Defaults to JWT_SECRET when unset;
|
||||
# setting it explicitly survives a JWT_SECRET rotation.
|
||||
SECRETS_KEY=
|
||||
|
||||
# Shared secret for node-agent registration/heartbeat auth.
|
||||
CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
|
||||
# Optional rotation list (comma-separated). When set, this takes precedence.
|
||||
|
||||
@@ -17,6 +17,30 @@ 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 }}
|
||||
mobile: ${{ steps.filter.outputs.mobile }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
code:
|
||||
- 'app/**'
|
||||
- 'infra/**'
|
||||
- 'docker-compose.yml'
|
||||
- 'install.sh'
|
||||
- '.github/workflows/**'
|
||||
- 'docs/api/**'
|
||||
mobile:
|
||||
- 'app/mobile/**'
|
||||
|
||||
infra:
|
||||
name: Infra (Compose + Scripts)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -48,6 +72,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 +96,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
|
||||
@@ -96,10 +124,40 @@ jobs:
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
mobile:
|
||||
name: Mobile (Expo)
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.mobile == 'true' || github.event_name != 'pull_request'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: app/mobile
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: app/mobile/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Lint
|
||||
run: npx expo lint
|
||||
|
||||
smoke:
|
||||
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 +195,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 +286,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
|
||||
|
||||
@@ -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.
|
||||
@@ -57,6 +57,7 @@ dedicated console.
|
||||
- **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), 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
|
||||
|
||||
|
||||
+98
-62
@@ -121,16 +121,16 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
|
||||
- `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)
|
||||
### Phase A — Agent surface (do this FIRST; everything after inherits it) ✅ SHIPPED (PRs #14–#18)
|
||||
|
||||
- [ ] **Personal access tokens.** `user_tokens` table — `id`, `user_id`,
|
||||
- [x] **Personal access tokens.** `user_tokens` table — `id`, `user_id`,
|
||||
`name`, `key_prefix` (`cnp_…`), `key_hash` (sha256), `scopes`
|
||||
(`read`/`write`/`admin`, later resource-scoped), `expires_at`,
|
||||
`last_used_at`. Model on `agent_auth_tokens` (raw shown once, hash stored).
|
||||
`POST/GET/DELETE /api/v1/user/tokens`; `middleware.Auth` accepts
|
||||
`Bearer cnp_…`; Settings → Access tokens UI with one-time display +
|
||||
copyable CLI/MCP config snippets.
|
||||
- [ ] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client:
|
||||
- [x] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client:
|
||||
- `auth login [--token] [--url] [--profile]` (multi-instance profiles).
|
||||
- Resources: `projects services deploy logs exec variables databases
|
||||
cron templates nodes scaling ha security gateway notifications
|
||||
@@ -143,16 +143,16 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
|
||||
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
|
||||
- [x] **MCP server** (`cmd/mcp`, same module, stdio). Tools mirror the
|
||||
CLI: `list_projects create_service deploy get_logs set_variable
|
||||
restart rollback create_database trigger_cron deploy_template exec
|
||||
get_metrics security_scan …`. Auth: `CONTAINR_URL` + `CONTAINR_TOKEN`
|
||||
or shared `~/.containr/config`. `containr mcp serve` + install snippets
|
||||
for Devin/Claude/Cursor.
|
||||
- [ ] **API agent ergonomics.** `Idempotency-Key` on mutating endpoints;
|
||||
- [x] **API agent ergonomics.** `Idempotency-Key` on mutating endpoints;
|
||||
uniform `{error, code, details}` envelope (audit bare-string returns);
|
||||
cursor pagination on growing lists; name-or-id lookup everywhere.
|
||||
- [ ] **Agent docs.** Root `AGENTS.md`, `/docs` serves `llms.txt` +
|
||||
- [x] **Agent docs.** Root `AGENTS.md`, `/docs` serves `llms.txt` +
|
||||
`llms-full.txt`, MCP tool list generated from OpenAPI where possible.
|
||||
|
||||
**Gate**: an agent holding only a PAT registers a project, deploys from a
|
||||
@@ -161,78 +161,68 @@ for a human.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase B — Deployment pipeline maturity
|
||||
## 3. Phase B — Deployment pipeline maturity ✅ SHIPPED (PR #19)
|
||||
|
||||
dflow's one real architectural edge. Containr deploys are fire-and-forget
|
||||
goroutines; this fixes correctness *and* unlocks the ops surface.
|
||||
|
||||
- [ ] **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.
|
||||
- [x] **Deploy job queue.** Lightweight internal queue — per-service
|
||||
FIFO in `internal/deployqueue`; status gains `queued`.
|
||||
- [x] **Cancel deployments.** `POST /deployments/:id/cancel` — queued
|
||||
jobs cancel immediately, active jobs cancel context; `cancelled` status.
|
||||
- [x] **Cache modes.** `no_cache` on deploys; webhook-triggered deploys
|
||||
default to no-cache (dflow convention — prevents stale-layer surprises).
|
||||
- [x] **Queued→building→running status + persisted step logs.** `queued`
|
||||
state lands; streaming over existing log surface.
|
||||
- [x] **Env var apply → redeploy.** `--redeploy` flag on variable writes;
|
||||
backend requeues honoring running state.
|
||||
- [x] **Encrypt variables at rest.** `is_secret` values encrypted
|
||||
AES-GCM `enc:v1:` (key from `SECRETS_KEY`); masked-echo preserves
|
||||
ciphertext.
|
||||
|
||||
## 4. Phase C — Service configuration parity
|
||||
## 4. Phase C — Service configuration parity ✅ SHIPPED (PRs #20–#23)
|
||||
|
||||
- [ ] **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
|
||||
- [x] **Volumes on services** — `services.volumes` jsonb
|
||||
`[{volume|host_path, container_path, read_only}]`; Volumes tab +
|
||||
admin volume inventory.
|
||||
- [x] **Multi-domain** — `service_domains(service_id, domain, is_default,
|
||||
cert_type, status, last_checked_at)`. Domains UI: add/remove/
|
||||
set-default/DNS-status badge. `services.domain` stays derived default.
|
||||
- [x] **DNS preflight** — `GET /services/:id/domains/check` resolves and
|
||||
compares vs edge IP → `ok|wrong-target|pending`.
|
||||
- [ ] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
|
||||
- [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
|
||||
no proxy code needed.
|
||||
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with
|
||||
allowlist validation (middlewares, headers, redirects).
|
||||
- [ ] **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;
|
||||
- [x] **Builder override** — `services.builder` enum
|
||||
`auto|railpack|nixpacks|dockerfile|static`; static builder = build +
|
||||
nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug.
|
||||
- [x] **Resource limits + reserves** — `cpu`/`memory` caps +
|
||||
`cpu_reserve`/`memory_reserve` → NanoCPUs/Memory/MemoryReservation;
|
||||
instance defaults in `app_settings`.
|
||||
- [x] **Pre-deploy capacity check** — reservations vs node capacity;
|
||||
`app_settings.capacity_policy=block` rejects, default warns.
|
||||
- [x] **Service clone** + **move between projects** — copies volumes,
|
||||
domains, access gates, vars (ciphertext verbatim); audit-logged.
|
||||
- [x] **Private registries** — `registry_credentials`
|
||||
(server/username/secret, encrypted); pull auth resolved at deploy;
|
||||
per-service registry link.
|
||||
- [ ] **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).
|
||||
- [x] **Service detail tabs to match dflow's surface**: Domains,
|
||||
Volumes, access-controls sections added to the service page.
|
||||
|
||||
## 5. Phase D — Data layer maturity
|
||||
|
||||
- [ ] **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.
|
||||
- [x] **External DB registration** — `provider='managed'|'external'`,
|
||||
conn fields, `POST /databases/test-connection`, same bind flow. (PR #24)
|
||||
- [x] **Public DB port expose** — `public_port` opt-in `0.0.0.0` host
|
||||
bind; default stays loopback. (PR #25)
|
||||
- [x] **Offsite backups** — `backup_targets` (S3-compatible:
|
||||
endpoint/bucket/keys encrypted); per-DB destination; archives shipped
|
||||
via holder container + minio-go; restore-from-S3 fallback. (PR #26)
|
||||
- [ ] **Cross-node DB migration** — `POST /databases/:id/migrate
|
||||
{target_node_id}`: dump → stream transfer → provision → restore →
|
||||
re-point bound services. Needs ≥2 nodes or local↔node.
|
||||
re-point bound services. Deferred until Phase F lands real second
|
||||
nodes — nothing to migrate between today.
|
||||
- [ ] **Backup import** — upload archive → new restore point (pairs with
|
||||
migrators).
|
||||
|
||||
@@ -318,6 +308,32 @@ and wired refs, one click or one CLI call.
|
||||
audit logs, deployments, notifications tables.
|
||||
- [ ] **Notification producers round-out** — backups, agent offline,
|
||||
upgrade available, security findings.
|
||||
- [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) —
|
||||
`services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper
|
||||
polls container NetworkIO deltas; idle past threshold → workload
|
||||
removed, status `sleeping`. Wake-on-traffic via per-service
|
||||
placeholder container on the edge network carrying the same Traefik
|
||||
host rule at higher priority — serves a waking page, calls
|
||||
`/internal/wake/:id`, exits; Traefik falls back once the app is up.
|
||||
Verified `running → sleeping → waking → running` end to end.
|
||||
- [x] **Env doctor** — `GET /services/:id/env-check` surfaces
|
||||
unresolved `${{…}}` refs, empty values, and unreadable
|
||||
(rotated-key) secrets; template `plan` endpoint reports missing
|
||||
required vars before deploy (PR #30). Frontend Variables section
|
||||
shows the banner.
|
||||
- [x] **Shared project variables** — `project_variables` table,
|
||||
`GET/PUT /projects/:id/variables`, `${{shared.KEY}}` refs resolved
|
||||
at deploy/reconcile, secrets `enc:v1:` at rest and masked (PR #32).
|
||||
- [x] **One-click install** — `install.sh` (curl|sh): detects docker,
|
||||
writes compose + .env with generated secrets incl. `SECRETS_KEY`,
|
||||
pulls images, runs migrations, prints admin URL. GHCR images mean
|
||||
installs never build from source.
|
||||
- [x] **CI/CD v2** — required checks (Go test/vet, frontend
|
||||
lint/build/test, compose boot smoke), `dorny/paths-filter` gate so
|
||||
docs-only PRs skip heavyweight jobs, and release on main/tag:
|
||||
conventional-commit version bump → tag → GHCR images (backend,
|
||||
frontend) → cross-platform CLI + MCP binaries → SHA-256 checksums
|
||||
→ GitHub Release → Trivy scans (PR #33).
|
||||
- [ ] **Custom roles/RBAC** — defer until demanded; PAT scopes designed
|
||||
forward-compatible now.
|
||||
|
||||
@@ -335,6 +351,26 @@ and wired refs, one click or one CLI call.
|
||||
fetch/publish; bundled catalog works offline (dflow's self-contained
|
||||
principle — nothing phones home).
|
||||
|
||||
## 9b. Phase I — Companion surfaces
|
||||
|
||||
- [x] **Phone app (v1)** — `app/mobile`, Expo/React Native thin client
|
||||
over the agent-shaped API; dark UI mirroring the design system. PAT
|
||||
auth against any instance URL, stored in platform secure storage.
|
||||
- **Dashboard** — service status counts (running/sleeping/failed/
|
||||
stopped), per-project service health dots, recent deployments feed.
|
||||
- **Operate** — start/stop/restart/redeploy, sleep/wake, rollback to
|
||||
a prior deployment, database start/stop/restart/backup-now.
|
||||
- **Observe** — polling log tail (300 lines, 5s refresh), per-service
|
||||
deployment history, env-doctor issues surfaced on the service view.
|
||||
- [ ] **env var edit + apply-redeploy, domain management, replica
|
||||
scaling, cron execution history** — next iteration.
|
||||
- [ ] **Push notifications** — zero-infra path = self-hosted
|
||||
ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional.
|
||||
- [ ] **Offline resilience** — cached last-known state + unreachable
|
||||
banner.
|
||||
- [ ] **Status/offline resilience** — app + CLI degrade gracefully when
|
||||
the instance is unreachable (cached last-known state, clear banner).
|
||||
|
||||
## 10. Carried-over debt (ROADMAP.md items still open)
|
||||
|
||||
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit →
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"containr/internal/mcpserver"
|
||||
)
|
||||
|
||||
// Injected at build time via -ldflags.
|
||||
var Version = "dev"
|
||||
|
||||
// containr-mcp exposes the whole Containr API as MCP tools over stdio.
|
||||
// Configure it in an MCP client with:
|
||||
//
|
||||
// command: containr-mcp
|
||||
// env: CONTAINR_API_URL, CONTAINR_TOKEN (a cnp_ personal access token)
|
||||
func main() {
|
||||
apiURL := os.Getenv("CONTAINR_API_URL")
|
||||
if apiURL == "" {
|
||||
apiURL = "http://localhost:8080/api/v1"
|
||||
}
|
||||
apiURL = strings.TrimSuffix(apiURL, "/")
|
||||
if !strings.HasSuffix(apiURL, "/api/v1") {
|
||||
apiURL += "/api/v1"
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := mcpserver.Run(ctx, apiURL, os.Getenv("CONTAINR_TOKEN"), Version); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "containr-mcp: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
+21
-5
@@ -8,11 +8,13 @@ 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/minio/minio-go/v7 v7.0.95
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0
|
||||
github.com/pressly/goose/v3 v3.24.1
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/rs/cors v1.10.1
|
||||
@@ -21,6 +23,8 @@ 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
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -35,22 +39,28 @@ require (
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
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/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/minio/crc64nvme v1.0.2 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
@@ -60,17 +70,23 @@ require (
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/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
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/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,13 +100,13 @@ 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
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
modernc.org/sqlite v1.39.0 // indirect
|
||||
)
|
||||
+39
-9
@@ -46,6 +46,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -63,13 +65,15 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
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/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/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=
|
||||
@@ -80,9 +84,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -97,6 +104,12 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
|
||||
github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
|
||||
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
@@ -105,6 +118,8 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/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=
|
||||
@@ -126,6 +141,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -140,9 +157,15 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
|
||||
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/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=
|
||||
@@ -174,10 +197,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/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 +240,19 @@ golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/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=
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/docker"
|
||||
"containr/internal/secrets"
|
||||
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
// BackupTarget is an S3-compatible archive destination. Secret keys are
|
||||
// stored encrypted and never serialized — has_credentials signals presence.
|
||||
type BackupTarget struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Bucket string `json:"bucket"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
HasCredentials bool `json:"has_credentials"`
|
||||
UseTLS bool `json:"use_tls"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
accessKey string `json:"-"`
|
||||
secretKey string `json:"-"`
|
||||
}
|
||||
|
||||
type backupTargetRequest struct {
|
||||
Name string `json:"name"`
|
||||
Endpoint string `json:"endpoint" binding:"required"`
|
||||
Bucket string `json:"bucket" binding:"required"`
|
||||
Region string `json:"region"`
|
||||
Prefix string `json:"prefix"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
UseTLS *bool `json:"use_tls"`
|
||||
}
|
||||
|
||||
func mapBackupTargetRow(row sqlcdb.BackupTarget) BackupTarget {
|
||||
return BackupTarget{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Endpoint: row.Endpoint,
|
||||
Bucket: row.Bucket,
|
||||
Region: row.Region,
|
||||
Prefix: row.Prefix,
|
||||
HasCredentials: row.SecretKey != "",
|
||||
UseTLS: row.UseTls,
|
||||
CreatedAt: databaseNullTime(row.CreatedAt),
|
||||
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
||||
accessKey: secrets.Decrypt(row.AccessKey),
|
||||
secretKey: secrets.Decrypt(row.SecretKey),
|
||||
}
|
||||
}
|
||||
|
||||
// s3Client builds a client for the target. Endpoint is a bare host[:port] —
|
||||
// scheme comes from use_tls, matching MinIO/Garage/AWS conventions.
|
||||
func (t BackupTarget) s3Client() (*minio.Client, error) {
|
||||
opts := &minio.Options{Secure: t.UseTLS, Region: t.Region}
|
||||
if t.accessKey != "" || t.secretKey != "" {
|
||||
opts.Creds = credentials.NewStaticV4(t.accessKey, t.secretKey, "")
|
||||
} else {
|
||||
opts.Creds = credentials.NewIAM("")
|
||||
}
|
||||
return minio.New(t.Endpoint, opts)
|
||||
}
|
||||
|
||||
func normalizeBackupTargetRequest(req *backupTargetRequest) error {
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Endpoint = strings.TrimSpace(req.Endpoint)
|
||||
req.Endpoint = strings.TrimPrefix(strings.TrimPrefix(req.Endpoint, "https://"), "http://")
|
||||
req.Endpoint = strings.TrimSuffix(req.Endpoint, "/")
|
||||
req.Bucket = strings.TrimSpace(req.Bucket)
|
||||
req.Prefix = strings.Trim(strings.TrimSpace(req.Prefix), "/")
|
||||
if req.Name == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
if req.Endpoint == "" || req.Bucket == "" {
|
||||
return errors.New("endpoint and bucket are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeBackupTarget verifies credentials and bucket access.
|
||||
func probeBackupTarget(ctx context.Context, t BackupTarget) (time.Duration, error) {
|
||||
client, err := t.s3Client()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
start := time.Now()
|
||||
exists, err := client.BucketExists(ctx, t.Bucket)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !exists {
|
||||
return 0, fmt.Errorf("bucket %q not found or not accessible", t.Bucket)
|
||||
}
|
||||
return time.Since(start), nil
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) ListBackupTargets(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := h.queries.ListBackupTargetsByUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list backup targets"})
|
||||
return
|
||||
}
|
||||
targets := make([]BackupTarget, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
t := mapBackupTargetRow(row)
|
||||
t.accessKey, t.secretKey = "", ""
|
||||
targets = append(targets, t)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"backup_targets": targets})
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) CreateBackupTarget(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req backupTargetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
if err := normalizeBackupTargetRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
|
||||
useTLS := true
|
||||
if req.UseTLS != nil {
|
||||
useTLS = *req.UseTLS
|
||||
}
|
||||
target := BackupTarget{
|
||||
ID: "bt_" + strings.ReplaceAll(uuid.NewString(), "-", "")[:12],
|
||||
Name: req.Name,
|
||||
Endpoint: req.Endpoint,
|
||||
Bucket: req.Bucket,
|
||||
Region: req.Region,
|
||||
Prefix: req.Prefix,
|
||||
UseTLS: useTLS,
|
||||
accessKey: req.AccessKey,
|
||||
secretKey: req.SecretKey,
|
||||
}
|
||||
|
||||
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := probeBackupTarget(probeCtx, target); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Connection check failed: %v", err), "code": "DEPENDENCY_UNAVAILABLE"})
|
||||
return
|
||||
}
|
||||
|
||||
enc := func(v string) string {
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
return secrets.Encrypt(v)
|
||||
}
|
||||
now := sql.NullTime{Time: time.Now(), Valid: true}
|
||||
if err := h.queries.CreateBackupTarget(c.Request.Context(), sqlcdb.CreateBackupTargetParams{
|
||||
ID: target.ID,
|
||||
UserID: userID,
|
||||
Name: target.Name,
|
||||
Endpoint: target.Endpoint,
|
||||
Bucket: target.Bucket,
|
||||
Region: target.Region,
|
||||
Prefix: target.Prefix,
|
||||
AccessKey: enc(req.AccessKey),
|
||||
SecretKey: enc(req.SecretKey),
|
||||
UseTls: useTLS,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create backup target"})
|
||||
return
|
||||
}
|
||||
LogAudit(userID, "backup_target", target.ID, "create", map[string]interface{}{"name": req.Name, "endpoint": req.Endpoint, "bucket": req.Bucket})
|
||||
target.accessKey, target.secretKey = "", ""
|
||||
target.HasCredentials = req.SecretKey != ""
|
||||
target.CreatedAt, target.UpdatedAt = now.Time, now.Time
|
||||
c.JSON(http.StatusCreated, gin.H{"backup_target": target})
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) UpdateBackupTarget(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetID := c.Param("id")
|
||||
row, err := h.queries.GetBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.GetBackupTargetByIDAndUserParams{ID: targetID, UserID: userID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Backup target not found", "code": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
var req backupTargetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
if err := normalizeBackupTargetRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
useTLS := row.UseTls
|
||||
if req.UseTLS != nil {
|
||||
useTLS = *req.UseTLS
|
||||
}
|
||||
// Empty credentials keep the stored pair.
|
||||
accessKey := row.AccessKey
|
||||
secretKey := row.SecretKey
|
||||
if req.AccessKey != "" || req.SecretKey != "" {
|
||||
accessKey = secrets.Encrypt(req.AccessKey)
|
||||
secretKey = secrets.Encrypt(req.SecretKey)
|
||||
}
|
||||
|
||||
target := BackupTarget{
|
||||
Endpoint: req.Endpoint,
|
||||
Bucket: req.Bucket,
|
||||
Region: req.Region,
|
||||
Prefix: req.Prefix,
|
||||
UseTLS: useTLS,
|
||||
accessKey: secrets.Decrypt(accessKey),
|
||||
secretKey: secrets.Decrypt(secretKey),
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := probeBackupTarget(probeCtx, target); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Connection check failed: %v", err), "code": "DEPENDENCY_UNAVAILABLE"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.queries.UpdateBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.UpdateBackupTargetByIDAndUserParams{
|
||||
Name: req.Name,
|
||||
Endpoint: req.Endpoint,
|
||||
Bucket: req.Bucket,
|
||||
Region: req.Region,
|
||||
Prefix: req.Prefix,
|
||||
AccessKey: accessKey,
|
||||
SecretKey: secretKey,
|
||||
UseTls: useTLS,
|
||||
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
ID: targetID,
|
||||
UserID: userID,
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update backup target"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Backup target updated"})
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) DeleteBackupTarget(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetID := c.Param("id")
|
||||
count, err := h.queries.CountDatabasesUsingBackupTarget(c.Request.Context(), sql.NullString{String: targetID, Valid: true})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check target usage"})
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("%d database(s) still back up to this target", count), "code": "CONFLICT"})
|
||||
return
|
||||
}
|
||||
if err := h.queries.DeleteBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.DeleteBackupTargetByIDAndUserParams{ID: targetID, UserID: userID}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete backup target"})
|
||||
return
|
||||
}
|
||||
LogAudit(userID, "backup_target", targetID, "delete", nil)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Backup target deleted"})
|
||||
}
|
||||
|
||||
// ImportBackup accepts a `.tar.gz` archive (multipart `file` field or raw
|
||||
// body) and stores it as a restore point — the counterpart to offsite
|
||||
// export, and the entry path for migrators.
|
||||
func (h *DatabaseHandler) ImportBackup(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
databaseID := c.Param("id")
|
||||
userID = h.effectiveUserID(c, userID, databaseID)
|
||||
|
||||
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||
ID: databaseID, UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
if row.Provider == "external" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "backup import applies to managed databases — external restores are not supported yet", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
|
||||
// Buffer to a temp file — tar headers need the size up front, and a
|
||||
// streamed upload can't be rewound for the archive write.
|
||||
src, _, err := openUploadStream(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
tmp, err := os.CreateTemp("", "containr-backup-import-*")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to stage upload"})
|
||||
return
|
||||
}
|
||||
defer func() { tmp.Close(); os.Remove(tmp.Name()) }()
|
||||
|
||||
const maxImport = 2 << 30 // 2 GiB ceiling for staged archives
|
||||
size, err := io.Copy(tmp, http.MaxBytesReader(c.Writer, src, maxImport))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "archive too large or unreadable", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read staged upload"})
|
||||
return
|
||||
}
|
||||
magic := make([]byte, 2)
|
||||
if _, err := tmp.Read(magic); err != nil || magic[0] != 0x1f || magic[1] != 0x8b {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "archive must be gzip-compressed (.tar.gz)", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read staged upload"})
|
||||
return
|
||||
}
|
||||
|
||||
backupID := generateDatabaseBackupID(databaseID)
|
||||
archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID))
|
||||
|
||||
if _, err := h.dockerClient.CreateVolume(c.Request.Context(), docker.VolumeConfig{Name: managedDatabaseBackupVolume}); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already exists") {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to prepare backup volume"})
|
||||
return
|
||||
}
|
||||
holderID, err := h.createBackupVolumeHolder(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to open backup volume: %v", err)})
|
||||
return
|
||||
}
|
||||
defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) }()
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
tw := tar.NewWriter(pw)
|
||||
go func() {
|
||||
err := tw.WriteHeader(&tar.Header{Name: archivePath, Mode: 0o644, Size: size})
|
||||
if err == nil {
|
||||
_, err = io.Copy(tw, tmp)
|
||||
}
|
||||
if cerr := tw.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
_ = pw.CloseWithError(err)
|
||||
}()
|
||||
if err := h.dockerClient.CopyToContainer(c.Request.Context(), holderID, "/backup", pr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to store archive: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
now := sql.NullTime{Time: time.Now(), Valid: true}
|
||||
if err := h.queries.CreateDatabaseBackup(c.Request.Context(), sqlcdb.CreateDatabaseBackupParams{
|
||||
ID: backupID,
|
||||
DatabaseID: databaseID,
|
||||
Size: humanReadableBytes(size),
|
||||
Status: "completed",
|
||||
BackupPath: sql.NullString{String: archivePath, Valid: true},
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register backup"})
|
||||
return
|
||||
}
|
||||
_ = h.queries.SetDatabaseBackupStatusByID(c.Request.Context(), sqlcdb.SetDatabaseBackupStatusByIDParams{
|
||||
Status: "completed",
|
||||
Size: humanReadableBytes(size),
|
||||
CompletedAt: now,
|
||||
ID: backupID,
|
||||
})
|
||||
|
||||
// Imported archives ship offsite too when a target is configured —
|
||||
// the import should be a first-class restore point.
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
defer cancel()
|
||||
if key, err := h.uploadBackupToTarget(ctx, row, archivePath, backupID); err != nil {
|
||||
log.Printf("containr: imported backup %s remote upload failed: %v (local archive kept)", backupID, err)
|
||||
} else if key != "" {
|
||||
log.Printf("containr: imported backup %s shipped to %s", backupID, key)
|
||||
}
|
||||
}()
|
||||
|
||||
LogAudit(userID, "database", databaseID, "backup_import", map[string]interface{}{"backup_id": backupID, "size": size})
|
||||
c.JSON(http.StatusCreated, gin.H{"backup_id": backupID, "size": humanReadableBytes(size), "status": "completed"})
|
||||
}
|
||||
|
||||
// openUploadStream resolves the request body: multipart `file` field when
|
||||
// present, otherwise the raw body (e.g. CLI pipe of a .tar.gz).
|
||||
func openUploadStream(c *gin.Context) (io.ReadCloser, string, error) {
|
||||
if fh, err := c.FormFile("file"); err == nil {
|
||||
src, err := fh.Open()
|
||||
return src, fh.Filename, err
|
||||
}
|
||||
if c.Request.Body == nil || c.Request.ContentLength == 0 {
|
||||
return nil, "", errors.New("no archive uploaded — send multipart field 'file' or a raw .tar.gz body")
|
||||
}
|
||||
return c.Request.Body, "archive.tar.gz", nil
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) TestBackupTarget(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetID := c.Param("id")
|
||||
row, err := h.queries.GetBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.GetBackupTargetByIDAndUserParams{ID: targetID, UserID: userID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Backup target not found", "code": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
target := mapBackupTargetRow(row)
|
||||
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
latency, probeErr := probeBackupTarget(probeCtx, target)
|
||||
if probeErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": false, "error": probeErr.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
|
||||
}
|
||||
|
||||
// uploadBackupToTarget copies the archive out of the backup volume and puts
|
||||
// it in the configured S3 target. Returns the object key, or "" when no
|
||||
// target is configured.
|
||||
func (h *DatabaseHandler) uploadBackupToTarget(ctx context.Context, db sqlcdb.DatabaseService, archivePath, backupID string) (string, error) {
|
||||
if !db.BackupTargetID.Valid || db.BackupTargetID.String == "" {
|
||||
return "", nil
|
||||
}
|
||||
targetRow, err := h.queries.GetBackupTargetByID(ctx, db.BackupTargetID.String)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("backup target lookup failed: %w", err)
|
||||
}
|
||||
target := mapBackupTargetRow(targetRow)
|
||||
|
||||
archive, err := h.readBackupArchive(ctx, archivePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read archive: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, archive)
|
||||
_ = archive.Close()
|
||||
}()
|
||||
|
||||
client, err := target.s3Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := target.Prefix
|
||||
if key != "" {
|
||||
key += "/"
|
||||
}
|
||||
key += "databases/" + db.ID + "/" + sanitizeBackupArchivePath(archivePath)
|
||||
_, err = client.PutObject(ctx, target.Bucket, key, archive, -1, minio.PutObjectOptions{ContentType: "application/gzip"})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("s3 upload: %w", err)
|
||||
}
|
||||
_ = h.queries.SetDatabaseBackupRemoteKeyByID(ctx, sqlcdb.SetDatabaseBackupRemoteKeyByIDParams{
|
||||
RemoteKey: sql.NullString{String: key, Valid: true},
|
||||
ID: backupID,
|
||||
})
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// readBackupArchive streams an archive out of the shared backup volume via a
|
||||
// stopped holder container — the volume has no direct host path.
|
||||
func (h *DatabaseHandler) readBackupArchive(ctx context.Context, archivePath string) (io.ReadCloser, error) {
|
||||
holderID, err := h.createBackupVolumeHolder(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader, err := h.dockerClient.CopyFromContainer(ctx, holderID, "/backup/"+sanitizeBackupArchivePath(archivePath))
|
||||
if err != nil {
|
||||
_ = h.dockerClient.RemoveContainer(context.Background(), holderID, true)
|
||||
return nil, err
|
||||
}
|
||||
// The holder must outlive the stream — deleting the source container while
|
||||
// dockerd serves the archive stalls the read without an error.
|
||||
return &archiveReader{tar: reader, cleanup: func() {
|
||||
_ = h.dockerClient.RemoveContainer(context.Background(), holderID, true)
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// downloadBackupToVolume fetches a remote archive back into the backup volume
|
||||
// so the normal restore path can run unchanged.
|
||||
func (h *DatabaseHandler) downloadBackupToVolume(ctx context.Context, db sqlcdb.DatabaseService, remoteKey, archivePath string) error {
|
||||
targetRow, err := h.queries.GetBackupTargetByID(ctx, db.BackupTargetID.String)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup target lookup failed: %w", err)
|
||||
}
|
||||
target := mapBackupTargetRow(targetRow)
|
||||
client, err := target.s3Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
obj, err := client.GetObject(ctx, target.Bucket, remoteKey, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer obj.Close()
|
||||
|
||||
archive := sanitizeBackupArchivePath(archivePath)
|
||||
if archive == "" {
|
||||
return errors.New("backup path is required")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
data, err := io.ReadAll(obj)
|
||||
if err != nil {
|
||||
return fmt.Errorf("s3 download: %w", err)
|
||||
}
|
||||
if err := tw.WriteHeader(&tar.Header{Name: archive, Mode: 0o644, Size: int64(len(data))}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
holderID, err := h.createBackupVolumeHolder(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) }()
|
||||
return h.dockerClient.CopyToContainer(ctx, holderID, "/backup", &buf)
|
||||
}
|
||||
|
||||
// backupArchiveExists reports whether the archive is still in the local
|
||||
// backup volume — a copy attempt on a holder container is the cheapest check.
|
||||
func (h *DatabaseHandler) backupArchiveExists(ctx context.Context, archivePath string) (bool, error) {
|
||||
holderID, err := h.createBackupVolumeHolder(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) }()
|
||||
reader, err := h.dockerClient.CopyFromContainer(ctx, holderID, "/backup/"+sanitizeBackupArchivePath(archivePath))
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
_ = reader.Close()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// createBackupVolumeHolder creates a container mounting the backup volume so
|
||||
// archives can be copied in and out. It must be started — docker cp/archive
|
||||
// only resolves volume mounts on containers that have run at least once.
|
||||
func (h *DatabaseHandler) createBackupVolumeHolder(ctx context.Context) (string, error) {
|
||||
if err := h.ensureImage(ctx, managedDatabaseBackupImage); err != nil {
|
||||
return "", err
|
||||
}
|
||||
holderID, err := h.dockerClient.CreateContainer(ctx, docker.ContainerConfig{
|
||||
Image: managedDatabaseBackupImage,
|
||||
Cmd: []string{"true"},
|
||||
RestartPolicy: "no",
|
||||
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: managedDatabaseBackupVolume, Target: "/backup"}},
|
||||
Labels: map[string]string{"containr.managed": "true", "containr.utility": "backup-io"},
|
||||
NetworkMode: "none",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := h.dockerClient.StartContainer(ctx, holderID); err != nil {
|
||||
_ = h.dockerClient.RemoveContainer(context.Background(), holderID, true)
|
||||
return "", fmt.Errorf("failed to start backup holder: %w", err)
|
||||
}
|
||||
return holderID, nil
|
||||
}
|
||||
|
||||
// archiveReader unwraps the tar stream CopyFromContainer returns.
|
||||
type archiveReader struct {
|
||||
tar io.ReadCloser
|
||||
tr *tar.Reader
|
||||
err error
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
func (r *archiveReader) Read(p []byte) (int, error) {
|
||||
if r.err != nil {
|
||||
return 0, r.err
|
||||
}
|
||||
if r.tr == nil {
|
||||
r.tr = tar.NewReader(r.tar)
|
||||
// Skip to the first regular file entry.
|
||||
for {
|
||||
hdr, err := r.tr.Next()
|
||||
if err != nil {
|
||||
r.err = err
|
||||
return 0, err
|
||||
}
|
||||
if hdr.Typeflag == tar.TypeReg {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
n, err := r.tr.Read(p)
|
||||
if err != nil {
|
||||
r.err = err
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *archiveReader) Close() error {
|
||||
err := r.tar.Close()
|
||||
if r.cleanup != nil {
|
||||
r.cleanup()
|
||||
r.cleanup = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeBackupTargetRequest(t *testing.T) {
|
||||
req := backupTargetRequest{
|
||||
Name: " garage ",
|
||||
Endpoint: "https://garage.local:3900/",
|
||||
Bucket: " backups ",
|
||||
Prefix: " /prod/dbs/ ",
|
||||
}
|
||||
if err := normalizeBackupTargetRequest(&req); err != nil {
|
||||
t.Fatalf("normalize failed: %v", err)
|
||||
}
|
||||
if req.Endpoint != "garage.local:3900" {
|
||||
t.Fatalf("endpoint not normalized: %q", req.Endpoint)
|
||||
}
|
||||
if req.Bucket != "backups" || req.Prefix != "prod/dbs" {
|
||||
t.Fatalf("bucket/prefix not trimmed: %q %q", req.Bucket, req.Prefix)
|
||||
}
|
||||
|
||||
for i, bad := range []backupTargetRequest{
|
||||
{Endpoint: "e", Bucket: "b"}, // no name
|
||||
{Name: "n", Bucket: "b"}, // no endpoint
|
||||
{Name: "n", Endpoint: "e"}, // no bucket
|
||||
} {
|
||||
if err := normalizeBackupTargetRequest(&bad); err == nil {
|
||||
t.Fatalf("case %d: expected error", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveReaderUnwrapsTar(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
payload := []byte("backup-bytes")
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "b.tar.gz", Mode: 0o644, Size: int64(len(payload))}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := &archiveReader{tar: io.NopCloser(&buf)}
|
||||
got, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("payload mismatch: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package api
|
||||
import (
|
||||
"archive/tar"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/docker"
|
||||
"containr/internal/secrets"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
@@ -51,12 +53,32 @@ type DatabaseService struct {
|
||||
Region string `json:"region" db:"region"`
|
||||
BackupSchedule string `json:"backup_schedule,omitempty"`
|
||||
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
|
||||
Provider string `json:"provider"` // managed | external
|
||||
PublicPort bool `json:"public_port" db:"public_port"`
|
||||
BackupTargetID string `json:"backup_target_id,omitempty"` // S3-compatible archive destination
|
||||
External *DatabaseExternalRef `json:"external,omitempty"` // connection target for external DBs
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
ConnectionURL string `json:"connection_url"`
|
||||
Metrics DatabaseMetrics `json:"metrics"`
|
||||
Backups DatabaseBackupConfig `json:"backups"`
|
||||
Settings DatabaseSettings `json:"settings"`
|
||||
|
||||
// externalPasswordEnc is the stored ciphertext — needed to compose the
|
||||
// connection URL on privileged reads, never serialized.
|
||||
externalPasswordEnc string `json:"-"`
|
||||
}
|
||||
|
||||
// DatabaseExternalRef describes a registered external database's connection
|
||||
// target. The password is stored encrypted and never returned — has_password
|
||||
// signals presence.
|
||||
type DatabaseExternalRef struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Database string `json:"database"`
|
||||
Username string `json:"username"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
SSL bool `json:"ssl"`
|
||||
}
|
||||
|
||||
// DatabaseMetrics represents database performance metrics
|
||||
@@ -86,6 +108,7 @@ type DatabaseBackup struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Size string `json:"size"`
|
||||
Status string `json:"status"` // completed, failed, in_progress
|
||||
Remote bool `json:"remote"` // archive also exists on the configured backup target
|
||||
}
|
||||
|
||||
// DatabaseSettings represents database configuration
|
||||
@@ -102,6 +125,7 @@ type DatabaseCreateRequest struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
Plan string `json:"plan" binding:"required,oneof=hobby starter standard business"`
|
||||
Region string `json:"region" binding:"required"`
|
||||
PublicPort bool `json:"public_port"`
|
||||
}
|
||||
|
||||
// DatabaseUpdateRequest represents a request to update a database
|
||||
@@ -109,6 +133,8 @@ type DatabaseUpdateRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Plan string `json:"plan,omitempty"`
|
||||
BackupSchedule *string `json:"backup_schedule,omitempty"`
|
||||
PublicPort *bool `json:"public_port,omitempty"`
|
||||
BackupTargetID *string `json:"backup_target_id,omitempty"` // empty string clears
|
||||
}
|
||||
|
||||
// DatabaseActionRequest represents a request to perform database actions
|
||||
@@ -276,6 +302,7 @@ func (h *DatabaseHandler) CreateDatabase(c *gin.Context) {
|
||||
Type: req.Type,
|
||||
Plan: req.Plan,
|
||||
Region: req.Region,
|
||||
PublicPort: req.PublicPort,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
@@ -303,7 +330,13 @@ type managedDatabaseCreateRequest struct {
|
||||
Type string
|
||||
Plan string
|
||||
Region string
|
||||
PublicPort bool
|
||||
RuntimeVariables map[string]string
|
||||
// ProjectID + NetworkAlias attach the container to a project network
|
||||
// under Alias — set by graph-template deploys so sibling services can
|
||||
// resolve the database by name.
|
||||
ProjectID string
|
||||
NetworkAlias string
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID string, req managedDatabaseCreateRequest) (databaseID string, name string, dbType string, err error) {
|
||||
@@ -354,6 +387,7 @@ func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID stri
|
||||
Version: version,
|
||||
Plan: plan,
|
||||
Region: region,
|
||||
PublicPort: req.PublicPort,
|
||||
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
})
|
||||
@@ -370,10 +404,17 @@ func (h *DatabaseHandler) createManagedDatabaseAndProvision(ctx context.Context,
|
||||
return "", err
|
||||
}
|
||||
|
||||
go h.provisionDatabaseWithVariables(databaseID, name, dbType, req.RuntimeVariables)
|
||||
go h.provisionDatabaseWithVariables(databaseID, name, dbType, req.RuntimeVariables, dbNetworkAttach{ProjectID: req.ProjectID, Alias: req.NetworkAlias})
|
||||
return databaseID, nil
|
||||
}
|
||||
|
||||
// dbNetworkAttach carries the optional project-network membership for a
|
||||
// managed database provisioned by a graph template.
|
||||
type dbNetworkAttach struct {
|
||||
ProjectID string
|
||||
Alias string
|
||||
}
|
||||
|
||||
// UpdateDatabase updates a database service
|
||||
func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
@@ -392,11 +433,64 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Plan = strings.TrimSpace(req.Plan)
|
||||
|
||||
if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil {
|
||||
if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil && req.PublicPort == nil && req.BackupTargetID == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.PublicPort != nil || req.BackupTargetID != nil {
|
||||
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||
ID: databaseID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
if req.PublicPort != nil {
|
||||
if row.Provider == "external" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "public_port applies to managed databases — external databases are already reachable", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
if err := h.queries.SetDatabaseServicePublicPortByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseServicePublicPortByIDAndUserParams{
|
||||
PublicPort: *req.PublicPort,
|
||||
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
ID: databaseID,
|
||||
UserID: userID,
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update public port"})
|
||||
return
|
||||
}
|
||||
// Port bindings are baked into the container — recreate it so the
|
||||
// new bind scope takes effect. The data volume survives.
|
||||
if *req.PublicPort != row.PublicPort {
|
||||
go h.reprovisionManagedDatabase(databaseID, row.Name, row.Type)
|
||||
}
|
||||
}
|
||||
if req.BackupTargetID != nil {
|
||||
if row.Provider == "external" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "backup targets apply to managed databases — external backups are not supported yet", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
targetID := strings.TrimSpace(*req.BackupTargetID)
|
||||
if targetID != "" {
|
||||
if _, err := h.queries.GetBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.GetBackupTargetByIDAndUserParams{ID: targetID, UserID: userID}); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Backup target not found", "code": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.queries.SetDatabaseBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseBackupTargetByIDAndUserParams{
|
||||
BackupTargetID: sql.NullString{String: targetID, Valid: targetID != ""},
|
||||
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
ID: databaseID,
|
||||
UserID: userID,
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update backup target"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.BackupSchedule != nil {
|
||||
var schedule sql.NullString
|
||||
var nextBackup sql.NullTime
|
||||
@@ -467,21 +561,20 @@ func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) {
|
||||
databaseID := c.Param("id")
|
||||
userID = h.effectiveUserID(c, userID, databaseID)
|
||||
|
||||
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
|
||||
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||
ID: databaseID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
||||
return
|
||||
}
|
||||
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if h.dockerClient != nil {
|
||||
if row.Provider != "external" && h.dockerClient != nil {
|
||||
if err := h.deleteManagedDatabaseRuntime(databaseID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete runtime database"})
|
||||
return
|
||||
@@ -515,17 +608,24 @@ func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
|
||||
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||
ID: databaseID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
||||
return
|
||||
}
|
||||
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
||||
if row.Provider == "external" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Lifecycle actions apply to managed databases only — external databases are run elsewhere",
|
||||
"code": "VALIDATION",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -619,6 +719,13 @@ func (h *DatabaseHandler) CreateBackup(c *gin.Context) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Database is still provisioning"})
|
||||
return
|
||||
}
|
||||
if row.Provider == "external" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Backups run against the managed dump pipeline — external database backups are not supported yet",
|
||||
"code": "VALIDATION",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
backupID := generateDatabaseBackupID(databaseID)
|
||||
archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID))
|
||||
@@ -659,17 +766,23 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
|
||||
dbRow, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||
ID: databaseID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
||||
return
|
||||
}
|
||||
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
||||
if dbRow.Provider == "external" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Restores run against the managed dump pipeline — external database restores are not supported yet",
|
||||
"code": "VALIDATION",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -787,7 +900,7 @@ func (h *DatabaseHandler) DownloadBackup(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics {
|
||||
if h.dockerClient == nil {
|
||||
if h.dockerClient == nil || db.Provider == "external" {
|
||||
return h.generateMockMetrics()
|
||||
}
|
||||
|
||||
@@ -918,6 +1031,7 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string
|
||||
CreatedAt: createdAt,
|
||||
Size: row.Size,
|
||||
Status: row.Status,
|
||||
Remote: row.RemoteKey.Valid && row.RemoteKey.String != "",
|
||||
})
|
||||
if lastBackup == nil && row.Status == "completed" {
|
||||
t := createdAt
|
||||
@@ -945,7 +1059,11 @@ func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
|
||||
|
||||
// resolveConnectionURL returns the stored connection URL for privileged
|
||||
// viewers, or "" when the runtime has not produced one (not provisioned yet).
|
||||
// External databases compose it from the registered fields on demand.
|
||||
func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string {
|
||||
if db.Provider == "external" && db.External != nil {
|
||||
return externalConnectionURL(db.Type, db.External, secrets.Decrypt(db.externalPasswordEnc))
|
||||
}
|
||||
return strings.TrimSpace(db.ConnectionURL)
|
||||
}
|
||||
|
||||
@@ -987,7 +1105,7 @@ func rewriteLoopbackURL(rawURL, host string) string {
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService {
|
||||
if h.dockerClient == nil {
|
||||
if h.dockerClient == nil || db.Provider == "external" {
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -1054,7 +1172,7 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
|
||||
}
|
||||
|
||||
func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
|
||||
return DatabaseService{
|
||||
svc := DatabaseService{
|
||||
ID: row.ID,
|
||||
OwnerID: row.UserID,
|
||||
Name: row.Name,
|
||||
@@ -1065,10 +1183,25 @@ func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
|
||||
Region: row.Region,
|
||||
BackupSchedule: databaseNullString(row.BackupSchedule),
|
||||
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
|
||||
Provider: row.Provider,
|
||||
PublicPort: row.PublicPort,
|
||||
BackupTargetID: databaseNullString(row.BackupTargetID),
|
||||
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
||||
CreatedAt: databaseNullTime(row.CreatedAt),
|
||||
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
||||
}
|
||||
if svc.Provider == "external" {
|
||||
svc.External = &DatabaseExternalRef{
|
||||
Host: databaseNullString(row.ExternalHost),
|
||||
Port: int(row.ExternalPort.Int32),
|
||||
Database: databaseNullString(row.ExternalName),
|
||||
Username: databaseNullString(row.ExternalUsername),
|
||||
HasPassword: row.ExternalPassword != "",
|
||||
SSL: row.ExternalSsl,
|
||||
}
|
||||
svc.externalPasswordEnc = row.ExternalPassword
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
func databaseNullTime(value sql.NullTime) time.Time {
|
||||
@@ -1230,10 +1363,21 @@ type databaseRuntimePlan struct {
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) provisionDatabase(databaseID, databaseName, dbType string) {
|
||||
h.provisionDatabaseWithVariables(databaseID, databaseName, dbType, nil)
|
||||
h.provisionDatabaseWithVariables(databaseID, databaseName, dbType, nil, dbNetworkAttach{})
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string) {
|
||||
// reprovisionManagedDatabase recreates a managed container so changed
|
||||
// runtime settings (public_port) take effect. The data volume persists.
|
||||
func (h *DatabaseHandler) reprovisionManagedDatabase(databaseID, databaseName, dbType string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
if h.dockerClient != nil {
|
||||
_ = h.dockerClient.RemoveContainer(ctx, managedDatabaseContainerName(databaseID), true)
|
||||
}
|
||||
h.provisionDatabase(databaseID, databaseName, dbType)
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string, attach dbNetworkAttach) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -1242,7 +1386,7 @@ func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseNam
|
||||
return
|
||||
}
|
||||
|
||||
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables)
|
||||
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables, attach)
|
||||
if err != nil {
|
||||
log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err)
|
||||
_ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{})
|
||||
@@ -1264,7 +1408,7 @@ func (h *DatabaseHandler) setDatabaseStatusAndConnection(databaseID, status stri
|
||||
})
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, databaseID, databaseName, dbType string, runtimeVariables map[string]string) (string, error) {
|
||||
func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, databaseID, databaseName, dbType string, runtimeVariables map[string]string, attach dbNetworkAttach) (string, error) {
|
||||
plan, err := buildDatabaseRuntimePlan(dbType, databaseName, runtimeVariables)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -1289,8 +1433,12 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
|
||||
}
|
||||
}
|
||||
|
||||
hostIP := "127.0.0.1"
|
||||
if row, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil && row.PublicPort {
|
||||
hostIP = "0.0.0.0"
|
||||
}
|
||||
portBindings := nat.PortMap{
|
||||
plan.Port: []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: ""}},
|
||||
plan.Port: []nat.PortBinding{{HostIP: hostIP, HostPort: ""}},
|
||||
}
|
||||
|
||||
exposedPorts := nat.PortSet{plan.Port: struct{}{}}
|
||||
@@ -1348,6 +1496,17 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
|
||||
return "", fmt.Errorf("failed to start container: %w", err)
|
||||
}
|
||||
|
||||
// Graph-template deploys attach the database to the project network so
|
||||
// sibling services resolve it by alias ({{service.<key>.host}}).
|
||||
if attach.ProjectID != "" && attach.Alias != "" {
|
||||
projectNet := deployment.ProjectNetworkName(attach.ProjectID)
|
||||
if err := h.dockerClient.ConnectNetwork(ctx, projectNet, containerID, network.EndpointSettings{
|
||||
Aliases: []string{attach.Alias},
|
||||
}); err != nil {
|
||||
log.Printf("containr: database %s attach to %s failed: %v", databaseID, projectNet, err)
|
||||
}
|
||||
}
|
||||
|
||||
hostPort, err := h.resolvePublishedHostPort(ctx, containerID, plan.Port)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -1709,10 +1868,17 @@ func (h *DatabaseHandler) createBackupProcess(databaseID, backupID, backupPath s
|
||||
_ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false)
|
||||
return
|
||||
}
|
||||
if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil {
|
||||
if key, err := h.uploadBackupToTarget(ctx, dbRow, backupPath, backupID); err != nil {
|
||||
log.Printf("containr: backup %s remote upload failed: %v (local archive kept)", backupID, err)
|
||||
} else if key != "" {
|
||||
log.Printf("containr: backup %s shipped to %s", backupID, key)
|
||||
}
|
||||
}
|
||||
_ = h.setDatabaseBackupStatus(backupID, "completed", sizeLabel, true)
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) restoreBackupProcess(databaseID, _ string, backupPath string) {
|
||||
func (h *DatabaseHandler) restoreBackupProcess(databaseID, backupID string, backupPath string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -1721,6 +1887,22 @@ func (h *DatabaseHandler) restoreBackupProcess(databaseID, _ string, backupPath
|
||||
return
|
||||
}
|
||||
|
||||
// Restore the archive from the configured S3 target when the local copy
|
||||
// is gone (fresh node, pruned volume) — local takes precedence.
|
||||
if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil && dbRow.BackupTargetID.Valid && dbRow.BackupTargetID.String != "" {
|
||||
if ok, _ := h.backupArchiveExists(ctx, backupPath); !ok {
|
||||
if b, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(ctx, sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{
|
||||
ID: backupID, DatabaseID: databaseID, UserID: dbRow.UserID,
|
||||
}); err == nil && b.RemoteKey.Valid && b.RemoteKey.String != "" {
|
||||
if err := h.downloadBackupToVolume(ctx, dbRow, b.RemoteKey.String, backupPath); err != nil {
|
||||
log.Printf("containr: remote restore fetch failed for backup %s: %v", backupID, err)
|
||||
_ = h.setDatabaseStatus(databaseID, "error")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = h.setDatabaseStatus(databaseID, "building")
|
||||
_ = h.stopManagedDatabase(databaseID)
|
||||
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/secrets"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
// externalDatabaseRequest is the shared payload for registering an external
|
||||
// database and for ad-hoc connection tests.
|
||||
type externalDatabaseRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Host string `json:"host" binding:"required"`
|
||||
Port int `json:"port"`
|
||||
Database string `json:"database"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
SSL bool `json:"ssl"`
|
||||
}
|
||||
|
||||
var externalDefaultPorts = map[string]int{
|
||||
"postgresql": 5432,
|
||||
"mysql": 3306,
|
||||
"mariadb": 3306,
|
||||
"mongodb": 27017,
|
||||
"redis": 6379,
|
||||
"dragonfly": 6379,
|
||||
"clickhouse": 9000,
|
||||
}
|
||||
|
||||
// externalConnectionURL composes a DSN for display/bind use. The password is
|
||||
// embedded only on privileged reads — the value is decrypted at that point.
|
||||
func externalConnectionURL(dbType string, ext *DatabaseExternalRef, password string) string {
|
||||
host := net.JoinHostPort(ext.Host, fmt.Sprintf("%d", ext.Port))
|
||||
creds := ""
|
||||
if ext.Username != "" {
|
||||
creds = url.QueryEscape(ext.Username)
|
||||
if password != "" {
|
||||
creds += ":" + url.QueryEscape(password)
|
||||
}
|
||||
creds += "@"
|
||||
}
|
||||
switch dbType {
|
||||
case "postgresql":
|
||||
u := fmt.Sprintf("postgres://%s%s/%s", creds, host, ext.Database)
|
||||
if ext.SSL {
|
||||
u += "?sslmode=require"
|
||||
}
|
||||
return u
|
||||
case "mysql", "mariadb":
|
||||
return fmt.Sprintf("mysql://%s%s/%s", creds, host, ext.Database)
|
||||
case "mongodb":
|
||||
u := fmt.Sprintf("mongodb://%s%s/%s", creds, host, ext.Database)
|
||||
if ext.SSL {
|
||||
u += "?tls=true"
|
||||
}
|
||||
return u
|
||||
case "redis", "dragonfly":
|
||||
scheme := "redis"
|
||||
if ext.SSL {
|
||||
scheme = "rediss"
|
||||
}
|
||||
// Redis auth is password-only; username support is ACL-era.
|
||||
if password != "" {
|
||||
creds = ":" + url.QueryEscape(password) + "@"
|
||||
if ext.Username != "" {
|
||||
creds = url.QueryEscape(ext.Username) + creds
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%s", scheme, creds, host)
|
||||
case "clickhouse":
|
||||
return fmt.Sprintf("clickhouse://%s%s/%s", creds, host, ext.Database)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%s", dbType, creds, host)
|
||||
}
|
||||
|
||||
// probeExternalDatabase verifies reachability. postgres and redis-family get
|
||||
// real protocol checks via the vendored drivers; everything else gets a TCP
|
||||
// dial — enough to catch typos and firewall issues without new dependencies.
|
||||
func probeExternalDatabase(ctx context.Context, req externalDatabaseRequest) (time.Duration, error) {
|
||||
addr := net.JoinHostPort(req.Host, fmt.Sprintf("%d", req.Port))
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
start := time.Now()
|
||||
|
||||
switch req.Type {
|
||||
case "postgresql":
|
||||
sslmode := "disable"
|
||||
if req.SSL {
|
||||
sslmode = "require"
|
||||
}
|
||||
dsn := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s&connect_timeout=5",
|
||||
url.QueryEscape(req.Username), url.QueryEscape(req.Password), addr,
|
||||
url.QueryEscape(req.Database), sslmode)
|
||||
conn, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := conn.PingContext(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
case "redis", "dragonfly":
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
DialTimeout: 5 * time.Second,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
TLSConfig: redisTLSConfig(req.SSL, req.Host),
|
||||
})
|
||||
defer client.Close()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
default:
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
return time.Since(start), nil
|
||||
}
|
||||
|
||||
func redisTLSConfig(enabled bool, host string) *tls.Config {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
return &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
|
||||
// handleTestDatabaseConnection probes arbitrary connection parameters — used
|
||||
// by the register form's "test" button and by agents before registering.
|
||||
func (h *DatabaseHandler) TestDatabaseConnection(c *gin.Context) {
|
||||
if _, ok := requireAuthenticatedUserID(c); !ok {
|
||||
return
|
||||
}
|
||||
var req externalDatabaseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
if err := normalizeExternalRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
latency, err := probeExternalDatabase(ctx, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
|
||||
}
|
||||
|
||||
// TestDatabaseConnectionByID re-probes a registered external database using
|
||||
// its stored (decrypted) credentials.
|
||||
func (h *DatabaseHandler) TestDatabaseConnectionByID(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
databaseID := c.Param("id")
|
||||
userID = h.effectiveUserID(c, userID, databaseID)
|
||||
|
||||
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||
ID: databaseID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"})
|
||||
return
|
||||
}
|
||||
if row.Provider != "external" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "test-connection applies to external databases", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
|
||||
req := externalDatabaseRequest{
|
||||
Type: row.Type,
|
||||
Host: databaseNullString(row.ExternalHost),
|
||||
Port: int(row.ExternalPort.Int32),
|
||||
Database: databaseNullString(row.ExternalName),
|
||||
Username: databaseNullString(row.ExternalUsername),
|
||||
Password: secrets.Decrypt(row.ExternalPassword),
|
||||
SSL: row.ExternalSsl,
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
latency, probeErr := probeExternalDatabase(ctx, req)
|
||||
if probeErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": false, "error": probeErr.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
|
||||
}
|
||||
|
||||
func normalizeExternalRequest(req *externalDatabaseRequest) error {
|
||||
req.Type = normalizeDatabaseType(req.Type)
|
||||
if _, ok := supportedDatabaseTypes[req.Type]; !ok {
|
||||
return fmt.Errorf("%w: %s", errUnsupportedDatabaseType, req.Type)
|
||||
}
|
||||
req.Host = strings.TrimSpace(req.Host)
|
||||
if req.Host == "" {
|
||||
return errors.New("host is required")
|
||||
}
|
||||
if req.Port <= 0 {
|
||||
req.Port = externalDefaultPorts[req.Type]
|
||||
}
|
||||
if req.Port <= 0 || req.Port > 65535 {
|
||||
return errors.New("port must be 1-65535")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterExternalDatabase records a database hosted elsewhere — same list,
|
||||
// same bind surface, no container. The connection is probed first so typos
|
||||
// fail at registration, not at the first service bind.
|
||||
func (h *DatabaseHandler) RegisterExternalDatabase(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req externalDatabaseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
if err := normalizeExternalRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
|
||||
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := probeExternalDatabase(probeCtx, req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Connection check failed: %v", err),
|
||||
"code": "DEPENDENCY_UNAVAILABLE",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
count, err := h.queries.CountDatabaseServicesByUserAndName(c.Request.Context(), sqlcdb.CountDatabaseServicesByUserAndNameParams{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate database name"})
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "database name already exists", "code": "CONFLICT"})
|
||||
return
|
||||
}
|
||||
|
||||
databaseID := generateDatabaseID(req.Name)
|
||||
now := sql.NullTime{Time: time.Now(), Valid: true}
|
||||
encPassword := ""
|
||||
if req.Password != "" {
|
||||
encPassword = secrets.Encrypt(req.Password)
|
||||
}
|
||||
if err := h.queries.CreateExternalDatabaseService(c.Request.Context(), sqlcdb.CreateExternalDatabaseServiceParams{
|
||||
ID: databaseID,
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Version: "",
|
||||
Plan: "external",
|
||||
Region: "external",
|
||||
ExternalHost: sql.NullString{String: req.Host, Valid: true},
|
||||
ExternalPort: sql.NullInt32{Int32: int32(req.Port), Valid: true},
|
||||
ExternalName: sql.NullString{String: req.Database, Valid: req.Database != ""},
|
||||
ExternalUsername: sql.NullString{String: req.Username, Valid: req.Username != ""},
|
||||
ExternalPassword: encPassword,
|
||||
ExternalSsl: req.SSL,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register database"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(userID, "database", databaseID, "register-external", map[string]interface{}{
|
||||
"name": req.Name,
|
||||
"type": req.Type,
|
||||
"host": req.Host,
|
||||
})
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": databaseID,
|
||||
"message": "External database registered",
|
||||
"status": "running",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeExternalRequestDefaults(t *testing.T) {
|
||||
req := externalDatabaseRequest{Type: "PostgreSQL", Host: " db.example.com ", Port: 0}
|
||||
if err := normalizeExternalRequest(&req); err != nil {
|
||||
t.Fatalf("normalize failed: %v", err)
|
||||
}
|
||||
if req.Type != "postgresql" {
|
||||
t.Fatalf("type not normalized: %q", req.Type)
|
||||
}
|
||||
if req.Host != "db.example.com" {
|
||||
t.Fatalf("host not trimmed: %q", req.Host)
|
||||
}
|
||||
if req.Port != 5432 {
|
||||
t.Fatalf("default port = %d, want 5432", req.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeExternalRequestRejects(t *testing.T) {
|
||||
cases := []externalDatabaseRequest{
|
||||
{Type: "sqlite", Host: "x"}, // unsupported type
|
||||
{Type: "postgresql", Host: " "}, // empty host
|
||||
{Type: "postgresql", Host: "h", Port: 70000}, // out of range
|
||||
}
|
||||
for i, req := range cases {
|
||||
if err := normalizeExternalRequest(&req); err == nil {
|
||||
t.Fatalf("case %d: expected error", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalConnectionURLMasking(t *testing.T) {
|
||||
ext := &DatabaseExternalRef{
|
||||
Host: "db.example.com",
|
||||
Port: 5432,
|
||||
Database: "appdb",
|
||||
Username: "svc",
|
||||
SSL: true,
|
||||
}
|
||||
u := externalConnectionURL("postgresql", ext, "s3cret")
|
||||
if u != "postgres://svc:[email protected]:5432/appdb?sslmode=require" {
|
||||
t.Fatalf("unexpected pg url: %s", u)
|
||||
}
|
||||
u = externalConnectionURL("postgresql", ext, "")
|
||||
if u != "postgres://[email protected]:5432/appdb?sslmode=require" {
|
||||
t.Fatalf("unexpected passwordless url: %s", u)
|
||||
}
|
||||
|
||||
r := &DatabaseExternalRef{Host: "cache.internal", Port: 6379, SSL: true}
|
||||
u = externalConnectionURL("redis", r, "pw")
|
||||
if u != "rediss://:[email protected]:6379" {
|
||||
t.Fatalf("unexpected redis url: %s", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeExternalDatabaseTCP(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
// TCP fallback path — mysql uses a plain dial.
|
||||
latency, err := probeExternalDatabase(context.Background(), externalDatabaseRequest{
|
||||
Type: "mysql", Host: "127.0.0.1", Port: port,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("probe should succeed against open listener: %v", err)
|
||||
}
|
||||
if latency <= 0 {
|
||||
t.Fatalf("latency not recorded")
|
||||
}
|
||||
|
||||
// Closed port must fail fast.
|
||||
if _, err := probeExternalDatabase(context.Background(), externalDatabaseRequest{
|
||||
Type: "mysql", Host: "127.0.0.1", Port: 1,
|
||||
}); err == nil {
|
||||
t.Fatalf("probe should fail against closed port")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisTLSConfig(t *testing.T) {
|
||||
if redisTLSConfig(false, "h") != nil {
|
||||
t.Fatalf("disabled ssl must produce nil tls config")
|
||||
}
|
||||
cfg := redisTLSConfig(true, "db.example.com")
|
||||
if cfg == nil || cfg.ServerName != "db.example.com" {
|
||||
t.Fatalf("expected ServerName passthrough, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
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,14 +605,87 @@ func runDeploymentAndSyncWithImage(
|
||||
}
|
||||
|
||||
func mapEngineStatusToDBStatus(status string) string {
|
||||
switch status {
|
||||
case "running":
|
||||
if status == "running" {
|
||||
return "deployed"
|
||||
case "cancelled":
|
||||
return "failed"
|
||||
default:
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// getDeployQueue returns the per-service deploy queue. SetupRoutes always
|
||||
// sets it; the fallback keeps handler tests that skip SetupRoutes working.
|
||||
func getDeployQueue(c *gin.Context) *deployqueue.Queue {
|
||||
if v, exists := c.Get("deploy_queue"); exists {
|
||||
if q, ok := v.(*deployqueue.Queue); ok && q != nil {
|
||||
return q
|
||||
}
|
||||
}
|
||||
return deployqueue.New()
|
||||
}
|
||||
|
||||
// handleCancelDeployment cancels a queued or in-flight deployment. Queued
|
||||
// jobs are dropped and marked cancelled immediately; active jobs get their
|
||||
// context cancelled so the build/reconcile aborts and the sync loop writes
|
||||
// the terminal 'cancelled' status.
|
||||
func handleCancelDeployment(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available", "code": "DEPENDENCY_UNAVAILABLE"})
|
||||
return
|
||||
}
|
||||
|
||||
deploymentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated", "code": "UNAUTHENTICATED"})
|
||||
return
|
||||
}
|
||||
|
||||
var status, owner string
|
||||
var serviceID uuid.UUID
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT d.status, d.service_id, p.owner_id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1`,
|
||||
deploymentID,
|
||||
).Scan(&status, &serviceID, &owner)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found", "code": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
if owner != userID.(string) && !contextIsAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied", "code": "FORBIDDEN"})
|
||||
return
|
||||
}
|
||||
|
||||
switch status {
|
||||
case "deployed", "failed", "cancelled":
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Deployment already finished", "code": "NOT_CANCELLABLE"})
|
||||
return
|
||||
}
|
||||
|
||||
switch getDeployQueue(c).Cancel(serviceID, deploymentID) {
|
||||
case deployqueue.WasQueued:
|
||||
now := time.Now()
|
||||
_, _ = db.(*database.DB).Exec(
|
||||
`UPDATE deployments SET status = 'cancelled', error = 'Deployment cancelled', completed_at = $1, updated_at = $1 WHERE id = $2`,
|
||||
now, deploymentID,
|
||||
)
|
||||
_, _ = db.(*database.DB).Exec(
|
||||
`UPDATE services SET status = 'cancelled', updated_at = $1 WHERE id = $2`,
|
||||
now, serviceID,
|
||||
)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
|
||||
case deployqueue.WasActive:
|
||||
c.JSON(http.StatusAccepted, gin.H{"status": "cancelling"})
|
||||
default:
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Deployment is not running on this node", "code": "NOT_CANCELLABLE"})
|
||||
}
|
||||
}
|
||||
|
||||
func splitImageReference(image, fallbackTag string) (string, string) {
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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>`))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
"pagination": paginationMeta(offset, limit, int(total)),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -95,9 +98,51 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// Start the cron scheduler — executes due cron_jobs via docker exec.
|
||||
StartCronScheduler(context.Background(), db, dockerClient)
|
||||
|
||||
// Sleep sweeper — scales idle services to zero and prunes wake containers.
|
||||
StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port)
|
||||
|
||||
// Initialize database handler
|
||||
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)
|
||||
|
||||
@@ -115,6 +160,8 @@ 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("api_port", cfg.Port)
|
||||
c.Set("scheduler", scheduler)
|
||||
c.Set("metrics_collector", metricsCollector)
|
||||
c.Set("auto_scaler", autoScaler)
|
||||
@@ -205,6 +252,11 @@ 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)
|
||||
|
||||
// Wake callback for sleep-mode placeholders. Unauthenticated but
|
||||
// non-destructive: it only reconciles the service back to running.
|
||||
public.Any("/internal/wake/:id", handleServiceWake)
|
||||
}
|
||||
|
||||
// Read routes — public browsing. OptionalAuth resolves a session when
|
||||
@@ -225,6 +277,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
read.GET("/services/:id/metrics", handleGetServiceMetrics)
|
||||
read.GET("/services/:id/runtime", handleGetServiceRuntime)
|
||||
read.GET("/services/:id/deployments", handleGetDeployments)
|
||||
read.GET("/services/:id/domains", handleListServiceDomains)
|
||||
read.GET("/services/:id/domains/check", handleCheckServiceDomains)
|
||||
read.GET("/services/:id/env-check", handleServiceEnvCheck)
|
||||
read.GET("/deployments", handleGetRecentDeployments)
|
||||
read.GET("/deployments/:id", handleGetDeployment)
|
||||
|
||||
@@ -256,6 +311,7 @@ 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)
|
||||
@@ -282,17 +338,29 @@ 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/sleep", handleServiceSleep)
|
||||
authed.POST("/services/:id/wake", handleServiceWakeNow)
|
||||
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)
|
||||
@@ -301,6 +369,19 @@ 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)
|
||||
|
||||
// S3-compatible backup targets — owner-scoped, keys encrypted at rest.
|
||||
authed.GET("/backup-targets", databaseHandler.ListBackupTargets)
|
||||
authed.POST("/backup-targets", databaseHandler.CreateBackupTarget)
|
||||
authed.PUT("/backup-targets/:id", databaseHandler.UpdateBackupTarget)
|
||||
authed.DELETE("/backup-targets/:id", databaseHandler.DeleteBackupTarget)
|
||||
authed.POST("/backup-targets/:id/test", databaseHandler.TestBackupTarget)
|
||||
|
||||
// Private-image registries — owner-scoped, credentials encrypted at rest.
|
||||
authed.GET("/registries", handleListRegistries)
|
||||
authed.POST("/registries", handleCreateRegistry)
|
||||
authed.PUT("/registries/:id", handleUpdateRegistry)
|
||||
authed.DELETE("/registries/:id", handleDeleteRegistry)
|
||||
|
||||
// Git integration — providers carry per-user credentials.
|
||||
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
|
||||
authed.POST("/git/github-app/connect", handleConnectGitHubApp)
|
||||
@@ -324,11 +405,15 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
authed.POST("/builds/plan", buildHandler.GetBuildPlan)
|
||||
|
||||
authed.POST("/databases", databaseHandler.CreateDatabase)
|
||||
authed.POST("/databases/register-external", databaseHandler.RegisterExternalDatabase)
|
||||
authed.POST("/databases/test-connection", databaseHandler.TestDatabaseConnection)
|
||||
authed.POST("/databases/:id/test-connection", databaseHandler.TestDatabaseConnectionByID)
|
||||
authed.PUT("/databases/:id", databaseHandler.UpdateDatabase)
|
||||
authed.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
|
||||
authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
|
||||
authed.POST("/databases/:id/backup", databaseHandler.CreateBackup)
|
||||
authed.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
|
||||
authed.POST("/databases/:id/backups/import", databaseHandler.ImportBackup)
|
||||
authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
|
||||
|
||||
authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
|
||||
@@ -347,6 +432,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
authed.PUT("/templates/:id", handleUpdateTemplate)
|
||||
authed.DELETE("/templates/:id", handleDeleteTemplate)
|
||||
authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
|
||||
authed.POST("/templates/:id/plan", handlePlanTemplate)
|
||||
authed.POST("/templates/import/compose", handleImportCompose)
|
||||
authed.POST("/templates/deploy", handleDeployInlineGraph)
|
||||
|
||||
authed.POST("/cron-jobs", handleCreateCronJob)
|
||||
authed.PUT("/cron-jobs/:id", handleUpdateCronJob)
|
||||
@@ -357,6 +445,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// Admin routes — platform-wide controls and cross-user management.
|
||||
admin := v1.Group("/")
|
||||
admin.Use(middleware.Auth(cfg.JWTSecret))
|
||||
admin.Use(middleware.Idempotency())
|
||||
admin.Use(middleware.RequireAdmin())
|
||||
{
|
||||
admin.GET("/admin/overview", handleAdminOverview)
|
||||
@@ -377,6 +466,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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -510,3 +641,112 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
|
||||
_, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleServiceEnvCheck reports environment problems without exposing
|
||||
// values: empty vars, ${{service.KEY}} refs that fail to resolve, and
|
||||
// secrets whose ciphertext can't be decrypted (key rotation).
|
||||
func handleServiceEnvCheck(c *gin.Context) {
|
||||
dbVal, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
db := dbVal.(*database.DB)
|
||||
service, ok := loadReadableService(c, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT key, value, COALESCE(is_secret, false) FROM environment_variables WHERE service_id = $1`,
|
||||
service.ID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var unresolved, empty, unreadable []string
|
||||
var raw []struct{ key, val string }
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
var secret bool
|
||||
if err := rows.Scan(&k, &v, &secret); err != nil {
|
||||
continue
|
||||
}
|
||||
decrypted := secrets.Decrypt(v)
|
||||
if secret && secrets.IsEncrypted(v) && decrypted == v {
|
||||
// Ciphertext failed to decrypt — Decrypt returns the input.
|
||||
unreadable = append(unreadable, k)
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(decrypted) == "" {
|
||||
empty = append(empty, k)
|
||||
}
|
||||
raw = append(raw, struct{ key, val string }{k, decrypted})
|
||||
}
|
||||
|
||||
// Project siblings for ${{name.KEY}} resolution.
|
||||
siblingIDs := map[string]string{}
|
||||
svcRows, err := db.Query(`SELECT id, name FROM services WHERE project_id = $1`, service.ProjectID)
|
||||
if err == nil {
|
||||
for svcRows.Next() {
|
||||
var id, name string
|
||||
if err := svcRows.Scan(&id, &name); err == nil {
|
||||
siblingIDs[strings.ToLower(name)] = id
|
||||
}
|
||||
}
|
||||
svcRows.Close()
|
||||
}
|
||||
|
||||
resolvable := func(serviceName, key string) bool {
|
||||
id, ok := siblingIDs[strings.ToLower(serviceName)]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(key, "HOST") {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(key, "PORT") {
|
||||
var port int
|
||||
if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil {
|
||||
return port > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM environment_variables WHERE service_id = $1 AND key = $2`,
|
||||
id, key,
|
||||
).Scan(&n); err == nil {
|
||||
return n > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for _, kv := range raw {
|
||||
for _, match := range refVarPattern.FindAllStringSubmatch(kv.val, -1) {
|
||||
if len(match) == 3 && !resolvable(match[1], match[2]) {
|
||||
unresolved = append(unresolved, fmt.Sprintf("%s → %s", kv.key, match[0]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if unresolved == nil {
|
||||
unresolved = []string{}
|
||||
}
|
||||
if empty == nil {
|
||||
empty = []string{}
|
||||
}
|
||||
if unreadable == nil {
|
||||
unreadable = []string{}
|
||||
}
|
||||
okResult := len(unresolved) == 0 && len(empty) == 0 && len(unreadable) == 0
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": okResult,
|
||||
"unresolved": unresolved,
|
||||
"empty": empty,
|
||||
"unreadable": unreadable,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -33,10 +37,84 @@ type Service struct {
|
||||
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"`
|
||||
SleepEnabled bool `json:"sleep_enabled" db:"-"`
|
||||
SleepIdleMinutes int `json:"sleep_idle_minutes" db:"-"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// loadServiceSleep fills the sleep-mode columns, which live outside the
|
||||
// wide service SELECTs. Missing columns (pre-migration) degrade to off.
|
||||
func loadServiceSleep(db *database.DB, s *Service) {
|
||||
var idle int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15) FROM services WHERE id = $1`,
|
||||
s.ID,
|
||||
).Scan(&s.SleepEnabled, &idle); err == nil {
|
||||
s.SleepIdleMinutes = idle
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceVolume is a volume/bind mount applied to every replica.
|
||||
type ServiceVolume struct {
|
||||
Type string `json:"type"` // volume | bind (empty = volume)
|
||||
Source string `json:"source"` // volume name or host path
|
||||
Target string `json:"target"` // container path (absolute)
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades
|
||||
// to no mounts rather than failing the request.
|
||||
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
|
||||
var raw []byte
|
||||
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
|
||||
return nil
|
||||
}
|
||||
var stored []ServiceVolume
|
||||
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]deployment.VolumeMount, 0, len(stored))
|
||||
for _, v := range stored {
|
||||
out = append(out, deployment.VolumeMount{
|
||||
Type: v.Type,
|
||||
Source: v.Source,
|
||||
Destination: v.Target,
|
||||
ReadOnly: v.ReadOnly,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validateServiceVolumes enforces the mount contract — bad paths fail
|
||||
// fast at write time instead of surfacing as docker create errors later.
|
||||
func validateServiceVolumes(vols []ServiceVolume) error {
|
||||
for i, v := range vols {
|
||||
if v.Type == "" {
|
||||
v.Type = "volume"
|
||||
}
|
||||
if v.Type != "volume" && v.Type != "bind" {
|
||||
return fmt.Errorf("volumes[%d].type must be volume or bind", i)
|
||||
}
|
||||
if strings.TrimSpace(v.Source) == "" {
|
||||
return fmt.Errorf("volumes[%d].source is required", i)
|
||||
}
|
||||
if !strings.HasPrefix(v.Target, "/") {
|
||||
return fmt.Errorf("volumes[%d].target must be an absolute container path", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateServiceRequest represents a request to create a service
|
||||
type CreateServiceRequest struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
@@ -55,6 +133,14 @@ type CreateServiceRequest struct {
|
||||
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
|
||||
@@ -74,6 +160,16 @@ type UpdateServiceRequest struct {
|
||||
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"`
|
||||
SleepEnabled *bool `json:"sleep_enabled"`
|
||||
SleepIdleMinutes *int `json:"sleep_idle_minutes"`
|
||||
}
|
||||
|
||||
// handleGetServices retrieves all services for a project
|
||||
@@ -118,6 +214,10 @@ 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, ''),
|
||||
COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15),
|
||||
created_at, updated_at
|
||||
FROM services
|
||||
WHERE project_id = $1
|
||||
@@ -138,7 +238,10 @@ 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.SleepEnabled, &service.SleepIdleMinutes,
|
||||
&service.CreatedAt, &service.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
|
||||
@@ -249,12 +352,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 +391,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 +415,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 +434,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 +511,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 +525,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 +536,16 @@ 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)
|
||||
loadServiceSleep(db.(*database.DB), &service)
|
||||
maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
|
||||
service.MaintenanceMode = maintenance
|
||||
service.BasicAuth = basicAuthUsernames(basicAuth)
|
||||
c.JSON(http.StatusOK, gin.H{"service": service})
|
||||
}
|
||||
|
||||
@@ -417,6 +594,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 +609,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 +666,60 @@ 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"
|
||||
}
|
||||
loadServiceSleep(db.(*database.DB), &existingService)
|
||||
if req.SleepEnabled != nil {
|
||||
existingService.SleepEnabled = *req.SleepEnabled
|
||||
}
|
||||
if req.SleepIdleMinutes != nil {
|
||||
if *req.SleepIdleMinutes < 1 || *req.SleepIdleMinutes > 1440 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "sleep_idle_minutes must be 1-1440", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
existingService.SleepIdleMinutes = *req.SleepIdleMinutes
|
||||
}
|
||||
for label, check := range map[string]func(string) error{
|
||||
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
||||
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
||||
} {
|
||||
v := map[string]string{
|
||||
"cpu": existingService.CPU, "cpu_reserve": existingService.CPUReserve,
|
||||
"memory": existingService.Memory, "memory_reserve": existingService.MemoryReserve,
|
||||
}[label]
|
||||
if err := check(v); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
}
|
||||
// lib/pq sends a nil []byte as an empty bytea literal rather than NULL,
|
||||
// so the COALESCE fallback needs an untyped nil when volumes aren't sent.
|
||||
var volumesArg interface{}
|
||||
if req.Volumes != nil {
|
||||
if err := validateServiceVolumes(*req.Volumes); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
existingService.Volumes = *req.Volumes
|
||||
volumesJSON, _ := json.Marshal(*req.Volumes)
|
||||
volumesArg = volumesJSON
|
||||
}
|
||||
|
||||
existingService.UpdatedAt = time.Now()
|
||||
|
||||
@@ -492,14 +729,20 @@ 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,
|
||||
sleep_enabled = $24, sleep_idle_minutes = $25
|
||||
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,
|
||||
existingService.SleepEnabled, existingService.SleepIdleMinutes,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -507,23 +750,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)
|
||||
}
|
||||
}()
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/docker"
|
||||
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Sleep mode: services with sleep_enabled are scaled to zero after
|
||||
// sleep_idle_minutes without inbound traffic. Idleness is measured as
|
||||
// NetworkIO byte deltas across the service's containers — we have no
|
||||
// per-request layer in front of Traefik, so counters are the signal.
|
||||
//
|
||||
// When a service sleeps its containers are removed and a tiny busybox
|
||||
// "wake" container takes over the same Traefik host rule at a higher
|
||||
// priority: it serves a reloading "waking" page and pings the unauthenticated
|
||||
// internal wake endpoint, which reconciles the service back to running.
|
||||
// Wake containers carry a dedicated label (not the service label) so
|
||||
// RuntimeState and reconcile never count them as replicas; the sweeper
|
||||
// prunes them once the workload is running again.
|
||||
|
||||
const (
|
||||
wakeImage = "busybox:1.36"
|
||||
wakeLabel = "containr.wake"
|
||||
wakeForLabel = "containr.wake-for"
|
||||
wakeSweepInterval = 30 * time.Second
|
||||
)
|
||||
|
||||
type sleepTracker struct {
|
||||
mu sync.Mutex
|
||||
activity map[uuid.UUID]sleepActivity
|
||||
}
|
||||
|
||||
type sleepActivity struct {
|
||||
bytes uint64
|
||||
idleSince time.Time
|
||||
}
|
||||
|
||||
var tracker = &sleepTracker{activity: make(map[uuid.UUID]sleepActivity)}
|
||||
|
||||
// StartSleepSweeper polls enabled services and scales idle ones to zero.
|
||||
func StartSleepSweeper(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, apiPort int) {
|
||||
if db == nil || dc == nil || engine == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(wakeSweepInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
sweepSleeping(ctx, db, dc, engine, apiPort)
|
||||
pruneWakeContainers(ctx, db, dc, engine)
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("sleep sweeper: running (interval %s)", wakeSweepInterval)
|
||||
}
|
||||
|
||||
type sleepCandidate struct {
|
||||
ID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
Name string
|
||||
Status string
|
||||
IdleMinutes int
|
||||
}
|
||||
|
||||
func sweepSleeping(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, apiPort int) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, project_id, name, COALESCE(status,''), COALESCE(sleep_idle_minutes,15)
|
||||
FROM services WHERE sleep_enabled AND COALESCE(status,'') IN ('running','deployed','degraded','starting')`)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var candidates []sleepCandidate
|
||||
for rows.Next() {
|
||||
var s sleepCandidate
|
||||
if err := rows.Scan(&s.ID, &s.ProjectID, &s.Name, &s.Status, &s.IdleMinutes); err == nil {
|
||||
candidates = append(candidates, s)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, svc := range candidates {
|
||||
containers, err := engine.ListServiceContainers(ctx, svc.ID.String())
|
||||
if err != nil || len(containers) == 0 {
|
||||
continue
|
||||
}
|
||||
// Ignore wake containers — they're the placeholder, not the workload.
|
||||
workload := containers[:0]
|
||||
for _, c := range containers {
|
||||
if c.Labels[wakeLabel] != "true" {
|
||||
workload = append(workload, c)
|
||||
}
|
||||
}
|
||||
if len(workload) == 0 {
|
||||
continue
|
||||
}
|
||||
var total uint64
|
||||
for _, c := range workload {
|
||||
total += containerNetBytes(ctx, dc, c.ID)
|
||||
}
|
||||
if markIdle(svc.ID, total, time.Duration(svc.IdleMinutes)*time.Minute) {
|
||||
putToSleep(ctx, db, dc, engine, svc, apiPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// markIdle returns true when the network counter stopped growing past the
|
||||
// timeout. First sighting only sets the baseline — no idle without history.
|
||||
func markIdle(id uuid.UUID, bytes uint64, timeout time.Duration) bool {
|
||||
tracker.mu.Lock()
|
||||
defer tracker.mu.Unlock()
|
||||
a := tracker.activity[id]
|
||||
if bytes != a.bytes || a.idleSince.IsZero() {
|
||||
tracker.activity[id] = sleepActivity{bytes: bytes, idleSince: time.Now()}
|
||||
return false
|
||||
}
|
||||
return time.Since(a.idleSince) >= timeout
|
||||
}
|
||||
|
||||
func containerNetBytes(ctx context.Context, dc *docker.Client, id string) uint64 {
|
||||
stats, err := dc.GetContainerStats(ctx, id, false)
|
||||
if err != nil || stats == nil {
|
||||
return 0
|
||||
}
|
||||
defer stats.Body.Close()
|
||||
var snap struct {
|
||||
Networks map[string]struct {
|
||||
RxBytes uint64 `json:"rx_bytes"`
|
||||
TxBytes uint64 `json:"tx_bytes"`
|
||||
} `json:"networks"`
|
||||
}
|
||||
if err := json.NewDecoder(stats.Body).Decode(&snap); err != nil {
|
||||
return 0
|
||||
}
|
||||
var total uint64
|
||||
for _, n := range snap.Networks {
|
||||
total += n.RxBytes + n.TxBytes
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// putToSleep removes the workload containers, marks the service sleeping,
|
||||
// and installs the wake placeholder when the service has a public domain.
|
||||
func putToSleep(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, svc sleepCandidate, apiPort int) {
|
||||
containers, err := engine.ListServiceContainers(ctx, svc.ID.String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, c := range containers {
|
||||
if c.Labels[wakeLabel] == "true" {
|
||||
continue
|
||||
}
|
||||
_ = dc.StopContainer(ctx, c.ID, nil)
|
||||
_ = dc.RemoveContainer(ctx, c.ID, true)
|
||||
}
|
||||
tracker.mu.Lock()
|
||||
delete(tracker.activity, svc.ID)
|
||||
tracker.mu.Unlock()
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'sleeping', updated_at = $1 WHERE id = $2`, time.Now(), svc.ID)
|
||||
log.Printf("sleep: %s (%s) scaled to zero after idle timeout", svc.Name, svc.ID)
|
||||
|
||||
var domain string
|
||||
_ = db.QueryRow(`SELECT COALESCE(domain,'') FROM services WHERE id = $1`, svc.ID).Scan(&domain)
|
||||
domains := serviceDomainNames(db, svc.ID, domain)
|
||||
if len(domains) == 0 {
|
||||
return
|
||||
}
|
||||
if err := spawnWakeContainer(ctx, db, dc, engine, svc, domains, apiPort); err != nil {
|
||||
log.Printf("sleep: wake placeholder for %s failed: %v", svc.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// spawnWakeContainer runs a busybox httpd holding the service's Traefik
|
||||
// host rule at higher priority. On boot it calls the wake endpoint, then
|
||||
// keeps serving a reloading page until the next reconcile removes it.
|
||||
func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, svc sleepCandidate, domains []string, apiPort int) error {
|
||||
edgeNet := ""
|
||||
for _, n := range networkList(ctx, dc) {
|
||||
if n == "containr-network" {
|
||||
edgeNet = n
|
||||
}
|
||||
}
|
||||
if edgeNet == "" {
|
||||
return fmt.Errorf("no edge network")
|
||||
}
|
||||
router := "svc-" + svc.ID.String()[:8]
|
||||
sort.Strings(domains)
|
||||
rules := make([]string, 0, len(domains))
|
||||
for _, d := range domains {
|
||||
rules = append(rules, "Host(`"+d+"`)")
|
||||
}
|
||||
labels := map[string]string{
|
||||
deployment.LabelManaged: "true",
|
||||
deployment.LabelProject: svc.ProjectID.String(),
|
||||
wakeForLabel: svc.ID.String(),
|
||||
wakeLabel: "true",
|
||||
"traefik.enable": "true",
|
||||
"traefik.docker.network": edgeNet,
|
||||
"traefik.http.routers." + router + ".rule": strings.Join(rules, " || "),
|
||||
"traefik.http.routers." + router + ".entrypoints": "web",
|
||||
"traefik.http.routers." + router + ".priority": "100",
|
||||
"traefik.http.services." + router + ".loadbalancer.server.port": "80",
|
||||
}
|
||||
|
||||
wakeURL := wakeEndpointURL(ctx, dc, apiPort, svc.ID, edgeNet)
|
||||
page := `<!doctype html><html><head><meta http-equiv="refresh" content="4"><title>Waking up</title>` +
|
||||
`<style>body{font-family:system-ui,sans-serif;background:#0b0d12;color:#dde2ea;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}` +
|
||||
`.card{text-align:center;max-width:34rem;padding:2rem}h1{font-size:1.25rem;font-weight:600}p{color:#98a2b3}</style></head>` +
|
||||
`<body><div class="card"><h1>Service is waking up</h1><p>This Containr service was sleeping and is resuming now. The page reloads automatically.</p></div></body></html>`
|
||||
// httpd without -f daemonizes, so the sequence is: write page, start
|
||||
// httpd, fire the wake callback, then hold PID1 on sleep infinity.
|
||||
cmd := fmt.Sprintf(
|
||||
`mkdir -p /www && printf '%%s' '%s' > /www/index.html && `+
|
||||
`httpd -p 80 -h /www && `+
|
||||
`(sleep 2; wget -q -O /dev/null --timeout=20 '%s' 2>/dev/null || true) && `+
|
||||
`sleep infinity`,
|
||||
strings.ReplaceAll(page, "'", "'\\''"), wakeURL)
|
||||
|
||||
endpoints := map[string]*network.EndpointSettings{edgeNet: {}}
|
||||
if ownNet := dc.OwnNetworkName(ctx); ownNet != "" && ownNet != edgeNet {
|
||||
endpoints[ownNet] = &network.EndpointSettings{}
|
||||
}
|
||||
|
||||
// Pull is best-effort — the image almost always sits in cache.
|
||||
_ = dc.PullImageWait(ctx, wakeImage, registry.AuthConfig{})
|
||||
|
||||
// A previous placeholder may linger as Exited — clear the name first.
|
||||
name := fmt.Sprintf("containr-wake-%s", svc.ID.String()[:8])
|
||||
if stale, err := dc.ListContainersFiltered(ctx, filters.NewArgs(
|
||||
filters.Arg("name", "^/"+name+"$"),
|
||||
), true); err == nil {
|
||||
for _, s := range stale {
|
||||
_ = dc.RemoveContainer(ctx, s.ID, true)
|
||||
}
|
||||
}
|
||||
|
||||
id, err := dc.CreateContainer(ctx, docker.ContainerConfig{
|
||||
Name: name,
|
||||
Image: wakeImage,
|
||||
Cmd: []string{"sh", "-c", cmd},
|
||||
Labels: labels,
|
||||
Networks: endpoints,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return dc.StartContainer(ctx, id)
|
||||
}
|
||||
|
||||
func networkList(ctx context.Context, dc *docker.Client) []string {
|
||||
networks, err := dc.ListNetworks(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(networks))
|
||||
for _, n := range networks {
|
||||
names = append(names, n.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// wakeEndpointURL builds the internal URL the wake container calls. The
|
||||
// edge network's gateway reaches the host's published API port; when the
|
||||
// API itself is containerized its own name resolves on the shared network.
|
||||
func wakeEndpointURL(ctx context.Context, dc *docker.Client, apiPort int, serviceID uuid.UUID, edgeNet string) string {
|
||||
if dc.OwnNetworkName(ctx) != "" {
|
||||
if self, err := dc.GetContainer(ctx, selfID()); err == nil {
|
||||
name := strings.TrimPrefix(self.Name, "/")
|
||||
if name != "" {
|
||||
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", name, apiPort, serviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if gw := networkGateway(ctx, dc, edgeNet); gw != "" {
|
||||
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", gw, apiPort, serviceID)
|
||||
}
|
||||
if probe := dc.HostProbeHost(ctx); probe != "" {
|
||||
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", probe, apiPort, serviceID)
|
||||
}
|
||||
return fmt.Sprintf("http://127.0.0.1:%d/api/v1/internal/wake/%s", apiPort, serviceID)
|
||||
}
|
||||
|
||||
func networkGateway(ctx context.Context, dc *docker.Client, name string) string {
|
||||
// ListNetworks does not populate IPAM config — inspect is required.
|
||||
return dc.NetworkGateway(ctx, name)
|
||||
}
|
||||
|
||||
func selfID() string {
|
||||
// Inside a container the hostname is the short container ID.
|
||||
if hn, err := os.Hostname(); err == nil {
|
||||
return hn
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pruneWakeContainers removes wake placeholders whose service is healthy
|
||||
// again — belt & braces over the reconcile drift sweep.
|
||||
func pruneWakeContainers(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine) {
|
||||
containers, err := dc.ListContainersFiltered(ctx, filters.NewArgs(filters.Arg("label", wakeLabel+"=true")), true)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, c := range containers {
|
||||
svcID := c.Labels[wakeForLabel]
|
||||
if svcID == "" {
|
||||
continue
|
||||
}
|
||||
running, _ := engine.ListServiceContainers(ctx, svcID)
|
||||
live := 0
|
||||
for _, r := range running {
|
||||
if r.State == "running" {
|
||||
live++
|
||||
}
|
||||
}
|
||||
var status string
|
||||
_ = db.QueryRow(`SELECT COALESCE(status,'') FROM services WHERE id = $1`, svcID).Scan(&status)
|
||||
if live > 0 || status == "stopped" || status == "" {
|
||||
_ = dc.RemoveContainer(ctx, c.ID, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleServiceWake is the unauthenticated internal endpoint the wake
|
||||
// container hits. It reconciles the service back to running. Waking is
|
||||
// idempotent and non-destructive, so no token is required; the endpoint
|
||||
// never mutates configuration.
|
||||
func handleServiceWake(c *gin.Context) {
|
||||
dbVal, _ := c.Get("db")
|
||||
db := dbVal.(*database.DB)
|
||||
engineVal, _ := c.Get("deployment_engine")
|
||||
engine, _ := engineVal.(*deployment.DeploymentEngine)
|
||||
if engine == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "deployment engine unavailable"})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid service id"})
|
||||
return
|
||||
}
|
||||
var service Service
|
||||
err = db.QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||
COALESCE(s.static_dir, ''),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s WHERE s.id = $1`, id,
|
||||
).Scan(
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||
&service.CreatedAt, &service.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "service not found"})
|
||||
return
|
||||
}
|
||||
if service.Status == "running" || service.Status == "deployed" {
|
||||
c.JSON(http.StatusOK, gin.H{"status": service.Status})
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
spec, err := serviceRuntimeSpec(db, service)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
state, err := engine.ReconcileService(ctx, spec)
|
||||
if err != nil {
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||
return
|
||||
}
|
||||
status := "running"
|
||||
if state != nil {
|
||||
status = state.Status
|
||||
persistPublishedPort(db, service.ID, state.Ports)
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
|
||||
}()
|
||||
c.JSON(http.StatusAccepted, gin.H{"status": "waking"})
|
||||
}
|
||||
|
||||
// handleServiceSleep puts a service to sleep immediately (owner+ only).
|
||||
func handleServiceSleep(c *gin.Context) {
|
||||
dbVal, _ := c.Get("db")
|
||||
db := dbVal.(*database.DB)
|
||||
engineVal, _ := c.Get("deployment_engine")
|
||||
engine, _ := engineVal.(*deployment.DeploymentEngine)
|
||||
dcVal, _ := c.Get("docker_client")
|
||||
dc, _ := dcVal.(*docker.Client)
|
||||
if engine == nil || dc == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "deployment engine unavailable"})
|
||||
return
|
||||
}
|
||||
service, ok := loadOwnedService(c, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var port int
|
||||
_ = db.QueryRow(`SELECT COALESCE(port,0) FROM services WHERE id = $1`, service.ID).Scan(&port)
|
||||
svc := sleepCandidate{
|
||||
ID: service.ID,
|
||||
ProjectID: service.ProjectID,
|
||||
Name: service.Name,
|
||||
Status: service.Status,
|
||||
}
|
||||
go putToSleep(context.Background(), db, dc, engine, svc, apiPortFromContext(c))
|
||||
c.JSON(http.StatusAccepted, gin.H{"status": "sleeping"})
|
||||
}
|
||||
|
||||
// handleServiceWakeNow wakes a sleeping service on demand (owner+ only).
|
||||
func handleServiceWakeNow(c *gin.Context) {
|
||||
if _, ok := loadOwnedService(c, c.MustGet("db").(*database.DB)); !ok {
|
||||
return
|
||||
}
|
||||
handleServiceWake(c)
|
||||
}
|
||||
|
||||
// apiPortFromContext pulls the configured HTTP port for the wake callback URL.
|
||||
func apiPortFromContext(c *gin.Context) int {
|
||||
if v, exists := c.Get("api_port"); exists {
|
||||
if p, ok := v.(int); ok {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return 8080
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestMarkIdleTracksCounter(t *testing.T) {
|
||||
id := uuid.New()
|
||||
timeout := 50 * time.Millisecond
|
||||
|
||||
// First sighting sets the baseline only.
|
||||
if markIdle(id, 100, timeout) {
|
||||
t.Fatal("slept on first sighting")
|
||||
}
|
||||
// Counter growth resets the idle clock.
|
||||
if markIdle(id, 200, timeout) {
|
||||
t.Fatal("slept while counter grew")
|
||||
}
|
||||
// Flat counter within the window doesn't sleep.
|
||||
if markIdle(id, 200, timeout) {
|
||||
t.Fatal("slept before timeout")
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
if !markIdle(id, 200, timeout) {
|
||||
t.Fatal("did not sleep after timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkIdleZeroCounterSleeps(t *testing.T) {
|
||||
id := uuid.New()
|
||||
timeout := 20 * time.Millisecond
|
||||
|
||||
markIdle(id, 0, timeout)
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
if !markIdle(id, 0, timeout) {
|
||||
t.Fatal("zero-traffic service never slept")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,202 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTopoSortServicesOrdersByDependsOn(t *testing.T) {
|
||||
sorted, err := topoSortServices([]TemplateServiceSpec{
|
||||
{Key: "web", DependsOn: []string{"db"}},
|
||||
{Key: "db", Type: "database"},
|
||||
{Key: "worker", DependsOn: []string{"web", "db"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("topoSortServices: %v", err)
|
||||
}
|
||||
if sorted[0].Key != "db" || sorted[1].Key != "web" || sorted[2].Key != "worker" {
|
||||
t.Fatalf("unexpected order: %v", []string{sorted[0].Key, sorted[1].Key, sorted[2].Key})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopoSortServicesRejectsCycle(t *testing.T) {
|
||||
_, err := topoSortServices([]TemplateServiceSpec{
|
||||
{Key: "a", DependsOn: []string{"b"}},
|
||||
{Key: "b", DependsOn: []string{"a"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "cycle") {
|
||||
t.Fatalf("expected cycle error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopoSortServicesRejectsUnknownDep(t *testing.T) {
|
||||
_, err := topoSortServices([]TemplateServiceSpec{
|
||||
{Key: "a", DependsOn: []string{"ghost"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown key") {
|
||||
t.Fatalf("expected unknown-key error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStaticExpr(t *testing.T) {
|
||||
unresolved := []string{}
|
||||
var generated bool
|
||||
|
||||
v := resolveStaticExpr("{{secret}}", nil, &unresolved, &generated)
|
||||
if len(v) != 32 || !generated {
|
||||
t.Fatalf("secret: got %q generated=%v", v, generated)
|
||||
}
|
||||
v = resolveStaticExpr("{{secret(8)}}", nil, &unresolved, &generated)
|
||||
if len(v) != 16 {
|
||||
t.Fatalf("secret(8): got %q", v)
|
||||
}
|
||||
v = resolveStaticExpr("port-{{random:10}}", nil, &unresolved, &generated)
|
||||
if !strings.HasPrefix(v, "port-") {
|
||||
t.Fatalf("random: got %q", v)
|
||||
}
|
||||
v = resolveStaticExpr("{{DOMAIN:-example.com}}", nil, &unresolved, &generated)
|
||||
if v != "example.com" {
|
||||
t.Fatalf("var default: got %q", v)
|
||||
}
|
||||
v = resolveStaticExpr("{{DOMAIN}}", map[string]string{"DOMAIN": "x.io"}, &unresolved, &generated)
|
||||
if v != "x.io" {
|
||||
t.Fatalf("var override: got %q", v)
|
||||
}
|
||||
v = resolveStaticExpr("{{MISSING}}", nil, &unresolved, &generated)
|
||||
if v != "" || len(unresolved) == 0 || unresolved[0] != "MISSING" {
|
||||
t.Fatalf("missing var: got %q unresolved=%v", v, unresolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTemplateGraphDatabaseRefs(t *testing.T) {
|
||||
sorted, err := topoSortServices([]TemplateServiceSpec{
|
||||
{Key: "db", Type: "database", Runtime: "postgresql", Environment: map[string]string{
|
||||
"POSTGRES_DB": "shop",
|
||||
}},
|
||||
{Key: "web", Type: "web", Runtime: "nginx", DependsOn: []string{"db"}, Environment: map[string]string{
|
||||
"DATABASE_URL": "{{service.db.url}}",
|
||||
"DB_HOST": "{{service.db.host}}",
|
||||
"DB_PORT": "{{service.db.port}}",
|
||||
"SESSION_TOKEN": "{{secret}}",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("topo: %v", err)
|
||||
}
|
||||
graph := resolveTemplateGraph(sorted, nil)
|
||||
if len(graph.unresolved) > 0 {
|
||||
t.Fatalf("unresolved: %v", graph.unresolved)
|
||||
}
|
||||
web := graph.members[1]
|
||||
if web.env["DB_HOST"] != "db" {
|
||||
t.Fatalf("DB_HOST=%q", web.env["DB_HOST"])
|
||||
}
|
||||
if web.env["DB_PORT"] != "5432" {
|
||||
t.Fatalf("DB_PORT=%q", web.env["DB_PORT"])
|
||||
}
|
||||
dbURL := web.env["DATABASE_URL"]
|
||||
if !strings.HasPrefix(dbURL, "postgresql://") || !strings.Contains(dbURL, "@db:5432/shop") {
|
||||
t.Fatalf("DATABASE_URL=%q", dbURL)
|
||||
}
|
||||
if len(web.env["SESSION_TOKEN"]) != 32 || !web.secretKeys["SESSION_TOKEN"] {
|
||||
t.Fatalf("SESSION_TOKEN=%q secret=%v", web.env["SESSION_TOKEN"], web.secretKeys["SESSION_TOKEN"])
|
||||
}
|
||||
// Database member must carry the generated password so refs and the
|
||||
// container agree.
|
||||
db := graph.members[0]
|
||||
if pw := db.env["POSTGRES_PASSWORD"]; len(pw) != 32 || !db.secretKeys["POSTGRES_PASSWORD"] {
|
||||
t.Fatalf("POSTGRES_PASSWORD=%q secret=%v", pw, db.secretKeys["POSTGRES_PASSWORD"])
|
||||
}
|
||||
if !strings.Contains(dbURL, db.env["POSTGRES_PASSWORD"]) {
|
||||
t.Fatalf("url %q does not carry the resolved password", dbURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTemplateGraphServiceRefs(t *testing.T) {
|
||||
sorted, err := topoSortServices([]TemplateServiceSpec{
|
||||
{Key: "api", Type: "web", Runtime: "ghcr.io/x/api:1", Port: 8080},
|
||||
{Key: "front", Type: "web", Runtime: "ghcr.io/x/front:1", Port: 3000, DependsOn: []string{"api"}, Environment: map[string]string{
|
||||
"API_URL": "{{service.api.url}}/v2",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("topo: %v", err)
|
||||
}
|
||||
graph := resolveTemplateGraph(sorted, nil)
|
||||
if got := graph.members[1].env["API_URL"]; got != "http://api:8080/v2" {
|
||||
t.Fatalf("API_URL=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTemplateGraphUnresolvedServiceProp(t *testing.T) {
|
||||
sorted, _ := topoSortServices([]TemplateServiceSpec{
|
||||
{Key: "web", Type: "web", Runtime: "nginx", Environment: map[string]string{
|
||||
"X": "{{service.nope.host}}",
|
||||
}},
|
||||
})
|
||||
graph := resolveTemplateGraph(sorted, nil)
|
||||
if len(graph.unresolved) == 0 {
|
||||
t.Fatal("expected unresolved ref")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTemplateGraph(t *testing.T) {
|
||||
if err := validateTemplateGraph([]TemplateServiceSpec{
|
||||
{Key: "db", Type: "database", Runtime: "postgresql"},
|
||||
}); err != nil {
|
||||
t.Fatalf("valid db spec rejected: %v", err)
|
||||
}
|
||||
if err := validateTemplateGraph([]TemplateServiceSpec{
|
||||
{Key: "db", Type: "database", Runtime: "nonsense"},
|
||||
}); err == nil {
|
||||
t.Fatal("expected unsupported db engine error")
|
||||
}
|
||||
if err := validateTemplateGraph([]TemplateServiceSpec{
|
||||
{Key: "web", Type: "web"},
|
||||
}); err == nil {
|
||||
t.Fatal("expected missing runtime error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOfficialTemplatesAllParse walks every seeded template and asserts the
|
||||
// config parses, graph templates topo-sort + validate, and a dry-run resolve
|
||||
// produces no unresolved refs (defaults cover all expressions).
|
||||
func TestOfficialTemplatesAllParse(t *testing.T) {
|
||||
for _, tpl := range SeedTemplates() {
|
||||
var cfg TemplateConfig
|
||||
if err := json.Unmarshal([]byte(tpl.Config), &cfg); err != nil {
|
||||
t.Errorf("%s: config parse: %v", tpl.ID, err)
|
||||
continue
|
||||
}
|
||||
if len(cfg.Services) == 0 {
|
||||
continue
|
||||
}
|
||||
sorted, err := topoSortServices(cfg.Services)
|
||||
if err != nil {
|
||||
t.Errorf("%s: topo sort: %v", tpl.ID, err)
|
||||
continue
|
||||
}
|
||||
if err := validateTemplateGraph(sorted); err != nil {
|
||||
t.Errorf("%s: validate: %v", tpl.ID, err)
|
||||
continue
|
||||
}
|
||||
var vars []TemplateVariable
|
||||
if tpl.Variables != "" {
|
||||
if err := json.Unmarshal([]byte(tpl.Variables), &vars); err != nil {
|
||||
t.Errorf("%s: variables parse: %v", tpl.ID, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
userVars := map[string]string{}
|
||||
for _, v := range vars {
|
||||
if v.Default != "" {
|
||||
userVars[v.Key] = v.Default
|
||||
}
|
||||
}
|
||||
graph := resolveTemplateGraph(sorted, userVars)
|
||||
if len(graph.unresolved) > 0 {
|
||||
t.Errorf("%s: unresolved refs: %v", tpl.ID, graph.unresolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package api
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/secrets"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
@@ -34,6 +36,7 @@ type ServiceTemplate struct {
|
||||
}
|
||||
|
||||
type TemplateConfig struct {
|
||||
Version int `json:"version,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Runtime string `json:"runtime"`
|
||||
BuildCommand string `json:"build_command"`
|
||||
@@ -43,6 +46,9 @@ type TemplateConfig struct {
|
||||
Environment map[string]string `json:"environment"`
|
||||
Dockerfile string `json:"dockerfile,omitempty"`
|
||||
NixpacksConfig map[string]string `json:"nixpacks_config,omitempty"`
|
||||
// Services makes this a v2 graph template. When non-empty the flat
|
||||
// fields above are ignored and deployTemplateGraph handles the deploy.
|
||||
Services []TemplateServiceSpec `json:"services,omitempty"`
|
||||
}
|
||||
|
||||
type TemplateVariable struct {
|
||||
@@ -147,7 +153,7 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
|
||||
var req struct {
|
||||
ProjectID string `json:"project_id" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Name string `json:"name"`
|
||||
Plan string `json:"plan,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Variables map[string]string `json:"variables"`
|
||||
@@ -160,10 +166,6 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
|
||||
req.ProjectID = strings.TrimSpace(req.ProjectID)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
projectID, err := uuid.Parse(req.ProjectID)
|
||||
if err != nil {
|
||||
@@ -213,6 +215,51 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(config.Services) > 0 {
|
||||
var engine *deployment.DeploymentEngine
|
||||
if v, exists := c.Get("deployment_engine"); exists && v != nil {
|
||||
engine, _ = v.(*deployment.DeploymentEngine)
|
||||
}
|
||||
var dbHandler *DatabaseHandler
|
||||
if v, exists := c.Get("database_handler"); exists {
|
||||
dbHandler, _ = v.(*DatabaseHandler)
|
||||
}
|
||||
|
||||
userVars := map[string]string{}
|
||||
for _, v := range templateVars {
|
||||
if override, ok := req.Variables[v.Key]; ok {
|
||||
userVars[v.Key] = override
|
||||
} else if v.Default != "" {
|
||||
userVars[v.Key] = v.Default
|
||||
}
|
||||
}
|
||||
for k, v := range req.Variables {
|
||||
userVars[k] = v
|
||||
}
|
||||
|
||||
created, fail := deployTemplateGraph(c, db, engine, dbHandler, projectID, userID,
|
||||
config.Services, templateVars, userVars, req.Plan, req.Region, "template:"+templateID)
|
||||
if fail != nil {
|
||||
status := http.StatusBadRequest
|
||||
if (*fail)["code"] == "CONFLICT" {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, *fail)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"resource": "stack",
|
||||
"created": created,
|
||||
"message": fmt.Sprintf("Deployed %d resources from template %s", len(created), template.Name),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
serviceType, err := normalizeTemplateServiceType(config.Type)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -351,11 +398,15 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
stored := value
|
||||
if secretKeys[key] {
|
||||
stored = secrets.Encrypt(value)
|
||||
}
|
||||
err = txQueries.UpsertEnvironmentVariable(ctx, sqlcdb.UpsertEnvironmentVariableParams{
|
||||
ID: uuid.New(),
|
||||
ServiceID: serviceID,
|
||||
Key: key,
|
||||
Value: value,
|
||||
Value: stored,
|
||||
IsSecret: sql.NullBool{Bool: secretKeys[key], Valid: true},
|
||||
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
@@ -927,6 +978,102 @@ func SeedTemplates() []ServiceTemplate {
|
||||
Variables: `[]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
// Multi-service stacks — v2 graph configs. Database members become
|
||||
// managed databases attached to the project network under their key.
|
||||
{
|
||||
ID: "tpl-wordpress-stack",
|
||||
Name: "WordPress + MySQL",
|
||||
Description: "WordPress with a managed MySQL database on a private project network",
|
||||
Category: "stack",
|
||||
Logo: "https://cdn.simpleicons.org/wordpress",
|
||||
Config: `{"version":2,"services":[
|
||||
{"key":"db","type":"database","runtime":"mysql","environment":{"MYSQL_DATABASE":"wordpress"}},
|
||||
{"key":"web","name":"wordpress","type":"web","runtime":"wordpress:6-apache","port":80,"health_check":"/wp-login.php","depends_on":["db"],
|
||||
"environment":{"WORDPRESS_DB_HOST":"{{service.db.host}}","WORDPRESS_DB_USER":"{{service.db.user}}","WORDPRESS_DB_PASSWORD":"{{service.db.password}}","WORDPRESS_DB_NAME":"{{service.db.database}}"}}]}`,
|
||||
Variables: `[]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-n8n-stack",
|
||||
Name: "n8n + PostgreSQL",
|
||||
Description: "n8n workflow automation backed by a managed PostgreSQL database",
|
||||
Category: "stack",
|
||||
Logo: "https://cdn.simpleicons.org/n8n",
|
||||
Config: `{"version":2,"services":[
|
||||
{"key":"db","type":"database","runtime":"postgresql"},
|
||||
{"key":"n8n","type":"web","runtime":"n8nio/n8n:latest","port":5678,"health_check":"/healthz","depends_on":["db"],
|
||||
"environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"{{service.db.port}}","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","N8N_ENCRYPTION_KEY":"{{secret}}"}}]}`,
|
||||
Variables: `[]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-ghost-stack",
|
||||
Name: "Ghost + MySQL",
|
||||
Description: "Ghost publishing platform backed by a managed MySQL database",
|
||||
Category: "stack",
|
||||
Logo: "https://cdn.simpleicons.org/ghost",
|
||||
Config: `{"version":2,"services":[
|
||||
{"key":"db","type":"database","runtime":"mysql","environment":{"MYSQL_DATABASE":"ghost"}},
|
||||
{"key":"web","name":"ghost","type":"web","runtime":"ghost:5-alpine","port":2368,"depends_on":["db"],
|
||||
"environment":{"url":"{{APP_URL:-http://localhost:2368}}","database__client":"mysql","database__connection__host":"{{service.db.host}}","database__connection__user":"{{service.db.user}}","database__connection__password":"{{service.db.password}}","database__connection__database":"{{service.db.database}}"}}]}`,
|
||||
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-gitea-stack",
|
||||
Name: "Gitea + PostgreSQL",
|
||||
Description: "Self-hosted Git service backed by a managed PostgreSQL database",
|
||||
Category: "stack",
|
||||
Logo: "https://cdn.simpleicons.org/gitea",
|
||||
Config: `{"version":2,"services":[
|
||||
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"gitea"}},
|
||||
{"key":"web","name":"gitea","type":"web","runtime":"gitea/gitea:1","port":3000,"health_check":"/api/healthz","depends_on":["db"],
|
||||
"environment":{"GITEA__database__DB_TYPE":"postgres","GITEA__database__HOST":"{{service.db.host}}:{{service.db.port}}","GITEA__database__NAME":"{{service.db.database}}","GITEA__database__USER":"{{service.db.user}}","GITEA__database__PASSWD":"{{service.db.password}}","GITEA__security__SECRET_KEY":"{{secret}}","GITEA__security__INTERNAL_TOKEN":"{{secret(64)}}"}}]}`,
|
||||
Variables: `[]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-nextcloud-stack",
|
||||
Name: "Nextcloud + PostgreSQL + Redis",
|
||||
Description: "Nextcloud with a managed PostgreSQL database and Redis cache",
|
||||
Category: "stack",
|
||||
Logo: "https://cdn.simpleicons.org/nextcloud",
|
||||
Config: `{"version":2,"services":[
|
||||
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"nextcloud"}},
|
||||
{"key":"cache","type":"database","runtime":"redis"},
|
||||
{"key":"web","name":"nextcloud","type":"web","runtime":"nextcloud:30-apache","port":80,"health_check":"/status.php","depends_on":["db","cache"],
|
||||
"environment":{"POSTGRES_HOST":"{{service.db.host}}","POSTGRES_DB":"{{service.db.database}}","POSTGRES_USER":"{{service.db.user}}","POSTGRES_PASSWORD":"{{service.db.password}}","REDIS_HOST":"{{service.cache.host}}","REDIS_HOST_PASSWORD":"{{service.cache.password}}","NEXTCLOUD_ADMIN_USER":"admin","NEXTCLOUD_ADMIN_PASSWORD":"{{secret}}","NEXTCLOUD_TRUSTED_DOMAINS":"{{APP_DOMAIN:-localhost}}"}}]}`,
|
||||
Variables: `[{"key":"APP_DOMAIN","label":"Trusted domain","default":"","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-paperless-stack",
|
||||
Name: "Paperless-ngx + PostgreSQL + Redis",
|
||||
Description: "Document management with managed PostgreSQL and Redis",
|
||||
Category: "stack",
|
||||
Logo: "https://cdn.simpleicons.org/paperlessngx",
|
||||
Config: `{"version":2,"services":[
|
||||
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"paperless"}},
|
||||
{"key":"cache","type":"database","runtime":"redis"},
|
||||
{"key":"web","name":"paperless","type":"web","runtime":"ghcr.io/paperless-ngx/paperless-ngx:latest","port":8000,"depends_on":["db","cache"],
|
||||
"environment":{"PAPERLESS_DBHOST":"{{service.db.host}}","PAPERLESS_DBPORT":"{{service.db.port}}","PAPERLESS_DBNAME":"{{service.db.database}}","PAPERLESS_DBUSER":"{{service.db.user}}","PAPERLESS_DBPASS":"{{service.db.password}}","PAPERLESS_REDIS":"{{service.cache.url}}","PAPERLESS_ADMIN_USER":"admin","PAPERLESS_ADMIN_PASSWORD":"{{secret}}","PAPERLESS_SECRET_KEY":"{{secret(48)}}","PAPERLESS_URL":"{{APP_URL:-http://localhost:8000}}"}}]}`,
|
||||
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-plausible-stack",
|
||||
Name: "Plausible + PostgreSQL + ClickHouse",
|
||||
Description: "Privacy-friendly analytics with managed PostgreSQL and ClickHouse",
|
||||
Category: "stack",
|
||||
Logo: "https://cdn.simpleicons.org/plausibleanalytics",
|
||||
Config: `{"version":2,"services":[
|
||||
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"plausible"}},
|
||||
{"key":"events","type":"database","runtime":"clickhouse","environment":{"CLICKHOUSE_DB":"plausible"}},
|
||||
{"key":"web","name":"plausible","type":"web","runtime":"ghcr.io/plausible/community-edition:v2","port":8000,"health_check":"/api/health","depends_on":["db","events"],
|
||||
"environment":{"BASE_URL":"{{APP_URL:-http://localhost:8000}}","SECRET_KEY_BASE":"{{secret(64)}}","DATABASE_URL":"{{service.db.url}}","CLICKHOUSE_DATABASE_URL":"http://{{service.events.host}}:{{service.events.port}}/{{service.events.database}}","TOTP_VAULT_KEY":"{{secret(32)}}"}}]}`,
|
||||
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
}
|
||||
return templates
|
||||
}
|
||||
|
||||
@@ -72,36 +72,36 @@ func handleCreateUserToken(c *gin.Context) {
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
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"})
|
||||
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"})
|
||||
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"})
|
||||
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"})
|
||||
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"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ func handleCreateUserToken(c *gin.Context) {
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token", "code": "INTERNAL"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -140,13 +140,13 @@ func handleListUserTokens(c *gin.Context) {
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
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"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens", "code": "INTERNAL"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -167,12 +167,12 @@ func handleDeleteUserToken(c *gin.Context) {
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
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"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,11 +181,11 @@ func handleDeleteUserToken(c *gin.Context) {
|
||||
UserID: ownerUUID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token"})
|
||||
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"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found", "code": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -498,6 +498,7 @@ func (n *NixpacksBuilder) Build(ctx context.Context, req *types.BuildRequest) (*
|
||||
BuildArgs: map[string]*string{
|
||||
"BUILDKIT_INLINE_CACHE": strPtr("1"),
|
||||
},
|
||||
NoCache: req.NoCache,
|
||||
Remove: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// BackupTargetsCmd manages S3-compatible backup destinations.
|
||||
var BackupTargetsCmd = &cobra.Command{
|
||||
Use: "backup-targets",
|
||||
Aliases: []string{"backup-target", "targets"},
|
||||
Short: "Manage S3-compatible backup targets",
|
||||
}
|
||||
|
||||
var btListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List backup targets",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/backup-targets", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "backup_targets")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, t := range items {
|
||||
creds := "no"
|
||||
if str(t, "has_credentials") == "true" {
|
||||
creds = "yes"
|
||||
}
|
||||
rows = append(rows, []string{str(t, "id"), str(t, "name"), str(t, "endpoint"), str(t, "bucket"), creds})
|
||||
}
|
||||
printRows(data, []string{"ID", "NAME", "ENDPOINT", "BUCKET", "CREDS"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var btAddCmd = &cobra.Command{
|
||||
Use: "add <name>",
|
||||
Short: "Register an S3-compatible backup target (probed before storing)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{"name": args[0]}
|
||||
for _, f := range []string{"endpoint", "bucket", "region", "prefix", "access-key", "secret-key"} {
|
||||
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||
key := f
|
||||
switch f {
|
||||
case "access-key":
|
||||
key = "access_key"
|
||||
case "secret-key":
|
||||
key = "secret_key"
|
||||
}
|
||||
body[key] = v
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("no-tls") {
|
||||
v, _ := cmd.Flags().GetBool("no-tls")
|
||||
body["use_tls"] = !v
|
||||
}
|
||||
data, err := c.Do("POST", "/backup-targets", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var btUpdateCmd = &cobra.Command{
|
||||
Use: "update <id>",
|
||||
Short: "Update a backup target (re-probed; empty keys keep stored credentials)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
// endpoint/bucket are required by the API — fall back to stored values.
|
||||
for _, f := range []string{"name", "endpoint", "bucket", "region", "prefix", "access-key", "secret-key"} {
|
||||
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||
key := f
|
||||
switch f {
|
||||
case "access-key":
|
||||
key = "access_key"
|
||||
case "secret-key":
|
||||
key = "secret_key"
|
||||
}
|
||||
body[key] = v
|
||||
}
|
||||
}
|
||||
if _, hasEndpoint := body["endpoint"]; !hasEndpoint {
|
||||
data, err := c.Do("GET", "/backup-targets", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "backup_targets")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range items {
|
||||
if str(t, "id") == args[0] {
|
||||
body["endpoint"] = str(t, "endpoint")
|
||||
if _, ok := body["bucket"]; !ok {
|
||||
body["bucket"] = str(t, "bucket")
|
||||
}
|
||||
if _, ok := body["name"]; !ok {
|
||||
body["name"] = str(t, "name")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("no-tls") {
|
||||
v, _ := cmd.Flags().GetBool("no-tls")
|
||||
body["use_tls"] = !v
|
||||
}
|
||||
data, err := c.Do("PUT", "/backup-targets/"+args[0], body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var btRemoveCmd = &cobra.Command{
|
||||
Use: "remove <id>",
|
||||
Short: "Delete a backup target (fails while databases still point at it)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Confirm(fmt.Sprintf("Delete backup target %s?", args[0])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
if _, err := c.Do("DELETE", "/backup-targets/"+args[0], nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||
}
|
||||
fmt.Printf("Deleted backup target %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var btTestCmd = &cobra.Command{
|
||||
Use: "test <id>",
|
||||
Short: "Re-probe a backup target's bucket access",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/backup-targets/"+args[0]+"/test", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, _ := unwrapObject(data)
|
||||
if str(m, "ok") == "false" {
|
||||
return &APIError{Message: "probe failed: " + str(m, "error"), ExitCode: ExitError}
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Target ok (%sms)\n", str(m, "latency_ms"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
btAddCmd.Flags().String("endpoint", "", "S3 endpoint host[:port] (e.g. s3.amazonaws.com, garage.local:3900)")
|
||||
btAddCmd.Flags().String("bucket", "", "bucket name")
|
||||
btAddCmd.Flags().String("region", "", "region (optional for most S3-compatible stores)")
|
||||
btAddCmd.Flags().String("prefix", "", "key prefix for archives")
|
||||
btAddCmd.Flags().String("access-key", "", "access key (stored encrypted)")
|
||||
btAddCmd.Flags().String("secret-key", "", "secret key (stored encrypted)")
|
||||
btAddCmd.Flags().Bool("no-tls", false, "use plain HTTP")
|
||||
btUpdateCmd.Flags().String("name", "", "new name")
|
||||
btUpdateCmd.Flags().String("endpoint", "", "S3 endpoint host[:port]")
|
||||
btUpdateCmd.Flags().String("bucket", "", "bucket name")
|
||||
btUpdateCmd.Flags().String("region", "", "region")
|
||||
btUpdateCmd.Flags().String("prefix", "", "key prefix")
|
||||
btUpdateCmd.Flags().String("access-key", "", "access key")
|
||||
btUpdateCmd.Flags().String("secret-key", "", "secret key")
|
||||
btUpdateCmd.Flags().Bool("no-tls", false, "use plain HTTP")
|
||||
BackupTargetsCmd.AddCommand(btListCmd, btAddCmd, btUpdateCmd, btRemoveCmd, btTestCmd)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Exit codes — stable for scripting and agent use.
|
||||
const (
|
||||
ExitOK = 0
|
||||
ExitError = 1
|
||||
ExitAuth = 2
|
||||
ExitNotFound = 3
|
||||
ExitValidation = 4
|
||||
ExitUnavailable = 5
|
||||
)
|
||||
|
||||
// APIError is a non-2xx response from the Containr API. Code carries the
|
||||
// stable exit code so `main` can map errors to exit statuses.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Message string
|
||||
ExitCode int
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
}
|
||||
return fmt.Sprintf("request failed with status %d", e.Status)
|
||||
}
|
||||
|
||||
func exitCodeFor(status int) int {
|
||||
switch status {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return ExitAuth
|
||||
case http.StatusNotFound:
|
||||
return ExitNotFound
|
||||
case http.StatusBadRequest, http.StatusConflict, http.StatusUnprocessableEntity:
|
||||
return ExitValidation
|
||||
default:
|
||||
return ExitError
|
||||
}
|
||||
}
|
||||
|
||||
// Client is a thin authenticated HTTP client for the Containr API. All CLI
|
||||
// commands go through it — no command builds its own request.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient resolves the active profile (flag > env > current_profile) and
|
||||
// returns a client ready to call the API. Call RequireAuth() for commands
|
||||
// that need a token.
|
||||
func NewClient() *Client {
|
||||
return &Client{
|
||||
BaseURL: ResolveAPIURL(),
|
||||
Token: ResolveToken(),
|
||||
HTTPClient: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAuth returns an error suitable for the CLI when no token is set.
|
||||
func (c *Client) RequireAuth() error {
|
||||
if c.Token == "" {
|
||||
return &APIError{
|
||||
Status: 0,
|
||||
Message: "not authenticated — run `containr auth login <token>` or set CONTAINR_TOKEN",
|
||||
ExitCode: ExitAuth,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do performs an API request. body may be nil, a []byte, or any value that
|
||||
// marshals to JSON. Returns the raw response body.
|
||||
func (c *Client) Do(method, path string, body interface{}) ([]byte, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
var data []byte
|
||||
switch v := body.(type) {
|
||||
case []byte:
|
||||
data = v
|
||||
default:
|
||||
var err error
|
||||
data, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode request: %w", err)
|
||||
}
|
||||
}
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
url := c.BaseURL + path
|
||||
req, err := http.NewRequest(method, url, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if k := viper.GetString("idempotency-key"); k != "" && method == http.MethodPost {
|
||||
req.Header.Set("Idempotency-Key", k)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
hc := c.HTTPClient
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 60 * time.Second}
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
Status: 0,
|
||||
Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err),
|
||||
ExitCode: ExitUnavailable,
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
msg := strings.TrimSpace(string(data))
|
||||
var envelope struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if json.Unmarshal(data, &envelope) == nil && envelope.Error != "" {
|
||||
msg = envelope.Error
|
||||
if envelope.Code != "" {
|
||||
msg = envelope.Code + ": " + msg
|
||||
}
|
||||
}
|
||||
return nil, &APIError{
|
||||
Status: resp.StatusCode,
|
||||
Message: msg,
|
||||
ExitCode: exitCodeFor(resp.StatusCode),
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// DoRaw sends a non-JSON request body (file uploads) and returns the response.
|
||||
func (c *Client) DoRaw(method, path string, body io.Reader, contentType string) ([]byte, error) {
|
||||
req, err := http.NewRequest(method, c.BaseURL+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
hc := c.HTTPClient
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 5 * time.Minute}
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, &APIError{Status: 0, Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err), ExitCode: ExitUnavailable}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
msg := strings.TrimSpace(string(data))
|
||||
var envelope struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if json.Unmarshal(data, &envelope) == nil && envelope.Error != "" {
|
||||
msg = envelope.Error
|
||||
if envelope.Code != "" {
|
||||
msg = envelope.Code + ": " + msg
|
||||
}
|
||||
}
|
||||
return nil, &APIError{Status: resp.StatusCode, Message: msg, ExitCode: exitCodeFor(resp.StatusCode)}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// DoJSON performs a request and unmarshals the response into out.
|
||||
func (c *Client) DoJSON(method, path string, body interface{}, out interface{}) error {
|
||||
data, err := c.Do(method, path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out == nil || len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// CronCmd manages scheduled jobs.
|
||||
var CronCmd = &cobra.Command{
|
||||
Use: "cron",
|
||||
Aliases: []string{"cron-jobs"},
|
||||
Short: "Manage cron jobs",
|
||||
}
|
||||
|
||||
var cronListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List cron jobs",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/cron-jobs", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "cron_jobs", "jobs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, j := range items {
|
||||
rows = append(rows, []string{
|
||||
str(j, "id"), str(j, "name"), str(j, "schedule"), str(j, "service_id"),
|
||||
str(j, "enabled", "is_active"), relTime(j, "last_run_at"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "NAME", "SCHEDULE", "SERVICE", "ENABLED", "LAST RUN"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var cronGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Show a cron job",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/cron-jobs/"+args[0], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var cronCreateCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a cron job (5-field expression)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
for _, f := range []string{"name", "schedule", "command", "service-id", "timezone"} {
|
||||
v, _ := cmd.Flags().GetString(f)
|
||||
if v != "" {
|
||||
key := f
|
||||
if f == "service-id" {
|
||||
key = "service_id"
|
||||
}
|
||||
body[key] = v
|
||||
}
|
||||
}
|
||||
if body["service_id"] == nil || body["schedule"] == nil || body["command"] == nil {
|
||||
return &APIError{Message: "--service-id, --schedule, and --command are required", ExitCode: ExitValidation}
|
||||
}
|
||||
data, err := c.Do("POST", "/cron-jobs", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var cronDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a cron job",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Confirm(fmt.Sprintf("Delete cron job %s?", args[0])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
if _, err := c.Do("DELETE", "/cron-jobs/"+args[0], nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||
}
|
||||
fmt.Printf("Deleted cron job %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var cronTriggerCmd = &cobra.Command{
|
||||
Use: "trigger <id>",
|
||||
Short: "Run a cron job now",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/cron-jobs/"+args[0]+"/trigger", map[string]interface{}{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var cronExecutionsCmd = &cobra.Command{
|
||||
Use: "executions <id>",
|
||||
Short: "List a cron job's execution history",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/cron-jobs/"+args[0]+"/executions", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "executions")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, e := range items {
|
||||
rows = append(rows, []string{
|
||||
str(e, "id"), str(e, "status", "exit_code"), str(e, "started_at"), str(e, "output", "stdout"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "STATUS", "STARTED", "OUTPUT"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
cronCreateCmd.Flags().String("name", "", "job name")
|
||||
cronCreateCmd.Flags().String("service-id", "", "target service id (required)")
|
||||
cronCreateCmd.Flags().String("schedule", "", "5-field cron expression (required)")
|
||||
cronCreateCmd.Flags().String("command", "", "command run inside the container (required)")
|
||||
cronCreateCmd.Flags().String("timezone", "", "IANA timezone (default UTC)")
|
||||
CronCmd.AddCommand(cronListCmd, cronGetCmd, cronCreateCmd, cronDeleteCmd, cronTriggerCmd, cronExecutionsCmd)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// DatabasesCmd manages managed databases.
|
||||
var DatabasesCmd = &cobra.Command{
|
||||
Use: "databases",
|
||||
Aliases: []string{"database", "db"},
|
||||
Short: "Manage databases",
|
||||
}
|
||||
|
||||
var dbListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List managed databases",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/databases", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "databases")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, d := range items {
|
||||
rows = append(rows, []string{
|
||||
str(d, "id"), str(d, "name"), str(d, "type", "engine"), str(d, "status"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "NAME", "TYPE", "STATUS"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Show a database (connection URL included)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/databases/"+args[0], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbCreateCmd = &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Provision a managed database",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dbType, _ := cmd.Flags().GetString("type")
|
||||
body := map[string]interface{}{"name": args[0], "type": dbType}
|
||||
for _, f := range []string{"version", "storage-size"} {
|
||||
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||
key := f
|
||||
if f == "storage-size" {
|
||||
key = "storage_size"
|
||||
}
|
||||
body[key] = v
|
||||
}
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("public"); v {
|
||||
body["public_port"] = true
|
||||
}
|
||||
data, err := c.Do("POST", "/databases", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbUpdateCmd = &cobra.Command{
|
||||
Use: "update <id>",
|
||||
Short: "Update a database (--public/--public=false toggles the exposed host port)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if cmd.Flags().Changed("public") {
|
||||
v, _ := cmd.Flags().GetBool("public")
|
||||
body["public_port"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("name"); v != "" {
|
||||
body["name"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("backup-schedule") {
|
||||
v, _ := cmd.Flags().GetString("backup-schedule")
|
||||
body["backup_schedule"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("backup-target") {
|
||||
v, _ := cmd.Flags().GetString("backup-target")
|
||||
body["backup_target_id"] = v
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return &APIError{Message: "nothing to update — pass --public/--private-style flags, --name, --backup-schedule, or --backup-target", ExitCode: ExitError}
|
||||
}
|
||||
data, err := c.Do("PUT", "/databases/"+args[0], body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a database and its data",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Confirm(fmt.Sprintf("Delete database %s and all its data?", args[0])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
if _, err := c.Do("DELETE", "/databases/"+args[0], nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||
}
|
||||
fmt.Printf("Deleted database %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbActionCmd = &cobra.Command{
|
||||
Use: "action <id> <start|stop|restart>",
|
||||
Short: "Start, stop, or restart a database",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/databases/"+args[0]+"/action", map[string]string{"action": args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbBackupCmd = &cobra.Command{
|
||||
Use: "backup <id>",
|
||||
Short: "Take a manual backup snapshot",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/databases/"+args[0]+"/backup", map[string]interface{}{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbRestoreCmd = &cobra.Command{
|
||||
Use: "restore <id> <backup-id>",
|
||||
Short: "Restore a database from a backup",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Confirm(fmt.Sprintf("Restore database %s from backup %s? Current data will be overwritten.", args[0], args[1])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
data, err := c.Do("POST", "/databases/"+args[0]+"/restore", map[string]string{"backup_id": args[1], "database_id": args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbImportBackupCmd = &cobra.Command{
|
||||
Use: "import-backup <id> <file.tar.gz|->",
|
||||
Short: "Import a .tar.gz archive as a restore point (use '-' for stdin)",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var src io.ReadCloser
|
||||
if args[1] == "-" {
|
||||
src = io.NopCloser(os.Stdin)
|
||||
} else {
|
||||
f, err := os.Open(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src = f
|
||||
}
|
||||
defer src.Close()
|
||||
data, err := c.DoRaw("POST", "/databases/"+args[0]+"/backups/import", src, "application/gzip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbDownloadCmd = &cobra.Command{
|
||||
Use: "download-backup <id> <backup-id> [file]",
|
||||
Short: "Download a backup archive",
|
||||
Args: cobra.RangeArgs(2, 3),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", fmt.Sprintf("/databases/%s/backups/%s/download", args[0], args[1]), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := args[1] + ".tar.gz"
|
||||
if len(args) > 2 {
|
||||
out = args[2]
|
||||
}
|
||||
if err := os.WriteFile(out, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
return PrintJSON(map[string]string{"file": out})
|
||||
}
|
||||
fmt.Printf("Wrote %s (%d bytes)\n", out, len(data))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbRegisterCmd = &cobra.Command{
|
||||
Use: "register <name>",
|
||||
Short: "Register an external database (probes the connection first)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{"name": args[0]}
|
||||
for _, f := range []string{"type", "host", "database", "username", "password"} {
|
||||
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||
body[f] = v
|
||||
}
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("port"); v > 0 {
|
||||
body["port"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("ssl"); v {
|
||||
body["ssl"] = true
|
||||
}
|
||||
data, err := c.Do("POST", "/databases/register-external", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
m, _ := unwrapObject(data)
|
||||
fmt.Printf("Registered external database %s (%s)\n", args[0], str(m, "id"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbTestConnCmd = &cobra.Command{
|
||||
Use: "test-connection [<id>]",
|
||||
Short: "Probe a database connection — by id, or with --type/--host flags",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var data []byte
|
||||
if len(args) == 1 {
|
||||
data, err = c.Do("POST", "/databases/"+args[0]+"/test-connection", map[string]interface{}{})
|
||||
} else {
|
||||
body := map[string]interface{}{}
|
||||
for _, f := range []string{"type", "host", "database", "username", "password"} {
|
||||
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||
body[f] = v
|
||||
}
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("port"); v > 0 {
|
||||
body["port"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("ssl"); v {
|
||||
body["ssl"] = true
|
||||
}
|
||||
data, err = c.Do("POST", "/databases/test-connection", body)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, _ := unwrapObject(data)
|
||||
if str(m, "ok") == "false" {
|
||||
return &APIError{Message: "connection failed: " + str(m, "error"), ExitCode: ExitError}
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Connection ok (%sms)\n", str(m, "latency_ms"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
dbCreateCmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
|
||||
dbCreateCmd.Flags().String("version", "", "engine version tag")
|
||||
dbCreateCmd.Flags().String("storage-size", "", "volume size (e.g. 10Gi)")
|
||||
dbCreateCmd.Flags().Bool("public", false, "bind the database port on all interfaces (default: localhost only)")
|
||||
dbUpdateCmd.Flags().Bool("public", false, "bind the database port on all interfaces")
|
||||
dbUpdateCmd.Flags().String("name", "", "rename the database")
|
||||
dbUpdateCmd.Flags().String("backup-schedule", "", "cron expression for automatic backups (empty clears)")
|
||||
dbUpdateCmd.Flags().String("backup-target", "", "backup target id for offsite archives (empty clears)")
|
||||
for _, cmd := range []*cobra.Command{dbRegisterCmd, dbTestConnCmd} {
|
||||
cmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
|
||||
cmd.Flags().String("host", "", "database host")
|
||||
cmd.Flags().Int("port", 0, "database port (default per type)")
|
||||
cmd.Flags().String("database", "", "database name")
|
||||
cmd.Flags().String("username", "", "database username")
|
||||
cmd.Flags().String("password", "", "database password (stored encrypted on register)")
|
||||
cmd.Flags().Bool("ssl", false, "require TLS")
|
||||
}
|
||||
DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbUpdateCmd, dbRegisterCmd, dbTestConnCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbImportBackupCmd, dbDownloadCmd)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
Aliases: []string{"project"},
|
||||
Short: "Manage projects",
|
||||
Long: `Manage your Containr projects.
|
||||
You can list, create, update, and delete 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.`,
|
||||
var projectsGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Show a project",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runCreateProject,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/projects/"+args[0], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
p, _ := unwrapObject(data, "project")
|
||||
fmt.Printf("ID: %s\n", str(p, "id"))
|
||||
fmt.Printf("Name: %s\n", str(p, "name"))
|
||||
fmt.Printf("Description: %s\n", str(p, "description"))
|
||||
fmt.Printf("Created: %s\n", str(p, "created_at"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// deleteProjectCmd represents the delete command
|
||||
var deleteProjectCmd = &cobra.Command{
|
||||
Use: "delete [project-id]",
|
||||
Short: "Delete a project",
|
||||
Long: `Delete a Containr project by ID.`,
|
||||
var projectsCreateCmd = &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Create a project",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runDeleteProject,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
desc, _ := cmd.Flags().GetString("description")
|
||||
body := map[string]interface{}{"name": args[0]}
|
||||
if desc != "" {
|
||||
body["description"] = desc
|
||||
}
|
||||
data, err := c.Do("POST", "/projects", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
p, _ := unwrapObject(data, "project")
|
||||
fmt.Printf("Created project %s (%s)\n", str(p, "name"), str(p, "id"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var projectDescription string
|
||||
|
||||
// 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
|
||||
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
|
||||
}
|
||||
|
||||
// Ensure baseURL doesn't end with / and endpoint starts with /
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
if !strings.HasPrefix(endpoint, "/") {
|
||||
endpoint = "/" + endpoint
|
||||
if !Confirm(fmt.Sprintf("Delete project %s and all its services?", args[0])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
|
||||
return baseURL + endpoint
|
||||
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,567 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ServicesCmd manages services.
|
||||
var ServicesCmd = &cobra.Command{
|
||||
Use: "services",
|
||||
Aliases: []string{"service", "svc"},
|
||||
Short: "Manage services",
|
||||
}
|
||||
|
||||
var servicesListCmd = &cobra.Command{
|
||||
Use: "list [project-id]",
|
||||
Short: "List services in a project",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/projects/"+args[0]+"/services", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "services")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, s := range items {
|
||||
rows = append(rows, []string{
|
||||
str(s, "id"), str(s, "name"), str(s, "type"), str(s, "status"), str(s, "environment"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "NAME", "TYPE", "STATUS", "ENV"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var servicesGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Show a service",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/services/"+args[0], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
s, _ := unwrapObject(data, "service")
|
||||
for _, k := range []string{"id", "name", "type", "status", "environment", "image", "git_repo", "git_branch", "domain", "port"} {
|
||||
fmt.Printf("%-14s %s\n", k+":", str(s, k))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var servicesCreateCmd = &cobra.Command{
|
||||
Use: "create <project-id> <name>",
|
||||
Short: "Create a service (image- or git-based)",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"name": args[1],
|
||||
}
|
||||
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
|
||||
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||
key := f
|
||||
// JSON uses snake_case keys matching the API schema.
|
||||
switch f {
|
||||
case "git-repo":
|
||||
key = "git_repo"
|
||||
case "git-branch":
|
||||
key = "git_branch"
|
||||
case "build-path":
|
||||
key = "build_path"
|
||||
case "restart-policy":
|
||||
key = "restart_policy"
|
||||
case "healthcheck-path":
|
||||
key = "healthcheck_path"
|
||||
case "cpu-reserve":
|
||||
key = "cpu_reserve"
|
||||
case "memory-reserve":
|
||||
key = "memory_reserve"
|
||||
case "static-cmd":
|
||||
key = "static_build_cmd"
|
||||
case "static-dir":
|
||||
key = "static_dir"
|
||||
}
|
||||
body[key] = v
|
||||
}
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("port"); v > 0 {
|
||||
body["port"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("replicas"); v > 0 {
|
||||
body["replicas"] = v
|
||||
}
|
||||
data, err := c.Do("POST", "/projects/"+args[0]+"/services", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
s, _ := unwrapObject(data, "service")
|
||||
fmt.Printf("Created service %s (%s)\n", str(s, "name"), str(s, "id"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var servicesDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a service",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Confirm(fmt.Sprintf("Delete service %s?", args[0])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
if _, err := c.Do("DELETE", "/services/"+args[0], nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||
}
|
||||
fmt.Printf("Deleted service %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var servicesUpdateCmd = &cobra.Command{
|
||||
Use: "update <id>",
|
||||
Short: "Update a service (volumes, replicas, domain, ...)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
|
||||
if cmd.Flags().Changed(f) {
|
||||
key := f
|
||||
switch f {
|
||||
case "restart-policy":
|
||||
key = "restart_policy"
|
||||
case "healthcheck-path":
|
||||
key = "healthcheck_path"
|
||||
case "cpu-reserve":
|
||||
key = "cpu_reserve"
|
||||
case "memory-reserve":
|
||||
key = "memory_reserve"
|
||||
case "static-cmd":
|
||||
key = "static_build_cmd"
|
||||
case "static-dir":
|
||||
key = "static_dir"
|
||||
}
|
||||
v, _ := cmd.Flags().GetString(f)
|
||||
body[key] = v
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("replicas") {
|
||||
v, _ := cmd.Flags().GetInt("replicas")
|
||||
body["replicas"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("port") {
|
||||
v, _ := cmd.Flags().GetInt("port")
|
||||
body["port"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("volume") || cmd.Flags().Changed("bind") || cmd.Flags().Changed("clear-volumes") {
|
||||
volumes := []map[string]interface{}{}
|
||||
for _, spec := range volumeFlagValues(cmd, "volume", "bind") {
|
||||
volumes = append(volumes, spec)
|
||||
}
|
||||
body["volumes"] = volumes
|
||||
}
|
||||
if cmd.Flags().Changed("sleep") {
|
||||
v, _ := cmd.Flags().GetBool("sleep")
|
||||
body["sleep_enabled"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("sleep-idle") {
|
||||
v, _ := cmd.Flags().GetInt("sleep-idle")
|
||||
body["sleep_idle_minutes"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("maintenance") {
|
||||
v, _ := cmd.Flags().GetString("maintenance")
|
||||
body["maintenance_mode"] = v == "on" || v == "true"
|
||||
}
|
||||
if cmd.Flags().Changed("basic-auth") {
|
||||
creds := []map[string]interface{}{}
|
||||
pairs, _ := cmd.Flags().GetStringArray("basic-auth")
|
||||
for _, p := range pairs {
|
||||
user, pass, ok := strings.Cut(p, ":")
|
||||
if ok {
|
||||
creds = append(creds, map[string]interface{}{"username": user, "password": pass})
|
||||
}
|
||||
}
|
||||
body["basic_auth"] = creds
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
|
||||
}
|
||||
data, err := c.Do("PUT", "/services/"+args[0], body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Updated service %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var servicesCloneCmd = &cobra.Command{
|
||||
Use: "clone <id>",
|
||||
Short: "Clone a service (config, volumes, domains, variables)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
for _, f := range []string{"name", "project", "environment"} {
|
||||
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||
key := f
|
||||
if f == "project" {
|
||||
key = "project_id"
|
||||
}
|
||||
body[key] = v
|
||||
}
|
||||
}
|
||||
data, err := c.Do("POST", "/services/"+args[0]+"/clone", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
m, _ := unwrapObject(data)
|
||||
fmt.Printf("Cloned to service %s (%s)\n", str(m, "service_id"), str(m, "name"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var servicesMoveCmd = &cobra.Command{
|
||||
Use: "move <id> <project-id>",
|
||||
Short: "Move a service to another project",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/services/"+args[0]+"/move", map[string]interface{}{"project_id": args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Moved service %s to project %s\n", args[0], args[1])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// volumeFlagValues parses repeated "src:target[:ro]" flags into API mount
|
||||
// objects. Bind mounts are type=bind, named volumes type=volume.
|
||||
func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} {
|
||||
var out []map[string]interface{}
|
||||
for _, f := range flags {
|
||||
vals, _ := cmd.Flags().GetStringArray(f)
|
||||
for _, v := range vals {
|
||||
parts := strings.SplitN(v, ":", 3)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
m := map[string]interface{}{
|
||||
"type": f,
|
||||
"source": parts[0],
|
||||
"target": parts[1],
|
||||
}
|
||||
if len(parts) == 3 && parts[2] == "ro" {
|
||||
m["read_only"] = true
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var servicesDomainsCmd = &cobra.Command{
|
||||
Use: "domains",
|
||||
Short: "Manage service domains",
|
||||
}
|
||||
|
||||
var domainsListCmd = &cobra.Command{
|
||||
Use: "list <service-id>",
|
||||
Short: "List domains attached to a service",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/services/"+args[0]+"/domains", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "domains")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, d := range items {
|
||||
def := ""
|
||||
if b, ok := d["is_default"].(bool); ok && b {
|
||||
def = "*"
|
||||
}
|
||||
rows = append(rows, []string{str(d, "id"), str(d, "domain"), def, str(d, "cert_status")})
|
||||
}
|
||||
printRows(data, []string{"ID", "DOMAIN", "DEFAULT", "CERT"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var domainsAddCmd = &cobra.Command{
|
||||
Use: "add <service-id> <domain>",
|
||||
Short: "Attach a domain to a service",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
def, _ := cmd.Flags().GetBool("default")
|
||||
data, err := c.Do("POST", "/services/"+args[0]+"/domains", map[string]interface{}{
|
||||
"domain": args[1], "is_default": def,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Added domain %s\n", args[1])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var domainsRemoveCmd = &cobra.Command{
|
||||
Use: "remove <service-id> <domain-id>",
|
||||
Short: "Detach a domain",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.Do("DELETE", fmt.Sprintf("/services/%s/domains/%s", args[0], args[1]), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
return PrintJSON(map[string]string{"status": "deleted", "id": args[1]})
|
||||
}
|
||||
fmt.Printf("Removed domain %s\n", args[1])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var domainsDefaultCmd = &cobra.Command{
|
||||
Use: "default <service-id> <domain-id>",
|
||||
Short: "Set the default domain",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", fmt.Sprintf("/services/%s/domains/%s/default", args[0], args[1]), map[string]interface{}{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Default domain updated\n")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var domainsCheckCmd = &cobra.Command{
|
||||
Use: "check <service-id>",
|
||||
Short: "DNS preflight for all service domains",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/services/"+args[0]+"/domains/check", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "domains")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, d := range items {
|
||||
resolved, _ := d["resolved"].([]interface{})
|
||||
addrs := make([]string, 0, len(resolved))
|
||||
for _, a := range resolved {
|
||||
if s, ok := a.(string); ok {
|
||||
addrs = append(addrs, s)
|
||||
}
|
||||
}
|
||||
rows = append(rows, []string{str(d, "domain"), str(d, "status"), strings.Join(addrs, ", ")})
|
||||
}
|
||||
printRows(data, []string{"DOMAIN", "STATUS", "RESOLVED"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// serviceAction returns a RunE for the simple POST action endpoints.
|
||||
func serviceAction(verb string) func(*cobra.Command, []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", fmt.Sprintf("/services/%s/%s", args[0], verb), map[string]interface{}{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%s: %s\n", verb, args[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
f := servicesCreateCmd.Flags()
|
||||
f.String("type", "web", "service type: web|worker|database|cron")
|
||||
f.String("image", "", "container image (image-based service)")
|
||||
f.String("git-repo", "", "git repository URL (build-from-source service)")
|
||||
f.String("git-branch", "main", "git branch")
|
||||
f.String("build-path", "", "build context subdirectory")
|
||||
f.String("command", "", "container start command override")
|
||||
f.String("environment", "production", "production|preview|development")
|
||||
f.String("domain", "", "public domain")
|
||||
f.String("restart-policy", "unless-stopped", "docker restart policy")
|
||||
f.String("healthcheck-path", "", "HTTP health check path")
|
||||
f.String("cpu", "", "CPU limit (e.g. 0.5)")
|
||||
f.String("memory", "", "memory limit (e.g. 512m)")
|
||||
f.Int("port", 0, "container port")
|
||||
f.Int("replicas", 0, "replica count")
|
||||
f.String("builder", "", "build strategy: auto|railpack|nixpacks|dockerfile|static")
|
||||
f.String("cpu-reserve", "", "soft CPU reservation (e.g. 0.25)")
|
||||
f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)")
|
||||
f.String("static-cmd", "", "build command for the static builder")
|
||||
f.String("static-dir", "", "output dir for the static builder (default dist)")
|
||||
|
||||
uf := servicesUpdateCmd.Flags()
|
||||
uf.String("name", "", "service name")
|
||||
uf.String("image", "", "container image")
|
||||
uf.String("command", "", "container start command")
|
||||
uf.String("domain", "", "public domain (empty string clears)")
|
||||
uf.String("restart-policy", "", "docker restart policy")
|
||||
uf.String("healthcheck-path", "", "HTTP health check path")
|
||||
uf.String("cpu", "", "CPU limit (e.g. 0.5)")
|
||||
uf.String("memory", "", "memory limit (e.g. 512m)")
|
||||
uf.Int("replicas", 0, "replica count")
|
||||
uf.Int("port", 0, "container port")
|
||||
uf.StringArray("volume", nil, "named volume mount name:path[:ro] (repeatable, replaces all mounts)")
|
||||
uf.StringArray("bind", nil, "bind mount src:path[:ro] (repeatable)")
|
||||
uf.Bool("clear-volumes", false, "remove all volume mounts")
|
||||
uf.String("maintenance", "", "maintenance mode: on|off")
|
||||
uf.StringArray("basic-auth", nil, "basic-auth credential user:password (repeatable, replaces all)")
|
||||
uf.String("builder", "", "build strategy: auto|railpack|nixpacks|dockerfile|static")
|
||||
uf.String("cpu-reserve", "", "soft CPU reservation (empty clears)")
|
||||
uf.String("memory-reserve", "", "soft memory reservation (empty clears)")
|
||||
uf.String("static-cmd", "", "build command for the static builder (empty clears)")
|
||||
uf.String("static-dir", "", "output dir for the static builder (empty clears)")
|
||||
uf.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
|
||||
uf.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)")
|
||||
|
||||
for _, a := range []string{"start", "stop", "restart", "redeploy", "sleep", "wake"} {
|
||||
verb := a
|
||||
ServicesCmd.AddCommand(&cobra.Command{
|
||||
Use: verb + " <id>",
|
||||
Short: verb + " a service",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: serviceAction(verb),
|
||||
})
|
||||
}
|
||||
servicesCloneCmd.Flags().String("name", "", "name for the clone (default <name>-copy)")
|
||||
servicesCloneCmd.Flags().String("project", "", "target project id (default: same project)")
|
||||
servicesCloneCmd.Flags().String("environment", "", "target environment (default: source's)")
|
||||
|
||||
domainsAddCmd.Flags().Bool("default", false, "set as the default domain")
|
||||
servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd)
|
||||
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd, servicesEnvCheckCmd)
|
||||
}
|
||||
|
||||
var servicesEnvCheckCmd = &cobra.Command{
|
||||
Use: "env-check <id>",
|
||||
Short: "Diagnose service environment: empty vars, unresolved ${{refs}}",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/services/"+args[0]+"/env-check", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
m, _ := unwrapObject(data)
|
||||
if b, ok := m["ok"].(bool); ok && b {
|
||||
fmt.Println("env check: ok")
|
||||
return nil
|
||||
}
|
||||
for _, group := range []string{"unresolved", "empty", "unreadable"} {
|
||||
if list, ok := m[group].([]interface{}); ok {
|
||||
for _, item := range list {
|
||||
fmt.Printf("%s: %v\n", group, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return &APIError{Message: "env check failed", ExitCode: ExitError}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TemplatesCmd manages the template catalog.
|
||||
var TemplatesCmd = &cobra.Command{
|
||||
Use: "templates",
|
||||
Aliases: []string{"template"},
|
||||
Short: "Browse and deploy templates",
|
||||
}
|
||||
|
||||
var templatesListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List templates",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/templates", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "templates")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, t := range items {
|
||||
rows = append(rows, []string{
|
||||
str(t, "id"), str(t, "name"), str(t, "category"), str(t, "description"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "NAME", "CATEGORY", "DESCRIPTION"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var templatesGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Show a template definition",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/templates/"+args[0], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var templatesCreateCmd = &cobra.Command{
|
||||
Use: "create <file>",
|
||||
Short: "Create a user template from a JSON file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
raw, err := os.ReadFile(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read %s: %w", args[0], err)
|
||||
}
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/templates", raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var templatesDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a user template",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Confirm(fmt.Sprintf("Delete template %s?", args[0])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
if _, err := c.Do("DELETE", "/templates/"+args[0], nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||
}
|
||||
fmt.Printf("Deleted template %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var tplDeployVars []string
|
||||
var tplDeployName string
|
||||
|
||||
var templatesDeployCmd = &cobra.Command{
|
||||
Use: "deploy <template-id> <project-id>",
|
||||
Short: "Deploy a template into a project",
|
||||
Long: "Deploys a single-service template or a multi-service graph (v2) template. Pass --name for single-service templates; graph members take names from the template. Use --var KEY=VALUE to override template variables.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{"project_id": args[1]}
|
||||
if tplDeployName != "" {
|
||||
body["name"] = tplDeployName
|
||||
}
|
||||
vars, err := parseKVFlags(tplDeployVars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(vars) > 0 {
|
||||
body["variables"] = vars
|
||||
}
|
||||
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var templatesPlanCmd = &cobra.Command{
|
||||
Use: "plan <template-id>",
|
||||
Short: "Dry-resolve a template — expanded expressions, service refs, missing variables",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vars, err := parseKVFlags(tplDeployVars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if len(vars) > 0 {
|
||||
body["variables"] = vars
|
||||
}
|
||||
data, err := c.Do("POST", "/templates/"+args[0]+"/plan", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var templatesImportComposeCmd = &cobra.Command{
|
||||
Use: "import-compose <compose.yml|->",
|
||||
Short: "Convert a docker-compose file into a template graph config",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var raw []byte
|
||||
var err error
|
||||
if args[0] == "-" {
|
||||
raw, err = io.ReadAll(os.Stdin)
|
||||
} else {
|
||||
raw, err = os.ReadFile(args[0])
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read compose file: %w", err)
|
||||
}
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/templates/import/compose", map[string]string{"compose_yaml": string(raw)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var templatesDeployGraphCmd = &cobra.Command{
|
||||
Use: "deploy-graph <project-id> <config.json|->",
|
||||
Short: "Deploy an ad-hoc service graph (v2 config with services[]) without a stored template",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var raw []byte
|
||||
var err error
|
||||
if args[1] == "-" {
|
||||
raw, err = io.ReadAll(os.Stdin)
|
||||
} else {
|
||||
raw, err = os.ReadFile(args[1])
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read config: %w", err)
|
||||
}
|
||||
var config json.RawMessage
|
||||
if err := json.Unmarshal(raw, &config); err != nil {
|
||||
return fmt.Errorf("invalid config JSON: %w", err)
|
||||
}
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vars, err := parseKVFlags(tplDeployVars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{"project_id": args[0]}
|
||||
var cfg map[string]interface{}
|
||||
if err := json.Unmarshal(config, &cfg); err != nil {
|
||||
return fmt.Errorf("invalid config JSON: %w", err)
|
||||
}
|
||||
body["config"] = cfg
|
||||
if len(vars) > 0 {
|
||||
body["variables"] = vars
|
||||
}
|
||||
data, err := c.Do("POST", "/templates/deploy", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// parseKVFlags converts repeated --var KEY=VALUE flags into a map.
|
||||
func parseKVFlags(flags []string) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
for _, f := range flags {
|
||||
idx := strings.Index(f, "=")
|
||||
if idx <= 0 {
|
||||
return nil, fmt.Errorf("invalid --var %q — expected KEY=VALUE", f)
|
||||
}
|
||||
out[f[:idx]] = f[idx+1:]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
templatesDeployCmd.Flags().StringVar(&tplDeployName, "name", "", "Name for the created service (single-service templates)")
|
||||
templatesDeployCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
||||
templatesPlanCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
||||
templatesDeployGraphCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
||||
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd, templatesPlanCmd, templatesImportComposeCmd, templatesDeployGraphCmd)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,59 @@ 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.BackupTargetsCmd)
|
||||
rootCmd.AddCommand(commands.DatabasesCmd)
|
||||
rootCmd.AddCommand(commands.CronCmd)
|
||||
rootCmd.AddCommand(commands.TemplatesCmd)
|
||||
rootCmd.AddCommand(commands.NodesCmd)
|
||||
rootCmd.AddCommand(commands.ScalingCmd)
|
||||
rootCmd.AddCommand(commands.HACmd)
|
||||
rootCmd.AddCommand(commands.SecurityCmd)
|
||||
rootCmd.AddCommand(commands.GatewayCmd)
|
||||
rootCmd.AddCommand(commands.AdminCmd)
|
||||
rootCmd.AddCommand(commands.NotificationsCmd)
|
||||
rootCmd.AddCommand(commands.UpCmd)
|
||||
}
|
||||
|
||||
// initConfig reads in config file and ENV variables if set.
|
||||
func initConfig() {
|
||||
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()
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// getAPIURL constructs the full API URL for a given endpoint
|
||||
func getAPIURL(endpoint string) string {
|
||||
baseURL := viper.GetString("api-url")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:8080/api/v1" // Default for development
|
||||
}
|
||||
|
||||
// Ensure baseURL doesn't end with / and endpoint starts with /
|
||||
if strings.HasSuffix(baseURL, "/") {
|
||||
baseURL = baseURL[:len(baseURL)-1]
|
||||
}
|
||||
if !strings.HasPrefix(endpoint, "/") {
|
||||
endpoint = "/" + endpoint
|
||||
}
|
||||
|
||||
return baseURL + endpoint
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: backup_targets.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const countDatabasesUsingBackupTarget = `-- name: CountDatabasesUsingBackupTarget :one
|
||||
SELECT COUNT(*)
|
||||
FROM database_services
|
||||
WHERE backup_target_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countDatabasesUsingBackupTarget, targetID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createBackupTarget = `-- name: CreateBackupTarget :exec
|
||||
INSERT INTO backup_targets (id, user_id, name, endpoint, bucket, region, prefix, access_key, secret_key, use_tls, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
`
|
||||
|
||||
type CreateBackupTargetParams struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Bucket string `json:"bucket"`
|
||||
Region string `json:"region"`
|
||||
Prefix string `json:"prefix"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
UseTls bool `json:"use_tls"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createBackupTarget,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
arg.Name,
|
||||
arg.Endpoint,
|
||||
arg.Bucket,
|
||||
arg.Region,
|
||||
arg.Prefix,
|
||||
arg.AccessKey,
|
||||
arg.SecretKey,
|
||||
arg.UseTls,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteBackupTargetByIDAndUser = `-- name: DeleteBackupTargetByIDAndUser :exec
|
||||
DELETE FROM backup_targets
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type DeleteBackupTargetByIDAndUserParams struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteBackupTargetByIDAndUser, arg.ID, arg.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getBackupTargetByID = `-- name: GetBackupTargetByID :one
|
||||
SELECT backup_targets.id, backup_targets.user_id, backup_targets.name, backup_targets.endpoint, backup_targets.bucket, backup_targets.region, backup_targets.prefix, backup_targets.access_key, backup_targets.secret_key, backup_targets.use_tls, backup_targets.created_at, backup_targets.updated_at
|
||||
FROM backup_targets
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error) {
|
||||
row := q.db.QueryRowContext(ctx, getBackupTargetByID, id)
|
||||
var i BackupTarget
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Endpoint,
|
||||
&i.Bucket,
|
||||
&i.Region,
|
||||
&i.Prefix,
|
||||
&i.AccessKey,
|
||||
&i.SecretKey,
|
||||
&i.UseTls,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getBackupTargetByIDAndUser = `-- name: GetBackupTargetByIDAndUser :one
|
||||
SELECT backup_targets.id, backup_targets.user_id, backup_targets.name, backup_targets.endpoint, backup_targets.bucket, backup_targets.region, backup_targets.prefix, backup_targets.access_key, backup_targets.secret_key, backup_targets.use_tls, backup_targets.created_at, backup_targets.updated_at
|
||||
FROM backup_targets
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type GetBackupTargetByIDAndUserParams struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error) {
|
||||
row := q.db.QueryRowContext(ctx, getBackupTargetByIDAndUser, arg.ID, arg.UserID)
|
||||
var i BackupTarget
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Endpoint,
|
||||
&i.Bucket,
|
||||
&i.Region,
|
||||
&i.Prefix,
|
||||
&i.AccessKey,
|
||||
&i.SecretKey,
|
||||
&i.UseTls,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listBackupTargetsByUser = `-- name: ListBackupTargetsByUser :many
|
||||
SELECT backup_targets.id, backup_targets.user_id, backup_targets.name, backup_targets.endpoint, backup_targets.bucket, backup_targets.region, backup_targets.prefix, backup_targets.access_key, backup_targets.secret_key, backup_targets.use_tls, backup_targets.created_at, backup_targets.updated_at
|
||||
FROM backup_targets
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listBackupTargetsByUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []BackupTarget{}
|
||||
for rows.Next() {
|
||||
var i BackupTarget
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Endpoint,
|
||||
&i.Bucket,
|
||||
&i.Region,
|
||||
&i.Prefix,
|
||||
&i.AccessKey,
|
||||
&i.SecretKey,
|
||||
&i.UseTls,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setDatabaseBackupRemoteKeyByID = `-- name: SetDatabaseBackupRemoteKeyByID :exec
|
||||
UPDATE database_backups
|
||||
SET remote_key = $1
|
||||
WHERE id = $2
|
||||
`
|
||||
|
||||
type SetDatabaseBackupRemoteKeyByIDParams struct {
|
||||
RemoteKey sql.NullString `json:"remote_key"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setDatabaseBackupRemoteKeyByID, arg.RemoteKey, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const setDatabaseBackupTargetByIDAndUser = `-- name: SetDatabaseBackupTargetByIDAndUser :exec
|
||||
UPDATE database_services
|
||||
SET backup_target_id = $1, updated_at = $2
|
||||
WHERE id = $3 AND user_id = $4
|
||||
`
|
||||
|
||||
type SetDatabaseBackupTargetByIDAndUserParams struct {
|
||||
BackupTargetID sql.NullString `json:"backup_target_id"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetDatabaseBackupTargetByIDAndUser(ctx context.Context, arg SetDatabaseBackupTargetByIDAndUserParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setDatabaseBackupTargetByIDAndUser,
|
||||
arg.BackupTargetID,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateBackupTargetByIDAndUser = `-- name: UpdateBackupTargetByIDAndUser :exec
|
||||
UPDATE backup_targets
|
||||
SET name = $1, endpoint = $2, bucket = $3, region = $4, prefix = $5, access_key = $6, secret_key = $7, use_tls = $8, updated_at = $9
|
||||
WHERE id = $10 AND user_id = $11
|
||||
`
|
||||
|
||||
type UpdateBackupTargetByIDAndUserParams struct {
|
||||
Name string `json:"name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Bucket string `json:"bucket"`
|
||||
Region string `json:"region"`
|
||||
Prefix string `json:"prefix"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
UseTls bool `json:"use_tls"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateBackupTargetByIDAndUser,
|
||||
arg.Name,
|
||||
arg.Endpoint,
|
||||
arg.Bucket,
|
||||
arg.Region,
|
||||
arg.Prefix,
|
||||
arg.AccessKey,
|
||||
arg.SecretKey,
|
||||
arg.UseTls,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -55,8 +55,8 @@ func (q *Queries) CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBa
|
||||
}
|
||||
|
||||
const createDatabaseService = `-- name: CreateDatabaseService :exec
|
||||
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`
|
||||
|
||||
type CreateDatabaseServiceParams struct {
|
||||
@@ -68,6 +68,7 @@ type CreateDatabaseServiceParams struct {
|
||||
Version string `json:"version"`
|
||||
Plan string `json:"plan"`
|
||||
Region string `json:"region"`
|
||||
PublicPort bool `json:"public_port"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
@@ -82,6 +83,53 @@ func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseS
|
||||
arg.Version,
|
||||
arg.Plan,
|
||||
arg.Region,
|
||||
arg.PublicPort,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const createExternalDatabaseService = `-- name: CreateExternalDatabaseService :exec
|
||||
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region,
|
||||
provider, external_host, external_port, external_name, external_username, external_password, external_ssl,
|
||||
created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, 'running', $5, $6, $7, 'external', $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
`
|
||||
|
||||
type CreateExternalDatabaseServiceParams struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Version string `json:"version"`
|
||||
Plan string `json:"plan"`
|
||||
Region string `json:"region"`
|
||||
ExternalHost sql.NullString `json:"external_host"`
|
||||
ExternalPort sql.NullInt32 `json:"external_port"`
|
||||
ExternalName sql.NullString `json:"external_name"`
|
||||
ExternalUsername sql.NullString `json:"external_username"`
|
||||
ExternalPassword string `json:"external_password"`
|
||||
ExternalSsl bool `json:"external_ssl"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createExternalDatabaseService,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
arg.Name,
|
||||
arg.Type,
|
||||
arg.Version,
|
||||
arg.Plan,
|
||||
arg.Region,
|
||||
arg.ExternalHost,
|
||||
arg.ExternalPort,
|
||||
arg.ExternalName,
|
||||
arg.ExternalUsername,
|
||||
arg.ExternalPassword,
|
||||
arg.ExternalSsl,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
@@ -122,7 +170,7 @@ func (q *Queries) DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg Dele
|
||||
}
|
||||
|
||||
const getDatabaseBackupByIDAndDatabaseAndUser = `-- name: GetDatabaseBackupByIDAndDatabaseAndUser :one
|
||||
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
|
||||
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.remote_key, b.created_at, b.completed_at
|
||||
FROM database_backups b
|
||||
JOIN database_services s ON s.id = b.database_id
|
||||
WHERE b.id = $1 AND b.database_id = $2 AND s.user_id = $3
|
||||
@@ -143,6 +191,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
|
||||
&i.Size,
|
||||
&i.Status,
|
||||
&i.BackupPath,
|
||||
&i.RemoteKey,
|
||||
&i.CreatedAt,
|
||||
&i.CompletedAt,
|
||||
)
|
||||
@@ -150,7 +199,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
|
||||
}
|
||||
|
||||
const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at
|
||||
FROM database_services
|
||||
WHERE id = $1
|
||||
`
|
||||
@@ -170,6 +219,15 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
|
||||
&i.ConnectionUrl,
|
||||
&i.BackupSchedule,
|
||||
&i.NextBackupAt,
|
||||
&i.Provider,
|
||||
&i.ExternalHost,
|
||||
&i.ExternalPort,
|
||||
&i.ExternalName,
|
||||
&i.ExternalUsername,
|
||||
&i.ExternalPassword,
|
||||
&i.ExternalSsl,
|
||||
&i.PublicPort,
|
||||
&i.BackupTargetID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -177,7 +235,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
|
||||
}
|
||||
|
||||
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at
|
||||
FROM database_services
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
@@ -202,6 +260,15 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
|
||||
&i.ConnectionUrl,
|
||||
&i.BackupSchedule,
|
||||
&i.NextBackupAt,
|
||||
&i.Provider,
|
||||
&i.ExternalHost,
|
||||
&i.ExternalPort,
|
||||
&i.ExternalName,
|
||||
&i.ExternalUsername,
|
||||
&i.ExternalPassword,
|
||||
&i.ExternalSsl,
|
||||
&i.PublicPort,
|
||||
&i.BackupTargetID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -222,7 +289,7 @@ func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (str
|
||||
}
|
||||
|
||||
const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at
|
||||
FROM database_services
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
@@ -248,6 +315,15 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic
|
||||
&i.ConnectionUrl,
|
||||
&i.BackupSchedule,
|
||||
&i.NextBackupAt,
|
||||
&i.Provider,
|
||||
&i.ExternalHost,
|
||||
&i.ExternalPort,
|
||||
&i.ExternalName,
|
||||
&i.ExternalUsername,
|
||||
&i.ExternalPassword,
|
||||
&i.ExternalSsl,
|
||||
&i.PublicPort,
|
||||
&i.BackupTargetID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -265,7 +341,7 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic
|
||||
}
|
||||
|
||||
const listDatabaseBackupsByDatabaseAndUser = `-- name: ListDatabaseBackupsByDatabaseAndUser :many
|
||||
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
|
||||
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.remote_key, b.created_at, b.completed_at
|
||||
FROM database_backups b
|
||||
JOIN database_services s ON s.id = b.database_id
|
||||
WHERE b.database_id = $1 AND s.user_id = $2
|
||||
@@ -294,6 +370,7 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
|
||||
&i.Size,
|
||||
&i.Status,
|
||||
&i.BackupPath,
|
||||
&i.RemoteKey,
|
||||
&i.CreatedAt,
|
||||
&i.CompletedAt,
|
||||
); err != nil {
|
||||
@@ -311,7 +388,7 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
|
||||
}
|
||||
|
||||
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.created_at, database_services.updated_at
|
||||
FROM database_services
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
@@ -338,6 +415,15 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
|
||||
&i.ConnectionUrl,
|
||||
&i.BackupSchedule,
|
||||
&i.NextBackupAt,
|
||||
&i.Provider,
|
||||
&i.ExternalHost,
|
||||
&i.ExternalPort,
|
||||
&i.ExternalName,
|
||||
&i.ExternalUsername,
|
||||
&i.ExternalPassword,
|
||||
&i.ExternalSsl,
|
||||
&i.PublicPort,
|
||||
&i.BackupTargetID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -453,6 +539,29 @@ func (q *Queries) SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNe
|
||||
return err
|
||||
}
|
||||
|
||||
const setDatabaseServicePublicPortByIDAndUser = `-- name: SetDatabaseServicePublicPortByIDAndUser :exec
|
||||
UPDATE database_services
|
||||
SET public_port = $1, updated_at = $2
|
||||
WHERE id = $3 AND user_id = $4
|
||||
`
|
||||
|
||||
type SetDatabaseServicePublicPortByIDAndUserParams struct {
|
||||
PublicPort bool `json:"public_port"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetDatabaseServicePublicPortByIDAndUser(ctx context.Context, arg SetDatabaseServicePublicPortByIDAndUserParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setDatabaseServicePublicPortByIDAndUser,
|
||||
arg.PublicPort,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const setDatabaseServiceStatusAndConnectionByID = `-- name: SetDatabaseServiceStatusAndConnectionByID :exec
|
||||
UPDATE database_services
|
||||
SET status = $1, connection_url = $2, updated_at = $3
|
||||
|
||||
@@ -49,6 +49,21 @@ type AgentHeartbeat struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type BackupTarget struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Bucket string `json:"bucket"`
|
||||
Region string `json:"region"`
|
||||
Prefix string `json:"prefix"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
UseTls bool `json:"use_tls"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ContainerInstance struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -75,6 +90,7 @@ type DatabaseBackup struct {
|
||||
Size string `json:"size"`
|
||||
Status string `json:"status"`
|
||||
BackupPath sql.NullString `json:"backup_path"`
|
||||
RemoteKey sql.NullString `json:"remote_key"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
CompletedAt sql.NullTime `json:"completed_at"`
|
||||
}
|
||||
@@ -91,6 +107,15 @@ type DatabaseService struct {
|
||||
ConnectionUrl sql.NullString `json:"connection_url"`
|
||||
BackupSchedule sql.NullString `json:"backup_schedule"`
|
||||
NextBackupAt sql.NullTime `json:"next_backup_at"`
|
||||
Provider string `json:"provider"`
|
||||
ExternalHost sql.NullString `json:"external_host"`
|
||||
ExternalPort sql.NullInt32 `json:"external_port"`
|
||||
ExternalName sql.NullString `json:"external_name"`
|
||||
ExternalUsername sql.NullString `json:"external_username"`
|
||||
ExternalPassword string `json:"external_password"`
|
||||
ExternalSsl bool `json:"external_ssl"`
|
||||
PublicPort bool `json:"public_port"`
|
||||
BackupTargetID sql.NullString `json:"backup_target_id"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
@@ -167,6 +192,17 @@ type ProjectMember struct {
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -201,6 +237,25 @@ type Service struct {
|
||||
HealthcheckPath string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
PublishedPort int32 `json:"published_port"`
|
||||
Volumes json.RawMessage `json:"volumes"`
|
||||
MaintenanceMode bool `json:"maintenance_mode"`
|
||||
BasicAuthUsers string `json:"basic_auth_users"`
|
||||
Builder string `json:"builder"`
|
||||
CpuReserve string `json:"cpu_reserve"`
|
||||
MemoryReserve string `json:"memory_reserve"`
|
||||
StaticBuildCmd string `json:"static_build_cmd"`
|
||||
StaticDir string `json:"static_dir"`
|
||||
}
|
||||
|
||||
type ServiceDomain struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
Domain string `json:"domain"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
CertType string `json:"cert_type"`
|
||||
CertStatus string `json:"cert_status"`
|
||||
LastCheckedAt sql.NullTime `json:"last_checked_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ServiceTemplate struct {
|
||||
|
||||
@@ -6,6 +6,7 @@ package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -13,15 +14,18 @@ import (
|
||||
type Querier interface {
|
||||
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
|
||||
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
|
||||
CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error)
|
||||
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
|
||||
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
|
||||
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
|
||||
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
|
||||
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
|
||||
CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error
|
||||
CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error)
|
||||
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
|
||||
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
|
||||
CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error
|
||||
CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error
|
||||
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
|
||||
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
||||
@@ -29,12 +33,15 @@ type Querier interface {
|
||||
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
|
||||
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
||||
DeleteAgent(ctx context.Context, id string) error
|
||||
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
|
||||
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
||||
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
|
||||
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
|
||||
GetAgent(ctx context.Context, id string) (NodeAgent, error)
|
||||
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
|
||||
GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error)
|
||||
GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error)
|
||||
GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error)
|
||||
GetContainer(ctx context.Context, id string) (ContainerInstance, error)
|
||||
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
|
||||
@@ -54,6 +61,7 @@ type Querier interface {
|
||||
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
|
||||
ListAgents(ctx context.Context) ([]NodeAgent, error)
|
||||
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
|
||||
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
|
||||
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
|
||||
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
|
||||
@@ -73,9 +81,12 @@ type Querier interface {
|
||||
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
||||
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
||||
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
|
||||
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
|
||||
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
||||
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
||||
SetDatabaseBackupTargetByIDAndUser(ctx context.Context, arg SetDatabaseBackupTargetByIDAndUserParams) error
|
||||
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
|
||||
SetDatabaseServicePublicPortByIDAndUser(ctx context.Context, arg SetDatabaseServicePublicPortByIDAndUserParams) error
|
||||
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
|
||||
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
|
||||
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
||||
@@ -84,6 +95,7 @@ type Querier interface {
|
||||
TouchUserToken(ctx context.Context, id uuid.UUID) error
|
||||
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
|
||||
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
||||
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
|
||||
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
||||
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
|
||||
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
||||
|
||||
@@ -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
|
||||
@@ -80,6 +85,7 @@ type VolumeMount struct {
|
||||
|
||||
type ResourceLimits struct {
|
||||
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"`
|
||||
@@ -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"`
|
||||
// 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)
|
||||
@@ -323,7 +344,14 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -46,7 +47,14 @@ type RuntimeSpec struct {
|
||||
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.
|
||||
@@ -225,12 +233,45 @@ 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{
|
||||
@@ -241,10 +282,25 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
|
||||
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
|
||||
if spec.Port > 0 {
|
||||
wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
|
||||
@@ -474,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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -157,7 +157,9 @@ func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (s
|
||||
Mounts: config.Mounts,
|
||||
Resources: container.Resources{
|
||||
Memory: config.Memory,
|
||||
MemoryReservation: config.MemoryReservation,
|
||||
NanoCPUs: config.NanoCPUs,
|
||||
CPUShares: config.CPUShares,
|
||||
},
|
||||
NetworkMode: container.NetworkMode(config.NetworkMode),
|
||||
}
|
||||
@@ -236,6 +238,11 @@ func (c *Client) CopyFromContainer(ctx context.Context, containerID, srcPath str
|
||||
return reader, err
|
||||
}
|
||||
|
||||
// CopyToContainer copies a tar archive stream into a container path.
|
||||
func (c *Client) CopyToContainer(ctx context.Context, containerID, dstPath string, content io.Reader) error {
|
||||
return c.cli.CopyToContainer(ctx, containerID, dstPath, content, container.CopyToContainerOptions{})
|
||||
}
|
||||
|
||||
// GetContainerStats returns real-time resource usage statistics for a container
|
||||
func (c *Client) GetContainerStats(ctx context.Context, containerID string, stream bool) (*container.StatsResponseReader, error) {
|
||||
resp, err := c.cli.ContainerStats(ctx, containerID, stream)
|
||||
@@ -291,13 +298,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
|
||||
@@ -335,6 +371,21 @@ func (c *Client) RemoveNetwork(ctx context.Context, networkID string) error {
|
||||
return c.cli.NetworkRemove(ctx, networkID)
|
||||
}
|
||||
|
||||
// NetworkGateway returns the IPAM gateway of a network — the address
|
||||
// containers on it can use to reach host-published ports.
|
||||
func (c *Client) NetworkGateway(ctx context.Context, networkID string) string {
|
||||
resp, err := c.cli.NetworkInspect(ctx, networkID, network.InspectOptions{})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, cfg := range resp.IPAM.Config {
|
||||
if cfg.Gateway != "" {
|
||||
return cfg.Gateway
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ConnectNetwork connects a container to a network
|
||||
func (c *Client) ConnectNetwork(ctx context.Context, networkID, containerID string, config network.EndpointSettings) error {
|
||||
return c.cli.NetworkConnect(ctx, networkID, containerID, &config)
|
||||
|
||||
@@ -22,7 +22,9 @@ type ContainerConfig struct {
|
||||
PortBindings nat.PortMap
|
||||
Mounts []mount.Mount
|
||||
Memory int64
|
||||
MemoryReservation int64
|
||||
NanoCPUs int64
|
||||
CPUShares int64
|
||||
NetworkMode string
|
||||
Networks map[string]*network.EndpointSettings
|
||||
Healthcheck *container.HealthConfig
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
// Package mcpserver exposes the Containr API as MCP tools over stdio.
|
||||
// It reuses the CLI's thin client — same PAT auth, same endpoints —
|
||||
// so agents reach everything the UI and CLI do.
|
||||
package mcpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"containr/internal/cli/commands"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// spec describes one MCP tool as an HTTP call. {placeholder} segments in
|
||||
// Path are filled from arguments; remaining string/number/bool args become
|
||||
// the JSON body for POST/PUT/PATCH.
|
||||
type spec struct {
|
||||
name string
|
||||
description string
|
||||
method string
|
||||
path string
|
||||
// params are the arguments that fill path placeholders (required).
|
||||
params []string
|
||||
// bodyKeys restrict which args go into the request body; when empty all
|
||||
// non-param args are sent.
|
||||
bodyKeys []string
|
||||
// destructive tools refuse unless args["confirm"] == true.
|
||||
destructive bool
|
||||
}
|
||||
|
||||
var tools []spec
|
||||
|
||||
func register(s spec) { tools = append(tools, s) }
|
||||
|
||||
func init() {
|
||||
register(spec{"containr_auth_status", "Show which profile/token is active and the authenticated identity", "GET", "/user/profile", nil, nil, false})
|
||||
|
||||
// Personal access tokens
|
||||
register(spec{"containr_tokens_list", "List personal access tokens (metadata only, never values)", "GET", "/user/tokens", nil, nil, false})
|
||||
register(spec{"containr_tokens_create", "Create a personal access token. Args: name (required), scope (read|write|admin, default write), expires_in_days (0=never). Returns the raw token once.", "POST", "/user/tokens", nil, []string{"name", "scope", "expires_in_days"}, false})
|
||||
register(spec{"containr_tokens_revoke", "Revoke a personal access token by id. Requires confirm:true.", "DELETE", "/user/tokens/{id}", []string{"id"}, nil, true})
|
||||
|
||||
// Projects
|
||||
register(spec{"containr_projects_list", "List projects", "GET", "/projects", nil, nil, false})
|
||||
register(spec{"containr_projects_get", "Get a project by id", "GET", "/projects/{id}", []string{"id"}, nil, false})
|
||||
register(spec{"containr_projects_create", "Create a project. Args: name (required), description.", "POST", "/projects", nil, []string{"name", "description"}, false})
|
||||
register(spec{"containr_projects_delete", "Delete a project and all its services. Requires confirm:true.", "DELETE", "/projects/{id}", []string{"id"}, nil, true})
|
||||
|
||||
// Services
|
||||
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir.", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
|
||||
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_sleep", "Put a service to sleep immediately (scale to zero)", "POST", "/services/{id}/sleep", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_wake", "Wake a sleeping service", "POST", "/services/{id}/wake", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_env_check", "Diagnose service env: returns ok plus unresolved ${{refs}}, empty vars, and undecryptable secrets", "GET", "/services/{id}/env-check", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_clone", "Clone a service (config, volumes, domains, variables) into the same or another project. Args: id, name, project_id, environment.", "POST", "/services/{id}/clone", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false})
|
||||
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
|
||||
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes.", "PUT", "/services/{id}", []string{"id"}, nil, false})
|
||||
|
||||
// Domains
|
||||
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
|
||||
register(spec{"containr_domains_add", "Attach a domain to a service. Args: id, domain (required), is_default.", "POST", "/services/{id}/domains", []string{"id"}, []string{"domain", "is_default"}, false})
|
||||
register(spec{"containr_domains_remove", "Detach a domain. Args: id (service), domain_id.", "DELETE", "/services/{id}/domains/{domain_id}", []string{"id", "domain_id"}, nil, false})
|
||||
register(spec{"containr_domains_default", "Set the default domain. Args: id (service), domain_id.", "POST", "/services/{id}/domains/{domain_id}/default", []string{"id", "domain_id"}, nil, false})
|
||||
register(spec{"containr_domains_check", "DNS preflight for all service domains — returns ok|wrong-target|pending per domain", "GET", "/services/{id}/domains/check", []string{"id"}, nil, false})
|
||||
|
||||
// Registries — private-image pull credentials, owner-scoped.
|
||||
register(spec{"containr_registries_list", "List registry credentials (passwords never returned)", "GET", "/registries", nil, nil, false})
|
||||
register(spec{"containr_registries_create", "Add a registry credential. Args: name (required), host (required — e.g. ghcr.io, docker.io), username, password (stored encrypted).", "POST", "/registries", nil, []string{"name", "host"}, false})
|
||||
register(spec{"containr_registries_update", "Update a registry credential. Args: id, name, host (required), username, password (empty keeps stored).", "PUT", "/registries/{id}", []string{"id"}, []string{"name", "host"}, false})
|
||||
register(spec{"containr_registries_delete", "Remove a registry credential. Requires confirm:true.", "DELETE", "/registries/{id}", []string{"id"}, nil, true})
|
||||
|
||||
// Docker volumes (admin)
|
||||
register(spec{"containr_volumes_list", "List docker volumes on the node with in-use flags (admin)", "GET", "/admin/volumes", nil, nil, false})
|
||||
register(spec{"containr_volumes_delete", "Delete an unused docker volume. Requires confirm:true. (admin)", "DELETE", "/admin/volumes/{name}", []string{"name"}, nil, true})
|
||||
|
||||
// Variables
|
||||
register(spec{"containr_variables_list", "List service environment variables (secrets masked)", "GET", "/services/{id}/variables", []string{"id"}, nil, false})
|
||||
register(spec{"containr_variables_set", "Replace all variables on a service. Args: id, variables = [{key, value, is_secret}], redeploy (queue a redeploy to apply). Fetch the current list first and merge — the server replaces wholesale.", "PUT", "/services/{id}/variables", []string{"id"}, []string{"variables", "redeploy"}, false})
|
||||
register(spec{"containr_shared_variables_list", "List project-level shared variables, referenced from service env as ${{shared.KEY}} (secrets masked). Args: id (project).", "GET", "/projects/{id}/variables", []string{"id"}, nil, false})
|
||||
register(spec{"containr_shared_variables_set", "Replace all shared project variables. Args: id (project), variables = [{key, value, is_secret}]. Services reference them as ${{shared.KEY}} — resolved at deploy/refresh, not live-updated.", "PUT", "/projects/{id}/variables", []string{"id"}, []string{"variables"}, false})
|
||||
|
||||
// Deployments
|
||||
register(spec{"containr_deploy", "Trigger a deployment for a service. Args: id (service), commit_hash, branch, no_cache.", "POST", "/services/{id}/deployments", []string{"id"}, []string{"commit_hash", "branch", "trigger", "no_cache"}, false})
|
||||
register(spec{"containr_deployments_list", "List recent deployments across services", "GET", "/deployments", nil, nil, false})
|
||||
register(spec{"containr_services_deployments", "List deployments for one service", "GET", "/services/{id}/deployments", []string{"id"}, nil, false})
|
||||
register(spec{"containr_deployments_get", "Get a deployment", "GET", "/deployments/{id}", []string{"id"}, nil, false})
|
||||
register(spec{"containr_deployments_logs", "Get persisted build/runtime logs for a deployment. Args: id, type (all|build|runtime).", "GET", "/deployments/{id}/logs", []string{"id"}, nil, false})
|
||||
register(spec{"containr_deployments_rollback", "Roll back to a deployment. Requires confirm:true.", "POST", "/deployments/{id}/rollback", []string{"id"}, nil, true})
|
||||
register(spec{"containr_deployments_cancel", "Cancel a queued or running deployment", "POST", "/deployments/{id}/cancel", []string{"id"}, nil, false})
|
||||
|
||||
// Databases
|
||||
register(spec{"containr_databases_list", "List managed databases", "GET", "/databases", nil, nil, false})
|
||||
register(spec{"containr_databases_get", "Get a database (connection URL included)", "GET", "/databases/{id}", []string{"id"}, nil, false})
|
||||
register(spec{"containr_databases_create", "Provision a managed database. Args: name, type (postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse), public_port (bind on all interfaces, default localhost-only).", "POST", "/databases", nil, nil, false})
|
||||
register(spec{"containr_databases_update", "Update a database. Args: id, name, backup_schedule (empty clears), backup_target_id (empty clears), public_port — toggling recreates the container so the new bind scope applies.", "PUT", "/databases/{id}", []string{"id"}, nil, false})
|
||||
register(spec{"containr_databases_register_external", "Register a database hosted elsewhere — probed first, password stored encrypted. Args: name (required), type (required), host (required), port, database, username, password, ssl.", "POST", "/databases/register-external", nil, []string{"name", "type", "host"}, false})
|
||||
register(spec{"containr_databases_test_connection", "Probe a DB connection ad-hoc. Args: type, host (required), port, database, username, password, ssl.", "POST", "/databases/test-connection", nil, []string{"type", "host"}, false})
|
||||
register(spec{"containr_databases_test_connection_by_id", "Re-probe a registered external database with its stored credentials. Args: id.", "POST", "/databases/{id}/test-connection", []string{"id"}, nil, false})
|
||||
register(spec{"containr_databases_action", "Start/stop/restart a database. Args: id, action.", "POST", "/databases/{id}/action", []string{"id"}, []string{"action"}, false})
|
||||
register(spec{"containr_databases_backup", "Take a manual backup snapshot", "POST", "/databases/{id}/backup", []string{"id"}, nil, false})
|
||||
register(spec{"containr_databases_restore", "Restore a database from a backup — overwrites current data. Requires confirm:true.", "POST", "/databases/{id}/restore", []string{"id"}, []string{"backup_id"}, true})
|
||||
register(spec{"containr_databases_delete", "Delete a database and its data. Requires confirm:true.", "DELETE", "/databases/{id}", []string{"id"}, nil, true})
|
||||
|
||||
// S3-compatible backup targets
|
||||
register(spec{"containr_backup_targets_list", "List S3-compatible backup targets (credentials never returned)", "GET", "/backup-targets", nil, nil, false})
|
||||
register(spec{"containr_backup_targets_create", "Register a backup target — probed first, keys stored encrypted. Args: name, endpoint (required), bucket (required), region, prefix, access_key, secret_key, use_tls.", "POST", "/backup-targets", nil, []string{"endpoint", "bucket"}, false})
|
||||
register(spec{"containr_backup_targets_update", "Update a backup target; empty credentials keep stored keys. Args: id, name, endpoint, bucket, region, prefix, access_key, secret_key, use_tls.", "PUT", "/backup-targets/{id}", []string{"id"}, nil, false})
|
||||
register(spec{"containr_backup_targets_delete", "Delete a backup target — fails while databases still point at it. Requires confirm:true.", "DELETE", "/backup-targets/{id}", []string{"id"}, nil, true})
|
||||
register(spec{"containr_backup_targets_test", "Re-probe a backup target's bucket access. Args: id.", "POST", "/backup-targets/{id}/test", []string{"id"}, nil, false})
|
||||
|
||||
// Cron
|
||||
register(spec{"containr_cron_list", "List cron jobs", "GET", "/cron-jobs", nil, nil, false})
|
||||
register(spec{"containr_cron_create", "Create a cron job. Args: service_id, schedule (5-field), command, name, timezone.", "POST", "/cron-jobs", nil, nil, false})
|
||||
register(spec{"containr_cron_trigger", "Run a cron job now", "POST", "/cron-jobs/{id}/trigger", []string{"id"}, nil, false})
|
||||
register(spec{"containr_cron_executions", "List a cron job's execution history", "GET", "/cron-jobs/{id}/executions", []string{"id"}, nil, false})
|
||||
register(spec{"containr_cron_delete", "Delete a cron job. Requires confirm:true.", "DELETE", "/cron-jobs/{id}", []string{"id"}, nil, true})
|
||||
|
||||
// Templates
|
||||
register(spec{"containr_templates_list", "List templates", "GET", "/templates", nil, nil, false})
|
||||
register(spec{"containr_templates_get", "Get a template definition", "GET", "/templates/{id}", []string{"id"}, nil, false})
|
||||
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
|
||||
register(spec{"containr_templates_plan", "Dry-resolve a template: expanded expressions, service refs, missing variables. Args: id, variables (object).", "POST", "/templates/{id}/plan", []string{"id"}, nil, false})
|
||||
register(spec{"containr_templates_import_compose", "Convert docker-compose YAML into a v2 template graph config. Args: compose_yaml.", "POST", "/templates/import/compose", nil, []string{"compose_yaml"}, false})
|
||||
register(spec{"containr_templates_deploy_graph", "Deploy an ad-hoc service graph without a stored template. Args: project_id, config ({services:[...]}), variables, plan, region.", "POST", "/templates/deploy", nil, []string{"project_id", "config", "variables", "plan", "region"}, false})
|
||||
|
||||
// Notifications
|
||||
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
|
||||
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
|
||||
|
||||
// Nodes / agent tokens (admin)
|
||||
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false})
|
||||
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
|
||||
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
|
||||
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
|
||||
|
||||
// Scaling / HA / security
|
||||
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
|
||||
register(spec{"containr_scaling_policies", "List scaling policies", "GET", "/scaling/policies", nil, nil, false})
|
||||
register(spec{"containr_scaling_scale", "Manually scale a service. Args: service_id, replicas. (admin)", "POST", "/scaling/services/{service_id}/scale", []string{"service_id"}, []string{"replicas"}, false})
|
||||
register(spec{"containr_ha_status", "HA manager status", "GET", "/ha/status", nil, nil, false})
|
||||
register(spec{"containr_ha_alerts", "Active HA alerts", "GET", "/ha/alerts/active", nil, nil, false})
|
||||
register(spec{"containr_ha_failover", "Trigger a manual failover — reschedules services. Requires confirm:true. (admin)", "POST", "/ha/failover", nil, nil, true})
|
||||
register(spec{"containr_security_scan", "Run a security scan. Args: project_id (required), scan_type, service_id.", "POST", "/security/scans", nil, nil, false})
|
||||
register(spec{"containr_security_vulnerabilities", "List a project's vulnerabilities", "GET", "/projects/{project_id}/vulnerabilities", []string{"project_id"}, nil, false})
|
||||
|
||||
// Admin
|
||||
register(spec{"containr_admin_overview", "Platform overview (admin)", "GET", "/admin/overview", nil, nil, false})
|
||||
register(spec{"containr_admin_users", "List all users (admin)", "GET", "/admin/users", nil, nil, false})
|
||||
register(spec{"containr_admin_audit_logs", "List audit log entries (admin)", "GET", "/audit-logs", nil, nil, false})
|
||||
register(spec{"containr_gateway_services", "List gateway services (admin)", "GET", "/gateway/services", nil, nil, false})
|
||||
register(spec{"containr_gateway_keys", "List gateway API keys (admin)", "GET", "/gateway/keys", nil, nil, false})
|
||||
}
|
||||
|
||||
// Run starts the stdio MCP server. apiURL/token come from env or config —
|
||||
// the caller resolves them before calling.
|
||||
func Run(ctx context.Context, apiURL, token, version string) error {
|
||||
if token == "" {
|
||||
return fmt.Errorf("CONTAINR_TOKEN is required (create a cnp_ token under Settings → Personal Access Tokens)")
|
||||
}
|
||||
client := &commands.Client{BaseURL: apiURL, Token: token}
|
||||
|
||||
server := mcp.NewServer(&mcp.Implementation{
|
||||
Name: "containr",
|
||||
Version: version,
|
||||
}, nil)
|
||||
|
||||
for _, s := range tools {
|
||||
s := s
|
||||
server.AddTool(&mcp.Tool{
|
||||
Name: s.name,
|
||||
Description: s.description,
|
||||
InputSchema: schemaFor(s),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return call(ctx, client, s, req)
|
||||
})
|
||||
}
|
||||
|
||||
return server.Run(ctx, &mcp.StdioTransport{})
|
||||
}
|
||||
|
||||
// schemaFor builds a JSON Schema for the tool's arguments: path params +
|
||||
// body keys + confirm for destructive tools. Properties accept any JSON
|
||||
// type — the API validates values.
|
||||
func schemaFor(s spec) map[string]interface{} {
|
||||
props := map[string]interface{}{}
|
||||
var required []string
|
||||
for _, p := range s.params {
|
||||
props[p] = map[string]interface{}{"type": "string", "description": "path parameter"}
|
||||
required = append(required, p)
|
||||
}
|
||||
bodySrc := s.bodyKeys
|
||||
if len(bodySrc) == 0 && s.method != "GET" && s.method != "DELETE" {
|
||||
// Unrestricted body: advertise nothing, accept anything.
|
||||
}
|
||||
for _, k := range bodySrc {
|
||||
props[k] = map[string]interface{}{"description": "request body field"}
|
||||
}
|
||||
if s.destructive {
|
||||
props["confirm"] = map[string]interface{}{"type": "boolean", "description": "must be true — this operation is destructive"}
|
||||
required = append(required, "confirm")
|
||||
}
|
||||
schema := map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
"additionalProperties": true,
|
||||
}
|
||||
if len(required) > 0 {
|
||||
schema["required"] = required
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func call(ctx context.Context, c *commands.Client, s spec, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args map[string]interface{}
|
||||
if len(req.Params.Arguments) > 0 {
|
||||
if err := json.Unmarshal(req.Params.Arguments, &args); err != nil {
|
||||
return errorResult("invalid arguments: " + err.Error()), nil
|
||||
}
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]interface{}{}
|
||||
}
|
||||
|
||||
if s.destructive && args["confirm"] != true {
|
||||
return errorResult("destructive operation — pass confirm:true to proceed"), nil
|
||||
}
|
||||
|
||||
path := s.path
|
||||
for _, p := range s.params {
|
||||
v, ok := args[p]
|
||||
if !ok || fmt.Sprint(v) == "" {
|
||||
return errorResult(fmt.Sprintf("missing required argument %q", p)), nil
|
||||
}
|
||||
path = strings.ReplaceAll(path, "{"+p+"}", fmt.Sprint(v))
|
||||
}
|
||||
|
||||
var body interface{}
|
||||
var query []string
|
||||
for k, v := range args {
|
||||
if k == "confirm" {
|
||||
continue
|
||||
}
|
||||
inParams := false
|
||||
for _, p := range s.params {
|
||||
if k == p {
|
||||
inParams = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if inParams {
|
||||
continue
|
||||
}
|
||||
if s.method == "GET" || s.method == "DELETE" {
|
||||
// Non-path args on reads become query parameters.
|
||||
query = append(query, url.QueryEscape(k)+"="+url.QueryEscape(fmt.Sprint(v)))
|
||||
continue
|
||||
}
|
||||
if len(s.bodyKeys) > 0 {
|
||||
allowed := false
|
||||
for _, b := range s.bodyKeys {
|
||||
if k == b {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if body == nil {
|
||||
body = map[string]interface{}{}
|
||||
}
|
||||
body.(map[string]interface{})[k] = v
|
||||
}
|
||||
if len(query) > 0 {
|
||||
sep := "?"
|
||||
if strings.Contains(path, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
path += sep + strings.Join(query, "&")
|
||||
}
|
||||
|
||||
data, err := c.Do(s.method, path, body)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return textResult(data), nil
|
||||
}
|
||||
|
||||
func textResult(data []byte) *mcp.CallToolResult {
|
||||
var v interface{}
|
||||
if json.Unmarshal(data, &v) != nil {
|
||||
v = string(data)
|
||||
}
|
||||
out, _ := json.MarshalIndent(v, "", " ")
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: string(out)}},
|
||||
}
|
||||
}
|
||||
|
||||
func errorResult(msg string) *mcp.CallToolResult {
|
||||
return &mcp.CallToolResult{
|
||||
IsError: true,
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: msg}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
// Idempotency replays POST responses for a repeated Idempotency-Key header
|
||||
// so agents can safely retry mutations. The first request wins; concurrent
|
||||
// duplicates get 409 IDEMPOTENCY_IN_PROGRESS; completed responses replay
|
||||
// verbatim for 24h. Keys are scoped to the authenticated identity.
|
||||
func Idempotency() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method != http.MethodPost {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
key := c.GetHeader("Idempotency-Key")
|
||||
if key == "" || len(key) > 128 {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
dbValue, exists := c.Get("redis")
|
||||
if !exists {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
rdb, ok := dbValue.(*database.Redis)
|
||||
if !ok || rdb == nil || rdb.Client == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Scope by caller identity so a stolen key can't replay across users.
|
||||
actor := c.GetString("user_id")
|
||||
if actor == "" {
|
||||
actor = c.GetHeader("Authorization")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(actor + ":" + c.Request.URL.Path + ":" + key))
|
||||
redisKey := "idem:" + hex.EncodeToString(sum[:16])
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stored, err := rdb.Client.Get(ctx, redisKey).Result()
|
||||
switch {
|
||||
case err == nil:
|
||||
if stored == "processing" {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "a request with this idempotency key is still in progress",
|
||||
"code": "IDEMPOTENCY_IN_PROGRESS",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
var rec idemRecord
|
||||
if json.Unmarshal([]byte(stored), &rec) == nil {
|
||||
c.Header("X-Idempotent-Replay", "true")
|
||||
c.Data(rec.Status, rec.ContentType, rec.Body)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
case err != redis.Nil:
|
||||
// Redis hiccup — degrade to pass-through rather than failing the request.
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Claim the key for the duration of the request.
|
||||
if set, serr := rdb.Client.SetNX(ctx, redisKey, "processing", 2*time.Minute).Result(); serr != nil || !set {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "a request with this idempotency key is still in progress",
|
||||
"code": "IDEMPOTENCY_IN_PROGRESS",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
recorder := &responseRecorder{ResponseWriter: c.Writer, status: http.StatusOK, body: &bytes.Buffer{}}
|
||||
c.Writer = recorder
|
||||
c.Next()
|
||||
|
||||
// Cache terminal responses only — 5xx is retryable and must not be replayed.
|
||||
if recorder.status < 500 && recorder.body.Len() <= 1<<20 {
|
||||
rec := idemRecord{
|
||||
Status: recorder.status,
|
||||
ContentType: recorder.Header().Get("Content-Type"),
|
||||
Body: recorder.body.Bytes(),
|
||||
}
|
||||
if rec.ContentType == "" {
|
||||
rec.ContentType = "application/json; charset=utf-8"
|
||||
}
|
||||
if raw, merr := json.Marshal(rec); merr == nil {
|
||||
_ = rdb.Client.Set(context.Background(), redisKey, raw, 24*time.Hour).Err()
|
||||
}
|
||||
} else {
|
||||
_ = rdb.Client.Del(context.Background(), redisKey).Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type idemRecord struct {
|
||||
Status int `json:"s"`
|
||||
ContentType string `json:"ct"`
|
||||
Body []byte `json:"b"`
|
||||
}
|
||||
|
||||
type responseRecorder struct {
|
||||
gin.ResponseWriter
|
||||
status int
|
||||
body *bytes.Buffer
|
||||
}
|
||||
|
||||
func (r *responseRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *responseRecorder) Write(b []byte) (int, error) {
|
||||
r.body.Write(b)
|
||||
return r.ResponseWriter.Write(b)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestIdempotencyPassThroughWithoutKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
called := 0
|
||||
router := gin.New()
|
||||
router.Use(Idempotency())
|
||||
router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/x", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if called != 1 || rec.Code != 200 {
|
||||
t.Fatalf("expected pass-through, called=%d code=%d", called, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotencyPassThroughWithoutRedis(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
called := 0
|
||||
router := gin.New()
|
||||
router.Use(Idempotency())
|
||||
router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
// Key present but no redis in context — must not block the request.
|
||||
req := httptest.NewRequest(http.MethodPost, "/x", nil)
|
||||
req.Header.Set("Idempotency-Key", "abc-123")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if called != 1 || rec.Code != 200 {
|
||||
t.Fatalf("expected pass-through without redis, called=%d code=%d", called, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotencySkipsNonPOST(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
called := 0
|
||||
router := gin.New()
|
||||
router.Use(Idempotency())
|
||||
router.GET("/x", func(c *gin.Context) { called++; c.String(200, "ok") })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
req.Header.Set("Idempotency-Key", "abc")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if called != 1 {
|
||||
t.Fatalf("GET should bypass idempotency, called=%d", called)
|
||||
}
|
||||
}
|
||||
@@ -212,15 +212,15 @@ func Auth(jwtSecret string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -245,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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,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,33 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE database_services
|
||||
ADD COLUMN IF NOT EXISTS provider VARCHAR(20) NOT NULL DEFAULT 'managed',
|
||||
ADD COLUMN IF NOT EXISTS external_host VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS external_port INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS external_name VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS external_username VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS external_password TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS external_ssl BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE database_services
|
||||
DROP CONSTRAINT IF EXISTS database_services_plan_check,
|
||||
ADD CONSTRAINT database_services_plan_check
|
||||
CHECK (plan IN ('hobby', 'starter', 'standard', 'business', 'external'));
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE database_services
|
||||
DROP CONSTRAINT IF EXISTS database_services_plan_check,
|
||||
ADD CONSTRAINT database_services_plan_check
|
||||
CHECK (plan IN ('hobby', 'starter', 'standard', 'business'));
|
||||
|
||||
ALTER TABLE database_services
|
||||
DROP COLUMN IF EXISTS provider,
|
||||
DROP COLUMN IF EXISTS external_host,
|
||||
DROP COLUMN IF EXISTS external_port,
|
||||
DROP COLUMN IF EXISTS external_name,
|
||||
DROP COLUMN IF EXISTS external_username,
|
||||
DROP COLUMN IF EXISTS external_password,
|
||||
DROP COLUMN IF EXISTS external_ssl;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE database_services
|
||||
ADD COLUMN IF NOT EXISTS public_port BOOLEAN NOT NULL DEFAULT false;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE database_services
|
||||
DROP COLUMN IF EXISTS public_port;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,34 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE IF NOT EXISTS backup_targets (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
endpoint VARCHAR(512) NOT NULL,
|
||||
bucket VARCHAR(255) NOT NULL,
|
||||
region VARCHAR(100) NOT NULL DEFAULT '',
|
||||
prefix VARCHAR(255) NOT NULL DEFAULT '',
|
||||
access_key TEXT NOT NULL DEFAULT '',
|
||||
secret_key TEXT NOT NULL DEFAULT '',
|
||||
use_tls BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE database_services
|
||||
ADD COLUMN IF NOT EXISTS backup_target_id VARCHAR(255);
|
||||
|
||||
ALTER TABLE database_backups
|
||||
ADD COLUMN IF NOT EXISTS remote_key VARCHAR(512);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE database_backups
|
||||
DROP COLUMN IF EXISTS remote_key;
|
||||
|
||||
ALTER TABLE database_services
|
||||
DROP COLUMN IF EXISTS backup_target_id;
|
||||
|
||||
DROP TABLE IF EXISTS backup_targets;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS sleep_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS sleep_idle_minutes INTEGER NOT NULL DEFAULT 15;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE services DROP COLUMN IF EXISTS sleep_enabled;
|
||||
ALTER TABLE services DROP COLUMN IF EXISTS sleep_idle_minutes;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,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;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- name: ListBackupTargetsByUser :many
|
||||
SELECT backup_targets.*
|
||||
FROM backup_targets
|
||||
WHERE user_id = sqlc.arg(user_id)
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetBackupTargetByIDAndUser :one
|
||||
SELECT backup_targets.*
|
||||
FROM backup_targets
|
||||
WHERE id = sqlc.arg(id) AND user_id = sqlc.arg(user_id);
|
||||
|
||||
-- name: GetBackupTargetByID :one
|
||||
SELECT backup_targets.*
|
||||
FROM backup_targets
|
||||
WHERE id = sqlc.arg(id);
|
||||
|
||||
-- name: CreateBackupTarget :exec
|
||||
INSERT INTO backup_targets (id, user_id, name, endpoint, bucket, region, prefix, access_key, secret_key, use_tls, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);
|
||||
|
||||
-- name: UpdateBackupTargetByIDAndUser :exec
|
||||
UPDATE backup_targets
|
||||
SET name = $1, endpoint = $2, bucket = $3, region = $4, prefix = $5, access_key = $6, secret_key = $7, use_tls = $8, updated_at = $9
|
||||
WHERE id = $10 AND user_id = $11;
|
||||
|
||||
-- name: DeleteBackupTargetByIDAndUser :exec
|
||||
DELETE FROM backup_targets
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: SetDatabaseBackupTargetByIDAndUser :exec
|
||||
UPDATE database_services
|
||||
SET backup_target_id = $1, updated_at = $2
|
||||
WHERE id = $3 AND user_id = $4;
|
||||
|
||||
-- name: SetDatabaseBackupRemoteKeyByID :exec
|
||||
UPDATE database_backups
|
||||
SET remote_key = $1
|
||||
WHERE id = $2;
|
||||
|
||||
-- name: CountDatabasesUsingBackupTarget :one
|
||||
SELECT COUNT(*)
|
||||
FROM database_services
|
||||
WHERE backup_target_id = sqlc.arg(target_id);
|
||||
@@ -1,16 +1,16 @@
|
||||
-- name: ListDatabaseServicesByUser :many
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
SELECT database_services.*
|
||||
FROM database_services
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: ListAllDatabaseServices :many
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
SELECT database_services.*
|
||||
FROM database_services
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetDatabaseServiceByID :one
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
SELECT database_services.*
|
||||
FROM database_services
|
||||
WHERE id = $1;
|
||||
|
||||
@@ -20,7 +20,7 @@ FROM database_services
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetDatabaseServiceByIDAndUser :one
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
SELECT database_services.*
|
||||
FROM database_services
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
@@ -35,8 +35,13 @@ FROM database_services
|
||||
WHERE user_id = sqlc.arg(user_id) AND LOWER(name) = LOWER(sqlc.arg(name));
|
||||
|
||||
-- name: CreateDatabaseService :exec
|
||||
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
|
||||
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
|
||||
|
||||
-- name: SetDatabaseServicePublicPortByIDAndUser :exec
|
||||
UPDATE database_services
|
||||
SET public_port = $1, updated_at = $2
|
||||
WHERE id = $3 AND user_id = $4;
|
||||
|
||||
-- name: UpdateDatabaseServiceNameAndPlanByIDAndUser :exec
|
||||
UPDATE database_services
|
||||
@@ -73,7 +78,7 @@ SET status = $1, connection_url = $2, updated_at = $3
|
||||
WHERE id = $4;
|
||||
|
||||
-- name: ListDatabaseBackupsByDatabaseAndUser :many
|
||||
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
|
||||
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.remote_key, b.created_at, b.completed_at
|
||||
FROM database_backups b
|
||||
JOIN database_services s ON s.id = b.database_id
|
||||
WHERE b.database_id = $1 AND s.user_id = $2
|
||||
@@ -81,7 +86,7 @@ ORDER BY b.created_at DESC
|
||||
LIMIT $3;
|
||||
|
||||
-- name: GetDatabaseBackupByIDAndDatabaseAndUser :one
|
||||
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
|
||||
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.remote_key, b.created_at, b.completed_at
|
||||
FROM database_backups b
|
||||
JOIN database_services s ON s.id = b.database_id
|
||||
WHERE b.id = $1 AND b.database_id = $2 AND s.user_id = $3;
|
||||
@@ -110,3 +115,9 @@ WHERE backup_schedule IS NOT NULL AND backup_schedule <> ''
|
||||
UPDATE database_services
|
||||
SET next_backup_at = $1
|
||||
WHERE id = $2;
|
||||
|
||||
-- name: CreateExternalDatabaseService :exec
|
||||
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region,
|
||||
provider, external_host, external_port, external_name, external_username, external_password, external_ssl,
|
||||
created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, 'running', $5, $6, $7, 'external', $8, $9, $10, $11, $12, $13, $14, $15);
|
||||
@@ -58,7 +58,39 @@ CREATE TABLE services (
|
||||
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
|
||||
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 (
|
||||
@@ -107,6 +139,15 @@ CREATE TABLE database_services (
|
||||
connection_url TEXT,
|
||||
backup_schedule VARCHAR(100),
|
||||
next_backup_at TIMESTAMP WITH TIME ZONE,
|
||||
provider VARCHAR(20) NOT NULL DEFAULT 'managed',
|
||||
external_host VARCHAR(255),
|
||||
external_port INTEGER,
|
||||
external_name VARCHAR(255),
|
||||
external_username VARCHAR(255),
|
||||
external_password TEXT NOT NULL DEFAULT '',
|
||||
external_ssl BOOLEAN NOT NULL DEFAULT false,
|
||||
public_port BOOLEAN NOT NULL DEFAULT false,
|
||||
backup_target_id VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
@@ -117,10 +158,26 @@ CREATE TABLE database_backups (
|
||||
size VARCHAR(50) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'in_progress',
|
||||
backup_path TEXT,
|
||||
remote_key VARCHAR(512),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
completed_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE TABLE backup_targets (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
endpoint VARCHAR(512) NOT NULL,
|
||||
bucket VARCHAR(255) NOT NULL,
|
||||
region VARCHAR(100) NOT NULL DEFAULT '',
|
||||
prefix VARCHAR(255) NOT NULL DEFAULT '',
|
||||
access_key TEXT NOT NULL DEFAULT '',
|
||||
secret_key TEXT NOT NULL DEFAULT '',
|
||||
use_tls BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE node_agents (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
|
||||
@@ -20,12 +20,26 @@ import {
|
||||
createUserToken,
|
||||
listUserTokens,
|
||||
revokeUserToken,
|
||||
listRegistries,
|
||||
createRegistry,
|
||||
deleteRegistry,
|
||||
type Registry,
|
||||
listDatabases,
|
||||
databaseAction,
|
||||
updateDatabaseBackupSchedule,
|
||||
createDatabaseBackup,
|
||||
restoreDatabaseBackup,
|
||||
createManagedDatabase,
|
||||
registerExternalDatabase,
|
||||
importDatabaseBackup,
|
||||
updateDatabase,
|
||||
listBackupTargets,
|
||||
createBackupTarget,
|
||||
deleteBackupTarget,
|
||||
testBackupTarget,
|
||||
testDatabaseConnection,
|
||||
testDatabaseConnectionByID,
|
||||
type ExternalDatabaseInput,
|
||||
listServicesByProject,
|
||||
listServiceVariables,
|
||||
updateServiceVariables,
|
||||
@@ -117,6 +131,7 @@ import {
|
||||
HeartPulse,
|
||||
Cloud,
|
||||
ExternalLink,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
|
||||
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
||||
@@ -1430,6 +1445,268 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupTargetsSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const targetsQuery = useQuery({
|
||||
queryKey: ['backup-targets'],
|
||||
queryFn: listBackupTargets,
|
||||
});
|
||||
const [form, setForm] = useState({ name: '', endpoint: '', bucket: '', region: '', prefix: '', accessKey: '', secretKey: '', noTls: false });
|
||||
const [testResult, setTestResult] = useState<Record<string, { ok: boolean; latency_ms?: number; error?: string }>>({});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['backup-targets'] });
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createBackupTarget({
|
||||
name: form.name.trim(),
|
||||
endpoint: form.endpoint.trim(),
|
||||
bucket: form.bucket.trim(),
|
||||
region: form.region.trim() || undefined,
|
||||
prefix: form.prefix.trim() || undefined,
|
||||
access_key: form.accessKey || undefined,
|
||||
secret_key: form.secretKey || undefined,
|
||||
use_tls: !form.noTls,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setForm({ name: '', endpoint: '', bucket: '', region: '', prefix: '', accessKey: '', secretKey: '', noTls: false });
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteBackupTarget(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (id: string) => testBackupTarget(id),
|
||||
onSuccess: (result, id) => setTestResult((prev) => ({ ...prev, [id]: result })),
|
||||
onError: (error, id) =>
|
||||
setTestResult((prev) => ({ ...prev, [id]: { ok: false, error: error instanceof Error ? error.message : 'probe failed' } })),
|
||||
});
|
||||
|
||||
const targets = targetsQuery.data ?? [];
|
||||
const fieldCls = 'h-9 flex-1 min-w-[130px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all';
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<HardDrive size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Backup targets</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">S3-compatible destinations for database archives — keys stored encrypted</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Name (e.g. garage)" className={fieldCls} />
|
||||
<input value={form.endpoint} onChange={(e) => setForm((f) => ({ ...f, endpoint: e.target.value }))} placeholder="Endpoint host:port" className={fieldCls} />
|
||||
<input value={form.bucket} onChange={(e) => setForm((f) => ({ ...f, bucket: e.target.value }))} placeholder="Bucket" className="h-9 w-36 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all" />
|
||||
<input value={form.accessKey} onChange={(e) => setForm((f) => ({ ...f, accessKey: e.target.value }))} placeholder="Access key" className={fieldCls} />
|
||||
<input type="password" value={form.secretKey} onChange={(e) => setForm((f) => ({ ...f, secretKey: e.target.value }))} placeholder="Secret key" className={fieldCls} />
|
||||
<label className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||
<input type="checkbox" checked={form.noTls} onChange={(e) => setForm((f) => ({ ...f, noTls: e.target.checked }))} className="accent-[var(--accent-primary)]" />
|
||||
No TLS
|
||||
</label>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || !form.name.trim() || !form.endpoint.trim() || !form.bucket.trim()}
|
||||
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
{createMutation.isPending ? 'Checking…' : 'Add target'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createMutation.isError ? (
|
||||
<p className="text-xs text-[var(--error)]">
|
||||
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add target'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{targetsQuery.isLoading ? (
|
||||
<div className="py-4 text-center">
|
||||
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
) : targets.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
No backup targets. Database archives stay in the local backups volume until a target is configured.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
|
||||
{targets.map((t) => (
|
||||
<div key={t.id} className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="min-w-0">
|
||||
<span className="text-[var(--text-primary)]">{t.name}</span>
|
||||
<span className="ml-2 mono text-[var(--text-muted)]">{t.endpoint}/{t.bucket}{t.prefix ? `/${t.prefix}` : ''}</span>
|
||||
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
|
||||
{t.has_credentials ? 'keys set' : 'anonymous'}{t.use_tls ? '' : ' · no TLS'}
|
||||
</span>
|
||||
{testResult[t.id ?? ''] && (
|
||||
<span className={`ml-2 ${testResult[t.id ?? '']?.ok ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
|
||||
{testResult[t.id ?? '']?.ok ? `reachable (${testResult[t.id ?? '']?.latency_ms ?? 0}ms)` : `unreachable — ${testResult[t.id ?? '']?.error ?? 'probe failed'}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={() => testMutation.mutate(t.id ?? '')}
|
||||
disabled={testMutation.isPending}
|
||||
className="text-[var(--accent-primary)] hover:underline disabled:opacity-50"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete backup target ${t.name}?`)) {
|
||||
deleteMutation.mutate(t.id ?? '');
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-[var(--error)] hover:underline disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -1603,6 +1880,8 @@ export function SettingsPage() {
|
||||
|
||||
{/* Personal Access Tokens */}
|
||||
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
||||
<RegistriesSection />
|
||||
<BackupTargetsSection />
|
||||
|
||||
{/* Platform Settings - owner only */}
|
||||
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
||||
@@ -1767,7 +2046,11 @@ export function DatabasesPage() {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [bindDb, setBindDb] = useState<DatabaseEntity | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' });
|
||||
const [createMode, setCreateMode] = useState<'managed' | 'external'>('managed');
|
||||
const [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local', public: false });
|
||||
const [externalForm, setExternalForm] = useState({ name: '', type: 'postgresql', host: '', port: '', database: '', username: '', password: '', ssl: false });
|
||||
const [externalProbe, setExternalProbe] = useState<{ ok: boolean; latency_ms?: number; error?: string } | null>(null);
|
||||
const [externalTestResult, setExternalTestResult] = useState<Record<string, { ok: boolean; latency_ms?: number; error?: string }>>({});
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const databasesQuery = useQuery({
|
||||
queryKey: ['databases'],
|
||||
@@ -1775,6 +2058,11 @@ export function DatabasesPage() {
|
||||
refetchInterval: 15_000,
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
const backupTargetsQuery = useQuery({
|
||||
queryKey: ['backup-targets'],
|
||||
queryFn: listBackupTargets,
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['databases'] });
|
||||
const createMutation = useMutation({
|
||||
@@ -1784,10 +2072,11 @@ export function DatabasesPage() {
|
||||
type: createForm.type as CreateDatabaseInput['type'],
|
||||
plan: createForm.plan as CreateDatabaseInput['plan'],
|
||||
region: createForm.region.trim() || 'local',
|
||||
public_port: createForm.public,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setCreateOpen(false);
|
||||
setCreateForm({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' });
|
||||
setCreateForm({ name: '', type: 'postgresql', plan: 'hobby', region: 'local', public: false });
|
||||
setCreateError(null);
|
||||
invalidate();
|
||||
},
|
||||
@@ -1795,6 +2084,54 @@ export function DatabasesPage() {
|
||||
setCreateError(error instanceof Error ? error.message : 'Failed to create database');
|
||||
},
|
||||
});
|
||||
const externalPayload = (): ExternalDatabaseInput => ({
|
||||
name: externalForm.name.trim(),
|
||||
type: externalForm.type,
|
||||
host: externalForm.host.trim(),
|
||||
port: Number(externalForm.port) || undefined,
|
||||
database: externalForm.database.trim() || undefined,
|
||||
username: externalForm.username.trim() || undefined,
|
||||
password: externalForm.password || undefined,
|
||||
ssl: externalForm.ssl,
|
||||
});
|
||||
const registerExternalMutation = useMutation({
|
||||
mutationFn: () => registerExternalDatabase(externalPayload()),
|
||||
onSuccess: () => {
|
||||
setCreateOpen(false);
|
||||
setExternalForm({ name: '', type: 'postgresql', host: '', port: '', database: '', username: '', password: '', ssl: false });
|
||||
setExternalProbe(null);
|
||||
setCreateError(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
setCreateError(error instanceof Error ? error.message : 'Failed to register database');
|
||||
},
|
||||
});
|
||||
const probeMutation = useMutation({
|
||||
mutationFn: () => testDatabaseConnection(externalPayload()),
|
||||
onSuccess: (result) => setExternalProbe(result),
|
||||
onError: (error) => setExternalProbe({ ok: false, error: error instanceof Error ? error.message : 'probe failed' }),
|
||||
});
|
||||
const publicPortMutation = useMutation({
|
||||
mutationFn: ({ id, publicPort }: { id: string; publicPort: boolean }) =>
|
||||
updateDatabase(id, { public_port: publicPort }),
|
||||
onSuccess: () => window.setTimeout(invalidate, 1500),
|
||||
});
|
||||
const backupTargetMutation = useMutation({
|
||||
mutationFn: ({ id, targetId }: { id: string; targetId: string }) =>
|
||||
updateDatabase(id, { backup_target_id: targetId }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const importBackupMutation = useMutation({
|
||||
mutationFn: ({ id, file }: { id: string; file: File }) => importDatabaseBackup(id, file),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const testByIdMutation = useMutation({
|
||||
mutationFn: (id: string) => testDatabaseConnectionByID(id),
|
||||
onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })),
|
||||
onError: (error, id) =>
|
||||
setExternalTestResult((prev) => ({ ...prev, [id]: { ok: false, error: error instanceof Error ? error.message : 'probe failed' } })),
|
||||
});
|
||||
const actionMutation = useMutation({
|
||||
mutationFn: ({ id, action }: { id: string; action: 'start' | 'stop' | 'restart' }) =>
|
||||
databaseAction(id, action),
|
||||
@@ -1880,9 +2217,16 @@ export function DatabasesPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">{db.name}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] mono">{db.type}{db.version ? ` ${db.version}` : ''}</span>
|
||||
{db.provider === 'external' && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]">external</span>
|
||||
)}
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${dbStatusClass[db.status ?? ''] ?? 'bg-[var(--surface-muted)] text-[var(--text-muted)]'}`}>{db.status}</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-[var(--text-muted)]">{db.plan} · {db.region}</p>
|
||||
<p className="mt-0.5 text-xs text-[var(--text-muted)]">
|
||||
{db.provider === 'external' && db.external
|
||||
? `${db.external.host}:${db.external.port}${db.external.database ? ` · ${db.external.database}` : ''}`
|
||||
: `${db.plan} · ${db.region}`}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown size={15} className={`shrink-0 text-[var(--text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
@@ -1910,6 +2254,25 @@ export function DatabasesPage() {
|
||||
{signedIn ? (
|
||||
<>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mr-1">Actions</span>
|
||||
{db.provider === 'external' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => testByIdMutation.mutate(db.id ?? '')}
|
||||
disabled={testByIdMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<RefreshCw size={11} /> Test connection
|
||||
</button>
|
||||
{externalTestResult[db.id ?? ''] && (
|
||||
<span className={`text-xs ${externalTestResult[db.id ?? '']?.ok ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
|
||||
{externalTestResult[db.id ?? '']?.ok
|
||||
? `reachable (${externalTestResult[db.id ?? '']?.latency_ms ?? 0}ms)`
|
||||
: `unreachable — ${externalTestResult[db.id ?? '']?.error ?? 'probe failed'}`}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
|
||||
disabled={running || actionMutation.isPending}
|
||||
@@ -1931,6 +2294,15 @@ export function DatabasesPage() {
|
||||
>
|
||||
<RefreshCw size={11} /> Restart
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (window.confirm(`${db.public_port ? 'Stop exposing' : 'Expose'} ${db.name} on all interfaces? The container will be recreated; data persists.`)) publicPortMutation.mutate({ id: db.id ?? '', publicPort: !db.public_port }) }}
|
||||
disabled={publicPortMutation.isPending}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border text-xs font-medium transition-colors disabled:opacity-40 ${db.public_port ? 'border-[var(--warning)] text-[var(--warning)]' : 'border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--border-default)]'}`}
|
||||
>
|
||||
<Link2 size={11} /> {db.public_port ? 'Public ✓' : 'Local only'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setBindDb(bindDb?.id === db.id ? null : db)}
|
||||
disabled={!db.connection_url}
|
||||
@@ -1973,10 +2345,43 @@ export function DatabasesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{db.provider !== 'external' && (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p>
|
||||
{signedIn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{(backupTargetsQuery.data?.length ?? 0) > 0 && (
|
||||
<select
|
||||
value={db.backup_target_id ?? ''}
|
||||
onChange={(e) => backupTargetMutation.mutate({ id: db.id ?? '', targetId: e.target.value })}
|
||||
title="Offsite archive destination"
|
||||
className="h-7 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-xs text-[var(--text-secondary)]"
|
||||
>
|
||||
<option value="">local only</option>
|
||||
{(backupTargetsQuery.data ?? []).map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name} ({t.bucket})</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<label
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium border border-[var(--border-subtle)] text-[var(--text-secondary)] transition-all ${importBackupMutation.isPending ? 'opacity-50' : 'cursor-pointer hover:text-[var(--text-primary)]'}`}
|
||||
title="Import a .tar.gz archive as a restore point"
|
||||
>
|
||||
<Upload size={11} />
|
||||
{importBackupMutation.isPending ? 'Importing…' : 'Import'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".tar.gz,application/gzip"
|
||||
className="hidden"
|
||||
disabled={importBackupMutation.isPending}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) importBackupMutation.mutate({ id: db.id ?? '', file: f });
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => backupMutation.mutate(db.id ?? '')}
|
||||
disabled={!running || backupMutation.isPending}
|
||||
@@ -1986,6 +2391,7 @@ export function DatabasesPage() {
|
||||
<Archive size={11} />
|
||||
{backupMutation.isPending ? 'Starting…' : 'New backup'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<BackupScheduleRow
|
||||
@@ -2002,6 +2408,7 @@ export function DatabasesPage() {
|
||||
<span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span>
|
||||
<span className="mono text-[var(--text-muted)]">{b.size}</span>
|
||||
<span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span>
|
||||
{b.remote && <span className="rounded bg-[var(--surface-card)] px-1.5 py-0.5 text-[var(--accent-primary)]" title="Also shipped to the configured backup target">offsite</span>}
|
||||
{b.status === 'completed' && (
|
||||
<span className="ml-auto flex items-center gap-3">
|
||||
<a
|
||||
@@ -2029,6 +2436,7 @@ export function DatabasesPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2043,18 +2451,126 @@ export function DatabasesPage() {
|
||||
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
||||
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">New database</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Provision a managed database service.</p>
|
||||
<div className="mt-3 flex gap-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-1">
|
||||
{(['managed', 'external'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => { setCreateMode(mode); setCreateError(null); }}
|
||||
className={`flex-1 rounded-[calc(var(--radius-md)-2px)] px-3 py-1.5 text-xs font-medium capitalize transition-colors ${createMode === mode ? 'bg-[var(--bg-base)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[var(--text-tertiary)]">
|
||||
{createMode === 'managed' ? 'Provision a managed database service.' : 'Register a database hosted outside Containr.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Name</label>
|
||||
<input
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))}
|
||||
value={createMode === 'managed' ? createForm.name : externalForm.name}
|
||||
onChange={(e) => (createMode === 'managed' ? setCreateForm((f) => ({ ...f, name: e.target.value })) : setExternalForm((f) => ({ ...f, name: e.target.value })))}
|
||||
placeholder="my-postgres"
|
||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
{createMode === 'external' ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</label>
|
||||
<select
|
||||
value={externalForm.type}
|
||||
onChange={(e) => setExternalForm((f) => ({ ...f, type: e.target.value }))}
|
||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-sm focus:border-[var(--accent-primary)]"
|
||||
>
|
||||
{DATABASE_TYPES.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Host</label>
|
||||
<input
|
||||
value={externalForm.host}
|
||||
onChange={(e) => setExternalForm((f) => ({ ...f, host: e.target.value }))}
|
||||
placeholder="db.example.com"
|
||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Port</label>
|
||||
<input
|
||||
value={externalForm.port}
|
||||
onChange={(e) => setExternalForm((f) => ({ ...f, port: e.target.value }))}
|
||||
placeholder="default"
|
||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Database</label>
|
||||
<input
|
||||
value={externalForm.database}
|
||||
onChange={(e) => setExternalForm((f) => ({ ...f, database: e.target.value }))}
|
||||
placeholder="optional"
|
||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Username</label>
|
||||
<input
|
||||
value={externalForm.username}
|
||||
onChange={(e) => setExternalForm((f) => ({ ...f, username: e.target.value }))}
|
||||
placeholder="optional"
|
||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={externalForm.password}
|
||||
onChange={(e) => setExternalForm((f) => ({ ...f, password: e.target.value }))}
|
||||
placeholder="optional"
|
||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-[var(--text-secondary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={externalForm.ssl}
|
||||
onChange={(e) => setExternalForm((f) => ({ ...f, ssl: e.target.checked }))}
|
||||
className="accent-[var(--accent-primary)]"
|
||||
/>
|
||||
Require TLS/SSL
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => probeMutation.mutate()}
|
||||
disabled={probeMutation.isPending || externalForm.host.trim().length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40"
|
||||
>
|
||||
{probeMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : null}
|
||||
Test connection
|
||||
</button>
|
||||
{externalProbe && (
|
||||
<span className={`text-xs ${externalProbe.ok ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
|
||||
{externalProbe.ok ? `reachable (${externalProbe.latency_ms ?? 0}ms)` : `unreachable — ${externalProbe.error ?? 'probe failed'}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</label>
|
||||
@@ -2090,6 +2606,17 @@ export function DatabasesPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-[var(--text-secondary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createForm.public}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, public: e.target.checked }))}
|
||||
className="accent-[var(--accent-primary)]"
|
||||
/>
|
||||
Expose port on all interfaces (default: localhost only)
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{createError ? (
|
||||
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-3.5 py-2.5 text-xs text-[var(--error)]">{createError}</div>
|
||||
) : null}
|
||||
@@ -2104,12 +2631,15 @@ export function DatabasesPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || createForm.name.trim().length === 0}
|
||||
onClick={() => (createMode === 'managed' ? createMutation.mutate() : registerExternalMutation.mutate())}
|
||||
disabled={
|
||||
(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ||
|
||||
(createMode === 'managed' ? createForm.name.trim().length === 0 : externalForm.name.trim().length === 0 || externalForm.host.trim().length === 0)
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||
Create database
|
||||
{(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||
{createMode === 'managed' ? 'Create database' : 'Register database'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,12 +28,27 @@ import {
|
||||
manualScaleService,
|
||||
execInService,
|
||||
rollbackDeployment,
|
||||
cancelDeployment,
|
||||
updateServiceVariables,
|
||||
updateService,
|
||||
restartService,
|
||||
stopService,
|
||||
sleepService,
|
||||
wakeService,
|
||||
getServiceRuntime,
|
||||
getServiceEnvCheck,
|
||||
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 +81,7 @@ import {
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
Box,
|
||||
Ban,
|
||||
Play,
|
||||
ChevronDown,
|
||||
GitPullRequest,
|
||||
@@ -104,7 +120,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 };
|
||||
|
||||
@@ -172,6 +190,12 @@ export function ServiceDetailPage() {
|
||||
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'variables',
|
||||
});
|
||||
|
||||
const envCheckQuery = useQuery({
|
||||
queryKey: ['service-env-check', serviceId],
|
||||
queryFn: () => getServiceEnvCheck(serviceId),
|
||||
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'variables',
|
||||
});
|
||||
|
||||
const saveVariablesMutation = useMutation({
|
||||
mutationFn: (rows: VariableDraft[]) =>
|
||||
updateServiceVariables(
|
||||
@@ -374,6 +398,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 +424,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] });
|
||||
@@ -406,10 +468,19 @@ export function ServiceDetailPage() {
|
||||
mutationFn: () => stopService(serviceId),
|
||||
onSuccess: invalidateService,
|
||||
});
|
||||
const sleepMutation = useMutation({
|
||||
mutationFn: () => sleepService(serviceId),
|
||||
onSuccess: invalidateService,
|
||||
});
|
||||
const wakeMutation = useMutation({
|
||||
mutationFn: () => wakeService(serviceId),
|
||||
onSuccess: invalidateService,
|
||||
});
|
||||
const updateServiceMutation = useMutation({
|
||||
mutationFn: (input: Parameters<typeof updateService>[1]) => updateService(serviceId, input),
|
||||
onSuccess: () => {
|
||||
setNetworkForm(null);
|
||||
setSleepDraft(null);
|
||||
invalidateService();
|
||||
},
|
||||
});
|
||||
@@ -424,9 +495,34 @@ 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 [sleepDraft, setSleepDraft] = useState<{ id: string; enabled: boolean; minutes: 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;
|
||||
const sleepDraftCurrent = sleepDraft?.id === serviceId ? sleepDraft : null;
|
||||
const sleepEnabled = sleepDraftCurrent?.enabled ?? Boolean(service?.sleepEnabled);
|
||||
const sleepMinutes = sleepDraftCurrent?.minutes ?? String(service?.sleepIdleMinutes ?? 15);
|
||||
|
||||
const varRows: VariableDraft[] = useMemo(
|
||||
() =>
|
||||
@@ -514,7 +610,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;
|
||||
|
||||
@@ -615,7 +711,7 @@ export function ServiceDetailPage() {
|
||||
className={`badge-${service.status === 'running' ? 'active' : service.status === 'degraded' ? 'degraded' : 'stopped'}`}
|
||||
>
|
||||
{service.status === 'running' && <span className="live-dot" />}
|
||||
{service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : 'Stopped'}
|
||||
{service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : service.status === 'sleeping' ? 'Sleeping' : 'Stopped'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
|
||||
@@ -1181,8 +1277,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>
|
||||
@@ -1213,6 +1310,23 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{envCheckQuery.data && !envCheckQuery.data.ok && (
|
||||
<div className="mb-4 rounded-[var(--radius-md)] border border-[var(--warning)]/40 bg-[var(--warning-soft)] p-4">
|
||||
<p className="text-xs font-semibold text-[var(--warning)] mb-2">Environment issues detected</p>
|
||||
<ul className="space-y-1 text-xs text-[var(--text-secondary)]">
|
||||
{(envCheckQuery.data.unresolved ?? []).map((u) => (
|
||||
<li key={u} className="mono">Unresolved reference: {u}</li>
|
||||
))}
|
||||
{(envCheckQuery.data.empty ?? []).map((k) => (
|
||||
<li key={k} className="mono">Empty value: {k}</li>
|
||||
))}
|
||||
{(envCheckQuery.data.unreadable ?? []).map((k) => (
|
||||
<li key={k} className="mono">Cannot decrypt secret (key rotated?): {k}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bulkOpen && (
|
||||
<div className="mb-4 panel-soft p-4">
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
@@ -2054,6 +2168,516 @@ 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-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-[var(--text-primary)]">Sleep</h3>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-0.5">
|
||||
Scale to zero after idle minutes without traffic. Requests resume the service automatically.
|
||||
</p>
|
||||
</div>
|
||||
{service.status === 'sleeping' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={wakeMutation.isPending}
|
||||
onClick={() => wakeMutation.mutate()}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
{wakeMutation.isPending ? 'Waking…' : 'Wake now'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={sleepMutation.isPending || service.status !== 'running'}
|
||||
onClick={() => sleepMutation.mutate()}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{sleepMutation.isPending ? 'Sleeping…' : 'Sleep now'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<label className="flex items-center gap-2 text-[var(--text-primary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sleepEnabled}
|
||||
onChange={(e) =>
|
||||
setSleepDraft({ id: serviceId, enabled: e.target.checked, minutes: sleepMinutes })
|
||||
}
|
||||
/>
|
||||
Sleep on idle
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-[var(--text-tertiary)]">
|
||||
after
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
value={sleepMinutes}
|
||||
onChange={(e) =>
|
||||
setSleepDraft({ id: serviceId, enabled: sleepEnabled, minutes: e.target.value })
|
||||
}
|
||||
className="w-20 px-2 py-1 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
/>
|
||||
min
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateServiceMutation.isPending}
|
||||
onClick={() =>
|
||||
updateServiceMutation.mutate({
|
||||
sleep_enabled: sleepEnabled,
|
||||
sleep_idle_minutes: Math.max(1, Math.min(1440, Number(sleepMinutes) || 15)),
|
||||
})
|
||||
}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
{sleepDraftCurrent && (
|
||||
<span className="text-[var(--text-tertiary)]">unsaved</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && (
|
||||
<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 & 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 +2745,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 +2779,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>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
deployTemplate,
|
||||
getCurrentUserProfile,
|
||||
getTemplateById,
|
||||
importComposeTemplate,
|
||||
listProjects,
|
||||
listTemplates,
|
||||
updateTemplate,
|
||||
@@ -36,6 +37,8 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
FileCode,
|
||||
Network,
|
||||
} from 'lucide-react';
|
||||
|
||||
function toServiceName(value: string): string {
|
||||
@@ -147,10 +150,16 @@ export function TemplatesPage() {
|
||||
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
|
||||
Record<string, Record<string, string>>
|
||||
>({});
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [composeYaml, setComposeYaml] = useState('');
|
||||
const [composeWarnings, setComposeWarnings] = useState<string[]>([]);
|
||||
const [composeError, setComposeError] = useState<string | null>(null);
|
||||
|
||||
const [lastDeployment, setLastDeployment] = useState<{
|
||||
projectId: string;
|
||||
serviceId: string;
|
||||
serviceId?: string;
|
||||
serviceName: string;
|
||||
created?: { key: string; kind: string; id: string; name: string; deploymentId?: string }[];
|
||||
} | null>(null);
|
||||
|
||||
const templatesQuery = useQuery({
|
||||
@@ -281,6 +290,12 @@ export function TemplatesPage() {
|
||||
});
|
||||
}, [selectedDetail, variableValues]);
|
||||
|
||||
const graphMembers = useMemo(
|
||||
() => selectedDetail?.config.services ?? [],
|
||||
[selectedDetail],
|
||||
);
|
||||
const isGraphTemplate = graphMembers.length > 0;
|
||||
|
||||
const deployMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedTemplateId) {
|
||||
@@ -296,17 +311,21 @@ export function TemplatesPage() {
|
||||
|
||||
return deployTemplate(selectedTemplateId, {
|
||||
projectId: deployProjectId,
|
||||
name: deployName.trim(),
|
||||
name: isGraphTemplate ? undefined : deployName.trim(),
|
||||
variables,
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['databases'] });
|
||||
setLastDeployment({
|
||||
projectId: deployProjectId,
|
||||
serviceId: result.serviceId,
|
||||
serviceName: deployName.trim(),
|
||||
serviceName: isGraphTemplate
|
||||
? `${result.created?.length ?? 0} resources`
|
||||
: deployName.trim(),
|
||||
created: result.created,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -314,7 +333,7 @@ export function TemplatesPage() {
|
||||
const isDeployDisabled =
|
||||
!selectedTemplateId ||
|
||||
!deployProjectId ||
|
||||
deployName.trim().length === 0 ||
|
||||
(!isGraphTemplate && deployName.trim().length === 0) ||
|
||||
missingRequiredVariables.length > 0 ||
|
||||
deployMutation.isPending;
|
||||
|
||||
@@ -344,6 +363,37 @@ export function TemplatesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const composeImportMutation = useMutation({
|
||||
mutationFn: (yaml: string) => importComposeTemplate(yaml),
|
||||
onSuccess: (result) => {
|
||||
setComposeWarnings(result.warnings);
|
||||
setComposeError(null);
|
||||
setComposeOpen(false);
|
||||
// Load the converted graph into the template editor for review/save.
|
||||
setEditingTemplate(null);
|
||||
setEditorIsPublic(false);
|
||||
setEditorError(null);
|
||||
setEditorJson(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'compose-stack',
|
||||
description: 'Imported from docker-compose',
|
||||
category: 'stack',
|
||||
logo: '',
|
||||
config: result.config,
|
||||
variables: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
setEditorOpen(true);
|
||||
},
|
||||
onError: (error) => {
|
||||
setComposeError(error instanceof Error ? error.message : 'Failed to convert compose file');
|
||||
},
|
||||
});
|
||||
|
||||
const openCreateEditor = () => {
|
||||
setEditingTemplate(null);
|
||||
setEditorJson(EMPTY_TEMPLATE_JSON);
|
||||
@@ -438,6 +488,19 @@ export function TemplatesPage() {
|
||||
<Layers size={16} />
|
||||
<span>{filteredTemplates.length} templates</span>
|
||||
{signedIn && !isDemoMode ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setComposeYaml('');
|
||||
setComposeWarnings([]);
|
||||
setComposeError(null);
|
||||
setComposeOpen(true);
|
||||
}}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3.5 text-[12.5px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<FileCode size={14} /> Import Compose
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateEditor}
|
||||
@@ -445,6 +508,7 @@ export function TemplatesPage() {
|
||||
>
|
||||
<Plus size={14} /> New template
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -725,6 +789,42 @@ export function TemplatesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isGraphTemplate ? (
|
||||
<div className="panel-soft p-4 mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Network size={14} className="text-[var(--accent-primary)]" />
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
Stack — {graphMembers.length} members
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{graphMembers.map((member) => (
|
||||
<div key={member.key} className="flex items-center justify-between gap-3 text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{member.type === 'database' ? (
|
||||
<Database size={13} className="text-[var(--info)] shrink-0" />
|
||||
) : (
|
||||
<Box size={13} className="text-[var(--text-tertiary)] shrink-0" />
|
||||
)}
|
||||
<span className="mono text-[var(--text-primary)] truncate">{member.name || member.key}</span>
|
||||
{member.dependsOn && member.dependsOn.length > 0 ? (
|
||||
<span className="text-[10px] text-[var(--text-tertiary)]">
|
||||
after {member.dependsOn.join(', ')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="mono text-[11px] text-[var(--text-tertiary)] truncate">
|
||||
{member.type === 'database' ? `managed ${member.runtime}` : member.runtime || member.repo || '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-3">
|
||||
Members join a private project network; references like {'{{service.db.url}}'} resolve to the member key.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Deploy Section */}
|
||||
<div className="border-t border-[var(--border-subtle)] pt-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
@@ -733,7 +833,9 @@ export function TemplatesPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Configure and create a new service</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{isGraphTemplate ? 'Deploy all members into a project' : 'Configure and create a new service'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -767,6 +869,7 @@ export function TemplatesPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{!isGraphTemplate ? (
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Service Name
|
||||
@@ -784,6 +887,7 @@ export function TemplatesPage() {
|
||||
placeholder="service-name"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{selectedDetail.variables.length > 0 && (
|
||||
@@ -846,15 +950,30 @@ export function TemplatesPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Check size={16} />
|
||||
<span>
|
||||
Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created
|
||||
{lastDeployment.created
|
||||
? `Stack deployed — ${lastDeployment.created.length} resources`
|
||||
: <>Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created</>}
|
||||
</span>
|
||||
<Link
|
||||
to={`/projects/${lastDeployment.projectId}/services/${lastDeployment.serviceId}${isDemoMode ? '?demo=1' : ''}`}
|
||||
to={lastDeployment.created
|
||||
? `/projects/${lastDeployment.projectId}${isDemoMode ? '?demo=1' : ''}`
|
||||
: `/projects/${lastDeployment.projectId}/services/${lastDeployment.serviceId}${isDemoMode ? '?demo=1' : ''}`}
|
||||
className="flex items-center gap-1 ml-2 underline underline-offset-2 hover:no-underline"
|
||||
>
|
||||
View <ArrowRight size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
{lastDeployment.created ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{lastDeployment.created.map((m) => (
|
||||
<div key={m.id} className="flex items-center gap-2 text-xs">
|
||||
{m.kind === 'database' ? <Database size={11} /> : <Box size={11} />}
|
||||
<span className="mono">{m.name}</span>
|
||||
<span className="opacity-70">({m.kind})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -868,12 +987,12 @@ export function TemplatesPage() {
|
||||
{deployMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Creating...
|
||||
Deploying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} />
|
||||
Create Service
|
||||
{isGraphTemplate ? 'Deploy Stack' : 'Create Service'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -929,6 +1048,14 @@ export function TemplatesPage() {
|
||||
spellCheck={false}
|
||||
className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
|
||||
/>
|
||||
{composeWarnings.length > 0 ? (
|
||||
<div className="border-t border-[var(--border-subtle)] bg-[var(--warning-soft)]/60 px-5 py-2.5 text-xs text-[var(--warning)]">
|
||||
<p className="font-medium mb-1">Compose import notes</p>
|
||||
{composeWarnings.map((w) => (
|
||||
<p key={w}>• {w}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{editorError ? (
|
||||
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
|
||||
{editorError}
|
||||
@@ -966,6 +1093,59 @@ export function TemplatesPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Compose import */}
|
||||
{composeOpen ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-subtle)] px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Import docker-compose</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
Paste a compose file — known database images become managed databases, everything else becomes services.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setComposeOpen(false)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={composeYaml}
|
||||
onChange={(e) => setComposeYaml(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'}
|
||||
className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
|
||||
/>
|
||||
{composeError ? (
|
||||
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
|
||||
{composeError}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setComposeOpen(false)}
|
||||
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => composeImportMutation.mutate(composeYaml)}
|
||||
disabled={!composeYaml.trim() || composeImportMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
>
|
||||
{composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />}
|
||||
Convert to template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user