mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b22ac401c8 | ||
|
|
ea96929435 | ||
|
|
971773f80c | ||
|
|
7e99e41f8d | ||
|
|
6c5ff7be41 | ||
|
|
7b93506e2d | ||
|
|
f34fba165c | ||
|
|
6949c01464 | ||
|
|
f22ae22375 | ||
|
|
a4b6ea4bd2 | ||
|
|
e995c98d05 | ||
|
|
2f8f595d6e | ||
|
|
747b714a02 | ||
|
|
7839dc48a3 | ||
|
|
84d58cf808 | ||
|
|
18f78f00a3 | ||
|
|
485e41a817 | ||
|
|
e51b20b2d4 | ||
|
|
17e429b86b | ||
|
|
e881747388 | ||
|
|
f9ae5b962c |
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
github: Dvorinka
|
||||||
@@ -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.
|
||||||
@@ -56,7 +56,8 @@ dedicated console.
|
|||||||
- **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete
|
- **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete
|
||||||
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
|
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
|
||||||
- **HA & scaling** — failover policies, node agents, autoscaling rules
|
- **HA & scaling** — failover policies, node agents, autoscaling rules
|
||||||
- **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vulnerability scans
|
- **Auth & security** — Better Auth sessions (email/password self-hosted), personal access tokens with `read`/`write`/`admin` scopes for CLI and agents, audit logs, vulnerability scans
|
||||||
|
- **CLI** — `containr` covers the full API: projects, services, deployments, logs, exec, variables, databases, cron, templates, nodes, scaling, HA, security, gateway, admin — with `--json`, `--yes`, named profiles, and `containr up` for current-directory deploys
|
||||||
- **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline
|
- **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline
|
||||||
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
|
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
|
||||||
|
|
||||||
|
|||||||
+396
@@ -0,0 +1,396 @@
|
|||||||
|
# Containr — Roadmap 2: The dflow Convergence
|
||||||
|
|
||||||
|
Last verified: **2026-10-08** — deep pass over the full dflow codebase
|
||||||
|
(`~/Desktop/PROG+HTML/dflow`, community fork of dflow-sh/dflow, frozen for
|
||||||
|
preservation) against Containr `main` (`15b5b0e`).
|
||||||
|
|
||||||
|
Goal: make Containr the superior successor — absorb everything worth having
|
||||||
|
from dflow, then go past it and past Railway. Same power for humans and
|
||||||
|
agents: the UI is the friendly face, the CLI and MCP are the machine face,
|
||||||
|
all three sit on the same API. **Branding stays Containr/Vertice** —
|
||||||
|
white-labeling is an admin option, not an identity change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What dflow actually is (verified)
|
||||||
|
|
||||||
|
Next.js 15 + PayloadCMS 3 + MongoDB + Redis/BullMQ. Runtime model: every
|
||||||
|
host is a remote server driven **over SSH** with Dokku auto-installed as the
|
||||||
|
per-app container manager. Async work = 43 BullMQ queue processors
|
||||||
|
(`src/queues/**`), live logs via Redis pub/sub → SSE endpoint, persisted to
|
||||||
|
deployment records. ~30 collections, ~180 server actions, 11 dashboard
|
||||||
|
routes per service (general, deployments, logs, environment, domains,
|
||||||
|
volumes, proxy, scaling, backups, settings).
|
||||||
|
|
||||||
|
### Full capability matrix — dflow → Containr
|
||||||
|
|
||||||
|
Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable (dokku/SSH-specific)
|
||||||
|
|
||||||
|
#### Deployment pipeline
|
||||||
|
| dflow capability | Containr status | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| Queued deployments, serialized per server, cancellable while queued | ❌ goroutine fire-and-forget; `CancelDeployment` exists in engine but is **never routed** | Add deploy queue + cancel route |
|
||||||
|
| Deployment states `queued → building → success/failed`, live SSE logs + persisted record | 🟡 states exist (`building→running`); WS streams builds; no queued state | Extend status + stream |
|
||||||
|
| Redeploy cache modes (`ps:rebuild` vs fresh pull); webhook pushes force `no-cache` | ❌ always rebuilds | Add `cache` flag on deploy/redeploy |
|
||||||
|
| Cancel queued deployment from UI | ❌ (engine fn unrouted) | Route it |
|
||||||
|
| Per-service job ordering | ❌ concurrent deploys of one service can race | Serialize per service |
|
||||||
|
|
||||||
|
#### Service configuration
|
||||||
|
| dflow capability | Containr status | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| Multi-domain per service + default flag + cert type | ❌ single `services.domain` | `service_domains` table |
|
||||||
|
| Cert types: letsencrypt / none / Cloudflare Origin / **CF-for-SaaS custom hostnames** | 🟡 Traefik ACME only | LE default; CF custom hostnames deferred (only needed if hosting tenant domains) |
|
||||||
|
| DNS preflight check (`checkDNSConfigAction`) | ❌ | `GET /services/:id/domains/check` |
|
||||||
|
| Volumes per service + dangling-volume browser/attach/delete | ❌ engine supports `VolumeMounts`; no API/UI | `services.volumes` jsonb + Volumes tab |
|
||||||
|
| Resource **limits and reservations**, per-server defaults | 🟡 `cpu_limit`/`memory_limit` int columns (unclear units) | explicit string units + reserves + instance defaults |
|
||||||
|
| Pre-deploy capacity check (dokku binary interceptor) | ❌ | Node capacity check pre-deploy/scale |
|
||||||
|
| Maintenance mode toggle | ❌ | Traefik middleware label |
|
||||||
|
| Basic-auth gate per service (`toggleHttpAuth`) | ❌ | Traefik basic-auth middleware |
|
||||||
|
| Raw proxy config override (`setServiceNginxConfig`) | ❌ | Allowlisted `traefik_labels` jsonb |
|
||||||
|
| Service clone (full config copy) | ❌ | `POST /services/:id/clone` |
|
||||||
|
| Move service between projects (`SwitchServiceProjectDialog`) | ❌ | `PUT /services/:id {project_id}` |
|
||||||
|
| Env var save → hot-apply + optional restart (`noRestart`) | ❌ rows rewritten; containers keep stale env until manual redeploy | Apply→redeploy flow |
|
||||||
|
| Static-site builder; Heroku buildpacks | 🟡 railpack/nixpacks/dockerfile auto-detect, no user override | `services.builder` enum + static builder |
|
||||||
|
| Docker registry accounts (private registries) | ❌ | `registry_credentials` per service/instance |
|
||||||
|
|
||||||
|
#### Databases
|
||||||
|
| dflow capability | Containr status | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| Managed DBs (pg/mysql/mariadb/mongo/redis/clickhouse via dokku plugins) | ✅ plus dragonfly | — |
|
||||||
|
| **External DB registration** (Neon/Atlas/RDS conn details + test) | ❌ | `provider=external` + test endpoint |
|
||||||
|
| Internal backups + restore (same-type enforced) | ✅ | — |
|
||||||
|
| Scheduled backups | ✅ cron field | — |
|
||||||
|
| **External S3 backups** (auth/schedule/unschedule ops) | ❌ | `backup_targets` + shipper |
|
||||||
|
| **Cross-server DB migration** (export → sftp hop → import) | ❌ | `POST /databases/:id/migrate` |
|
||||||
|
| Public DB port expose/unexpose | ❌ loopback only | opt-in TCP expose |
|
||||||
|
| DB → service env linking (`dokku link`) | ✅ Bind action injects `DATABASE_URL` | — |
|
||||||
|
|
||||||
|
#### Servers / infrastructure
|
||||||
|
| dflow capability | Containr status | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| Attach server = paste IP + SSH key; Dokku auto-installs | 🟡 agents exist, manual install | **SSH bootstrap**: one SSH session installs agent, never needed again |
|
||||||
|
| Tailscale/Headscale, NetBird, ZeroTier enroll + mesh connect | ❌ | Agent mesh-IP detection first; auth-key enroll optional |
|
||||||
|
| AWS EC2 + Hetzner provisioning w/ cloud-init | ❌ | IaC (ROADMAP §2.2): Terraform Proxmox/AWS + Hetzner module |
|
||||||
|
| Security groups (AWS SG rules, sync) | ❌ | Under IaC provider modules |
|
||||||
|
| Server-level global/wildcard domain (`updateServerDomain`) | ❌ | `nodes.default_domain` |
|
||||||
|
| Scheduled server cleanup (dokku cleanup → docker prune) | ❌ | Agent `prune` command + schedule |
|
||||||
|
| Queue flush/inspect per server (`flushServerQueues`, `getServerQueues`) | ➖ no queue yet | Ops page shows jobs once queue exists |
|
||||||
|
| Arbitrary SSH command on server (`executeCommand`) | ➖ exec is container-scoped | Node terminal via agent (PTY) |
|
||||||
|
| Server onboarding wizard, reset, cleanup-on-delete | 🟡 token issue/revoke only | Onboarding flow + `nodes add` UX |
|
||||||
|
| Ansible playbooks (stored, runnable per server, exec history) | ➖ | **Defer** — security surface; SSH bootstrap covers provisioning |
|
||||||
|
| Per-node resource dashboards | 🟡 heartbeat telemetry exists | Node detail page |
|
||||||
|
|
||||||
|
#### Platform / org
|
||||||
|
| dflow capability | Containr status | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| Multi-tenant organisations + custom RBAC (per-resource CRUD, create-limits, read-scope) | 🟡 `is_admin` + `project_members` | Defer full RBAC; design PAT scopes role-compatible |
|
||||||
|
| Team invite links/emails | 🟡 manual user create only | `POST /invites` → link join |
|
||||||
|
| User API keys → full REST API | ❌ **the big gap** — session/JWT only | PATs (Phase A) |
|
||||||
|
| Outbound webhooks (collection events, HMAC, custom headers) | ❌ | `webhooks` table + delivery log |
|
||||||
|
| Banners (global + per-tenant, dismissible) | ❌ | `banners` table + top bar |
|
||||||
|
| White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` — **Containr remains the default brand** |
|
||||||
|
| Activity feed (icon/severity/category/metadata, 90-day TTL job) | 🟡 `audit_logs` coarser | Enrich + project feed UI |
|
||||||
|
| Admin metrics dashboard (users/servers/queued/failed counts) | 🟡 `/admin` exists | Extend |
|
||||||
|
| **Impersonate user** | ❌ | Admin support tool |
|
||||||
|
| In-app docs (content-collections markdown) | ✅ `/docs` | — |
|
||||||
|
| Dynamic client-side filter framework (535-line `filter.utils`) | 🟡 audit page filters only | Reuse pattern for tables |
|
||||||
|
| Ops "bubble" (queue monitor, terminal, sync) | ❌ | Operations page |
|
||||||
|
|
||||||
|
#### Agent surface
|
||||||
|
| dflow capability | Containr status | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| Payload REST/GraphQL auto-API + per-user API keys | ❌ | PATs + OpenAPI (better: typed, documented) |
|
||||||
|
| CLI | ❌ | none in dflow — **Containr CLI is a genuine leap past it** |
|
||||||
|
| MCP | ❌ | none — same leap |
|
||||||
|
| Railway migrator script | ❌ | `containr migrate railway` |
|
||||||
|
| GitHub App **manifest flow** (instance self-provisions the App) | 🟡 env-configured App | `POST /git/github-app/manifest` flow |
|
||||||
|
| fork-sync webhook deploy trigger | ❌ | ~20 lines on receiver |
|
||||||
|
| Azure DevOps provider | ❌ | Optional (dflow does token-clone only) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The spine: agent-first platform
|
||||||
|
|
||||||
|
*Agent and user on the same level.* Concretely:
|
||||||
|
|
||||||
|
- Every operation reachable by a **personal access token**, not just
|
||||||
|
sessions.
|
||||||
|
- Every operation in the **CLI** with `--json`, stable exit codes, `--yes`.
|
||||||
|
- Every operation in an **MCP server** (stdio first) so Devin/Claude Code/
|
||||||
|
Cursor/Codex drive Containr natively.
|
||||||
|
- `openapi.yaml` stays the single source of truth; UI, CLI, MCP are thin
|
||||||
|
clients.
|
||||||
|
|
||||||
|
### Phase A — Agent surface (do this FIRST; everything after inherits it)
|
||||||
|
|
||||||
|
- [ ] **Personal access tokens.** `user_tokens` table — `id`, `user_id`,
|
||||||
|
`name`, `key_prefix` (`cnp_…`), `key_hash` (sha256), `scopes`
|
||||||
|
(`read`/`write`/`admin`, later resource-scoped), `expires_at`,
|
||||||
|
`last_used_at`. Model on `agent_auth_tokens` (raw shown once, hash stored).
|
||||||
|
`POST/GET/DELETE /api/v1/user/tokens`; `middleware.Auth` accepts
|
||||||
|
`Bearer cnp_…`; Settings → Access tokens UI with one-time display +
|
||||||
|
copyable CLI/MCP config snippets.
|
||||||
|
- [ ] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client:
|
||||||
|
- `auth login [--token] [--url] [--profile]` (multi-instance profiles).
|
||||||
|
- Resources: `projects services deploy logs exec variables databases
|
||||||
|
cron templates nodes scaling ha security gateway notifications
|
||||||
|
webhooks domains volumes backups admin`.
|
||||||
|
- `containr up` / `deploy` — cwd→service create-or-update→watch
|
||||||
|
(the `railway up` equivalent; single most important command).
|
||||||
|
- `containr link` (bind cwd→service), `containr open`, `containr run`,
|
||||||
|
`containr shell` (PTY once exec upgrade lands), `completion`.
|
||||||
|
- Universal `--json`, `-o yaml`, `--watch`, `--yes`; exit codes
|
||||||
|
0/1/2/3/4 (ok/err/not-found/unauthorized/conflict).
|
||||||
|
- GoReleaser → GH releases + `install.sh`; `version` command.
|
||||||
|
- Conventions from docsync/envdiff/cix/repolint.
|
||||||
|
- [ ] **MCP server** (`cmd/mcp`, same module, stdio). Tools mirror the
|
||||||
|
CLI: `list_projects create_service deploy get_logs set_variable
|
||||||
|
restart rollback create_database trigger_cron deploy_template exec
|
||||||
|
get_metrics security_scan …`. Auth: `CONTAINR_URL` + `CONTAINR_TOKEN`
|
||||||
|
or shared `~/.containr/config`. `containr mcp serve` + install snippets
|
||||||
|
for Devin/Claude/Cursor.
|
||||||
|
- [ ] **API agent ergonomics.** `Idempotency-Key` on mutating endpoints;
|
||||||
|
uniform `{error, code, details}` envelope (audit bare-string returns);
|
||||||
|
cursor pagination on growing lists; name-or-id lookup everywhere.
|
||||||
|
- [ ] **Agent docs.** Root `AGENTS.md`, `/docs` serves `llms.txt` +
|
||||||
|
`llms-full.txt`, MCP tool list generated from OpenAPI where possible.
|
||||||
|
|
||||||
|
**Gate**: an agent holding only a PAT registers a project, deploys from a
|
||||||
|
git repo, streams logs, sets vars, rolls back — zero UI. Same via CLI
|
||||||
|
for a human.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Phase B — Deployment pipeline maturity
|
||||||
|
|
||||||
|
dflow's one real architectural edge. Containr deploys are fire-and-forget
|
||||||
|
goroutines; this fixes correctness *and* unlocks the ops surface.
|
||||||
|
|
||||||
|
- [ ] **Deploy job queue.** Lightweight internal queue (Postgres
|
||||||
|
`deployment_queue` table + single worker loop — no new infra; Redis
|
||||||
|
exists but don't add BullMQ-style sprawl). Serialize per service;
|
||||||
|
optional per-node serialization. Status gains `queued`.
|
||||||
|
- [ ] **Cancel deployments.** Route `POST /deployments/:id/cancel` →
|
||||||
|
engine `CancelDeployment` (exists, unrouted). Queued jobs cancel
|
||||||
|
cleanly; building jobs cancel context where safe.
|
||||||
|
- [ ] **Cache modes.** `?cache=full` on redeploy → rebuild from existing
|
||||||
|
image layers vs clean pull/checkout. Webhook-triggered deploys default
|
||||||
|
to no-cache (dflow convention — prevents stale-layer surprises).
|
||||||
|
- [ ] **Queued→building→running status + persisted step logs.** Already
|
||||||
|
mostly there; add `queued`, stream over existing `/ws`.
|
||||||
|
- [ ] **Env var apply → redeploy.** Saving variables asks "restart to
|
||||||
|
apply?" (or `--apply` flag in CLI); backend triggers redeploy honoring
|
||||||
|
running state. Currently rows update but containers keep stale env —
|
||||||
|
silent-drift bug class, not just UX.
|
||||||
|
- [ ] **Encrypt variables at rest.** `environment_variables.value` is
|
||||||
|
plaintext today (verified). Envelope-encrypt `is_secret` values
|
||||||
|
(AES-GCM, key from `JWT_SECRET` or dedicated `SECRETS_KEY`); dflow
|
||||||
|
encrypts every var, we encrypt secrets — migration = transparent
|
||||||
|
encrypt-on-write + decrypt-on-read.
|
||||||
|
|
||||||
|
## 4. Phase C — Service configuration parity
|
||||||
|
|
||||||
|
- [ ] **Volumes on services** — `services.volumes` jsonb
|
||||||
|
`[{volume|host_path, container_path, read_only}]`; engine accepts
|
||||||
|
`VolumeMounts` already. Volumes tab + orphan-volume listing
|
||||||
|
(`docker volume ls -f dangling`) with delete/attach.
|
||||||
|
- [ ] **Multi-domain** — `service_domains(service_id, domain, is_default,
|
||||||
|
cert_type, status, last_checked_at)`. Domains tab: add/remove/
|
||||||
|
set-default/DNS-status badge. Keep `services.domain` as derived
|
||||||
|
default for compat.
|
||||||
|
- [ ] **DNS preflight** — `GET /services/:id/domains/check` resolves and
|
||||||
|
compares vs edge IP → `ok|wrong-target|pending`.
|
||||||
|
- [ ] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
|
||||||
|
no proxy code needed.
|
||||||
|
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with
|
||||||
|
allowlist validation (middlewares, headers, redirects).
|
||||||
|
- [ ] **Builder override** — `services.builder` enum
|
||||||
|
`auto|railpack|nixpacks|dockerfile|static`; fix hardcoded `nixpacks`
|
||||||
|
at `deployments.go:406`. Static builder = build + nginx sidecar.
|
||||||
|
- [ ] **Resource limits + reserves** — adopt dflow-style explicit units
|
||||||
|
(`"0.5"`/`"512m"`); add `cpu_reserve`/`memory_reserve` →
|
||||||
|
`NanoCPUs`/`Memory`/`MemoryReservation`; instance defaults in
|
||||||
|
`app_settings`.
|
||||||
|
- [ ] **Pre-deploy capacity check** — sum reservations vs node capacity
|
||||||
|
(agent telemetry); warn or block per policy flag.
|
||||||
|
- [ ] **Service clone** + **move between projects**.
|
||||||
|
- [ ] **Private registries** — `registry_credentials`
|
||||||
|
(server/username/secret, encrypted); `docker login` at pull time;
|
||||||
|
per-service registry link.
|
||||||
|
- [ ] **Service detail tabs to match dflow's surface**: add Domains,
|
||||||
|
Volumes, Proxy/access-controls, Backups sections to the service page
|
||||||
|
(Cron/Previews/Scaling already exist).
|
||||||
|
|
||||||
|
## 5. Phase D — Data layer maturity
|
||||||
|
|
||||||
|
- [ ] **External DB registration** — `provider='managed'|'external'`,
|
||||||
|
conn fields, `POST /databases/test-connection`, same bind flow.
|
||||||
|
- [ ] **Public DB port expose** — opt-in Traefik TCP route or host port;
|
||||||
|
default stays loopback.
|
||||||
|
- [ ] **Offsite backups** — `backup_targets` (S3-compatible:
|
||||||
|
endpoint/bucket/keys encrypted); per-DB destination; ship archives via
|
||||||
|
utility container + s3 SDK; restore-from-S3.
|
||||||
|
- [ ] **Cross-node DB migration** — `POST /databases/:id/migrate
|
||||||
|
{target_node_id}`: dump → stream transfer → provision → restore →
|
||||||
|
re-point bound services. Needs ≥2 nodes or local↔node.
|
||||||
|
- [ ] **Backup import** — upload archive → new restore point (pairs with
|
||||||
|
migrators).
|
||||||
|
|
||||||
|
## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates)
|
||||||
|
|
||||||
|
- [ ] **Template = ordered service graph** — `services[]` entries with
|
||||||
|
`{name, type, source, build, ports, volumes, variables, depends_on}`;
|
||||||
|
deploy order from dependency refs.
|
||||||
|
- [ ] **Expressions** — `${{service.KEY}}` (already the runtime ref
|
||||||
|
syntax — reuse), `{{secret(len,"charset")}}`, `{{random:int}}`,
|
||||||
|
`{{VAR:-default}}`; `POST /templates/:id/plan` returns the resolved
|
||||||
|
graph without deploying (agents love this).
|
||||||
|
- [ ] **Compose import** — `POST /templates/import/compose` (compose-go
|
||||||
|
parser → graph), `POST /templates/import/github` (compose file from
|
||||||
|
repo path). Implements `docs/superpowers/specs/2026-04-14-*`.
|
||||||
|
- [ ] **Graph deploy pipeline** — creates project + services in
|
||||||
|
dependency order, resolves vars, triggers builds; defined
|
||||||
|
partial-failure policy.
|
||||||
|
- [ ] **Visual template builder** — canvas mode: drop git/image/db
|
||||||
|
nodes, wire refs, set order, save/publish. Reuses existing canvas
|
||||||
|
primitives (dflow's `templates/compose` is exactly this).
|
||||||
|
- [ ] **Catalog growth** — convert `templates/*.md` (20 app guides) into
|
||||||
|
real graph templates; screenshots + badges per the old spec.
|
||||||
|
|
||||||
|
**Gate**: paste n8n compose → 3-service project with generated secrets
|
||||||
|
and wired refs, one click or one CLI call.
|
||||||
|
|
||||||
|
## 7. Phase F — Nodes & infrastructure
|
||||||
|
|
||||||
|
- [ ] **SSH bootstrap install** — `containr nodes add --ssh user@host` +
|
||||||
|
UI flow: one SSH session runs agent install (binary + systemd +
|
||||||
|
enroll token), SSH closed forever. dflow onboarding UX, agent-model
|
||||||
|
security.
|
||||||
|
- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/
|
||||||
|
zt0 IPs at register; backend stores + displays. Optional
|
||||||
|
`TAILSCALE_AUTH_KEY` enroll-at-provision later.
|
||||||
|
- [ ] **Node detail page** — placed services, telemetry history, agent
|
||||||
|
version + self-upgrade, **drain/cordon**, remove.
|
||||||
|
- [ ] **Node housekeeping** — agent `prune` command (docker system
|
||||||
|
prune, bounded) + optional schedule (`setServerAutoCleanup` analog —
|
||||||
|
disk pressure is the #1 small-VPS killer).
|
||||||
|
- [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists):
|
||||||
|
pin/spread/affinity, prefer-lowest-usage; deploy engine routes to
|
||||||
|
`services.node_id` via agent commands (agent already runs containers).
|
||||||
|
- [ ] **Build-on-node** — agents currently `docker run` prebuilt images
|
||||||
|
only; extend agent to pull registry images (build stays central) —
|
||||||
|
private registry creds flow through Phase C.
|
||||||
|
- [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/
|
||||||
|
OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module
|
||||||
|
(dflow's second provider), cloud-init → auto-enroll.
|
||||||
|
- [ ] **Global/wildcard domain per node** (`nodes.default_domain`) —
|
||||||
|
auto-domains for services on that node.
|
||||||
|
|
||||||
|
## 8. Phase G — Platform polish
|
||||||
|
|
||||||
|
- [ ] **Outbound webhooks** — `webhooks(url, secret, events[], headers,
|
||||||
|
enabled)` + `webhook_deliveries` log; HMAC `X-Containr-Signature`;
|
||||||
|
events: `deployment.* service.* database.* security.* agent.*`.
|
||||||
|
- [ ] **Activity feed** — enrich `audit_logs` with `severity category
|
||||||
|
icon label metadata`; per-project timeline + global feed; TTL job.
|
||||||
|
- [ ] **Banners** — `banners(title, body, level, scope, active)`; top
|
||||||
|
bar in UI + surfaced to CLI on `auth status`.
|
||||||
|
- [ ] **White-label** — `app_settings.branding`: product name, logos,
|
||||||
|
favicon, accent override, docs/support links. `GET /api/v1/branding`
|
||||||
|
public + cached. **Containr/Vertice remains default;** this is for
|
||||||
|
self-hosters who rebrand their instance.
|
||||||
|
- [ ] **Operations page** — unified view of running/queued/failed jobs
|
||||||
|
(deploys, builds, backups, cron, scans, agent commands) — dflow's
|
||||||
|
bubble panel as a real page; flush/stuck-job tools once queue exists.
|
||||||
|
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`);
|
||||||
|
`nodes/:id/terminal` via agent (dflow's wetty without the extra
|
||||||
|
container).
|
||||||
|
- [ ] **Team invites** — `POST /invites` → signed link → join flow;
|
||||||
|
replaces manual-only user creation.
|
||||||
|
- [ ] **Impersonate user** — admin support tool, audit-logged.
|
||||||
|
- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest`
|
||||||
|
returns manifest + redirect; instance self-provisions its App
|
||||||
|
(dflow pattern — removes the env-setup barrier).
|
||||||
|
- [ ] **fork-sync trigger** — accept repo-sync events on webhook
|
||||||
|
receiver.
|
||||||
|
- [ ] **Dynamic filter framework** — port dflow's `filter.utils`
|
||||||
|
pattern (typed filter configs → URL state → client filtering) for
|
||||||
|
audit logs, deployments, notifications tables.
|
||||||
|
- [ ] **Notification producers round-out** — backups, agent offline,
|
||||||
|
upgrade available, security findings.
|
||||||
|
- [ ] **Custom roles/RBAC** — defer until demanded; PAT scopes designed
|
||||||
|
forward-compatible now.
|
||||||
|
|
||||||
|
## 9. Phase H — Migration & ecosystem
|
||||||
|
|
||||||
|
- [ ] **Railway migrator** — `containr migrate railway --token --project
|
||||||
|
--dry-run`: Railway GraphQL → services/vars/domains → recreate via
|
||||||
|
PAT. Landing-page pitch: "off Railway in one command."
|
||||||
|
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST
|
||||||
|
export or Mongo dump: servers→nodes, projects, services(+vars/
|
||||||
|
domains/volumes), databases, templates. Onboards dflow refugees.
|
||||||
|
- [ ] **`containr import compose`** — Phase-E parser straight to a
|
||||||
|
project (no template intermediate).
|
||||||
|
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
|
||||||
|
fetch/publish; bundled catalog works offline (dflow's self-contained
|
||||||
|
principle — nothing phones home).
|
||||||
|
|
||||||
|
## 10. Carried-over debt (ROADMAP.md items still open)
|
||||||
|
|
||||||
|
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit →
|
||||||
|
`gateway/proxy.go` → metrics. Management API exists; proxy unwired.
|
||||||
|
- [ ] **Preview environments runtime** — real build+deploy+subdomain+TTL
|
||||||
|
(bookkeeping only now). Pairs with clone + multi-domain.
|
||||||
|
- [ ] **Environments** — env switcher, per-env vars/domains, promote.
|
||||||
|
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`.
|
||||||
|
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
|
||||||
|
webhook table tests, Playwright e2e golden path.
|
||||||
|
- [ ] **First-run wizard** — admin → GitHub App (manifest flow from
|
||||||
|
Phase G makes this self-serve) → Tunnel → done.
|
||||||
|
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release.
|
||||||
|
|
||||||
|
## 11. Deliberately not imported from dflow
|
||||||
|
|
||||||
|
- Dokku-over-SSH runtime — our Docker API + agent model is strictly
|
||||||
|
better.
|
||||||
|
- Per-server-named BullMQ queues — our queue stays a single ordered
|
||||||
|
table.
|
||||||
|
- Payload/Mongo/Next.js stack, multi-tenant plugin machinery.
|
||||||
|
- Ansible playbook runner — arbitrary code-on-node is a security
|
||||||
|
surface; SSH bootstrap + agent commands cover the real needs.
|
||||||
|
Revisit only with a concrete demand.
|
||||||
|
- `isAdmin`-only collection access — our owner/admin/public model is
|
||||||
|
richer already.
|
||||||
|
- Netdata install flow — our agent telemetry + optional Beszel covers
|
||||||
|
monitoring; don't ship a third dashboard stack.
|
||||||
|
|
||||||
|
## 12. Recommended order
|
||||||
|
|
||||||
|
```
|
||||||
|
A (agent surface) ──► B (deploy pipeline) ──► C/D (service+data depth)
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
CLI/MCP dogfood every feature E (templates v2)
|
||||||
|
│
|
||||||
|
F (nodes/infra) ──► H (migrators)
|
||||||
|
│
|
||||||
|
G (polish) + debt
|
||||||
|
```
|
||||||
|
|
||||||
|
Why this order: PATs+CLI+MCP first = every later feature ships
|
||||||
|
user-and-agent-visible at once. The queue (B) is prerequisite for
|
||||||
|
the ops surface and correct concurrency. Templates v2 needs volumes,
|
||||||
|
multi-domain, clone. Migrators last — they're launch marketing, not
|
||||||
|
foundation.
|
||||||
|
|
||||||
|
## 13. Design rules
|
||||||
|
|
||||||
|
- **API is the contract.** UI, CLI, MCP are thin clients over
|
||||||
|
`openapi.yaml`. A feature isn't done until all three reach it.
|
||||||
|
- **Everything scriptable.** JSON in/out, `--json`/`--yes`, uniform
|
||||||
|
pagination, name-or-id addressing.
|
||||||
|
- **Same powers, same position.** Nothing user-facing that isn't
|
||||||
|
API-facing; agent-visible state (tokens, jobs) visible to users too.
|
||||||
|
- **dflow is a donor, not a dependency.** Port semantics, not code.
|
||||||
|
- **Containr branding is the default**; white-label is instance config.
|
||||||
|
- **Self-hosted first** — no required external service, no phone-home.
|
||||||
@@ -15,8 +15,5 @@ var (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime))
|
cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime))
|
||||||
if err := cli.Run(); err != nil {
|
os.Exit(cli.Execute())
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"containr/internal/mcpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Injected at build time via -ldflags.
|
||||||
|
var Version = "dev"
|
||||||
|
|
||||||
|
// containr-mcp exposes the whole Containr API as MCP tools over stdio.
|
||||||
|
// Configure it in an MCP client with:
|
||||||
|
//
|
||||||
|
// command: containr-mcp
|
||||||
|
// env: CONTAINR_API_URL, CONTAINR_TOKEN (a cnp_ personal access token)
|
||||||
|
func main() {
|
||||||
|
apiURL := os.Getenv("CONTAINR_API_URL")
|
||||||
|
if apiURL == "" {
|
||||||
|
apiURL = "http://localhost:8080/api/v1"
|
||||||
|
}
|
||||||
|
apiURL = strings.TrimSuffix(apiURL, "/")
|
||||||
|
if !strings.HasSuffix(apiURL, "/api/v1") {
|
||||||
|
apiURL += "/api/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
if err := mcpserver.Run(ctx, apiURL, os.Getenv("CONTAINR_TOKEN"), Version); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "containr-mcp: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-2
@@ -8,11 +8,12 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/go-playground/validator/v10 v10.30.1
|
github.com/go-playground/validator/v10 v10.30.1
|
||||||
github.com/go-redis/redis/v8 v8.11.5
|
github.com/go-redis/redis/v8 v8.11.5
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/miekg/dns v1.1.72
|
github.com/miekg/dns v1.1.72
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.7.0
|
||||||
github.com/pressly/goose/v3 v3.24.1
|
github.com/pressly/goose/v3 v3.24.1
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/rs/cors v1.10.1
|
github.com/rs/cors v1.10.1
|
||||||
@@ -21,6 +22,7 @@ require (
|
|||||||
github.com/sqlc-dev/pqtype v0.3.0
|
github.com/sqlc-dev/pqtype v0.3.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
|
golang.org/x/term v0.46.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -45,6 +47,7 @@ require (
|
|||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
@@ -63,6 +66,8 @@ require (
|
|||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
@@ -71,6 +76,7 @@ require (
|
|||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||||
@@ -84,10 +90,11 @@ require (
|
|||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/mod v0.41.0 // indirect
|
golang.org/x/mod v0.41.0 // indirect
|
||||||
golang.org/x/net v0.59.0 // indirect
|
golang.org/x/net v0.59.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.35.0 // indirect
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
golang.org/x/time v0.11.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/tools v0.49.0 // indirect
|
golang.org/x/tools v0.49.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
+18
-4
@@ -65,11 +65,13 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
|
|||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
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.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||||
|
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
@@ -105,6 +107,8 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z
|
|||||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -143,6 +147,10 @@ github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU
|
|||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||||
|
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
@@ -178,6 +186,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||||
@@ -213,16 +223,20 @@ golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
|||||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
|
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
|
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||||
|
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ package api
|
|||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/deployqueue"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ type CreateDeploymentRequest struct {
|
|||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
Trigger string `json:"trigger"`
|
Trigger string `json:"trigger"`
|
||||||
EnvVars map[string]string `json:"env_vars"`
|
EnvVars map[string]string `json:"env_vars"`
|
||||||
|
NoCache bool `json:"no_cache"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeploymentResponse struct {
|
type DeploymentResponse struct {
|
||||||
@@ -74,14 +76,21 @@ func handleGetDeployments(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
offset, limit := pageWindow(c, 50, 100)
|
||||||
|
|
||||||
|
var total int
|
||||||
|
_ = db.(*database.DB).QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM deployments WHERE service_id = $1`, serviceID,
|
||||||
|
).Scan(&total)
|
||||||
|
|
||||||
rows, err := db.(*database.DB).Query(
|
rows, err := db.(*database.DB).Query(
|
||||||
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
|
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
|
||||||
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
|
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
|
||||||
FROM deployments
|
FROM deployments
|
||||||
WHERE service_id = $1
|
WHERE service_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 50`,
|
LIMIT $2 OFFSET $3`,
|
||||||
serviceID,
|
serviceID, limit, offset,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
||||||
@@ -104,7 +113,10 @@ func handleGetDeployments(c *gin.Context) {
|
|||||||
deployments = append(deployments, d)
|
deployments = append(deployments, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"deployments": deployments,
|
||||||
|
"pagination": paginationMeta(offset, limit, total),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecentDeployment is a deployment row joined with its service and project names.
|
// RecentDeployment is a deployment row joined with its service and project names.
|
||||||
@@ -132,11 +144,19 @@ func handleGetRecentDeployments(c *gin.Context) {
|
|||||||
userID := optionalUserUUID(c)
|
userID := optionalUserUUID(c)
|
||||||
isAdmin := contextIsAdmin(c)
|
isAdmin := contextIsAdmin(c)
|
||||||
|
|
||||||
limit := 10
|
offset, limit := pageWindow(c, 10, 50)
|
||||||
if v := c.Query("limit"); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
|
var total int
|
||||||
limit = n
|
if err := db.(*database.DB).QueryRow(
|
||||||
}
|
`SELECT COUNT(*)
|
||||||
|
FROM deployments d
|
||||||
|
JOIN services s ON s.id = d.service_id
|
||||||
|
JOIN projects p ON p.id = s.project_id
|
||||||
|
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
|
||||||
|
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`,
|
||||||
|
userID, isAdmin,
|
||||||
|
).Scan(&total); err != nil {
|
||||||
|
total = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := db.(*database.DB).Query(
|
rows, err := db.(*database.DB).Query(
|
||||||
@@ -148,8 +168,8 @@ func handleGetRecentDeployments(c *gin.Context) {
|
|||||||
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
|
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
|
||||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
|
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
|
||||||
ORDER BY d.created_at DESC
|
ORDER BY d.created_at DESC
|
||||||
LIMIT $3`,
|
LIMIT $3 OFFSET $4`,
|
||||||
userID, isAdmin, limit,
|
userID, isAdmin, limit, offset,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
||||||
@@ -168,7 +188,10 @@ func handleGetRecentDeployments(c *gin.Context) {
|
|||||||
deployments = append(deployments, d)
|
deployments = append(deployments, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"deployments": deployments,
|
||||||
|
"pagination": paginationMeta(offset, limit, total),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCreateDeployment(c *gin.Context) {
|
func handleCreateDeployment(c *gin.Context) {
|
||||||
@@ -289,7 +312,23 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
engineInstance := engine.(*deployment.DeploymentEngine)
|
engineInstance := engine.(*deployment.DeploymentEngine)
|
||||||
go runDeploymentAndSync(context.Background(), db.(*database.DB), engineInstance, &d, service, req, userID.(string))
|
pos := getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
|
||||||
|
DeploymentID: d.ID,
|
||||||
|
Run: func(jctx context.Context) {
|
||||||
|
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, service, req, userID.(string))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if pos > 0 {
|
||||||
|
d.Status = "queued"
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), d.ID,
|
||||||
|
)
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), serviceID,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, DeploymentResponse{
|
c.JSON(http.StatusCreated, DeploymentResponse{
|
||||||
@@ -362,6 +401,12 @@ func runDeploymentAndSyncWithImage(
|
|||||||
// ephemeral ports.
|
// ephemeral ports.
|
||||||
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
|
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
|
||||||
|
|
||||||
|
maintenanceMode, basicAuthUsers := serviceAccess(db, service.ID)
|
||||||
|
maintURL := ""
|
||||||
|
if maintenanceMode {
|
||||||
|
maintURL = maintenanceURL(db)
|
||||||
|
}
|
||||||
|
|
||||||
deployReq := &deployment.DeploymentRequest{
|
deployReq := &deployment.DeploymentRequest{
|
||||||
ProjectID: service.ProjectID.String(),
|
ProjectID: service.ProjectID.String(),
|
||||||
ServiceID: service.ID.String(),
|
ServiceID: service.ID.String(),
|
||||||
@@ -377,10 +422,15 @@ func runDeploymentAndSyncWithImage(
|
|||||||
Domain: service.Domain,
|
Domain: service.Domain,
|
||||||
HealthPath: service.HealthCheckPath,
|
HealthPath: service.HealthCheckPath,
|
||||||
RestartPolicy: service.RestartPolicy,
|
RestartPolicy: service.RestartPolicy,
|
||||||
|
VolumeMounts: loadServiceVolumes(db, service.ID),
|
||||||
|
Domains: serviceDomainNames(db, service.ID, service.Domain),
|
||||||
Resources: deployment.ResourceLimits{
|
Resources: deployment.ResourceLimits{
|
||||||
MemoryBytes: parseMemoryLimit(service.Memory),
|
MemoryBytes: parseMemoryLimit(service.Memory),
|
||||||
CPUQuota: parseCPULimit(service.CPU),
|
CPUQuota: parseCPULimit(service.CPU),
|
||||||
},
|
},
|
||||||
|
Maintenance: maintenanceMode,
|
||||||
|
MaintenanceURL: maintURL,
|
||||||
|
BasicAuthUsers: basicAuthUsers,
|
||||||
},
|
},
|
||||||
Trigger: deployment.TriggerConfig{
|
Trigger: deployment.TriggerConfig{
|
||||||
Type: req.Trigger,
|
Type: req.Trigger,
|
||||||
@@ -407,6 +457,7 @@ func runDeploymentAndSyncWithImage(
|
|||||||
SourcePath: sourcePath,
|
SourcePath: sourcePath,
|
||||||
Branch: req.Branch,
|
Branch: req.Branch,
|
||||||
Commit: req.CommitHash,
|
Commit: req.CommitHash,
|
||||||
|
NoCache: req.NoCache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,16 +485,19 @@ func runDeploymentAndSyncWithImage(
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
failedAt := time.Now()
|
failedAt := time.Now()
|
||||||
timeoutErr := "Deployment timed out before completion"
|
finalStatus, finalErr := "failed", "Deployment timed out before completion"
|
||||||
|
if errors.Is(ctx.Err(), context.Canceled) {
|
||||||
|
finalStatus, finalErr = "cancelled", "Deployment cancelled"
|
||||||
|
}
|
||||||
_, _ = db.Exec(
|
_, _ = db.Exec(
|
||||||
`UPDATE deployments
|
`UPDATE deployments
|
||||||
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
|
SET status = $1, error = $2, completed_at = $3, updated_at = $3
|
||||||
WHERE id = $3`,
|
WHERE id = $4`,
|
||||||
timeoutErr, failedAt, dbDeployment.ID,
|
finalStatus, finalErr, failedAt, dbDeployment.ID,
|
||||||
)
|
)
|
||||||
_, _ = db.Exec(
|
_, _ = db.Exec(
|
||||||
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`,
|
||||||
failedAt, service.ID,
|
finalStatus, failedAt, service.ID,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
case <-syncTicker.C:
|
case <-syncTicker.C:
|
||||||
@@ -510,13 +564,86 @@ func runDeploymentAndSyncWithImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mapEngineStatusToDBStatus(status string) string {
|
func mapEngineStatusToDBStatus(status string) string {
|
||||||
switch status {
|
if status == "running" {
|
||||||
case "running":
|
|
||||||
return "deployed"
|
return "deployed"
|
||||||
case "cancelled":
|
}
|
||||||
return "failed"
|
|
||||||
default:
|
|
||||||
return status
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDeployQueue returns the per-service deploy queue. SetupRoutes always
|
||||||
|
// sets it; the fallback keeps handler tests that skip SetupRoutes working.
|
||||||
|
func getDeployQueue(c *gin.Context) *deployqueue.Queue {
|
||||||
|
if v, exists := c.Get("deploy_queue"); exists {
|
||||||
|
if q, ok := v.(*deployqueue.Queue); ok && q != nil {
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deployqueue.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCancelDeployment cancels a queued or in-flight deployment. Queued
|
||||||
|
// jobs are dropped and marked cancelled immediately; active jobs get their
|
||||||
|
// context cancelled so the build/reconcile aborts and the sync loop writes
|
||||||
|
// the terminal 'cancelled' status.
|
||||||
|
func handleCancelDeployment(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available", "code": "DEPENDENCY_UNAVAILABLE"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deploymentID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated", "code": "UNAUTHENTICATED"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var status, owner string
|
||||||
|
var serviceID uuid.UUID
|
||||||
|
err = db.(*database.DB).QueryRow(
|
||||||
|
`SELECT d.status, d.service_id, p.owner_id
|
||||||
|
FROM deployments d
|
||||||
|
JOIN services s ON d.service_id = s.id
|
||||||
|
JOIN projects p ON s.project_id = p.id
|
||||||
|
WHERE d.id = $1`,
|
||||||
|
deploymentID,
|
||||||
|
).Scan(&status, &serviceID, &owner)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if owner != userID.(string) && !contextIsAdmin(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied", "code": "FORBIDDEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case "deployed", "failed", "cancelled":
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Deployment already finished", "code": "NOT_CANCELLABLE"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch getDeployQueue(c).Cancel(serviceID, deploymentID) {
|
||||||
|
case deployqueue.WasQueued:
|
||||||
|
now := time.Now()
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE deployments SET status = 'cancelled', error = 'Deployment cancelled', completed_at = $1, updated_at = $1 WHERE id = $2`,
|
||||||
|
now, deploymentID,
|
||||||
|
)
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE services SET status = 'cancelled', updated_at = $1 WHERE id = $2`,
|
||||||
|
now, serviceID,
|
||||||
|
)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
|
||||||
|
case deployqueue.WasActive:
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"status": "cancelling"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Deployment is not running on this node", "code": "NOT_CANCELLABLE"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,7 +842,12 @@ func handleRollbackDeployment(c *gin.Context) {
|
|||||||
Trigger: "rollback",
|
Trigger: "rollback",
|
||||||
}
|
}
|
||||||
// Reuse the stored image directly — no rebuild.
|
// Reuse the stored image directly — no rebuild.
|
||||||
go runDeploymentAndSyncWithImage(context.Background(), db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
|
getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
|
||||||
|
DeploymentID: rollback.ID,
|
||||||
|
Run: func(jctx context.Context) {
|
||||||
|
runDeploymentAndSyncWithImage(jctx, db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
|||||||
@@ -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/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/deployqueue"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -178,11 +179,24 @@ func handleGitWebhookPush(c *gin.Context) {
|
|||||||
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
||||||
time.Now(), service.ID,
|
time.Now(), service.ID,
|
||||||
)
|
)
|
||||||
go runDeploymentAndSync(
|
// Webhook pushes default to a clean build — stale layers are the
|
||||||
context.Background(), db, engine, &d, service,
|
// classic silent-rollback failure (dflow convention).
|
||||||
CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook"},
|
webhookReq := CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook", NoCache: true}
|
||||||
repoUserID,
|
if pos := getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
|
||||||
|
DeploymentID: d.ID,
|
||||||
|
Run: func(jctx context.Context) {
|
||||||
|
runDeploymentAndSync(jctx, db, engine, &d, service, webhookReq, repoUserID)
|
||||||
|
},
|
||||||
|
}); pos > 0 {
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), d.ID,
|
||||||
)
|
)
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), service.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
enqueued++
|
enqueued++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -55,18 +54,9 @@ func handleGetProjects(c *gin.Context) {
|
|||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
queries := sqlcdb.New(db.DB)
|
queries := sqlcdb.New(db.DB)
|
||||||
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
offset, limit := pageWindow(c, 10, 100)
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
|
||||||
search := strings.TrimSpace(c.DefaultQuery("search", ""))
|
search := strings.TrimSpace(c.DefaultQuery("search", ""))
|
||||||
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if limit > 100 || limit < 1 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
offset := (page - 1) * limit
|
|
||||||
|
|
||||||
searchParam := sql.NullString{}
|
searchParam := sql.NullString{}
|
||||||
if search != "" {
|
if search != "" {
|
||||||
searchParam = sql.NullString{String: search, Valid: true}
|
searchParam = sql.NullString{String: search, Valid: true}
|
||||||
@@ -99,15 +89,9 @@ func handleGetProjects(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
totalCount := int(total)
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"projects": projects,
|
"projects": projects,
|
||||||
"pagination": gin.H{
|
"pagination": paginationMeta(offset, limit, int(total)),
|
||||||
"page": page,
|
|
||||||
"limit": limit,
|
|
||||||
"total": totalCount,
|
|
||||||
"pages": (totalCount + limit - 1) / limit,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,20 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"containr/internal/build"
|
"containr/internal/build"
|
||||||
"containr/internal/config"
|
"containr/internal/config"
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/deployqueue"
|
||||||
"containr/internal/docker"
|
"containr/internal/docker"
|
||||||
"containr/internal/ha"
|
"containr/internal/ha"
|
||||||
"containr/internal/metrics"
|
"containr/internal/metrics"
|
||||||
"containr/internal/middleware"
|
"containr/internal/middleware"
|
||||||
"containr/internal/scaling"
|
"containr/internal/scaling"
|
||||||
|
"containr/internal/secrets"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -98,6 +101,45 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// Initialize database handler
|
// Initialize database handler
|
||||||
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
||||||
|
|
||||||
|
// Per-service serialized deploy queue — one active deployment per
|
||||||
|
// service, the rest wait as 'queued'.
|
||||||
|
deployQueue := deployqueue.New()
|
||||||
|
|
||||||
|
// Encrypt-at-rest for secret env vars. SECRETS_KEY wins, JWT_SECRET is
|
||||||
|
// the fallback so existing installs need no new config.
|
||||||
|
secrets.Configure(os.Getenv("SECRETS_KEY"), cfg.JWTSecret)
|
||||||
|
|
||||||
|
// Boot sweep: in-memory engine state dies on restart, so anything that
|
||||||
|
// looked in-flight is actually orphaned — mark it failed rather than
|
||||||
|
// leaving phantom 'building'/'queued' rows.
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back')`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Boot sweep: encrypt any plaintext secret values left over from before
|
||||||
|
// encryption-at-rest landed. Transparent and idempotent.
|
||||||
|
if encRows, err := db.Query(`SELECT id, value FROM environment_variables WHERE is_secret = TRUE`); err == nil {
|
||||||
|
type encRow struct {
|
||||||
|
id string
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
var toEncrypt []encRow
|
||||||
|
for encRows.Next() {
|
||||||
|
var r encRow
|
||||||
|
if err := encRows.Scan(&r.id, &r.value); err == nil && !secrets.IsEncrypted(r.value) {
|
||||||
|
toEncrypt = append(toEncrypt, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encRows.Close()
|
||||||
|
for _, r := range toEncrypt {
|
||||||
|
_, _ = db.Exec(`UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2`, secrets.Encrypt(r.value), r.id)
|
||||||
|
}
|
||||||
|
if len(toEncrypt) > 0 {
|
||||||
|
log.Printf("secrets: encrypted %d plaintext secret variable(s)", len(toEncrypt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize security handler
|
// Initialize security handler
|
||||||
securityHandler := NewSecurityHandler(db, cfg.JWTSecret)
|
securityHandler := NewSecurityHandler(db, cfg.JWTSecret)
|
||||||
|
|
||||||
@@ -115,6 +157,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
if deploymentEngine != nil {
|
if deploymentEngine != nil {
|
||||||
c.Set("deployment_engine", deploymentEngine)
|
c.Set("deployment_engine", deploymentEngine)
|
||||||
}
|
}
|
||||||
|
c.Set("deploy_queue", deployQueue)
|
||||||
c.Set("scheduler", scheduler)
|
c.Set("scheduler", scheduler)
|
||||||
c.Set("metrics_collector", metricsCollector)
|
c.Set("metrics_collector", metricsCollector)
|
||||||
c.Set("auto_scaler", autoScaler)
|
c.Set("auto_scaler", autoScaler)
|
||||||
@@ -205,6 +248,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
public.GET("/auth/bootstrap", handleAuthBootstrap)
|
public.GET("/auth/bootstrap", handleAuthBootstrap)
|
||||||
public.POST("/auth/login", handleLogin)
|
public.POST("/auth/login", handleLogin)
|
||||||
public.POST("/auth/register", handleRegister)
|
public.POST("/auth/register", handleRegister)
|
||||||
|
public.GET("/maintenance", handleMaintenancePage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read routes — public browsing. OptionalAuth resolves a session when
|
// Read routes — public browsing. OptionalAuth resolves a session when
|
||||||
@@ -225,6 +269,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
read.GET("/services/:id/metrics", handleGetServiceMetrics)
|
read.GET("/services/:id/metrics", handleGetServiceMetrics)
|
||||||
read.GET("/services/:id/runtime", handleGetServiceRuntime)
|
read.GET("/services/:id/runtime", handleGetServiceRuntime)
|
||||||
read.GET("/services/:id/deployments", handleGetDeployments)
|
read.GET("/services/:id/deployments", handleGetDeployments)
|
||||||
|
read.GET("/services/:id/domains", handleListServiceDomains)
|
||||||
|
read.GET("/services/:id/domains/check", handleCheckServiceDomains)
|
||||||
read.GET("/deployments", handleGetRecentDeployments)
|
read.GET("/deployments", handleGetRecentDeployments)
|
||||||
read.GET("/deployments/:id", handleGetDeployment)
|
read.GET("/deployments/:id", handleGetDeployment)
|
||||||
|
|
||||||
@@ -256,10 +302,16 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// enforce ownership; platform admins additionally pass every check.
|
// enforce ownership; platform admins additionally pass every check.
|
||||||
authed := v1.Group("/")
|
authed := v1.Group("/")
|
||||||
authed.Use(middleware.Auth(cfg.JWTSecret))
|
authed.Use(middleware.Auth(cfg.JWTSecret))
|
||||||
|
authed.Use(middleware.Idempotency())
|
||||||
{
|
{
|
||||||
authed.GET("/user/profile", handleGetProfile)
|
authed.GET("/user/profile", handleGetProfile)
|
||||||
authed.PUT("/user/profile", handleUpdateProfile)
|
authed.PUT("/user/profile", handleUpdateProfile)
|
||||||
|
|
||||||
|
// Personal access tokens for CLI/MCP/agent auth.
|
||||||
|
authed.GET("/user/tokens", handleListUserTokens)
|
||||||
|
authed.POST("/user/tokens", handleCreateUserToken)
|
||||||
|
authed.DELETE("/user/tokens/:id", handleDeleteUserToken)
|
||||||
|
|
||||||
authed.GET("/notifications", handleListNotifications)
|
authed.GET("/notifications", handleListNotifications)
|
||||||
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
|
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
|
||||||
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
|
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
|
||||||
@@ -277,12 +329,16 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
|
|
||||||
authed.PUT("/services/:id", handleUpdateService)
|
authed.PUT("/services/:id", handleUpdateService)
|
||||||
authed.DELETE("/services/:id", handleDeleteService)
|
authed.DELETE("/services/:id", handleDeleteService)
|
||||||
|
authed.POST("/services/:id/domains", handleAddServiceDomain)
|
||||||
|
authed.DELETE("/services/:id/domains/:domain_id", handleDeleteServiceDomain)
|
||||||
|
authed.POST("/services/:id/domains/:domain_id/default", handleSetDefaultDomain)
|
||||||
authed.POST("/services/:id/start", handleServiceStart)
|
authed.POST("/services/:id/start", handleServiceStart)
|
||||||
authed.POST("/services/:id/stop", handleServiceStop)
|
authed.POST("/services/:id/stop", handleServiceStop)
|
||||||
authed.POST("/services/:id/restart", handleServiceRestart)
|
authed.POST("/services/:id/restart", handleServiceRestart)
|
||||||
authed.POST("/services/:id/redeploy", handleServiceRedeploy)
|
authed.POST("/services/:id/redeploy", handleServiceRedeploy)
|
||||||
authed.POST("/services/:id/deployments", handleCreateDeployment)
|
authed.POST("/services/:id/deployments", handleCreateDeployment)
|
||||||
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
||||||
|
authed.POST("/deployments/:id/cancel", handleCancelDeployment)
|
||||||
|
|
||||||
// Environment variables — secrets, owner/admin only.
|
// Environment variables — secrets, owner/admin only.
|
||||||
authed.GET("/services/:id/variables", handleGetVariables)
|
authed.GET("/services/:id/variables", handleGetVariables)
|
||||||
@@ -352,6 +408,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// Admin routes — platform-wide controls and cross-user management.
|
// Admin routes — platform-wide controls and cross-user management.
|
||||||
admin := v1.Group("/")
|
admin := v1.Group("/")
|
||||||
admin.Use(middleware.Auth(cfg.JWTSecret))
|
admin.Use(middleware.Auth(cfg.JWTSecret))
|
||||||
|
admin.Use(middleware.Idempotency())
|
||||||
admin.Use(middleware.RequireAdmin())
|
admin.Use(middleware.RequireAdmin())
|
||||||
{
|
{
|
||||||
admin.GET("/admin/overview", handleAdminOverview)
|
admin.GET("/admin/overview", handleAdminOverview)
|
||||||
@@ -372,6 +429,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
scalingHandler.RegisterAdminRoutes(admin)
|
scalingHandler.RegisterAdminRoutes(admin)
|
||||||
agentHandler.SetupAdminRoutes(admin)
|
agentHandler.SetupAdminRoutes(admin)
|
||||||
|
|
||||||
|
admin.GET("/admin/volumes", handleAdminListVolumes)
|
||||||
|
admin.DELETE("/admin/volumes/:name", handleAdminDeleteVolume)
|
||||||
admin.GET("/audit-logs", handleGetAuditLogs)
|
admin.GET("/audit-logs", handleGetAuditLogs)
|
||||||
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
|
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/deployqueue"
|
||||||
|
"containr/internal/secrets"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -51,6 +53,12 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
|
|||||||
}
|
}
|
||||||
spec.NanoCPUs = parseCPULimit(service.CPU)
|
spec.NanoCPUs = parseCPULimit(service.CPU)
|
||||||
spec.MemoryBytes = parseMemoryLimit(service.Memory)
|
spec.MemoryBytes = parseMemoryLimit(service.Memory)
|
||||||
|
spec.Volumes = loadServiceVolumes(db, service.ID)
|
||||||
|
spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
|
||||||
|
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
|
||||||
|
if spec.Maintenance {
|
||||||
|
spec.MaintenanceURL = maintenanceURL(db)
|
||||||
|
}
|
||||||
if spec.Port > 0 {
|
if spec.Port > 0 {
|
||||||
env["PORT"] = strconv.Itoa(service.Port)
|
env["PORT"] = strconv.Itoa(service.Port)
|
||||||
}
|
}
|
||||||
@@ -78,7 +86,7 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
|
|||||||
if err := rows.Scan(&k, &v); err != nil {
|
if err := rows.Scan(&k, &v); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
env[k] = v
|
env[k] = secrets.Decrypt(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
var refServices []struct{ ID, Name string }
|
var refServices []struct{ ID, Name string }
|
||||||
@@ -126,7 +134,7 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return value, true
|
return secrets.Decrypt(value), true
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range env {
|
for k, v := range env {
|
||||||
@@ -381,6 +389,7 @@ func handleServiceRestart(c *gin.Context) {
|
|||||||
|
|
||||||
// handleServiceRedeploy re-applies the current spec: pulls the image for
|
// handleServiceRedeploy re-applies the current spec: pulls the image for
|
||||||
// image-sourced services and reconciles containers (env, ports, replicas).
|
// image-sourced services and reconciles containers (env, ports, replicas).
|
||||||
|
// Runs through the per-service deploy queue so it never overlaps a build.
|
||||||
func handleServiceRedeploy(c *gin.Context) {
|
func handleServiceRedeploy(c *gin.Context) {
|
||||||
engine, db, ok := getRuntimeEngine(c)
|
engine, db, ok := getRuntimeEngine(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -406,7 +415,78 @@ func handleServiceRedeploy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
service.Image = image
|
service.Image = image
|
||||||
reconcileNow(c, engine, db, service)
|
|
||||||
|
finished := make(chan struct{})
|
||||||
|
var recErr error
|
||||||
|
var state *deployment.RuntimeState
|
||||||
|
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{Run: func(jctx context.Context) {
|
||||||
|
defer close(finished)
|
||||||
|
state, recErr = reconcileServiceJob(jctx, db, engine, service)
|
||||||
|
}})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-finished:
|
||||||
|
if recErr != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": recErr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"runtime": state})
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"status": "queued"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileServiceJob is the shared queue-job body for redeploy paths:
|
||||||
|
// build the spec from current DB state, reconcile containers, persist the
|
||||||
|
// resulting status and published port.
|
||||||
|
func reconcileServiceJob(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, service Service) (*deployment.RuntimeState, error) {
|
||||||
|
spec, err := serviceRuntimeSpec(db, service)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
state, err := engine.ReconcileService(ctx, spec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if state != nil {
|
||||||
|
persistPublishedPort(db, service.ID, state.Ports)
|
||||||
|
status := "stopped"
|
||||||
|
if state.Status != "" {
|
||||||
|
status = state.Status
|
||||||
|
}
|
||||||
|
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueueServiceRedeploy resolves a deployable image (service.Image, else
|
||||||
|
// the last successful deployment's tag) and queues a reconcile. Returns
|
||||||
|
// false when no image is available or the engine is missing.
|
||||||
|
func enqueueServiceRedeploy(c *gin.Context, db *database.DB, service Service) bool {
|
||||||
|
engineValue, exists := c.Get("deployment_engine")
|
||||||
|
if !exists || engineValue == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
engine, ok := engineValue.(*deployment.DeploymentEngine)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
image := service.Image
|
||||||
|
if image == "" {
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT image_name || ':' || image_tag FROM deployments
|
||||||
|
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
|
||||||
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
service.ID,
|
||||||
|
).Scan(&image); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service.Image = image
|
||||||
|
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{Run: func(jctx context.Context) {
|
||||||
|
_, _ = reconcileServiceJob(jctx, db, engine, service)
|
||||||
|
}})
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// persistPublishedPort records the host port Docker actually bound so later
|
// persistPublishedPort records the host port Docker actually bound so later
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ package api
|
|||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/deployqueue"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -33,10 +37,65 @@ type Service struct {
|
|||||||
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
|
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
|
||||||
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
|
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
|
||||||
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
|
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
|
||||||
|
Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB
|
||||||
|
Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
|
||||||
|
MaintenanceMode bool `json:"maintenance_mode" db:"-"`
|
||||||
|
BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServiceVolume is a volume/bind mount applied to every replica.
|
||||||
|
type ServiceVolume struct {
|
||||||
|
Type string `json:"type"` // volume | bind (empty = volume)
|
||||||
|
Source string `json:"source"` // volume name or host path
|
||||||
|
Target string `json:"target"` // container path (absolute)
|
||||||
|
ReadOnly bool `json:"read_only"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades
|
||||||
|
// to no mounts rather than failing the request.
|
||||||
|
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
|
||||||
|
var raw []byte
|
||||||
|
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var stored []ServiceVolume
|
||||||
|
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]deployment.VolumeMount, 0, len(stored))
|
||||||
|
for _, v := range stored {
|
||||||
|
out = append(out, deployment.VolumeMount{
|
||||||
|
Type: v.Type,
|
||||||
|
Source: v.Source,
|
||||||
|
Destination: v.Target,
|
||||||
|
ReadOnly: v.ReadOnly,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateServiceVolumes enforces the mount contract — bad paths fail
|
||||||
|
// fast at write time instead of surfacing as docker create errors later.
|
||||||
|
func validateServiceVolumes(vols []ServiceVolume) error {
|
||||||
|
for i, v := range vols {
|
||||||
|
if v.Type == "" {
|
||||||
|
v.Type = "volume"
|
||||||
|
}
|
||||||
|
if v.Type != "volume" && v.Type != "bind" {
|
||||||
|
return fmt.Errorf("volumes[%d].type must be volume or bind", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(v.Source) == "" {
|
||||||
|
return fmt.Errorf("volumes[%d].source is required", i)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(v.Target, "/") {
|
||||||
|
return fmt.Errorf("volumes[%d].target must be an absolute container path", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateServiceRequest represents a request to create a service
|
// CreateServiceRequest represents a request to create a service
|
||||||
type CreateServiceRequest struct {
|
type CreateServiceRequest struct {
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
@@ -55,6 +114,9 @@ type CreateServiceRequest struct {
|
|||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
HealthCheckPath string `json:"healthcheck_path"`
|
HealthCheckPath string `json:"healthcheck_path"`
|
||||||
RestartPolicy string `json:"restart_policy"`
|
RestartPolicy string `json:"restart_policy"`
|
||||||
|
Volumes []ServiceVolume `json:"volumes"`
|
||||||
|
MaintenanceMode bool `json:"maintenance_mode"`
|
||||||
|
BasicAuth []BasicAuthCred `json:"basic_auth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateServiceRequest represents a request to update a service
|
// UpdateServiceRequest represents a request to update a service
|
||||||
@@ -74,6 +136,9 @@ type UpdateServiceRequest struct {
|
|||||||
Domain *string `json:"domain"`
|
Domain *string `json:"domain"`
|
||||||
HealthCheckPath *string `json:"healthcheck_path"`
|
HealthCheckPath *string `json:"healthcheck_path"`
|
||||||
RestartPolicy string `json:"restart_policy"`
|
RestartPolicy string `json:"restart_policy"`
|
||||||
|
Volumes *[]ServiceVolume `json:"volumes"`
|
||||||
|
MaintenanceMode *bool `json:"maintenance_mode"`
|
||||||
|
BasicAuth *[]BasicAuthCred `json:"basic_auth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetServices retrieves all services for a project
|
// handleGetServices retrieves all services for a project
|
||||||
@@ -262,6 +327,15 @@ func handleCreateService(c *gin.Context) {
|
|||||||
if service.RestartPolicy == "" {
|
if service.RestartPolicy == "" {
|
||||||
service.RestartPolicy = "unless-stopped"
|
service.RestartPolicy = "unless-stopped"
|
||||||
}
|
}
|
||||||
|
if err := validateServiceVolumes(req.Volumes); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service.Volumes = req.Volumes
|
||||||
|
volumesJSON, _ := json.Marshal(req.Volumes)
|
||||||
|
if len(req.Volumes) == 0 {
|
||||||
|
volumesJSON = []byte("[]")
|
||||||
|
}
|
||||||
|
|
||||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -277,14 +351,14 @@ func handleCreateService(c *gin.Context) {
|
|||||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||||
build_command, start_command, type, status, image, command, environment,
|
build_command, start_command, type, status, image, command, environment,
|
||||||
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||||
healthcheck_path, restart_policy, created_at, updated_at)
|
healthcheck_path, restart_policy, volumes, 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)`,
|
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)`,
|
||||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||||
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
||||||
service.CreatedAt, service.UpdatedAt,
|
volumesJSON, service.CreatedAt, service.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -292,6 +366,26 @@ func handleCreateService(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Domains live in service_domains; services.domain is the derived default.
|
||||||
|
if req.Domain != "" && validHostname(strings.ToLower(req.Domain)) {
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true)
|
||||||
|
ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`,
|
||||||
|
service.ID, strings.ToLower(req.Domain))
|
||||||
|
}
|
||||||
|
if req.MaintenanceMode || len(req.BasicAuth) > 0 {
|
||||||
|
basicAuthUsers := ""
|
||||||
|
if encoded, encErr := encodeBasicAuth(req.BasicAuth); encErr == nil {
|
||||||
|
basicAuthUsers = encoded
|
||||||
|
}
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`,
|
||||||
|
req.MaintenanceMode, basicAuthUsers, service.ID)
|
||||||
|
service.MaintenanceMode = req.MaintenanceMode
|
||||||
|
service.BasicAuth = basicAuthUsernames(basicAuthUsers)
|
||||||
|
}
|
||||||
|
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"service": service})
|
c.JSON(http.StatusCreated, gin.H{"service": service})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +463,15 @@ func handleGetService(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
liveServiceStatus(c, db.(*database.DB), &service)
|
liveServiceStatus(c, db.(*database.DB), &service)
|
||||||
|
for _, v := range loadServiceVolumes(db.(*database.DB), service.ID) {
|
||||||
|
service.Volumes = append(service.Volumes, ServiceVolume{
|
||||||
|
Type: v.Type, Source: v.Source, Target: v.Destination, ReadOnly: v.ReadOnly,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
|
||||||
|
maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
|
||||||
|
service.MaintenanceMode = maintenance
|
||||||
|
service.BasicAuth = basicAuthUsernames(basicAuth)
|
||||||
c.JSON(http.StatusOK, gin.H{"service": service})
|
c.JSON(http.StatusOK, gin.H{"service": service})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,6 +586,18 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
if req.RestartPolicy != "" {
|
if req.RestartPolicy != "" {
|
||||||
existingService.RestartPolicy = req.RestartPolicy
|
existingService.RestartPolicy = req.RestartPolicy
|
||||||
}
|
}
|
||||||
|
// lib/pq sends a nil []byte as an empty bytea literal rather than NULL,
|
||||||
|
// so the COALESCE fallback needs an untyped nil when volumes aren't sent.
|
||||||
|
var volumesArg interface{}
|
||||||
|
if req.Volumes != nil {
|
||||||
|
if err := validateServiceVolumes(*req.Volumes); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existingService.Volumes = *req.Volumes
|
||||||
|
volumesJSON, _ := json.Marshal(*req.Volumes)
|
||||||
|
volumesArg = volumesJSON
|
||||||
|
}
|
||||||
|
|
||||||
existingService.UpdatedAt = time.Now()
|
existingService.UpdatedAt = time.Now()
|
||||||
|
|
||||||
@@ -492,14 +607,14 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
|
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
|
||||||
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
|
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
|
||||||
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
|
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
|
||||||
restart_policy = $15, updated_at = $16
|
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16
|
||||||
WHERE id = $17`,
|
WHERE id = $18`,
|
||||||
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
||||||
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
||||||
existingService.BuildPath, existingService.CPU, existingService.Memory,
|
existingService.BuildPath, existingService.CPU, existingService.Memory,
|
||||||
existingService.Replicas, existingService.Port, existingService.Domain,
|
existingService.Replicas, existingService.Port, existingService.Domain,
|
||||||
existingService.HealthCheckPath, existingService.RestartPolicy,
|
existingService.HealthCheckPath, existingService.RestartPolicy,
|
||||||
existingService.UpdatedAt, existingService.ID,
|
existingService.UpdatedAt, volumesArg, existingService.ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -507,23 +622,65 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Domain writes route through service_domains — services.domain is the
|
||||||
|
// derived default and gets rewritten by syncDefaultDomain.
|
||||||
|
if req.Domain != nil {
|
||||||
|
domain := strings.ToLower(strings.TrimSpace(*req.Domain))
|
||||||
|
if domain == "" {
|
||||||
|
_, _ = db.(*database.DB).Exec(`DELETE FROM service_domains WHERE service_id = $1`, serviceID)
|
||||||
|
} else if validHostname(domain) {
|
||||||
|
_, _ = db.(*database.DB).Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID)
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true)
|
||||||
|
ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`,
|
||||||
|
serviceID, domain)
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid hostname", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncDefaultDomain(db.(*database.DB), serviceID)
|
||||||
|
_ = db.(*database.DB).QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&existingService.Domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.MaintenanceMode != nil || req.BasicAuth != nil {
|
||||||
|
maintenance, basicAuth := serviceAccess(db.(*database.DB), serviceID)
|
||||||
|
if req.MaintenanceMode != nil {
|
||||||
|
maintenance = *req.MaintenanceMode
|
||||||
|
}
|
||||||
|
if req.BasicAuth != nil {
|
||||||
|
encoded, encErr := encodeBasicAuth(*req.BasicAuth)
|
||||||
|
if encErr != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encode basic auth", "code": "INTERNAL"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
basicAuth = encoded
|
||||||
|
}
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`,
|
||||||
|
maintenance, basicAuth, serviceID)
|
||||||
|
existingService.MaintenanceMode = maintenance
|
||||||
|
}
|
||||||
|
existingService.Domains = loadServiceDomains(db.(*database.DB), serviceID)
|
||||||
|
_, storedAuth := serviceAccess(db.(*database.DB), serviceID)
|
||||||
|
existingService.BasicAuth = basicAuthUsernames(storedAuth)
|
||||||
|
|
||||||
// A replica change applies immediately when the service has live
|
// A replica change applies immediately when the service has live
|
||||||
// containers; other spec fields take effect on the next deploy.
|
// containers; other spec fields take effect on the next deploy.
|
||||||
|
// Runs through the deploy queue so it can't overlap a build.
|
||||||
if req.Replicas != nil && existingService.Status == "running" {
|
if req.Replicas != nil && existingService.Status == "running" {
|
||||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||||
engine := engineValue.(*deployment.DeploymentEngine)
|
engine := engineValue.(*deployment.DeploymentEngine)
|
||||||
serviceCopy := existingService
|
serviceCopy := existingService
|
||||||
go func() {
|
dbCopy := db.(*database.DB)
|
||||||
spec, err := serviceRuntimeSpec(db.(*database.DB), serviceCopy)
|
getDeployQueue(c).Enqueue(serviceCopy.ID, deployqueue.Job{Run: func(jctx context.Context) {
|
||||||
|
spec, err := serviceRuntimeSpec(dbCopy, serviceCopy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
if state, err := engine.ReconcileService(jctx, spec); err == nil && state != nil {
|
||||||
defer cancel()
|
persistPublishedPort(dbCopy, serviceCopy.ID, state.Ports)
|
||||||
if state, err := engine.ReconcileService(ctx, spec); err == nil && state != nil {
|
|
||||||
persistPublishedPort(db.(*database.DB), serviceCopy.ID, state.Ports)
|
|
||||||
}
|
}
|
||||||
}()
|
}})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
sqlcdb "containr/internal/database/sqlcdb"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Personal access tokens let the CLI, MCP server, and third-party agents
|
||||||
|
// authenticate without a browser session. The raw token (cnp_…) is
|
||||||
|
// returned exactly once at creation; only its sha256 hash persists.
|
||||||
|
|
||||||
|
var userTokenScopes = map[string]bool{"read": true, "write": true, "admin": true}
|
||||||
|
|
||||||
|
func generateUserToken() (token string, prefix string, hash string, err error) {
|
||||||
|
buf := make([]byte, 24)
|
||||||
|
if _, err = rand.Read(buf); err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
token = "cnp_" + hex.EncodeToString(buf)
|
||||||
|
prefix = token[:12] // cnp_ + 8 hex chars, enough to identify in a list
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return token, prefix, hex.EncodeToString(sum[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userTokenJSON(t sqlcdb.UserToken) gin.H {
|
||||||
|
row := gin.H{
|
||||||
|
"id": t.ID.String(),
|
||||||
|
"name": t.Name,
|
||||||
|
"key_prefix": t.KeyPrefix,
|
||||||
|
"scope": t.Scope,
|
||||||
|
}
|
||||||
|
if t.CreatedAt.Valid {
|
||||||
|
row["created_at"] = t.CreatedAt.Time
|
||||||
|
}
|
||||||
|
if t.LastUsedAt.Valid {
|
||||||
|
row["last_used_at"] = t.LastUsedAt.Time
|
||||||
|
}
|
||||||
|
if t.ExpiresAt.Valid {
|
||||||
|
row["expires_at"] = t.ExpiresAt.Time
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateUserToken(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
queries := sqlcdb.New(db.DB)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
ExpiresInDays int `json:"expires_in_days"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Scope == "" {
|
||||||
|
req.Scope = "write"
|
||||||
|
}
|
||||||
|
if !userTokenScopes[req.Scope] {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be one of: read, write, admin", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A token can never outrank its owner: non-admin users cannot mint
|
||||||
|
// admin-scope tokens even for themselves.
|
||||||
|
if req.Scope == "admin" && !c.GetBool("is_admin") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "admin scope requires a platform admin account", "code": "FORBIDDEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ExpiresInDays < 0 || req.ExpiresInDays > 3650 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "expires_in_days must be between 0 (never) and 3650", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, prefix, hash, err := generateUserToken()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token", "code": "INTERNAL"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := sql.NullTime{}
|
||||||
|
if req.ExpiresInDays > 0 {
|
||||||
|
expiresAt = sql.NullTime{Time: time.Now().Add(time.Duration(req.ExpiresInDays) * 24 * time.Hour), Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := queries.CreateUserToken(c.Request.Context(), sqlcdb.CreateUserTokenParams{
|
||||||
|
UserID: ownerUUID,
|
||||||
|
Name: req.Name,
|
||||||
|
KeyPrefix: prefix,
|
||||||
|
TokenHash: hash,
|
||||||
|
Scope: req.Scope,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token", "code": "INTERNAL"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogAudit(userID, "user_token", row.ID.String(), "create", map[string]interface{}{"name": req.Name, "scope": req.Scope})
|
||||||
|
|
||||||
|
payload := userTokenJSON(row)
|
||||||
|
payload["token"] = token
|
||||||
|
c.JSON(http.StatusCreated, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListUserTokens(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
queries := sqlcdb.New(db.DB)
|
||||||
|
|
||||||
|
ownerUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := queries.ListUserTokens(c.Request.Context(), ownerUUID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens", "code": "INTERNAL"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]gin.H, 0, len(rows))
|
||||||
|
for _, t := range rows {
|
||||||
|
out = append(out, userTokenJSON(t))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"tokens": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteUserToken(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
queries := sqlcdb.New(db.DB)
|
||||||
|
|
||||||
|
ownerUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenUUID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := queries.RevokeUserToken(c.Request.Context(), sqlcdb.RevokeUserTokenParams{
|
||||||
|
ID: tokenUUID,
|
||||||
|
UserID: ownerUUID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token", "code": "INTERNAL"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogAudit(userID, "user_token", tokenUUID.String(), "revoke", nil)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Token revoked"})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
|
"containr/internal/secrets"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ type EnvironmentVariable struct {
|
|||||||
|
|
||||||
type UpdateVariablesRequest struct {
|
type UpdateVariablesRequest struct {
|
||||||
Variables []VariableInput `json:"variables" binding:"required"`
|
Variables []VariableInput `json:"variables" binding:"required"`
|
||||||
|
Redeploy bool `json:"redeploy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VariableInput struct {
|
type VariableInput struct {
|
||||||
@@ -159,6 +161,9 @@ func handleUpdateVariables(c *gin.Context) {
|
|||||||
value = stored
|
value = stored
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if v.IsSecret {
|
||||||
|
value = secrets.Encrypt(value)
|
||||||
|
}
|
||||||
varID := uuid.New()
|
varID := uuid.New()
|
||||||
_, err = tx.Exec(
|
_, err = tx.Exec(
|
||||||
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
||||||
@@ -204,5 +209,22 @@ func handleUpdateVariables(c *gin.Context) {
|
|||||||
variables = append(variables, v)
|
variables = append(variables, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Environment variables updated successfully"})
|
resp := gin.H{"variables": variables, "message": "Environment variables updated successfully"}
|
||||||
|
if req.Redeploy {
|
||||||
|
// Load the service spec inputs and queue a reconcile behind any
|
||||||
|
// in-flight deployment — new env lands in the recreated containers.
|
||||||
|
var svc Service
|
||||||
|
svcErr := db.(*database.DB).QueryRow(
|
||||||
|
`SELECT id, project_id, name, COALESCE(image, ''), COALESCE(command, ''),
|
||||||
|
COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(domain, ''),
|
||||||
|
COALESCE(healthcheck_path, ''), COALESCE(restart_policy, 'unless-stopped'),
|
||||||
|
COALESCE(cpu, ''), COALESCE(memory, '')
|
||||||
|
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)
|
||||||
|
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})
|
||||||
|
}
|
||||||
@@ -222,6 +222,7 @@ func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest)
|
|||||||
Dockerfile: "Dockerfile",
|
Dockerfile: "Dockerfile",
|
||||||
Tags: []string{imageName},
|
Tags: []string{imageName},
|
||||||
BuildArgs: buildArgs,
|
BuildArgs: buildArgs,
|
||||||
|
NoCache: req.NoCache,
|
||||||
Remove: true,
|
Remove: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -498,6 +498,7 @@ func (n *NixpacksBuilder) Build(ctx context.Context, req *types.BuildRequest) (*
|
|||||||
BuildArgs: map[string]*string{
|
BuildArgs: map[string]*string{
|
||||||
"BUILDKIT_INLINE_CACHE": strPtr("1"),
|
"BUILDKIT_INLINE_CACHE": strPtr("1"),
|
||||||
},
|
},
|
||||||
|
NoCache: req.NoCache,
|
||||||
Remove: true,
|
Remove: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ func (rb *RailpackBuilder) Build(ctx context.Context, req *types.BuildRequest) (
|
|||||||
Tags: []string{imageName},
|
Tags: []string{imageName},
|
||||||
BuildArgs: buildArgs,
|
BuildArgs: buildArgs,
|
||||||
Labels: req.Labels,
|
Labels: req.Labels,
|
||||||
|
NoCache: req.NoCache,
|
||||||
Remove: true,
|
Remove: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthCmd represents the auth command
|
// AuthCmd groups authentication commands.
|
||||||
var AuthCmd = &cobra.Command{
|
var AuthCmd = &cobra.Command{
|
||||||
Use: "auth",
|
Use: "auth",
|
||||||
Short: "Authenticate with Containr API",
|
Short: "Authenticate with a Containr instance",
|
||||||
Long: `Manage authentication with the Containr API.
|
Long: `Login with a personal access token (cnp_...) created under
|
||||||
You can login, logout, and check your current authentication status.`,
|
Settings → Personal Access Tokens, check status, or switch profiles.`,
|
||||||
}
|
}
|
||||||
|
|
||||||
// loginCmd represents the login command
|
|
||||||
var loginCmd = &cobra.Command{
|
var loginCmd = &cobra.Command{
|
||||||
Use: "login [token]",
|
Use: "login [token]",
|
||||||
Short: "Login to Containr",
|
Short: "Store a personal access token",
|
||||||
Long: `Login to Containr using your API token.
|
Long: `Login with a cnp_ token. Reads interactively when no argument is
|
||||||
You can get your token from the Containr web interface.`,
|
given. The token is verified against the API before it is stored.`,
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: runLogin,
|
RunE: runLogin,
|
||||||
}
|
}
|
||||||
|
|
||||||
// logoutCmd represents the logout command
|
|
||||||
var logoutCmd = &cobra.Command{
|
var logoutCmd = &cobra.Command{
|
||||||
Use: "logout",
|
Use: "logout",
|
||||||
Short: "Logout from Containr",
|
Short: "Remove the stored token",
|
||||||
Long: `Remove stored authentication credentials.`,
|
|
||||||
RunE: runLogout,
|
RunE: runLogout,
|
||||||
}
|
}
|
||||||
|
|
||||||
// statusCmd represents the status command
|
|
||||||
var statusCmd = &cobra.Command{
|
var statusCmd = &cobra.Command{
|
||||||
Use: "status",
|
Use: "status",
|
||||||
Short: "Check authentication status",
|
Short: "Show the active profile and verify the token",
|
||||||
Long: `Check if you are currently authenticated with Containr.`,
|
|
||||||
RunE: runStatus,
|
RunE: runStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
AuthCmd.AddCommand(loginCmd)
|
loginCmd.Flags().String("url", "", "API URL to store in the profile (e.g. https://containr.example.com)")
|
||||||
AuthCmd.AddCommand(logoutCmd)
|
loginCmd.Flags().String("name", "", "profile name to save under (default: active profile)")
|
||||||
AuthCmd.AddCommand(statusCmd)
|
AuthCmd.AddCommand(loginCmd, logoutCmd, statusCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
type profileResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func runLogin(cmd *cobra.Command, args []string) error {
|
func runLogin(cmd *cobra.Command, args []string) error {
|
||||||
var token string
|
var token string
|
||||||
|
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
token = args[0]
|
token = args[0]
|
||||||
} else {
|
} else if isTerminal() {
|
||||||
// Prompt for token
|
fmt.Fprint(os.Stderr, "Token: ")
|
||||||
fmt.Print("Enter your Containr API token: ")
|
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
fmt.Scanln(&token)
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token = string(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return fmt.Errorf("token is required")
|
return &APIError{Message: "token is required — pass it as an argument or pipe it via stdin", ExitCode: ExitValidation}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store token in config
|
// Verify before saving so a mistyped token fails loudly.
|
||||||
viper.Set("token", token)
|
urlFlag, _ := cmd.Flags().GetString("url")
|
||||||
if err := viper.WriteConfig(); err != nil {
|
probe := &Client{BaseURL: ResolveAPIURL(), Token: token}
|
||||||
return fmt.Errorf("failed to save token: %w", err)
|
if urlFlag != "" {
|
||||||
|
probe.BaseURL = normalizeBase(urlFlag)
|
||||||
|
}
|
||||||
|
var profile profileResponse
|
||||||
|
if err := probe.DoJSON("GET", "/user/profile", nil, &profile); err != nil {
|
||||||
|
return fmt.Errorf("token verification failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("✓ Successfully logged in to Containr")
|
name, _ := cmd.Flags().GetString("name")
|
||||||
|
if name == "" {
|
||||||
|
name = ActiveProfile()
|
||||||
|
}
|
||||||
|
if err := SaveProfile(name, probe.BaseURL, token); err != nil {
|
||||||
|
return fmt.Errorf("failed to save profile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]interface{}{
|
||||||
|
"profile": name, "api_url": probe.BaseURL,
|
||||||
|
"user": profile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fmt.Printf("Logged in as %s (%s) — profile %q → %s\n", profile.Name, profile.Email, name, probe.BaseURL)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runLogout(cmd *cobra.Command, args []string) error {
|
func runLogout(cmd *cobra.Command, args []string) error {
|
||||||
// Remove token from config
|
name := ActiveProfile()
|
||||||
viper.Set("token", "")
|
if err := ClearProfileToken(name); err != nil {
|
||||||
if err := viper.WriteConfig(); err != nil {
|
return fmt.Errorf("failed to update config: %w", err)
|
||||||
return fmt.Errorf("failed to remove token: %w", err)
|
}
|
||||||
|
if !JSONMode() {
|
||||||
|
fmt.Printf("Removed token from profile %q\n", name)
|
||||||
|
} else {
|
||||||
|
return PrintJSON(map[string]string{"status": "ok", "profile": name})
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("✓ Successfully logged out from Containr")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runStatus(cmd *cobra.Command, args []string) error {
|
func runStatus(cmd *cobra.Command, args []string) error {
|
||||||
token := viper.GetString("token")
|
client := NewClient()
|
||||||
if token == "" {
|
profile := ActiveProfile()
|
||||||
fmt.Println("❌ Not authenticated")
|
|
||||||
fmt.Println("Run 'containr auth login <token>' to authenticate")
|
if JSONMode() {
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"profile": profile,
|
||||||
|
"api_url": client.BaseURL,
|
||||||
|
}
|
||||||
|
if client.Token != "" {
|
||||||
|
var p profileResponse
|
||||||
|
if err := client.DoJSON("GET", "/user/profile", nil, &p); err == nil {
|
||||||
|
out["user"] = p
|
||||||
|
out["authenticated"] = true
|
||||||
|
} else {
|
||||||
|
out["authenticated"] = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out["authenticated"] = false
|
||||||
|
}
|
||||||
|
return PrintJSON(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Profile: %s\n", profile)
|
||||||
|
fmt.Printf("API URL: %s\n", client.BaseURL)
|
||||||
|
if client.Token == "" {
|
||||||
|
fmt.Println("Token: not set — run `containr auth login <token>`")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
var p profileResponse
|
||||||
fmt.Println("✓ Authenticated with Containr")
|
if err := client.DoJSON("GET", "/user/profile", nil, &p); err != nil {
|
||||||
apiURL := buildAPIURL("/user/profile")
|
fmt.Printf("Token: present but rejected (%v)\n", err)
|
||||||
fmt.Printf("API URL: %s\n", strings.TrimSuffix(buildAPIURL(""), "/"))
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create verification request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("⚠ Token verification unavailable: %v\n", err)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
fmt.Printf("Identity: %s <%s>\n", p.Name, p.Email)
|
||||||
|
if p.IsAdmin {
|
||||||
switch resp.StatusCode {
|
fmt.Println("Role: platform admin")
|
||||||
case http.StatusOK:
|
|
||||||
fmt.Println("✓ Token verified against API")
|
|
||||||
case http.StatusUnauthorized, http.StatusForbidden:
|
|
||||||
fmt.Println("❌ Token rejected by API (expired/invalid)")
|
|
||||||
default:
|
|
||||||
fmt.Printf("⚠ Token verification returned unexpected status: %s\n", resp.Status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildAPIURL(endpoint string) string {
|
|
||||||
baseURL := viper.GetString("api-url")
|
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = "http://localhost:8080/api/v1"
|
|
||||||
}
|
|
||||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
|
||||||
if endpoint == "" {
|
|
||||||
return baseURL
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(endpoint, "/") {
|
|
||||||
endpoint = "/" + endpoint
|
|
||||||
}
|
|
||||||
return baseURL + endpoint
|
|
||||||
}
|
|
||||||
@@ -6,16 +6,52 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildAPIURLDefaultsAndTrimming(t *testing.T) {
|
func TestResolveAPIURLDefaultsAndTrimming(t *testing.T) {
|
||||||
viper.Set("api-url", "")
|
viper.Set("api-url", "")
|
||||||
got := buildAPIURL("/user/profile")
|
viper.Set("current_profile", "")
|
||||||
if got != "http://localhost:8080/api/v1/user/profile" {
|
viper.Set("profiles.default.api_url", "")
|
||||||
|
if got := ResolveAPIURL(); got != "http://localhost:8080/api/v1" {
|
||||||
t.Fatalf("unexpected default api url: %s", got)
|
t.Fatalf("unexpected default api url: %s", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
viper.Set("api-url", "https://api.example.com/api/v1/")
|
viper.Set("api-url", "https://api.example.com/api/v1/")
|
||||||
got = buildAPIURL("user/profile")
|
if got := ResolveAPIURL(); got != "https://api.example.com/api/v1" {
|
||||||
if got != "https://api.example.com/api/v1/user/profile" {
|
|
||||||
t.Fatalf("unexpected trimmed api url: %s", got)
|
t.Fatalf("unexpected trimmed api url: %s", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bare hosts gain the /api/v1 suffix.
|
||||||
|
viper.Set("api-url", "https://containr.example.com")
|
||||||
|
if got := ResolveAPIURL(); got != "https://containr.example.com/api/v1" {
|
||||||
|
t.Fatalf("unexpected suffixed api url: %s", got)
|
||||||
|
}
|
||||||
|
viper.Set("api-url", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTokenProfileAndEnv(t *testing.T) {
|
||||||
|
viper.Set("token", "")
|
||||||
|
viper.Set("current_profile", "staging")
|
||||||
|
viper.Set("profiles.staging.token", "cnp_staging")
|
||||||
|
if got := ResolveToken(); got != "cnp_staging" {
|
||||||
|
t.Fatalf("expected profile token, got %s", got)
|
||||||
|
}
|
||||||
|
t.Setenv("CONTAINR_TOKEN", "cnp_env")
|
||||||
|
if got := ResolveToken(); got != "cnp_env" {
|
||||||
|
t.Fatalf("env should win over profile, got %s", got)
|
||||||
|
}
|
||||||
|
viper.Set("token", "cnp_flag")
|
||||||
|
if got := ResolveToken(); got != "cnp_flag" {
|
||||||
|
t.Fatalf("flag should win over env, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExitCodeMapping(t *testing.T) {
|
||||||
|
if got := exitCodeFor(401); got != ExitAuth {
|
||||||
|
t.Fatalf("401 → %d, want %d", got, ExitAuth)
|
||||||
|
}
|
||||||
|
if got := exitCodeFor(404); got != ExitNotFound {
|
||||||
|
t.Fatalf("404 → %d, want %d", got, ExitNotFound)
|
||||||
|
}
|
||||||
|
if got := exitCodeFor(400); got != ExitValidation {
|
||||||
|
t.Fatalf("400 → %d, want %d", got, ExitValidation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exit codes — stable for scripting and agent use.
|
||||||
|
const (
|
||||||
|
ExitOK = 0
|
||||||
|
ExitError = 1
|
||||||
|
ExitAuth = 2
|
||||||
|
ExitNotFound = 3
|
||||||
|
ExitValidation = 4
|
||||||
|
ExitUnavailable = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIError is a non-2xx response from the Containr API. Code carries the
|
||||||
|
// stable exit code so `main` can map errors to exit statuses.
|
||||||
|
type APIError struct {
|
||||||
|
Status int
|
||||||
|
Message string
|
||||||
|
ExitCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
if e.Message != "" {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("request failed with status %d", e.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exitCodeFor(status int) int {
|
||||||
|
switch status {
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
return ExitAuth
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return ExitNotFound
|
||||||
|
case http.StatusBadRequest, http.StatusConflict, http.StatusUnprocessableEntity:
|
||||||
|
return ExitValidation
|
||||||
|
default:
|
||||||
|
return ExitError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is a thin authenticated HTTP client for the Containr API. All CLI
|
||||||
|
// commands go through it — no command builds its own request.
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
Token string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient resolves the active profile (flag > env > current_profile) and
|
||||||
|
// returns a client ready to call the API. Call RequireAuth() for commands
|
||||||
|
// that need a token.
|
||||||
|
func NewClient() *Client {
|
||||||
|
return &Client{
|
||||||
|
BaseURL: ResolveAPIURL(),
|
||||||
|
Token: ResolveToken(),
|
||||||
|
HTTPClient: &http.Client{Timeout: 60 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAuth returns an error suitable for the CLI when no token is set.
|
||||||
|
func (c *Client) RequireAuth() error {
|
||||||
|
if c.Token == "" {
|
||||||
|
return &APIError{
|
||||||
|
Status: 0,
|
||||||
|
Message: "not authenticated — run `containr auth login <token>` or set CONTAINR_TOKEN",
|
||||||
|
ExitCode: ExitAuth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do performs an API request. body may be nil, a []byte, or any value that
|
||||||
|
// marshals to JSON. Returns the raw response body.
|
||||||
|
func (c *Client) Do(method, path string, body interface{}) ([]byte, error) {
|
||||||
|
var reader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
var data []byte
|
||||||
|
switch v := body.(type) {
|
||||||
|
case []byte:
|
||||||
|
data = v
|
||||||
|
default:
|
||||||
|
var err error
|
||||||
|
data, err = json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode request: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := c.BaseURL + path
|
||||||
|
req, err := http.NewRequest(method, url, reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if k := viper.GetString("idempotency-key"); k != "" && method == http.MethodPost {
|
||||||
|
req.Header.Set("Idempotency-Key", k)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
hc := c.HTTPClient
|
||||||
|
if hc == nil {
|
||||||
|
hc = &http.Client{Timeout: 60 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &APIError{
|
||||||
|
Status: 0,
|
||||||
|
Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err),
|
||||||
|
ExitCode: ExitUnavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
msg := strings.TrimSpace(string(data))
|
||||||
|
var envelope struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &envelope) == nil && envelope.Error != "" {
|
||||||
|
msg = envelope.Error
|
||||||
|
if envelope.Code != "" {
|
||||||
|
msg = envelope.Code + ": " + msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, &APIError{
|
||||||
|
Status: resp.StatusCode,
|
||||||
|
Message: msg,
|
||||||
|
ExitCode: exitCodeFor(resp.StatusCode),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoJSON performs a request and unmarshals the response into out.
|
||||||
|
func (c *Client) DoJSON(method, path string, body interface{}, out interface{}) error {
|
||||||
|
data, err := c.Do(method, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if out == nil || len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, out)
|
||||||
|
}
|
||||||
@@ -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,204 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DatabasesCmd manages managed databases.
|
||||||
|
var DatabasesCmd = &cobra.Command{
|
||||||
|
Use: "databases",
|
||||||
|
Aliases: []string{"database", "db"},
|
||||||
|
Short: "Manage databases",
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List managed databases",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/databases", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "databases")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, d := range items {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(d, "id"), str(d, "name"), str(d, "type", "engine"), str(d, "status"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "NAME", "TYPE", "STATUS"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbGetCmd = &cobra.Command{
|
||||||
|
Use: "get <id>",
|
||||||
|
Short: "Show a database (connection URL included)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/databases/"+args[0], nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbCreateCmd = &cobra.Command{
|
||||||
|
Use: "create <name>",
|
||||||
|
Short: "Provision a managed database",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dbType, _ := cmd.Flags().GetString("type")
|
||||||
|
body := map[string]interface{}{"name": args[0], "type": dbType}
|
||||||
|
for _, f := range []string{"version", "storage-size"} {
|
||||||
|
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||||
|
key := f
|
||||||
|
if f == "storage-size" {
|
||||||
|
key = "storage_size"
|
||||||
|
}
|
||||||
|
body[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/databases", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbDeleteCmd = &cobra.Command{
|
||||||
|
Use: "delete <id>",
|
||||||
|
Short: "Delete a database and its data",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !Confirm(fmt.Sprintf("Delete database %s and all its data?", args[0])) {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if _, err := c.Do("DELETE", "/databases/"+args[0], nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||||
|
}
|
||||||
|
fmt.Printf("Deleted database %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbActionCmd = &cobra.Command{
|
||||||
|
Use: "action <id> <start|stop|restart>",
|
||||||
|
Short: "Start, stop, or restart a database",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/databases/"+args[0]+"/action", map[string]string{"action": args[1]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbBackupCmd = &cobra.Command{
|
||||||
|
Use: "backup <id>",
|
||||||
|
Short: "Take a manual backup snapshot",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/databases/"+args[0]+"/backup", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbRestoreCmd = &cobra.Command{
|
||||||
|
Use: "restore <id> <backup-id>",
|
||||||
|
Short: "Restore a database from a backup",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !Confirm(fmt.Sprintf("Restore database %s from backup %s? Current data will be overwritten.", args[0], args[1])) {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/databases/"+args[0]+"/restore", map[string]string{"backup_id": args[1]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbDownloadCmd = &cobra.Command{
|
||||||
|
Use: "download-backup <id> <backup-id> [file]",
|
||||||
|
Short: "Download a backup archive",
|
||||||
|
Args: cobra.RangeArgs(2, 3),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", fmt.Sprintf("/databases/%s/backups/%s/download", args[0], args[1]), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := args[1] + ".tar.gz"
|
||||||
|
if len(args) > 2 {
|
||||||
|
out = args[2]
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(out, data, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]string{"file": out})
|
||||||
|
}
|
||||||
|
fmt.Printf("Wrote %s (%d bytes)\n", out, len(data))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
dbCreateCmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
|
||||||
|
dbCreateCmd.Flags().String("version", "", "engine version tag")
|
||||||
|
dbCreateCmd.Flags().String("storage-size", "", "volume size (e.g. 10Gi)")
|
||||||
|
DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbDownloadCmd)
|
||||||
|
}
|
||||||
@@ -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
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"containr/internal/pkg/utils"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Project represents a Containr project
|
// ProjectsCmd manages projects.
|
||||||
type Project struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
OwnerID string `json:"owner_id"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProjectsCmd represents the projects command
|
|
||||||
var ProjectsCmd = &cobra.Command{
|
var ProjectsCmd = &cobra.Command{
|
||||||
Use: "projects",
|
Use: "projects",
|
||||||
|
Aliases: []string{"project"},
|
||||||
Short: "Manage projects",
|
Short: "Manage projects",
|
||||||
Long: `Manage your Containr projects.
|
|
||||||
You can list, create, update, and delete projects.`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// listProjectsCmd represents the list command
|
var projectsListCmd = &cobra.Command{
|
||||||
var listProjectsCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List all projects",
|
Short: "List projects",
|
||||||
Long: `List all your Containr projects.`,
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
RunE: runListProjects,
|
c, err := client()
|
||||||
}
|
if err != nil {
|
||||||
|
return err
|
||||||
// createProjectCmd represents the create command
|
|
||||||
var createProjectCmd = &cobra.Command{
|
|
||||||
Use: "create [name]",
|
|
||||||
Short: "Create a new project",
|
|
||||||
Long: `Create a new Containr project.
|
|
||||||
Provide a name and optional description.`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runCreateProject,
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteProjectCmd represents the delete command
|
|
||||||
var deleteProjectCmd = &cobra.Command{
|
|
||||||
Use: "delete [project-id]",
|
|
||||||
Short: "Delete a project",
|
|
||||||
Long: `Delete a Containr project by ID.`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runDeleteProject,
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
data, err := c.Do("GET", "/projects", nil)
|
||||||
// Ensure baseURL doesn't end with / and endpoint starts with /
|
if err != nil {
|
||||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
return err
|
||||||
if !strings.HasPrefix(endpoint, "/") {
|
|
||||||
endpoint = "/" + endpoint
|
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return baseURL + endpoint
|
var projectsGetCmd = &cobra.Command{
|
||||||
|
Use: "get <id>",
|
||||||
|
Short: "Show 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], nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p, _ := unwrapObject(data, "project")
|
||||||
|
fmt.Printf("ID: %s\n", str(p, "id"))
|
||||||
|
fmt.Printf("Name: %s\n", str(p, "name"))
|
||||||
|
fmt.Printf("Description: %s\n", str(p, "description"))
|
||||||
|
fmt.Printf("Created: %s\n", str(p, "created_at"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectsCreateCmd = &cobra.Command{
|
||||||
|
Use: "create <name>",
|
||||||
|
Short: "Create a project",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
desc, _ := cmd.Flags().GetString("description")
|
||||||
|
body := map[string]interface{}{"name": args[0]}
|
||||||
|
if desc != "" {
|
||||||
|
body["description"] = desc
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/projects", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p, _ := unwrapObject(data, "project")
|
||||||
|
fmt.Printf("Created project %s (%s)\n", str(p, "name"), str(p, "id"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectsDeleteCmd = &cobra.Command{
|
||||||
|
Use: "delete <id>",
|
||||||
|
Short: "Delete a project and all its services",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !Confirm(fmt.Sprintf("Delete project %s and all its services?", args[0])) {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if _, err := c.Do("DELETE", "/projects/"+args[0], nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||||
|
}
|
||||||
|
fmt.Printf("Deleted project %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
ProjectsCmd.AddCommand(listProjectsCmd)
|
projectsCreateCmd.Flags().StringP("description", "d", "", "project description")
|
||||||
ProjectsCmd.AddCommand(createProjectCmd)
|
ProjectsCmd.AddCommand(projectsListCmd, projectsGetCmd, projectsCreateCmd, projectsDeleteCmd)
|
||||||
ProjectsCmd.AddCommand(deleteProjectCmd)
|
|
||||||
|
|
||||||
// Add flags
|
|
||||||
createProjectCmd.Flags().StringVarP(&projectDescription, "description", "d", "", "Project description")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runListProjects(cmd *cobra.Command, args []string) error {
|
|
||||||
apiURL := getAPIURL("/projects")
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
client := &http.Client{}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to make request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var projects []Project
|
|
||||||
if err := json.Unmarshal(body, &projects); err != nil {
|
|
||||||
return fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(projects) == 0 {
|
|
||||||
fmt.Println("No projects found")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Your Projects:")
|
|
||||||
fmt.Println()
|
|
||||||
for _, project := range projects {
|
|
||||||
fmt.Printf("📦 %s (%s)\n", project.Name, project.ID)
|
|
||||||
if project.Description != "" {
|
|
||||||
fmt.Printf(" %s\n", project.Description)
|
|
||||||
}
|
|
||||||
fmt.Printf(" Created: %s\n", utils.FormatTime(project.CreatedAt))
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runCreateProject(cmd *cobra.Command, args []string) error {
|
|
||||||
name := args[0]
|
|
||||||
|
|
||||||
projectData := map[string]interface{}{
|
|
||||||
"name": name,
|
|
||||||
}
|
|
||||||
|
|
||||||
if projectDescription != "" {
|
|
||||||
projectData["description"] = projectDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonData, err := json.Marshal(projectData)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to marshal project data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
apiURL := getAPIURL("/projects")
|
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
client := &http.Client{}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to make request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusCreated {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var project Project
|
|
||||||
if err := json.Unmarshal(body, &project); err != nil {
|
|
||||||
return fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("✓ Project '%s' created successfully!\n", project.Name)
|
|
||||||
fmt.Printf("ID: %s\n", project.ID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDeleteProject(cmd *cobra.Command, args []string) error {
|
|
||||||
projectID := args[0]
|
|
||||||
|
|
||||||
apiURL := getAPIURL("/projects/" + projectID)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("DELETE", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
|
|
||||||
|
|
||||||
client := &http.Client{}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to make request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusNoContent {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("✓ Project '%s' deleted successfully!\n", projectID)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
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"} {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
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"} {
|
||||||
|
if cmd.Flags().Changed(f) {
|
||||||
|
key := f
|
||||||
|
switch f {
|
||||||
|
case "restart-policy":
|
||||||
|
key = "restart_policy"
|
||||||
|
case "healthcheck-path":
|
||||||
|
key = "healthcheck_path"
|
||||||
|
}
|
||||||
|
v, _ := cmd.Flags().GetString(f)
|
||||||
|
body[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("replicas") {
|
||||||
|
v, _ := cmd.Flags().GetInt("replicas")
|
||||||
|
body["replicas"] = v
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("port") {
|
||||||
|
v, _ := cmd.Flags().GetInt("port")
|
||||||
|
body["port"] = v
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("volume") || cmd.Flags().Changed("bind") || cmd.Flags().Changed("clear-volumes") {
|
||||||
|
volumes := []map[string]interface{}{}
|
||||||
|
for _, spec := range volumeFlagValues(cmd, "volume", "bind") {
|
||||||
|
volumes = append(volumes, spec)
|
||||||
|
}
|
||||||
|
body["volumes"] = volumes
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("maintenance") {
|
||||||
|
v, _ := cmd.Flags().GetString("maintenance")
|
||||||
|
body["maintenance_mode"] = v == "on" || v == "true"
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("basic-auth") {
|
||||||
|
creds := []map[string]interface{}{}
|
||||||
|
pairs, _ := cmd.Flags().GetStringArray("basic-auth")
|
||||||
|
for _, p := range pairs {
|
||||||
|
user, pass, ok := strings.Cut(p, ":")
|
||||||
|
if ok {
|
||||||
|
creds = append(creds, map[string]interface{}{"username": user, "password": pass})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body["basic_auth"] = creds
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
data, err := c.Do("PUT", "/services/"+args[0], body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Updated service %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
|
||||||
|
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)")
|
||||||
|
|
||||||
|
for _, a := range []string{"start", "stop", "restart", "redeploy"} {
|
||||||
|
verb := a
|
||||||
|
ServicesCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: verb + " <id>",
|
||||||
|
Short: verb + " a service",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: serviceAction(verb),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TemplatesCmd manages the template catalog.
|
||||||
|
var TemplatesCmd = &cobra.Command{
|
||||||
|
Use: "templates",
|
||||||
|
Aliases: []string{"template"},
|
||||||
|
Short: "Browse and deploy templates",
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List templates",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/templates", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "templates")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, t := range items {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(t, "id"), str(t, "name"), str(t, "category"), str(t, "description"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "NAME", "CATEGORY", "DESCRIPTION"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesGetCmd = &cobra.Command{
|
||||||
|
Use: "get <id>",
|
||||||
|
Short: "Show a template definition",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/templates/"+args[0], nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesCreateCmd = &cobra.Command{
|
||||||
|
Use: "create <file>",
|
||||||
|
Short: "Create a user template from a JSON file",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
raw, err := os.ReadFile(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot read %s: %w", args[0], err)
|
||||||
|
}
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/templates", raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesDeleteCmd = &cobra.Command{
|
||||||
|
Use: "delete <id>",
|
||||||
|
Short: "Delete a user template",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !Confirm(fmt.Sprintf("Delete template %s?", args[0])) {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if _, err := c.Do("DELETE", "/templates/"+args[0], nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||||
|
}
|
||||||
|
fmt.Printf("Deleted template %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesDeployCmd = &cobra.Command{
|
||||||
|
Use: "deploy <template-id> <project-id>",
|
||||||
|
Short: "Deploy a template into a project",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", map[string]string{"project_id": args[1]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd)
|
||||||
|
}
|
||||||
@@ -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,151 @@
|
|||||||
|
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(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(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
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchVars(c *Client, serviceID string) ([]map[string]interface{}, error) {
|
||||||
|
data, err := c.Do("GET", "/services/"+serviceID+"/variables", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return unwrapList(data, "variables")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mutateVars(serviceID string, redeploy bool, mutate func([]map[string]interface{}) []map[string]interface{}) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vars, err := fetchVars(c, serviceID)
|
||||||
|
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", "/services/"+serviceID+"/variables", 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", serviceID)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Variables updated on %s — redeploy to apply to running containers\n", serviceID)
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
VariablesCmd.AddCommand(varsListCmd, varsSetCmd, varsUnsetCmd)
|
||||||
|
}
|
||||||
@@ -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
|
var cfgFile string
|
||||||
|
|
||||||
// rootCmd represents the base command when called without any subcommands
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "containr",
|
Use: "containr",
|
||||||
Short: "Containr CLI - Manage your self-hosted PaaS",
|
Short: "Containr CLI - manage your self-hosted PaaS",
|
||||||
Long: `Containr CLI is a command-line interface for managing your Containr platform.
|
Long: `Containr CLI controls every part of a Containr instance: projects,
|
||||||
You can manage projects, services, deployments, databases, and more from your terminal.`,
|
services, deployments, databases, cron jobs, templates, nodes, scaling,
|
||||||
Version: "1.0.0",
|
HA, security, and the API gateway.
|
||||||
|
|
||||||
|
Authenticate once with a personal access token (Settings → Personal
|
||||||
|
Access Tokens in the web UI):
|
||||||
|
|
||||||
|
containr auth login cnp_...
|
||||||
|
|
||||||
|
Every command supports --json for machine-readable output, and
|
||||||
|
destructive actions accept --yes to skip confirmation.`,
|
||||||
|
Version: "2.0.0",
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
// Execute runs the root command and returns the process exit code.
|
||||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
func Execute() int {
|
||||||
func Execute() {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
cobra.CheckErr(rootCmd.Execute())
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
return commands.ExitCode(err)
|
||||||
|
}
|
||||||
|
return commands.ExitOK
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetVersion overrides the version reported by `containr --version`.
|
// SetVersion overrides the version reported by `containr --version`.
|
||||||
// Called by main with the value injected via -ldflags at build time.
|
|
||||||
func SetVersion(v string) {
|
func SetVersion(v string) {
|
||||||
rootCmd.Version = v
|
rootCmd.Version = v
|
||||||
}
|
}
|
||||||
@@ -36,46 +48,57 @@ func SetVersion(v string) {
|
|||||||
func init() {
|
func init() {
|
||||||
cobra.OnInitialize(initConfig)
|
cobra.OnInitialize(initConfig)
|
||||||
|
|
||||||
// Global flags
|
pf := rootCmd.PersistentFlags()
|
||||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.containr.yaml)")
|
pf.StringVar(&cfgFile, "config", "", "config file (default $HOME/.containr.yaml)")
|
||||||
rootCmd.PersistentFlags().String("api-url", "", "Containr API URL (default is https://api.containr.dev)")
|
pf.String("api-url", "", "Containr API URL (overrides profile and CONTAINR_API_URL)")
|
||||||
rootCmd.PersistentFlags().String("token", "", "Authentication token")
|
pf.String("token", "", "auth token (overrides profile and CONTAINR_TOKEN)")
|
||||||
|
pf.StringP("profile", "p", "", "config profile to use (default current_profile)")
|
||||||
|
pf.Bool("json", false, "machine-readable JSON output")
|
||||||
|
pf.BoolP("yes", "y", false, "skip confirmation prompts (required for destructive ops in scripts)")
|
||||||
|
pf.String("idempotency-key", "", "Idempotency-Key header for POST mutations (safe retries)")
|
||||||
|
|
||||||
// Bind flags to viper
|
viper.BindPFlag("api-url", pf.Lookup("api-url"))
|
||||||
viper.BindPFlag("api-url", rootCmd.PersistentFlags().Lookup("api-url"))
|
viper.BindPFlag("token", pf.Lookup("token"))
|
||||||
viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token"))
|
viper.BindPFlag("profile", pf.Lookup("profile"))
|
||||||
|
viper.BindPFlag("json", pf.Lookup("json"))
|
||||||
|
viper.BindPFlag("yes", pf.Lookup("yes"))
|
||||||
|
viper.BindPFlag("idempotency-key", pf.Lookup("idempotency-key"))
|
||||||
|
|
||||||
// Add command groups
|
|
||||||
rootCmd.AddCommand(commands.AuthCmd)
|
rootCmd.AddCommand(commands.AuthCmd)
|
||||||
|
rootCmd.AddCommand(commands.TokensCmd)
|
||||||
rootCmd.AddCommand(commands.ProjectsCmd)
|
rootCmd.AddCommand(commands.ProjectsCmd)
|
||||||
|
rootCmd.AddCommand(commands.ServicesCmd)
|
||||||
|
rootCmd.AddCommand(commands.DeployCmd)
|
||||||
|
rootCmd.AddCommand(commands.DeploymentsCmd)
|
||||||
|
rootCmd.AddCommand(commands.LogsCmd)
|
||||||
|
rootCmd.AddCommand(commands.ExecCmd)
|
||||||
|
rootCmd.AddCommand(commands.VariablesCmd)
|
||||||
|
rootCmd.AddCommand(commands.VolumesCmd)
|
||||||
|
rootCmd.AddCommand(commands.DatabasesCmd)
|
||||||
|
rootCmd.AddCommand(commands.CronCmd)
|
||||||
|
rootCmd.AddCommand(commands.TemplatesCmd)
|
||||||
|
rootCmd.AddCommand(commands.NodesCmd)
|
||||||
|
rootCmd.AddCommand(commands.ScalingCmd)
|
||||||
|
rootCmd.AddCommand(commands.HACmd)
|
||||||
|
rootCmd.AddCommand(commands.SecurityCmd)
|
||||||
|
rootCmd.AddCommand(commands.GatewayCmd)
|
||||||
|
rootCmd.AddCommand(commands.AdminCmd)
|
||||||
|
rootCmd.AddCommand(commands.NotificationsCmd)
|
||||||
|
rootCmd.AddCommand(commands.UpCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// initConfig reads in config file and ENV variables if set.
|
|
||||||
func initConfig() {
|
func initConfig() {
|
||||||
if cfgFile != "" {
|
if cfgFile != "" {
|
||||||
// Use config file from the flag.
|
|
||||||
viper.SetConfigFile(cfgFile)
|
viper.SetConfigFile(cfgFile)
|
||||||
} else {
|
} else {
|
||||||
// Find home directory.
|
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
cobra.CheckErr(err)
|
cobra.CheckErr(err)
|
||||||
|
|
||||||
// Search config in home directory with name ".containr" (without extension).
|
|
||||||
viper.AddConfigPath(home)
|
viper.AddConfigPath(home)
|
||||||
viper.SetConfigType("yaml")
|
viper.SetConfigType("yaml")
|
||||||
viper.SetConfigName(".containr")
|
viper.SetConfigName(".containr")
|
||||||
}
|
}
|
||||||
|
viper.AutomaticEnv()
|
||||||
viper.AutomaticEnv() // read in environment variables that match
|
// CONTAINR_TOKEN / CONTAINR_API_URL are read explicitly in config.go;
|
||||||
|
// AutomaticEnv covers them only when bound, so this is belt-and-braces.
|
||||||
// If a config file is found, read it in.
|
viper.ReadInConfig()
|
||||||
if err := viper.ReadInConfig(); err == nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run executes the CLI
|
|
||||||
func Run() error {
|
|
||||||
rootCmd.Execute()
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -195,6 +195,26 @@ type Service struct {
|
|||||||
BuildPath sql.NullString `json:"build_path"`
|
BuildPath sql.NullString `json:"build_path"`
|
||||||
Cpu sql.NullString `json:"cpu"`
|
Cpu sql.NullString `json:"cpu"`
|
||||||
Memory sql.NullString `json:"memory"`
|
Memory sql.NullString `json:"memory"`
|
||||||
|
Replicas int32 `json:"replicas"`
|
||||||
|
Port int32 `json:"port"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
HealthcheckPath string `json:"healthcheck_path"`
|
||||||
|
RestartPolicy string `json:"restart_policy"`
|
||||||
|
PublishedPort int32 `json:"published_port"`
|
||||||
|
Volumes json.RawMessage `json:"volumes"`
|
||||||
|
MaintenanceMode bool `json:"maintenance_mode"`
|
||||||
|
BasicAuthUsers string `json:"basic_auth_users"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceDomain struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ServiceID uuid.UUID `json:"service_id"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
CertType string `json:"cert_type"`
|
||||||
|
CertStatus string `json:"cert_status"`
|
||||||
|
LastCheckedAt sql.NullTime `json:"last_checked_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServiceTemplate struct {
|
type ServiceTemplate struct {
|
||||||
@@ -211,3 +231,16 @@ type ServiceTemplate struct {
|
|||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserToken struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
KeyPrefix string `json:"key_prefix"`
|
||||||
|
TokenHash string `json:"token_hash"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
ExpiresAt sql.NullTime `json:"expires_at"`
|
||||||
|
LastUsedAt sql.NullTime `json:"last_used_at"`
|
||||||
|
RevokedAt sql.NullTime `json:"revoked_at"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ type Querier interface {
|
|||||||
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||||
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
||||||
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
|
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
|
||||||
|
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
|
||||||
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
||||||
DeleteAgent(ctx context.Context, id string) error
|
DeleteAgent(ctx context.Context, id string) error
|
||||||
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
||||||
@@ -46,6 +47,7 @@ type Querier interface {
|
|||||||
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||||
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
||||||
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
|
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
|
||||||
|
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
|
||||||
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
|
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
|
||||||
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
|
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
|
||||||
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
||||||
@@ -66,9 +68,11 @@ type Querier interface {
|
|||||||
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
||||||
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
||||||
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
|
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
|
||||||
|
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
|
||||||
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
||||||
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
||||||
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
||||||
|
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
|
||||||
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
||||||
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
||||||
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
|
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
|
||||||
@@ -77,6 +81,7 @@ type Querier interface {
|
|||||||
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
||||||
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
|
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
|
||||||
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
|
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
|
||||||
|
TouchUserToken(ctx context.Context, id uuid.UUID) error
|
||||||
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
|
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
|
||||||
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
||||||
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: user_tokens.sql
|
||||||
|
|
||||||
|
package sqlcdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createUserToken = `-- name: CreateUserToken :one
|
||||||
|
INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateUserTokenParams struct {
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
KeyPrefix string `json:"key_prefix"`
|
||||||
|
TokenHash string `json:"token_hash"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
ExpiresAt sql.NullTime `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createUserToken,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Name,
|
||||||
|
arg.KeyPrefix,
|
||||||
|
arg.TokenHash,
|
||||||
|
arg.Scope,
|
||||||
|
arg.ExpiresAt,
|
||||||
|
)
|
||||||
|
var i UserToken
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.KeyPrefix,
|
||||||
|
&i.TokenHash,
|
||||||
|
&i.Scope,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.LastUsedAt,
|
||||||
|
&i.RevokedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserTokenByHash = `-- name: GetUserTokenByHash :one
|
||||||
|
SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens
|
||||||
|
WHERE token_hash = $1
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getUserTokenByHash, tokenHash)
|
||||||
|
var i UserToken
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.KeyPrefix,
|
||||||
|
&i.TokenHash,
|
||||||
|
&i.Scope,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.LastUsedAt,
|
||||||
|
&i.RevokedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listUserTokens = `-- name: ListUserTokens :many
|
||||||
|
SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens
|
||||||
|
WHERE user_id = $1 AND revoked_at IS NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listUserTokens, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []UserToken{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i UserToken
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.KeyPrefix,
|
||||||
|
&i.TokenHash,
|
||||||
|
&i.Scope,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.LastUsedAt,
|
||||||
|
&i.RevokedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokeUserToken = `-- name: RevokeUserToken :execrows
|
||||||
|
UPDATE user_tokens SET revoked_at = NOW()
|
||||||
|
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
type RevokeUserTokenParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, revokeUserToken, arg.ID, arg.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
const touchUserToken = `-- name: TouchUserToken :exec
|
||||||
|
UPDATE user_tokens SET last_used_at = NOW()
|
||||||
|
WHERE id = $1 AND revoked_at IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) TouchUserToken(ctx context.Context, id uuid.UUID) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, touchUserToken, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -57,6 +57,11 @@ type ServiceConfig struct {
|
|||||||
PublicPort int32 `json:"public_port,omitempty"`
|
PublicPort int32 `json:"public_port,omitempty"`
|
||||||
// Domain is a public hostname routed to PublicPort via Traefik.
|
// Domain is a public hostname routed to PublicPort via Traefik.
|
||||||
Domain string `json:"domain,omitempty"`
|
Domain string `json:"domain,omitempty"`
|
||||||
|
// Domains is the full hostname set (includes Domain when set).
|
||||||
|
Domains []string `json:"domains,omitempty"`
|
||||||
|
Maintenance bool `json:"maintenance,omitempty"`
|
||||||
|
MaintenanceURL string `json:"maintenance_url,omitempty"`
|
||||||
|
BasicAuthUsers string `json:"basic_auth_users,omitempty"`
|
||||||
// HealthPath is an HTTP path probed on PublicPort for container health.
|
// HealthPath is an HTTP path probed on PublicPort for container health.
|
||||||
HealthPath string `json:"health_path,omitempty"`
|
HealthPath string `json:"health_path,omitempty"`
|
||||||
// PublishedPort is the host port bound by the previous deployment; replica 0
|
// PublishedPort is the host port bound by the previous deployment; replica 0
|
||||||
@@ -150,6 +155,7 @@ type BuildConfig struct {
|
|||||||
Environment map[string]string `json:"environment"`
|
Environment map[string]string `json:"environment"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
Commit string `json:"commit"`
|
Commit string `json:"commit"`
|
||||||
|
NoCache bool `json:"no_cache"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TriggerConfig struct {
|
type TriggerConfig struct {
|
||||||
@@ -291,6 +297,7 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme
|
|||||||
TriggeredBy: "deployment_engine",
|
TriggeredBy: "deployment_engine",
|
||||||
Branch: buildConfig.Branch,
|
Branch: buildConfig.Branch,
|
||||||
Commit: buildConfig.Commit,
|
Commit: buildConfig.Commit,
|
||||||
|
NoCache: buildConfig.NoCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := de.buildManager.Build(ctx, buildReq)
|
response, err := de.buildManager.Build(ctx, buildReq)
|
||||||
@@ -324,6 +331,11 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
|||||||
RestartPolicy: cfg.RestartPolicy,
|
RestartPolicy: cfg.RestartPolicy,
|
||||||
MemoryBytes: cfg.Resources.MemoryBytes,
|
MemoryBytes: cfg.Resources.MemoryBytes,
|
||||||
NanoCPUs: cfg.Resources.CPUQuota,
|
NanoCPUs: cfg.Resources.CPUQuota,
|
||||||
|
Volumes: cfg.VolumeMounts,
|
||||||
|
Domains: cfg.Domains,
|
||||||
|
Maintenance: cfg.Maintenance,
|
||||||
|
MaintenanceURL: cfg.MaintenanceURL,
|
||||||
|
BasicAuthUsers: cfg.BasicAuthUsers,
|
||||||
}
|
}
|
||||||
for _, pm := range cfg.PortMappings {
|
for _, pm := range cfg.PortMappings {
|
||||||
if spec.Port == 0 {
|
if spec.Port == 0 {
|
||||||
@@ -334,8 +346,6 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
|||||||
spec.RestartPolicy = "unless-stopped"
|
spec.RestartPolicy = "unless-stopped"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Volume mounts aren't reconciled replicas-aware yet; still applied on the
|
|
||||||
// container config of every replica via spec extension later if needed.
|
|
||||||
state, err := de.ReconcileService(ctx, spec)
|
state, err := de.ReconcileService(ctx, spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
)
|
)
|
||||||
@@ -47,6 +48,11 @@ type RuntimeSpec struct {
|
|||||||
RestartPolicy string
|
RestartPolicy string
|
||||||
MemoryBytes int64
|
MemoryBytes int64
|
||||||
NanoCPUs int64
|
NanoCPUs int64
|
||||||
|
Volumes []VolumeMount // applied to every replica
|
||||||
|
Domains []string // all public hostnames; Domain is the default
|
||||||
|
Maintenance bool // redirect traffic to MaintenanceURL
|
||||||
|
MaintenanceURL string // absolute URL; empty = serve empty response
|
||||||
|
BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuntimeContainer describes one live replica.
|
// RuntimeContainer describes one live replica.
|
||||||
@@ -225,12 +231,45 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
|
|||||||
projectNet: {Aliases: []string{spec.Name}},
|
projectNet: {Aliases: []string{spec.Name}},
|
||||||
}
|
}
|
||||||
if edgeNet != "" {
|
if edgeNet != "" {
|
||||||
|
router := "svc-" + spec.ServiceID[:8]
|
||||||
|
domains := spec.Domains
|
||||||
|
if len(domains) == 0 && spec.Domain != "" {
|
||||||
|
domains = []string{spec.Domain}
|
||||||
|
}
|
||||||
|
sort.Strings(domains)
|
||||||
|
rules := make([]string, 0, len(domains))
|
||||||
|
for _, d := range domains {
|
||||||
|
rules = append(rules, "Host(`"+d+"`)")
|
||||||
|
}
|
||||||
endpoints[edgeNet] = &network.EndpointSettings{}
|
endpoints[edgeNet] = &network.EndpointSettings{}
|
||||||
labels["traefik.enable"] = "true"
|
labels["traefik.enable"] = "true"
|
||||||
labels["traefik.docker.network"] = edgeNet
|
labels["traefik.docker.network"] = edgeNet
|
||||||
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".rule"] = "Host(`" + spec.Domain + "`)"
|
labels["traefik.http.routers."+router+".rule"] = strings.Join(rules, " || ")
|
||||||
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".entrypoints"] = "web"
|
labels["traefik.http.routers."+router+".entrypoints"] = "web"
|
||||||
labels["traefik.http.services.svc-"+spec.ServiceID[:8]+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
|
labels["traefik.http.services."+router+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
|
||||||
|
|
||||||
|
var middlewares []string
|
||||||
|
if spec.Maintenance {
|
||||||
|
if spec.MaintenanceURL != "" {
|
||||||
|
mw := router + "-maint"
|
||||||
|
labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
|
||||||
|
labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
|
||||||
|
labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
|
||||||
|
middlewares = append(middlewares, mw)
|
||||||
|
} else {
|
||||||
|
// No redirect target configured — take the site offline with
|
||||||
|
// empty responses instead of leaking traffic to the app.
|
||||||
|
labels["traefik.http.routers."+router+".service"] = "noop@internal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if spec.BasicAuthUsers != "" {
|
||||||
|
mw := router + "-auth"
|
||||||
|
labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
|
||||||
|
middlewares = append(middlewares, mw)
|
||||||
|
}
|
||||||
|
if len(middlewares) > 0 {
|
||||||
|
labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := docker.ContainerConfig{
|
cfg := docker.ContainerConfig{
|
||||||
@@ -245,6 +284,19 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
|
|||||||
Networks: endpoints,
|
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
|
var wantPort nat.Port
|
||||||
if spec.Port > 0 {
|
if spec.Port > 0 {
|
||||||
wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
|
wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
|
||||||
@@ -474,8 +526,13 @@ func specHash(spec RuntimeSpec) string {
|
|||||||
Env map[string]string
|
Env map[string]string
|
||||||
Port int32
|
Port int32
|
||||||
MemoryBytes, NanoCPUs int64
|
MemoryBytes, NanoCPUs int64
|
||||||
|
Volumes []VolumeMount
|
||||||
|
Domains []string
|
||||||
|
Maintenance bool
|
||||||
|
BasicAuth string
|
||||||
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
|
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
|
||||||
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs})
|
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs, spec.Volumes,
|
||||||
|
spec.Domains, spec.Maintenance, spec.BasicAuthUsers})
|
||||||
sum := sha256.Sum256(payload)
|
sum := sha256.Sum256(payload)
|
||||||
return hex.EncodeToString(sum[:8])
|
return hex.EncodeToString(sum[:8])
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -297,6 +297,7 @@ func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options
|
|||||||
BuildArgs: options.BuildArgs,
|
BuildArgs: options.BuildArgs,
|
||||||
Labels: options.Labels,
|
Labels: options.Labels,
|
||||||
Remove: options.Remove,
|
Remove: options.Remove,
|
||||||
|
NoCache: options.NoCache,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type BuildOptions struct {
|
|||||||
BuildArgs map[string]*string
|
BuildArgs map[string]*string
|
||||||
Labels map[string]string
|
Labels map[string]string
|
||||||
Remove bool
|
Remove bool
|
||||||
|
NoCache bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkConfig represents the configuration for creating a network
|
// NetworkConfig represents the configuration for creating a network
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
// 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}].", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||||
|
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
|
||||||
|
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_services_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_services_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.", "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})
|
||||||
|
|
||||||
|
// 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})
|
||||||
|
|
||||||
|
// Deployments
|
||||||
|
register(spec{"containr_deploy", "Trigger a deployment for a service. Args: id (service), commit_hash, branch, no_cache.", "POST", "/services/{id}/deployments", []string{"id"}, []string{"commit_hash", "branch", "trigger", "no_cache"}, false})
|
||||||
|
register(spec{"containr_deployments_list", "List recent deployments across services", "GET", "/deployments", nil, nil, false})
|
||||||
|
register(spec{"containr_services_deployments", "List deployments for one service", "GET", "/services/{id}/deployments", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_deployments_get", "Get a deployment", "GET", "/deployments/{id}", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_deployments_logs", "Get persisted build/runtime logs for a deployment. Args: id, type (all|build|runtime).", "GET", "/deployments/{id}/logs", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_deployments_rollback", "Roll back to a deployment. Requires confirm:true.", "POST", "/deployments/{id}/rollback", []string{"id"}, nil, true})
|
||||||
|
register(spec{"containr_deployments_cancel", "Cancel a queued or running deployment", "POST", "/deployments/{id}/cancel", []string{"id"}, nil, false})
|
||||||
|
|
||||||
|
// Databases
|
||||||
|
register(spec{"containr_databases_list", "List managed databases", "GET", "/databases", nil, nil, false})
|
||||||
|
register(spec{"containr_databases_get", "Get a database (connection URL included)", "GET", "/databases/{id}", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_databases_create", "Provision a managed database. Args: name, type (postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse).", "POST", "/databases", nil, nil, false})
|
||||||
|
register(spec{"containr_databases_action", "Start/stop/restart a database. Args: id, action.", "POST", "/databases/{id}/action", []string{"id"}, []string{"action"}, false})
|
||||||
|
register(spec{"containr_databases_backup", "Take a manual backup snapshot", "POST", "/databases/{id}/backup", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_databases_restore", "Restore a database from a backup — overwrites current data. Requires confirm:true.", "POST", "/databases/{id}/restore", []string{"id"}, []string{"backup_id"}, true})
|
||||||
|
register(spec{"containr_databases_delete", "Delete a database and its data. Requires confirm:true.", "DELETE", "/databases/{id}", []string{"id"}, nil, true})
|
||||||
|
|
||||||
|
// Cron
|
||||||
|
register(spec{"containr_cron_list", "List cron jobs", "GET", "/cron-jobs", nil, nil, false})
|
||||||
|
register(spec{"containr_cron_create", "Create a cron job. Args: service_id, schedule (5-field), command, name, timezone.", "POST", "/cron-jobs", nil, nil, false})
|
||||||
|
register(spec{"containr_cron_trigger", "Run a cron job now", "POST", "/cron-jobs/{id}/trigger", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_cron_executions", "List a cron job's execution history", "GET", "/cron-jobs/{id}/executions", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_cron_delete", "Delete a cron job. Requires confirm:true.", "DELETE", "/cron-jobs/{id}", []string{"id"}, nil, true})
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
register(spec{"containr_templates_list", "List templates", "GET", "/templates", nil, nil, false})
|
||||||
|
register(spec{"containr_templates_get", "Get a template definition", "GET", "/templates/{id}", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
|
||||||
|
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
|
||||||
|
|
||||||
|
// Nodes / agent tokens (admin)
|
||||||
|
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false})
|
||||||
|
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
|
||||||
|
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
|
||||||
|
|
||||||
|
// Scaling / HA / security
|
||||||
|
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
|
||||||
|
register(spec{"containr_scaling_policies", "List scaling policies", "GET", "/scaling/policies", nil, nil, false})
|
||||||
|
register(spec{"containr_scaling_scale", "Manually scale a service. Args: service_id, replicas. (admin)", "POST", "/scaling/services/{service_id}/scale", []string{"service_id"}, []string{"replicas"}, false})
|
||||||
|
register(spec{"containr_ha_status", "HA manager status", "GET", "/ha/status", nil, nil, false})
|
||||||
|
register(spec{"containr_ha_alerts", "Active HA alerts", "GET", "/ha/alerts/active", nil, nil, false})
|
||||||
|
register(spec{"containr_ha_failover", "Trigger a manual failover — reschedules services. Requires confirm:true. (admin)", "POST", "/ha/failover", nil, nil, true})
|
||||||
|
register(spec{"containr_security_scan", "Run a security scan. Args: project_id (required), scan_type, service_id.", "POST", "/security/scans", nil, nil, false})
|
||||||
|
register(spec{"containr_security_vulnerabilities", "List a project's vulnerabilities", "GET", "/projects/{project_id}/vulnerabilities", []string{"project_id"}, nil, false})
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
register(spec{"containr_admin_overview", "Platform overview (admin)", "GET", "/admin/overview", nil, nil, false})
|
||||||
|
register(spec{"containr_admin_users", "List all users (admin)", "GET", "/admin/users", nil, nil, false})
|
||||||
|
register(spec{"containr_admin_audit_logs", "List audit log entries (admin)", "GET", "/audit-logs", nil, nil, false})
|
||||||
|
register(spec{"containr_gateway_services", "List gateway services (admin)", "GET", "/gateway/services", nil, nil, false})
|
||||||
|
register(spec{"containr_gateway_keys", "List gateway API keys (admin)", "GET", "/gateway/keys", nil, nil, false})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the stdio MCP server. apiURL/token come from env or config —
|
||||||
|
// the caller resolves them before calling.
|
||||||
|
func Run(ctx context.Context, apiURL, token, version string) error {
|
||||||
|
if token == "" {
|
||||||
|
return fmt.Errorf("CONTAINR_TOKEN is required (create a cnp_ token under Settings → Personal Access Tokens)")
|
||||||
|
}
|
||||||
|
client := &commands.Client{BaseURL: apiURL, Token: token}
|
||||||
|
|
||||||
|
server := mcp.NewServer(&mcp.Implementation{
|
||||||
|
Name: "containr",
|
||||||
|
Version: version,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
for _, s := range tools {
|
||||||
|
s := s
|
||||||
|
server.AddTool(&mcp.Tool{
|
||||||
|
Name: s.name,
|
||||||
|
Description: s.description,
|
||||||
|
InputSchema: schemaFor(s),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return call(ctx, client, s, req)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.Run(ctx, &mcp.StdioTransport{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemaFor builds a JSON Schema for the tool's arguments: path params +
|
||||||
|
// body keys + confirm for destructive tools. Properties accept any JSON
|
||||||
|
// type — the API validates values.
|
||||||
|
func schemaFor(s spec) map[string]interface{} {
|
||||||
|
props := map[string]interface{}{}
|
||||||
|
var required []string
|
||||||
|
for _, p := range s.params {
|
||||||
|
props[p] = map[string]interface{}{"type": "string", "description": "path parameter"}
|
||||||
|
required = append(required, p)
|
||||||
|
}
|
||||||
|
bodySrc := s.bodyKeys
|
||||||
|
if len(bodySrc) == 0 && s.method != "GET" && s.method != "DELETE" {
|
||||||
|
// Unrestricted body: advertise nothing, accept anything.
|
||||||
|
}
|
||||||
|
for _, k := range bodySrc {
|
||||||
|
props[k] = map[string]interface{}{"description": "request body field"}
|
||||||
|
}
|
||||||
|
if s.destructive {
|
||||||
|
props["confirm"] = map[string]interface{}{"type": "boolean", "description": "must be true — this operation is destructive"}
|
||||||
|
required = append(required, "confirm")
|
||||||
|
}
|
||||||
|
schema := map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": props,
|
||||||
|
"additionalProperties": true,
|
||||||
|
}
|
||||||
|
if len(required) > 0 {
|
||||||
|
schema["required"] = required
|
||||||
|
}
|
||||||
|
return schema
|
||||||
|
}
|
||||||
|
|
||||||
|
func call(ctx context.Context, c *commands.Client, s spec, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
var args map[string]interface{}
|
||||||
|
if len(req.Params.Arguments) > 0 {
|
||||||
|
if err := json.Unmarshal(req.Params.Arguments, &args); err != nil {
|
||||||
|
return errorResult("invalid arguments: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if args == nil {
|
||||||
|
args = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.destructive && args["confirm"] != true {
|
||||||
|
return errorResult("destructive operation — pass confirm:true to proceed"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
path := s.path
|
||||||
|
for _, p := range s.params {
|
||||||
|
v, ok := args[p]
|
||||||
|
if !ok || fmt.Sprint(v) == "" {
|
||||||
|
return errorResult(fmt.Sprintf("missing required argument %q", p)), nil
|
||||||
|
}
|
||||||
|
path = strings.ReplaceAll(path, "{"+p+"}", fmt.Sprint(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body interface{}
|
||||||
|
var query []string
|
||||||
|
for k, v := range args {
|
||||||
|
if k == "confirm" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inParams := false
|
||||||
|
for _, p := range s.params {
|
||||||
|
if k == p {
|
||||||
|
inParams = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inParams {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.method == "GET" || s.method == "DELETE" {
|
||||||
|
// Non-path args on reads become query parameters.
|
||||||
|
query = append(query, url.QueryEscape(k)+"="+url.QueryEscape(fmt.Sprint(v)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(s.bodyKeys) > 0 {
|
||||||
|
allowed := false
|
||||||
|
for _, b := range s.bodyKeys {
|
||||||
|
if k == b {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body == nil {
|
||||||
|
body = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
body.(map[string]interface{})[k] = v
|
||||||
|
}
|
||||||
|
if len(query) > 0 {
|
||||||
|
sep := "?"
|
||||||
|
if strings.Contains(path, "?") {
|
||||||
|
sep = "&"
|
||||||
|
}
|
||||||
|
path += sep + strings.Join(query, "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := c.Do(s.method, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func textResult(data []byte) *mcp.CallToolResult {
|
||||||
|
var v interface{}
|
||||||
|
if json.Unmarshal(data, &v) != nil {
|
||||||
|
v = string(data)
|
||||||
|
}
|
||||||
|
out, _ := json.MarshalIndent(v, "", " ")
|
||||||
|
return &mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{&mcp.TextContent{Text: string(out)}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorResult(msg string) *mcp.CallToolResult {
|
||||||
|
return &mcp.CallToolResult{
|
||||||
|
IsError: true,
|
||||||
|
Content: []mcp.Content{&mcp.TextContent{Text: msg}},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Idempotency replays POST responses for a repeated Idempotency-Key header
|
||||||
|
// so agents can safely retry mutations. The first request wins; concurrent
|
||||||
|
// duplicates get 409 IDEMPOTENCY_IN_PROGRESS; completed responses replay
|
||||||
|
// verbatim for 24h. Keys are scoped to the authenticated identity.
|
||||||
|
func Idempotency() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if c.Request.Method != http.MethodPost {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := c.GetHeader("Idempotency-Key")
|
||||||
|
if key == "" || len(key) > 128 {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbValue, exists := c.Get("redis")
|
||||||
|
if !exists {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rdb, ok := dbValue.(*database.Redis)
|
||||||
|
if !ok || rdb == nil || rdb.Client == nil {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope by caller identity so a stolen key can't replay across users.
|
||||||
|
actor := c.GetString("user_id")
|
||||||
|
if actor == "" {
|
||||||
|
actor = c.GetHeader("Authorization")
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(actor + ":" + c.Request.URL.Path + ":" + key))
|
||||||
|
redisKey := "idem:" + hex.EncodeToString(sum[:16])
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
stored, err := rdb.Client.Get(ctx, redisKey).Result()
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
if stored == "processing" {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{
|
||||||
|
"error": "a request with this idempotency key is still in progress",
|
||||||
|
"code": "IDEMPOTENCY_IN_PROGRESS",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rec idemRecord
|
||||||
|
if json.Unmarshal([]byte(stored), &rec) == nil {
|
||||||
|
c.Header("X-Idempotent-Replay", "true")
|
||||||
|
c.Data(rec.Status, rec.ContentType, rec.Body)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case err != redis.Nil:
|
||||||
|
// Redis hiccup — degrade to pass-through rather than failing the request.
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim the key for the duration of the request.
|
||||||
|
if set, serr := rdb.Client.SetNX(ctx, redisKey, "processing", 2*time.Minute).Result(); serr != nil || !set {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{
|
||||||
|
"error": "a request with this idempotency key is still in progress",
|
||||||
|
"code": "IDEMPOTENCY_IN_PROGRESS",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := &responseRecorder{ResponseWriter: c.Writer, status: http.StatusOK, body: &bytes.Buffer{}}
|
||||||
|
c.Writer = recorder
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
// Cache terminal responses only — 5xx is retryable and must not be replayed.
|
||||||
|
if recorder.status < 500 && recorder.body.Len() <= 1<<20 {
|
||||||
|
rec := idemRecord{
|
||||||
|
Status: recorder.status,
|
||||||
|
ContentType: recorder.Header().Get("Content-Type"),
|
||||||
|
Body: recorder.body.Bytes(),
|
||||||
|
}
|
||||||
|
if rec.ContentType == "" {
|
||||||
|
rec.ContentType = "application/json; charset=utf-8"
|
||||||
|
}
|
||||||
|
if raw, merr := json.Marshal(rec); merr == nil {
|
||||||
|
_ = rdb.Client.Set(context.Background(), redisKey, raw, 24*time.Hour).Err()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_ = rdb.Client.Del(context.Background(), redisKey).Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type idemRecord struct {
|
||||||
|
Status int `json:"s"`
|
||||||
|
ContentType string `json:"ct"`
|
||||||
|
Body []byte `json:"b"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type responseRecorder struct {
|
||||||
|
gin.ResponseWriter
|
||||||
|
status int
|
||||||
|
body *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *responseRecorder) WriteHeader(code int) {
|
||||||
|
r.status = code
|
||||||
|
r.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *responseRecorder) Write(b []byte) (int, error) {
|
||||||
|
r.body.Write(b)
|
||||||
|
return r.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIdempotencyPassThroughWithoutKey(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
called := 0
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(Idempotency())
|
||||||
|
router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/x", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if called != 1 || rec.Code != 200 {
|
||||||
|
t.Fatalf("expected pass-through, called=%d code=%d", called, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdempotencyPassThroughWithoutRedis(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
called := 0
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(Idempotency())
|
||||||
|
router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) })
|
||||||
|
|
||||||
|
// Key present but no redis in context — must not block the request.
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/x", nil)
|
||||||
|
req.Header.Set("Idempotency-Key", "abc-123")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if called != 1 || rec.Code != 200 {
|
||||||
|
t.Fatalf("expected pass-through without redis, called=%d code=%d", called, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdempotencySkipsNonPOST(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
called := 0
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(Idempotency())
|
||||||
|
router.GET("/x", func(c *gin.Context) { called++; c.String(200, "ok") })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||||
|
req.Header.Set("Idempotency-Key", "abc")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if called != 1 {
|
||||||
|
t.Fatalf("GET should bypass idempotency, called=%d", called)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
|
sqlcdb "containr/internal/database/sqlcdb"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -75,6 +78,14 @@ func RequestID() gin.HandlerFunc {
|
|||||||
func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) {
|
func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) {
|
||||||
tokenString, tokenErr, hasToken := extractJWTToken(c)
|
tokenString, tokenErr, hasToken := extractJWTToken(c)
|
||||||
if tokenString != "" {
|
if tokenString != "" {
|
||||||
|
// Personal access tokens (cnp_…) are not JWTs — they resolve
|
||||||
|
// against the user_tokens table by sha256 hash.
|
||||||
|
if strings.HasPrefix(tokenString, patPrefix) {
|
||||||
|
if id, mail, verified := resolvePersonalAccessToken(c, tokenString); verified {
|
||||||
|
return id, mail, "", hasToken, true
|
||||||
|
}
|
||||||
|
return "", "", "Invalid token", hasToken, false
|
||||||
|
}
|
||||||
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
|
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
|
||||||
userIDClaim, exists := claims["user_id"]
|
userIDClaim, exists := claims["user_id"]
|
||||||
if exists {
|
if exists {
|
||||||
@@ -101,12 +112,67 @@ func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *bette
|
|||||||
return "", "", tokenErr, hasToken, false
|
return "", "", tokenErr, hasToken, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const patPrefix = "cnp_"
|
||||||
|
|
||||||
|
// resolvePersonalAccessToken authenticates a `cnp_` bearer token against the
|
||||||
|
// user_tokens table (sha256 hash match, not revoked, not expired). On success
|
||||||
|
// it stamps last_used_at, records the token scope on the context, and
|
||||||
|
// returns the owner's id and email.
|
||||||
|
func resolvePersonalAccessToken(c *gin.Context, token string) (string, string, bool) {
|
||||||
|
dbValue, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
db, ok := dbValue.(*database.DB)
|
||||||
|
if !ok || db == nil || db.DB == nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
queries := sqlcdb.New(db.DB)
|
||||||
|
row, err := queries.GetUserTokenByHash(c.Request.Context(), hex.EncodeToString(sum[:]))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
var email string
|
||||||
|
if err := db.QueryRowContext(c.Request.Context(),
|
||||||
|
`SELECT email FROM users WHERE id = $1`, row.UserID).Scan(&email); err != nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = queries.TouchUserToken(c.Request.Context(), row.ID)
|
||||||
|
c.Set("pat_scope", row.Scope)
|
||||||
|
return row.UserID.String(), email, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// patScopeAllows reports whether a PAT-authenticated request may proceed.
|
||||||
|
// read scope permits only safe methods; write and admin are unrestricted at
|
||||||
|
// this layer (admin routes still require the admin scope AND a platform
|
||||||
|
// admin account — enforced via is_admin in setAuthenticatedContext).
|
||||||
|
func patScopeAllows(c *gin.Context) bool {
|
||||||
|
if c.GetString("pat_scope") != "read" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch c.Request.Method {
|
||||||
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// setAuthenticatedContext stores the resolved identity plus the admin flag on
|
// setAuthenticatedContext stores the resolved identity plus the admin flag on
|
||||||
// the gin context so handlers and RequireAdmin can rely on it.
|
// the gin context so handlers and RequireAdmin can rely on it.
|
||||||
func setAuthenticatedContext(c *gin.Context, userID, email string) {
|
func setAuthenticatedContext(c *gin.Context, userID, email string) {
|
||||||
c.Set("user_id", userID)
|
c.Set("user_id", userID)
|
||||||
c.Set("email", email)
|
c.Set("email", email)
|
||||||
c.Set("is_admin", loadIsAdmin(c, userID))
|
isAdmin := loadIsAdmin(c, userID)
|
||||||
|
// A PAT can never outrank its scope: non-admin scopes cap the token at
|
||||||
|
// non-admin even when the owning user is a platform admin.
|
||||||
|
if scope := c.GetString("pat_scope"); scope != "" && scope != "admin" {
|
||||||
|
isAdmin = false
|
||||||
|
}
|
||||||
|
c.Set("is_admin", isAdmin)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadIsAdmin(c *gin.Context, userID string) bool {
|
func loadIsAdmin(c *gin.Context, userID string) bool {
|
||||||
@@ -133,20 +199,28 @@ func Auth(jwtSecret string) gin.HandlerFunc {
|
|||||||
userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
|
userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
|
||||||
if ok {
|
if ok {
|
||||||
setAuthenticatedContext(c, userID, email)
|
setAuthenticatedContext(c, userID, email)
|
||||||
|
if !patScopeAllows(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"error": "Token scope is read-only",
|
||||||
|
"code": "SCOPE_INSUFFICIENT",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokenErr != "" {
|
if tokenErr != "" {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr, "code": "INVALID_TOKEN"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasToken {
|
if hasToken {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token", "code": "INVALID_TOKEN"})
|
||||||
} else {
|
} else {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"})
|
||||||
}
|
}
|
||||||
c.Abort()
|
c.Abort()
|
||||||
}
|
}
|
||||||
@@ -171,12 +245,12 @@ func OptionalAuth(jwtSecret string) gin.HandlerFunc {
|
|||||||
func RequireAdmin() gin.HandlerFunc {
|
func RequireAdmin() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if _, exists := c.Get("user_id"); !exists {
|
if _, exists := c.Get("user_id"); !exists {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !c.GetBool("is_admin") {
|
if !c.GetBool("is_admin") {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required", "code": "ADMIN_REQUIRED"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,61 @@ func TestAuthStoresStringUserIDForValidClaims(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthRejectsPATWithoutDatabase(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(Auth("secret"))
|
||||||
|
router.GET("/test", func(c *gin.Context) {
|
||||||
|
c.String(http.StatusOK, "ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
// A cnp_-prefixed token takes the PAT path, not JWT parsing — without a
|
||||||
|
// db handle it must fail closed with 401.
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer cnp_"+strings.Repeat("ab", 24))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPATScopeAllows(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
scope string
|
||||||
|
method string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"read allows GET", "read", http.MethodGet, true},
|
||||||
|
{"read allows HEAD", "read", http.MethodHead, true},
|
||||||
|
{"read allows OPTIONS", "read", http.MethodOptions, true},
|
||||||
|
{"read blocks POST", "read", http.MethodPost, false},
|
||||||
|
{"read blocks DELETE", "read", http.MethodDelete, false},
|
||||||
|
{"write allows POST", "write", http.MethodPost, true},
|
||||||
|
{"write allows DELETE", "write", http.MethodDelete, true},
|
||||||
|
{"admin allows PATCH", "admin", http.MethodPatch, true},
|
||||||
|
{"no scope (session/JWT) unrestricted", "", http.MethodPost, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(tc.method, "/x", nil)
|
||||||
|
if tc.scope != "" {
|
||||||
|
c.Set("pat_scope", tc.scope)
|
||||||
|
}
|
||||||
|
if got := patScopeAllows(c); got != tc.want {
|
||||||
|
t.Fatalf("patScopeAllows(scope=%q, method=%s) = %v, want %v", tc.scope, tc.method, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string {
|
func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
|||||||
@@ -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,7 @@ type BuildRequest struct {
|
|||||||
TriggeredBy string `json:"triggered_by"` // Who triggered the build
|
TriggeredBy string `json:"triggered_by"` // Who triggered the build
|
||||||
Branch string `json:"branch"` // Git branch
|
Branch string `json:"branch"` // Git branch
|
||||||
Commit string `json:"commit"` // Git commit SHA
|
Commit string `json:"commit"` // Git commit SHA
|
||||||
|
NoCache bool `json:"no_cache"` // Disable Docker layer cache
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildResponse represents the response from a build operation
|
// BuildResponse represents the response from a build operation
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Personal access tokens for CLI/MCP/agent authentication.
|
||||||
|
-- Only sha256 hashes are stored — raw tokens (cnp_…) are shown once at
|
||||||
|
-- creation and never persisted. Scope gates what the token may do:
|
||||||
|
-- read = GET/HEAD only, write = all non-admin routes, admin = everything
|
||||||
|
-- (admin still requires the owning user to be a platform admin).
|
||||||
|
CREATE TABLE IF NOT EXISTS public.user_tokens (
|
||||||
|
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
|
||||||
|
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
name character varying(255) NOT NULL,
|
||||||
|
key_prefix character varying(16) NOT NULL,
|
||||||
|
token_hash text NOT NULL UNIQUE,
|
||||||
|
scope character varying(20) NOT NULL DEFAULT 'write'
|
||||||
|
CHECK (scope IN ('read', 'write', 'admin')),
|
||||||
|
expires_at timestamp with time zone,
|
||||||
|
last_used_at timestamp with time zone,
|
||||||
|
revoked_at timestamp with time zone,
|
||||||
|
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_tokens_user_id ON public.user_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_tokens_token_hash ON public.user_tokens(token_hash);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS public.user_tokens;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Volume mounts on services: [{type: "volume"|"bind", source, target,
|
||||||
|
-- read_only}]. Applied to every replica's container config at deploy and
|
||||||
|
-- reconcile time. The baseline schema declared the column nullable with
|
||||||
|
-- no default — this normalizes it.
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS volumes JSONB DEFAULT '[]'::jsonb;
|
||||||
|
UPDATE services SET volumes = '[]'::jsonb WHERE volumes IS NULL;
|
||||||
|
ALTER TABLE services ALTER COLUMN volumes SET DEFAULT '[]'::jsonb;
|
||||||
|
ALTER TABLE services ALTER COLUMN volumes SET NOT NULL;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE services ALTER COLUMN volumes DROP NOT NULL;
|
||||||
|
ALTER TABLE services ALTER COLUMN volumes DROP DEFAULT;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Multi-domain support. services.domain stays as the derived default for
|
||||||
|
-- compatibility; service_domains is the source of truth for the full
|
||||||
|
-- hostname set. Backfills existing services.domain rows as defaults.
|
||||||
|
CREATE TABLE IF NOT EXISTS service_domains (
|
||||||
|
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
|
||||||
|
service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||||
|
domain varchar(255) NOT NULL,
|
||||||
|
is_default boolean NOT NULL DEFAULT false,
|
||||||
|
cert_type varchar(32) NOT NULL DEFAULT 'letsencrypt',
|
||||||
|
cert_status varchar(32) NOT NULL DEFAULT 'pending',
|
||||||
|
last_checked_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (service_id, domain)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_domains_service ON service_domains(service_id);
|
||||||
|
|
||||||
|
INSERT INTO service_domains (service_id, domain, is_default)
|
||||||
|
SELECT id, domain, true FROM services WHERE domain <> '' AND domain IS NOT NULL
|
||||||
|
ON CONFLICT (service_id, domain) DO NOTHING;
|
||||||
|
|
||||||
|
-- Access control: maintenance mode redirects to a platform page; basic auth
|
||||||
|
-- gates the route behind htpasswd-format user:hash pairs (Traefik middleware).
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS maintenance_mode boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS basic_auth_users text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS maintenance_mode;
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS basic_auth_users;
|
||||||
|
DROP TABLE IF EXISTS service_domains;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- name: CreateUserToken :one
|
||||||
|
INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListUserTokens :many
|
||||||
|
SELECT * FROM user_tokens
|
||||||
|
WHERE user_id = $1 AND revoked_at IS NULL
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: GetUserTokenByHash :one
|
||||||
|
SELECT * FROM user_tokens
|
||||||
|
WHERE token_hash = $1
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: TouchUserToken :exec
|
||||||
|
UPDATE user_tokens SET last_used_at = NOW()
|
||||||
|
WHERE id = $1 AND revoked_at IS NULL;
|
||||||
|
|
||||||
|
-- name: RevokeUserToken :execrows
|
||||||
|
UPDATE user_tokens SET revoked_at = NOW()
|
||||||
|
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL;
|
||||||
@@ -58,7 +58,22 @@ CREATE TABLE services (
|
|||||||
domain VARCHAR(255) NOT NULL DEFAULT '',
|
domain VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
healthcheck_path VARCHAR(255) NOT NULL DEFAULT '',
|
healthcheck_path VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
restart_policy VARCHAR(50) NOT NULL DEFAULT 'unless-stopped',
|
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 ''
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
CREATE TABLE deployments (
|
||||||
@@ -204,3 +219,16 @@ CREATE TABLE notifications (
|
|||||||
read_at TIMESTAMP WITH TIME ZONE,
|
read_at TIMESTAMP WITH TIME ZONE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
key_prefix VARCHAR(16) NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
scope VARCHAR(20) NOT NULL DEFAULT 'write',
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
last_used_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -17,6 +17,9 @@ import {
|
|||||||
createAgentToken,
|
createAgentToken,
|
||||||
listAgentTokens,
|
listAgentTokens,
|
||||||
revokeAgentToken,
|
revokeAgentToken,
|
||||||
|
createUserToken,
|
||||||
|
listUserTokens,
|
||||||
|
revokeUserToken,
|
||||||
listDatabases,
|
listDatabases,
|
||||||
databaseAction,
|
databaseAction,
|
||||||
updateDatabaseBackupSchedule,
|
updateDatabaseBackupSchedule,
|
||||||
@@ -46,6 +49,8 @@ import {
|
|||||||
updateVulnerability,
|
updateVulnerability,
|
||||||
getSecurityMetrics,
|
getSecurityMetrics,
|
||||||
type AgentAuthTokenCreated,
|
type AgentAuthTokenCreated,
|
||||||
|
type UserTokenCreated,
|
||||||
|
type UserTokenScope,
|
||||||
type CreateDatabaseInput,
|
type CreateDatabaseInput,
|
||||||
type DatabaseEntity,
|
type DatabaseEntity,
|
||||||
type FailoverPolicy,
|
type FailoverPolicy,
|
||||||
@@ -1267,6 +1272,164 @@ function PlatformSettingsSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAT_EXPIRY_OPTIONS: { label: string; days: number }[] = [
|
||||||
|
{ label: 'Never', days: 0 },
|
||||||
|
{ label: '30 days', days: 30 },
|
||||||
|
{ label: '90 days', days: 90 },
|
||||||
|
{ label: '1 year', days: 365 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const tokensQuery = useQuery({
|
||||||
|
queryKey: ['user-tokens'],
|
||||||
|
queryFn: listUserTokens,
|
||||||
|
});
|
||||||
|
const [tokenName, setTokenName] = useState('');
|
||||||
|
const [tokenScope, setTokenScope] = useState<UserTokenScope>('write');
|
||||||
|
const [expiryDays, setExpiryDays] = useState(0);
|
||||||
|
const [issuedToken, setIssuedToken] = useState<UserTokenCreated | null>(null);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createUserToken({
|
||||||
|
name: tokenName.trim(),
|
||||||
|
scope: tokenScope,
|
||||||
|
expiresInDays: expiryDays,
|
||||||
|
}),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setIssuedToken(data);
|
||||||
|
setTokenName('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['user-tokens'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => revokeUserToken(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['user-tokens'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens = tokensQuery.data ?? [];
|
||||||
|
const scopeOptions: { value: UserTokenScope; label: string }[] = isAdmin
|
||||||
|
? [
|
||||||
|
{ value: 'read', label: 'Read - GET requests only' },
|
||||||
|
{ value: 'write', label: 'Write - read and manage resources' },
|
||||||
|
{ value: 'admin', label: 'Admin - everything, incl. platform settings' },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ value: 'read', label: 'Read - GET requests only' },
|
||||||
|
{ value: 'write', label: 'Write - read and manage resources' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||||
|
<Key size={18} className="text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Personal Access Tokens</h2>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input
|
||||||
|
value={tokenName}
|
||||||
|
onChange={(e) => setTokenName(e.target.value)}
|
||||||
|
placeholder="Token name (e.g. laptop cli)"
|
||||||
|
className="h-9 flex-1 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={tokenScope}
|
||||||
|
onChange={(e) => setTokenScope(e.target.value as UserTokenScope)}
|
||||||
|
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
>
|
||||||
|
{scopeOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={expiryDays}
|
||||||
|
onChange={(e) => setExpiryDays(Number(e.target.value))}
|
||||||
|
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
>
|
||||||
|
{PAT_EXPIRY_OPTIONS.map((option) => (
|
||||||
|
<option key={option.days} value={option.days}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
disabled={createMutation.isPending || !tokenName.trim()}
|
||||||
|
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Creating…' : 'Create token'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issuedToken ? (
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
|
||||||
|
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
|
||||||
|
>
|
||||||
|
<Copy size={11} />
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-tertiary)]">
|
||||||
|
Shown once - store it now. Use with <span className="mono">containr auth login --token</span> or as a Bearer token.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{createMutation.isError ? (
|
||||||
|
<p className="text-xs text-[var(--error)]">
|
||||||
|
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create token'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tokensQuery.isLoading ? (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">No tokens yet. Create one to connect the CLI or an agent.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
|
||||||
|
{tokens.map((token) => (
|
||||||
|
<div key={token.id} className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-[var(--text-primary)]">{token.name}</span>
|
||||||
|
<span className="ml-2 mono text-[var(--text-muted)]">{token.key_prefix}…</span>
|
||||||
|
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">{token.scope}</span>
|
||||||
|
<span className="ml-2 text-[var(--text-muted)]">
|
||||||
|
{token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'}
|
||||||
|
{token.expires_at ? ` · expires ${formatRelative(token.expires_at)}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Revoke token "${token.name}"? Clients using it lose access immediately.`)) {
|
||||||
|
revokeMutation.mutate(token.id ?? '');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={revokeMutation.isPending}
|
||||||
|
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -1438,6 +1601,9 @@ export function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Personal Access Tokens */}
|
||||||
|
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
||||||
|
|
||||||
{/* Platform Settings - owner only */}
|
{/* Platform Settings - owner only */}
|
||||||
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,21 @@ import {
|
|||||||
manualScaleService,
|
manualScaleService,
|
||||||
execInService,
|
execInService,
|
||||||
rollbackDeployment,
|
rollbackDeployment,
|
||||||
|
cancelDeployment,
|
||||||
updateServiceVariables,
|
updateServiceVariables,
|
||||||
updateService,
|
updateService,
|
||||||
restartService,
|
restartService,
|
||||||
stopService,
|
stopService,
|
||||||
getServiceRuntime,
|
getServiceRuntime,
|
||||||
type CronJobEntity,
|
type CronJobEntity,
|
||||||
|
type ServiceVolume,
|
||||||
|
type ServiceDomain,
|
||||||
|
type DomainCheckResult,
|
||||||
|
listServiceDomains,
|
||||||
|
addServiceDomain,
|
||||||
|
removeServiceDomain,
|
||||||
|
setDefaultServiceDomain,
|
||||||
|
checkServiceDomains,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
||||||
import { useDemoMode } from '@/lib/demo-mode';
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
@@ -66,6 +75,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Box,
|
Box,
|
||||||
|
Ban,
|
||||||
Play,
|
Play,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
@@ -104,7 +114,9 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false },
|
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false },
|
||||||
building: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
|
building: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
|
||||||
pending: { 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 },
|
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 },
|
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 };
|
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false };
|
||||||
|
|
||||||
@@ -374,6 +386,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({
|
const rollbackMutation = useMutation({
|
||||||
mutationFn: (deploymentId: string) => rollbackDeployment(deploymentId),
|
mutationFn: (deploymentId: string) => rollbackDeployment(deploymentId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -424,6 +444,24 @@ export function ServiceDetailPage() {
|
|||||||
const [networkForm, setNetworkForm] = useState<{
|
const [networkForm, setNetworkForm] = useState<{
|
||||||
port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string;
|
port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string;
|
||||||
} | null>(null);
|
} | 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 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 project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
|
||||||
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
|
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
|
||||||
@@ -514,7 +552,7 @@ export function ServiceDetailPage() {
|
|||||||
const deployments = isDemoMode ? [] : deploymentsQuery.data ?? [];
|
const deployments = isDemoMode ? [] : deploymentsQuery.data ?? [];
|
||||||
const total = deployments.length;
|
const total = deployments.length;
|
||||||
const active = deployments.filter((deployment) =>
|
const active = deployments.filter((deployment) =>
|
||||||
['pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status),
|
['queued', 'pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status),
|
||||||
).length;
|
).length;
|
||||||
const failed = deployments.filter((deployment) => deployment.status === 'failed').length;
|
const failed = deployments.filter((deployment) => deployment.status === 'failed').length;
|
||||||
|
|
||||||
@@ -2054,6 +2092,304 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isDemoMode && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-[var(--text-primary)]">Volumes</h3>
|
||||||
|
{volumesForm === null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVolumesForm([...(service.volumes ?? [])])}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{volumesForm === null ? (
|
||||||
|
(service.volumes ?? []).length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">No volume mounts configured.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(service.volumes ?? []).map((v, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 py-2 text-xs">
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-tertiary)] uppercase tracking-wide">{v.type || 'volume'}</span>
|
||||||
|
<span className="mono text-[var(--text-primary)]">{v.source}</span>
|
||||||
|
<span className="text-[var(--text-tertiary)]">→</span>
|
||||||
|
<span className="mono text-[var(--text-primary)]">{v.target}</span>
|
||||||
|
{v.read_only && <span className="text-[var(--text-tertiary)]">(read-only)</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-3">
|
||||||
|
{volumesForm.map((v, i) => (
|
||||||
|
<div key={i} className="grid grid-cols-[80px_1fr_1fr_auto_auto] items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={v.type || 'volume'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...volumesForm];
|
||||||
|
next[i] = { ...v, type: e.target.value as 'volume' | 'bind' };
|
||||||
|
setVolumesForm(next);
|
||||||
|
}}
|
||||||
|
className="px-2 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<option value="volume">volume</option>
|
||||||
|
<option value="bind">bind</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
value={v.source}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...volumesForm];
|
||||||
|
next[i] = { ...v, source: e.target.value };
|
||||||
|
setVolumesForm(next);
|
||||||
|
}}
|
||||||
|
placeholder={v.type === 'bind' ? '/host/path' : 'volume-name'}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-xs mono text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={v.target}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...volumesForm];
|
||||||
|
next[i] = { ...v, target: e.target.value };
|
||||||
|
setVolumesForm(next);
|
||||||
|
}}
|
||||||
|
placeholder="/container/path"
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-xs mono text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-1 text-[10px] text-[var(--text-tertiary)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(v.read_only)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...volumesForm];
|
||||||
|
next[i] = { ...v, read_only: e.target.checked };
|
||||||
|
setVolumesForm(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
ro
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVolumesForm(volumesForm.filter((_, j) => j !== i))}
|
||||||
|
className="px-2 py-1 text-xs text-[var(--error)]"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVolumesForm([...volumesForm, { type: 'volume', source: '', target: '', read_only: false }])}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
Add mount
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={updateServiceMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
updateServiceMutation.mutate(
|
||||||
|
{
|
||||||
|
volumes: volumesForm
|
||||||
|
.filter((v) => v.source.trim() && v.target.startsWith('/'))
|
||||||
|
.map((v) => ({ type: v.type ?? 'volume', source: v.source, target: v.target, read_only: v.read_only ?? false })),
|
||||||
|
},
|
||||||
|
{ onSuccess: () => setVolumesForm(null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVolumesForm(null)}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<p className="text-[10px] text-[var(--text-tertiary)]">Replaces all mounts; applies on next deploy or redeploy</p>
|
||||||
|
</div>
|
||||||
|
{updateServiceMutation.isError && (
|
||||||
|
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isDemoMode && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-[var(--text-primary)]">Domains</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={domainMutation.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
const results = await checkServiceDomains(serviceId);
|
||||||
|
setDomainChecks(Object.fromEntries(results.map((r) => [r.domain, r])));
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
Check DNS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-3">
|
||||||
|
{(domainsQuery.data ?? []).length === 0 && !domainsQuery.isLoading && (
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">No domains attached.</p>
|
||||||
|
)}
|
||||||
|
{(domainsQuery.data ?? []).map((d: ServiceDomain) => {
|
||||||
|
const check = domainChecks?.[d.domain];
|
||||||
|
return (
|
||||||
|
<div key={d.id} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 py-2 text-xs">
|
||||||
|
<span className="mono text-[var(--text-primary)]">{d.domain}</span>
|
||||||
|
{d.is_default && <span className="px-1.5 py-0.5 rounded bg-[var(--accent-primary)]/15 text-[var(--accent-primary)]">default</span>}
|
||||||
|
{check && (
|
||||||
|
<span className={`px-1.5 py-0.5 rounded ${check.status === 'ok' ? 'bg-[var(--success-soft)] text-[var(--success)]' : check.status === 'pending' ? 'bg-[var(--warning-soft)] text-[var(--warning)]' : 'bg-[var(--error-soft)] text-[var(--error)]'}`}>
|
||||||
|
{check.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1" />
|
||||||
|
{!d.is_default && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => domainMutation.mutate(() => setDefaultServiceDomain(serviceId, d.id))}
|
||||||
|
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
set default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => domainMutation.mutate(() => removeServiceDomain(serviceId, d.id))}
|
||||||
|
className="text-[var(--error)]"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={domainInput}
|
||||||
|
onChange={(e) => setDomainInput(e.target.value)}
|
||||||
|
placeholder="app.example.com"
|
||||||
|
className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={domainMutation.isPending || !domainInput.trim()}
|
||||||
|
onClick={() =>
|
||||||
|
domainMutation.mutate(() => addServiceDomain(serviceId, domainInput.trim()), {
|
||||||
|
onSuccess: () => setDomainInput(''),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{domainMutation.isError && (
|
||||||
|
<p className="mt-2 text-xs text-[var(--error)]">{(domainMutation.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-[10px] text-[var(--text-tertiary)]">Point the domain at this node (A record or CNAME), then Check DNS. Applies on next deploy or redeploy.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isDemoMode && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-[var(--text-primary)]">Access</h3>
|
||||||
|
{accessForm === null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setAccessForm({ maintenance: Boolean(service.maintenanceMode), basicAuth: '' })
|
||||||
|
}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{accessForm === null ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||||
|
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Maintenance mode</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.maintenanceMode ? 'on' : 'off'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||||
|
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Basic auth</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{(service.basicAuth ?? []).length > 0 ? (service.basicAuth ?? []).join(', ') : 'off'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={accessForm.maintenance}
|
||||||
|
onChange={(e) => setAccessForm({ ...accessForm, maintenance: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Maintenance mode — redirect all traffic to the maintenance page
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Basic auth credentials (user:password per line — replaces all; empty clears)</label>
|
||||||
|
<textarea
|
||||||
|
value={accessForm.basicAuth}
|
||||||
|
onChange={(e) => setAccessForm({ ...accessForm, basicAuth: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
placeholder="admin:s3cret"
|
||||||
|
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={updateServiceMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const creds = accessForm.basicAuth
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((l) => {
|
||||||
|
const i = l.indexOf(':');
|
||||||
|
return i > 0 ? { username: l.slice(0, i), password: l.slice(i + 1) } : null;
|
||||||
|
})
|
||||||
|
.filter((x): x is { username: string; password: string } => x !== null);
|
||||||
|
updateServiceMutation.mutate(
|
||||||
|
{ maintenance_mode: accessForm.maintenance, basic_auth: creds },
|
||||||
|
{ onSuccess: () => setAccessForm(null) },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAccessForm(null)}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<p className="text-[10px] text-[var(--text-tertiary)]">Applies on next deploy or redeploy</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isDemoMode && (
|
{!isDemoMode && (
|
||||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -2121,6 +2457,19 @@ export function ServiceDetailPage() {
|
|||||||
>
|
>
|
||||||
Logs
|
Logs
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm(`Rollback to deployment ${deployment.id.slice(0, 8)}?`)) {
|
if (window.confirm(`Rollback to deployment ${deployment.id.slice(0, 8)}?`)) {
|
||||||
|
|||||||
@@ -231,6 +231,153 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/user/tokens": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List personal access tokens
|
||||||
|
* @description Returns the caller's non-revoked personal access token metadata. Token values and hashes are never returned.
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Token list */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
tokens?: components["schemas"]["UserToken"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Create personal access token
|
||||||
|
* @description Issues a `cnp_` bearer token for CLI/MCP/agent authentication. The raw token is returned exactly once and never stored — only its SHA-256 hash persists. Admin-scope tokens require a platform admin account.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateUserTokenRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Token created; raw value returned once */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UserTokenCreated"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Admin scope requires platform admin */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/user/tokens/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/**
|
||||||
|
* Revoke personal access token
|
||||||
|
* @description Marks one of the caller's tokens as revoked. Clients presenting it are rejected from then on.
|
||||||
|
*/
|
||||||
|
delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Token revoked */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Token not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/projects": {
|
"/projects": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -245,10 +392,12 @@ export interface paths {
|
|||||||
get: {
|
get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
/** @description Page number for pagination */
|
/** @description Page number for pagination (legacy — prefer cursor) */
|
||||||
page?: number;
|
page?: number;
|
||||||
/** @description Number of items per page */
|
/** @description Number of items per page */
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
/** @description Opaque cursor from a previous response's pagination.next_cursor */
|
||||||
|
cursor?: string;
|
||||||
/** @description Search term to filter projects */
|
/** @description Search term to filter projects */
|
||||||
search?: string;
|
search?: string;
|
||||||
};
|
};
|
||||||
@@ -822,6 +971,245 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/services/{id}/domains": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List service domains
|
||||||
|
* @description All hostnames attached to the service; the default is mirrored to services.domain
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Domain list */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
domains?: components["schemas"]["ServiceDomain"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Attach a domain
|
||||||
|
* @description Adds a hostname routed to the service via Traefik. The first domain becomes default automatically.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
domain: string;
|
||||||
|
is_default?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Domain attached */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
domain?: components["schemas"]["ServiceDomain"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Domain already attached */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/services/{id}/domains/check": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* DNS preflight
|
||||||
|
* @description Resolves every attached domain and compares it to the expected target (app_settings.public_ip / PUBLIC_IP, or base_url host)
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Per-domain DNS status */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
domains?: components["schemas"]["DomainCheckResult"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/services/{id}/domains/{domain_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Detach a domain */
|
||||||
|
delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
domain_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Domain removed */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/services/{id}/domains/{domain_id}/default": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Set the default domain
|
||||||
|
* @description Marks the domain default and mirrors it to services.domain
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
domain_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Updated domain list */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
domains?: components["schemas"]["ServiceDomain"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/maintenance": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Maintenance page
|
||||||
|
* @description Public HTML page Traefik redirects to while a service is in maintenance mode
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: never;
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/services/{id}/deployments": {
|
"/services/{id}/deployments": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -835,7 +1223,11 @@ export interface paths {
|
|||||||
*/
|
*/
|
||||||
get: {
|
get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: {
|
||||||
|
limit?: number;
|
||||||
|
/** @description Opaque cursor from a previous response's pagination.next_cursor */
|
||||||
|
cursor?: string;
|
||||||
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
/** @description Service ID */
|
/** @description Service ID */
|
||||||
@@ -909,6 +1301,8 @@ export interface paths {
|
|||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
/** @description Opaque cursor from a previous response's pagination.next_cursor */
|
||||||
|
cursor?: string;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -991,6 +1385,72 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/deployments/{id}/cancel": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Cancel a deployment
|
||||||
|
* @description Cancel a queued or in-flight deployment. Queued deployments are marked cancelled immediately; running ones get their build/reconcile aborted and settle as 'cancelled' shortly after.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Deployment ID */
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Queued deployment cancelled */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @example cancelled */
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Running deployment is cancelling */
|
||||||
|
202: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @example cancelling */
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Deployment already finished */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/deployments/{id}/rollback": {
|
"/deployments/{id}/rollback": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1669,6 +2129,95 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/volumes": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List docker volumes
|
||||||
|
* @description Returns the node's docker volume inventory with in-use flags. Admin only.
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Volume list */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
volumes?: components["schemas"]["VolumeInfo"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/volumes/{name}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/**
|
||||||
|
* Delete a docker volume
|
||||||
|
* @description Removes an unused docker volume. Docker refuses in-use volumes — returned as 409.
|
||||||
|
*/
|
||||||
|
delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Volume deleted */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Volume in use or removal failed */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/templates/{id}": {
|
"/templates/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -7493,6 +8042,14 @@ export interface components {
|
|||||||
healthcheck_path?: string;
|
healthcheck_path?: string;
|
||||||
/** @description Docker restart policy (default unless-stopped) */
|
/** @description Docker restart policy (default unless-stopped) */
|
||||||
restart_policy?: string;
|
restart_policy?: string;
|
||||||
|
/** @description Volume/bind mounts applied to every replica */
|
||||||
|
volumes?: components["schemas"]["ServiceVolume"][];
|
||||||
|
/** @description All attached hostnames; `domain` is the derived default */
|
||||||
|
domains?: components["schemas"]["ServiceDomain"][];
|
||||||
|
/** @description Redirect all traffic to the platform maintenance page */
|
||||||
|
maintenance_mode?: boolean;
|
||||||
|
/** @description Basic-auth usernames gating the route (hashes never returned) */
|
||||||
|
basic_auth?: string[];
|
||||||
/** @description Computed public URL (published port or domain) */
|
/** @description Computed public URL (published port or domain) */
|
||||||
public_url?: string;
|
public_url?: string;
|
||||||
/**
|
/**
|
||||||
@@ -7548,6 +8105,8 @@ export interface components {
|
|||||||
healthcheck_path?: string;
|
healthcheck_path?: string;
|
||||||
/** @description Docker restart policy */
|
/** @description Docker restart policy */
|
||||||
restart_policy?: string;
|
restart_policy?: string;
|
||||||
|
/** @description Volume/bind mounts applied to every replica */
|
||||||
|
volumes?: components["schemas"]["ServiceVolume"][];
|
||||||
};
|
};
|
||||||
UpdateServiceRequest: {
|
UpdateServiceRequest: {
|
||||||
/** @description Service name */
|
/** @description Service name */
|
||||||
@@ -7586,6 +8145,67 @@ export interface components {
|
|||||||
healthcheck_path?: string;
|
healthcheck_path?: string;
|
||||||
/** @description Docker restart policy */
|
/** @description Docker restart policy */
|
||||||
restart_policy?: string;
|
restart_policy?: string;
|
||||||
|
/** @description Replaces the entire mount list when present; [] clears all mounts. Takes effect on next deploy/reconcile. */
|
||||||
|
volumes?: components["schemas"]["ServiceVolume"][];
|
||||||
|
/** @description Redirect all traffic to the platform maintenance page */
|
||||||
|
maintenance_mode?: boolean;
|
||||||
|
/** @description Replaces all basic-auth credentials; [] clears the gate */
|
||||||
|
basic_auth?: components["schemas"]["BasicAuthCred"][];
|
||||||
|
};
|
||||||
|
ServiceDomain: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
service_id?: string;
|
||||||
|
domain?: string;
|
||||||
|
is_default?: boolean;
|
||||||
|
cert_type?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
cert_status?: "pending" | "issued" | "failed";
|
||||||
|
/** Format: date-time */
|
||||||
|
last_checked_at?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
DomainCheckResult: {
|
||||||
|
domain?: string;
|
||||||
|
/**
|
||||||
|
* @description ok = resolves to expected target (or no expectation configured), wrong-target = resolves elsewhere, pending = no DNS records
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
status?: "ok" | "wrong-target" | "pending";
|
||||||
|
/** @description A/AAAA records resolved */
|
||||||
|
resolved?: string[];
|
||||||
|
};
|
||||||
|
BasicAuthCred: {
|
||||||
|
username: string;
|
||||||
|
/** @description Plaintext on write — stored as bcrypt htpasswd; never returned */
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
ServiceVolume: {
|
||||||
|
/**
|
||||||
|
* @description volume = docker named volume, bind = host path
|
||||||
|
* @default volume
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
type: "volume" | "bind";
|
||||||
|
/** @description Volume name or host path */
|
||||||
|
source: string;
|
||||||
|
/** @description Absolute container path */
|
||||||
|
target: string;
|
||||||
|
/** @default false */
|
||||||
|
read_only: boolean;
|
||||||
|
};
|
||||||
|
VolumeInfo: {
|
||||||
|
name?: string;
|
||||||
|
driver?: string;
|
||||||
|
mountpoint?: string;
|
||||||
|
scope?: string;
|
||||||
|
in_use?: boolean;
|
||||||
|
/** @description Container names mounting this volume */
|
||||||
|
used_by?: string[];
|
||||||
|
/** Format: date-time */
|
||||||
|
created_at?: string;
|
||||||
};
|
};
|
||||||
Deployment: {
|
Deployment: {
|
||||||
/** @description Deployment ID */
|
/** @description Deployment ID */
|
||||||
@@ -7641,6 +8261,8 @@ export interface components {
|
|||||||
env_vars?: {
|
env_vars?: {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
|
/** @description Build without Docker layer cache */
|
||||||
|
no_cache?: boolean;
|
||||||
};
|
};
|
||||||
RollbackDeploymentResponse: {
|
RollbackDeploymentResponse: {
|
||||||
deployment?: components["schemas"]["Deployment"];
|
deployment?: components["schemas"]["Deployment"];
|
||||||
@@ -8192,6 +8814,8 @@ export interface components {
|
|||||||
};
|
};
|
||||||
UpdateVariablesRequest: {
|
UpdateVariablesRequest: {
|
||||||
variables: components["schemas"]["VariableInput"][];
|
variables: components["schemas"]["VariableInput"][];
|
||||||
|
/** @description Queue a service redeploy after saving so running containers pick up the new variables */
|
||||||
|
redeploy?: boolean;
|
||||||
};
|
};
|
||||||
ConnectGitHubAppRequest: {
|
ConnectGitHubAppRequest: {
|
||||||
/**
|
/**
|
||||||
@@ -8476,6 +9100,37 @@ export interface components {
|
|||||||
/** @description Human-readable label for the token (e.g. node name) */
|
/** @description Human-readable label for the token (e.g. node name) */
|
||||||
label?: string;
|
label?: string;
|
||||||
};
|
};
|
||||||
|
UserToken: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** @description First 12 chars of the token, for identification in lists */
|
||||||
|
key_prefix: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
scope: "read" | "write" | "admin";
|
||||||
|
/** Format: date-time */
|
||||||
|
expires_at?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
last_used_at?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
UserTokenCreated: components["schemas"]["UserToken"] & {
|
||||||
|
/** @description Raw cnp_ token value, returned exactly once at creation */
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
CreateUserTokenRequest: {
|
||||||
|
/** @description Human-readable label (e.g. "laptop cli", "ci deploy") */
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* @description read = GET/HEAD only; write = all non-admin routes; admin = everything (requires admin account)
|
||||||
|
* @default write
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
scope: "read" | "write" | "admin";
|
||||||
|
/** @description 0 or omitted = never expires */
|
||||||
|
expires_in_days?: number;
|
||||||
|
};
|
||||||
AgentCapabilities: {
|
AgentCapabilities: {
|
||||||
container_runtimes?: string[];
|
container_runtimes?: string[];
|
||||||
supported_architectures?: string[];
|
supported_architectures?: string[];
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ export const bundledDocs: BundledDoc[] = [
|
|||||||
"title": "🚀 Zero-Interaction Docker Deployment System",
|
"title": "🚀 Zero-Interaction Docker Deployment System",
|
||||||
"markdown": "# 🚀 Zero-Interaction Docker Deployment System\n\n## 🎯 **Fully Automatic Deployment**\n\nI've created a **completely automated deployment system** that requires **ZERO user interaction**. Just run one command and it does everything!\n\n## ⚡ **One-Click Deployment**\n\n### **Option 1: Quick Start (Recommended)**\n```bash\n# Run the quick deploy script\n./scripts/quick-deploy.sh\n```\n\n**What happens:**\n1. 🎯 **Shows you 6 deployment options**\n2. 🚀 **Deploys your choice automatically**\n3. 🔧 **Handles everything** (secrets, directories, ports)\n4. 🌐 **Shows access URLs** when done\n\n### **Option 2: Direct Command**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh umami\n./scripts/auto-deploy.sh plex\n./scripts/auto-deploy.sh nextcloud\n```\n\n### **Option 3: Deploy Everything**\n```bash\n# Deploy all 20 templates at once\n./scripts/auto-deploy.sh --all\n```\n\n## 🔄 **What Happens Automatically**\n\n### **🔍 Dependency Check**\n```bash\n✅ Checks if Docker is installed\n✅ Installs Docker if missing\n✅ Checks if Docker Compose is installed \n✅ Installs Docker Compose if missing\n```\n\n### **📁 Directory Creation**\n```bash\n✅ Creates deployment directory\n✅ Creates required subdirectories\n✅ Sets proper permissions\n✅ Handles existing deployments\n```\n\n### **🔧 Configuration Setup**\n```bash\n✅ Extracts docker-compose.yml from template\n✅ Creates .env file with all variables\n✅ Auto-generates secrets and passwords\n✅ Fixes port conflicts automatically\n✅ Optimizes volume paths\n```\n\n### **🐳 Docker Deployment**\n```bash\n✅ Pulls all required images\n✅ Starts all services\n✅ Waits for services to be ready\n✅ Checks service health\n✅ Shows deployment status\n```\n\n### **🌐 Access Information**\n```bash\n✅ Shows all access URLs\n✅ Provides management commands\n✅ Shows deployment directory\n✅ Opens browser automatically (optional)\n```\n\n## 🎮 **Try It Now**\n\n### **Easiest Way:**\n```bash\n./scripts/quick-deploy.sh\n```\n\nYou'll see:\n```\n🚀 One-Click Docker Deployment\n============================\n\n🎯 Choose your deployment:\n\n1. 🌐 Deploy Glance Dashboard (Recommended - Simple & Fast)\n2. 📊 Deploy Umami Analytics (Web Analytics)\n3. 📝 Deploy Memos (Note-taking)\n4. 🔍 Deploy MeiliSearch (Search Engine)\n5. 📈 Deploy Uptime Kuma (Monitoring)\n6. 🚀 Deploy ALL Templates (Advanced)\n\nEnter your choice (1-6): 1\n\n🌐 Deploying Glance Dashboard...\n```\n\n### **Direct Way:**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh glance\n```\n\n## 📊 **Example Output**\n\nHere's what you'll see during deployment:\n\n```\n🚀 Auto-Deploying glance\n========================================\n🔄 Checking dependencies...\n✅ Docker is installed\n✅ Docker Compose is installed\n🔄 Auto-deploying glance...\n✅ Created deployment directory: /path/to/deployments/glance\n🔄 Extracting docker-compose.yml...\n✅ docker-compose.yml extracted and optimized\n🔄 Creating .env file...\n✅ .env file created (no variables required)\n🔄 Auto-deploying glance...\n🔄 Pulling Docker images...\n✅ Images pulled\n🔄 Starting services...\n✅ Services started successfully\n\n🎉 Deployment Complete!\n========================================\n✅ Access URLs:\n 🌐 http://localhost:8080 (port: 8080)\n\n✅ Management Commands:\n 📋 View logs: docker-compose logs -f\n 🛑 Stop services: docker-compose down\n 🔄 Restart: docker-compose restart\n 📊 Status: docker-compose ps\n 🗑️ Cleanup: docker-compose down -v\n\n✅ Deployment directory: /path/to/deployments/glance\n🎉 glance deployed successfully!\n```\n\n## 🔧 **Advanced Options**\n\n### **Custom Configuration**\n```bash\n# Deploy without auto-generating secrets\n./scripts/auto-deploy.sh --no-secrets umami\n\n# Deploy without creating directories\n./scripts/auto-deploy.sh --no-dirs plex\n\n# Deploy without auto-starting services\n./scripts/auto-deploy.sh --no-start nextcloud\n\n# Deploy and auto-open browser\n./scripts/auto-deploy.sh --open-browser glance\n```\n\n### **Batch Deployment**\n```bash\n# Deploy multiple templates\nfor template in umami glance memos; do\n ./scripts/auto-deploy.sh \"$template\"\ndone\n\n# Deploy all templates\n./scripts/auto-deploy.sh --all\n```\n\n## 🛡️ **Safety Features**\n\n### **Automatic Conflict Resolution**\n```bash\n✅ Detects port conflicts\n✅ Auto-assigns available ports\n✅ Handles existing deployments\n✅ Cleans up previous installations\n```\n\n### **Security**\n```bash\n✅ Generates strong random secrets\n✅ Uses secure default passwords\n✅ Isolates deployments in separate directories\n✅ Logs all actions for audit trail\n```\n\n### **Error Handling**\n```bash\n✅ Validates template existence\n✅ Checks service health\n✅ Provides detailed error messages\n✅ Offers rollback commands\n```\n\n## 📁 **Di
Line truncated
|
"markdown": "# 🚀 Zero-Interaction Docker Deployment System\n\n## 🎯 **Fully Automatic Deployment**\n\nI've created a **completely automated deployment system** that requires **ZERO user interaction**. Just run one command and it does everything!\n\n## ⚡ **One-Click Deployment**\n\n### **Option 1: Quick Start (Recommended)**\n```bash\n# Run the quick deploy script\n./scripts/quick-deploy.sh\n```\n\n**What happens:**\n1. 🎯 **Shows you 6 deployment options**\n2. 🚀 **Deploys your choice automatically**\n3. 🔧 **Handles everything** (secrets, directories, ports)\n4. 🌐 **Shows access URLs** when done\n\n### **Option 2: Direct Command**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh umami\n./scripts/auto-deploy.sh plex\n./scripts/auto-deploy.sh nextcloud\n```\n\n### **Option 3: Deploy Everything**\n```bash\n# Deploy all 20 templates at once\n./scripts/auto-deploy.sh --all\n```\n\n## 🔄 **What Happens Automatically**\n\n### **🔍 Dependency Check**\n```bash\n✅ Checks if Docker is installed\n✅ Installs Docker if missing\n✅ Checks if Docker Compose is installed \n✅ Installs Docker Compose if missing\n```\n\n### **📁 Directory Creation**\n```bash\n✅ Creates deployment directory\n✅ Creates required subdirectories\n✅ Sets proper permissions\n✅ Handles existing deployments\n```\n\n### **🔧 Configuration Setup**\n```bash\n✅ Extracts docker-compose.yml from template\n✅ Creates .env file with all variables\n✅ Auto-generates secrets and passwords\n✅ Fixes port conflicts automatically\n✅ Optimizes volume paths\n```\n\n### **🐳 Docker Deployment**\n```bash\n✅ Pulls all required images\n✅ Starts all services\n✅ Waits for services to be ready\n✅ Checks service health\n✅ Shows deployment status\n```\n\n### **🌐 Access Information**\n```bash\n✅ Shows all access URLs\n✅ Provides management commands\n✅ Shows deployment directory\n✅ Opens browser automatically (optional)\n```\n\n## 🎮 **Try It Now**\n\n### **Easiest Way:**\n```bash\n./scripts/quick-deploy.sh\n```\n\nYou'll see:\n```\n🚀 One-Click Docker Deployment\n============================\n\n🎯 Choose your deployment:\n\n1. 🌐 Deploy Glance Dashboard (Recommended - Simple & Fast)\n2. 📊 Deploy Umami Analytics (Web Analytics)\n3. 📝 Deploy Memos (Note-taking)\n4. 🔍 Deploy MeiliSearch (Search Engine)\n5. 📈 Deploy Uptime Kuma (Monitoring)\n6. 🚀 Deploy ALL Templates (Advanced)\n\nEnter your choice (1-6): 1\n\n🌐 Deploying Glance Dashboard...\n```\n\n### **Direct Way:**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh glance\n```\n\n## 📊 **Example Output**\n\nHere's what you'll see during deployment:\n\n```\n🚀 Auto-Deploying glance\n========================================\n🔄 Checking dependencies...\n✅ Docker is installed\n✅ Docker Compose is installed\n🔄 Auto-deploying glance...\n✅ Created deployment directory: /path/to/deployments/glance\n🔄 Extracting docker-compose.yml...\n✅ docker-compose.yml extracted and optimized\n🔄 Creating .env file...\n✅ .env file created (no variables required)\n🔄 Auto-deploying glance...\n🔄 Pulling Docker images...\n✅ Images pulled\n🔄 Starting services...\n✅ Services started successfully\n\n🎉 Deployment Complete!\n========================================\n✅ Access URLs:\n 🌐 http://localhost:8080 (port: 8080)\n\n✅ Management Commands:\n 📋 View logs: docker-compose logs -f\n 🛑 Stop services: docker-compose down\n 🔄 Restart: docker-compose restart\n 📊 Status: docker-compose ps\n 🗑️ Cleanup: docker-compose down -v\n\n✅ Deployment directory: /path/to/deployments/glance\n🎉 glance deployed successfully!\n```\n\n## 🔧 **Advanced Options**\n\n### **Custom Configuration**\n```bash\n# Deploy without auto-generating secrets\n./scripts/auto-deploy.sh --no-secrets umami\n\n# Deploy without creating directories\n./scripts/auto-deploy.sh --no-dirs plex\n\n# Deploy without auto-starting services\n./scripts/auto-deploy.sh --no-start nextcloud\n\n# Deploy and auto-open browser\n./scripts/auto-deploy.sh --open-browser glance\n```\n\n### **Batch Deployment**\n```bash\n# Deploy multiple templates\nfor template in umami glance memos; do\n ./scripts/auto-deploy.sh \"$template\"\ndone\n\n# Deploy all templates\n./scripts/auto-deploy.sh --all\n```\n\n## 🛡️ **Safety Features**\n\n### **Automatic Conflict Resolution**\n```bash\n✅ Detects port conflicts\n✅ Auto-assigns available ports\n✅ Handles existing deployments\n✅ Cleans up previous installations\n```\n\n### **Security**\n```bash\n✅ Generates strong random secrets\n✅ Uses secure default passwords\n✅ Isolates deployments in separate directories\n✅ Logs all actions for audit trail\n```\n\n### **Error Handling**\n```bash\n✅ Validates template existence\n✅ Checks service health\n✅ Provides detailed error messages\n✅ Offers rollback commands\n```\n\n## 📁 **Di
Line truncated
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "guides/CLI.md",
|
||||||
|
"section": "guides",
|
||||||
|
"title": "Containr CLI",
|
||||||
|
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add <id> app.example.com --default\ncontainr services domains check <id> # DNS preflight per domain\ncontainr services domains remove <id> <domain-id>\n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update <id> --maintenance on\ncontainr services update <id> --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdiLine truncated
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||||
"section": "guides",
|
"section": "guides",
|
||||||
@@ -52,7 +58,7 @@ export const bundledDocs: BundledDoc[] = [
|
|||||||
"path": "guides/OPERATIONS.md",
|
"path": "guides/OPERATIONS.md",
|
||||||
"section": "guides",
|
"section": "guides",
|
||||||
"title": "Operations",
|
"title": "Operations",
|
||||||
"markdown": "# Operations\n\nDay-2 features for running services: scheduled jobs, ad-hoc exec, managed\ndatabases, preview environments, security scans, and high availability.\n\n## Cron jobs\n\nService detail → **Cron**. Standard 5-field cron expressions (`robfig/cron`\nsyntax), per-job timezone, enable/disable, manual trigger, and execution\nhistory with captured stdout/stderr.\n\n- The scheduler ticks every minute and runs due jobs via `docker exec`\n (`sh -c <command>`) inside the service's container.\n- Executions are retained per-job (`retention` count) and visible in the UI.\n- Requires a running container — a stopped service's due job records a\n failure, it does not queue.\n\n```\nGET/POST /api/v1/cron-jobs PUT/DELETE /api/v1/cron-jobs/:id\nPOST /api/v1/cron-jobs/:id/trigger\nGET /api/v1/cron-jobs/:id/executions\n```\n\n## Exec console\n\nService detail → **Console**. One-off `sh -c` commands against the running\ncontainer: 30s ceiling, 64KB output cap, exit code + captured output shown\nper run. Debugging aid — not an interactive shell.\n\n```\nPOST /api/v1/services/:id/exec {command}\n```\n\n## Managed databases\n\n**/databases** page — provision PostgreSQL/Redis/MySQL/MariaDB/MongoDB/\nClickHouse/Dragonfly as managed containers on the `containr` network.\n\n- Start/stop/restart actions; connection URL with copy.\n- **Backups**: manual snapshot now, or set a cron expression\n (`backup_schedule`) — due backups run inside the same scheduler tick as\n cron jobs. Archives live in the `containr-db-backups` volume and are\n downloadable (`GET /databases/:id/backups/:bid/download`) and restorable.\n- **Bind to service**: injects `connection_url` into a service's variables\n as a secret (`DATABASE_URL` by default). Redeploy the service afterward —\n variables are read at deploy time.\n\n## Preview environments\n\nService detail → **Previews**. Create a preview per branch/PR with a TTL,\npromote it to production, or delete it.\n\nCurrent limitation: previews are bookkeeping records — they do not build,\ndeploy, or route a real container. The generated `*.preview.containr.local`\nURL is a placeholder. Real preview runtime is tracked on the roadmap.\n\n## Security scans\n\n**/security** page — pick a project, run a scan (dependency / configuration\n/ comprehensive, optionally scoped to one service).\n\nThe scanner is heuristic config analysis, not Trivy/Grype: it flags\nunpinned image tags, `http://` source/public URLs, `npm install` without\n`npm ci`, `pip install` without hashes, `curl|sh` build steps, missing\nhealth checks, and similar smells. Findings get severity badges and can be\nresolved or ignored; the page also shows a security score, open/resolved\ncounts, scan history, and compliance status.\n\n```\nPOST /api/v1/security/scans {project_id, service_id?, scan_type}\nGET /api/v1/projects/:id/vulnerabilities\nPUT /api/v1/vulnerabilities/:id {status: open|resolved|ignored}\nGET /api/v1/projects/:id/security/metrics\nGET /api/v1/projects/:id/security/history\n```\n\n## High availability\n\n**/ha** page — manager enable/disable, node/health-check/alert status\ncards, manual failover (with confirmation), per-service failover policies\n(project → service picker, strategy, min healthy nodes, max failures),\nactive alerts with resolve, and health check results.\n\nHA state is in-memory like autoscaling — policies and alerts reset on\nbackend restart.\n\n## Node agents\n\n**/usage** page → *Connect node*: issue a per-node token (`cagt_…`, shown\nonce), copy the prefilled install command, revoke tokens when done. Agents\nregister over `POST /api/agents/register` and heartbeat via\n`POST /api/agents/heartbeat`.\n"
|
"markdown": "# Operations\n\nDay-2 features for running services: scheduled jobs, ad-hoc exec, managed\ndatabases, preview environments, security scans, and high availability.\n\n## Cron jobs\n\nService detail → **Cron**. Standard 5-field cron expressions (`robfig/cron`\nsyntax), per-job timezone, enable/disable, manual trigger, and execution\nhistory with captured stdout/stderr.\n\n- The scheduler ticks every minute and runs due jobs via `docker exec`\n (`sh -c <command>`) inside the service's container.\n- Executions are retained per-job (`retention` count) and visible in the UI.\n- Requires a running container — a stopped service's due job records a\n failure, it does not queue.\n\n```\nGET/POST /api/v1/cron-jobs PUT/DELETE /api/v1/cron-jobs/:id\nPOST /api/v1/cron-jobs/:id/trigger\nGET /api/v1/cron-jobs/:id/executions\n```\n\n## Exec console\n\nService detail → **Console**. One-off `sh -c` commands against the running\ncontainer: 30s ceiling, 64KB output cap, exit code + captured output shown\nper run. Debugging aid — not an interactive shell.\n\n```\nPOST /api/v1/services/:id/exec {command}\n```\n\n## Managed databases\n\n**/databases** page — provision PostgreSQL/Redis/MySQL/MariaDB/MongoDB/\nClickHouse/Dragonfly as managed containers on the `containr` network.\n\n- Start/stop/restart actions; connection URL with copy.\n- **Backups**: manual snapshot now, or set a cron expression\n (`backup_schedule`) — due backups run inside the same scheduler tick as\n cron jobs. Archives live in the `containr-db-backups` volume and are\n downloadable (`GET /databases/:id/backups/:bid/download`) and restorable.\n- **Bind to service**: injects `connection_url` into a service's variables\n as a secret (`DATABASE_URL` by default). Redeploy the service afterward —\n variables are read at deploy time.\n\n## Preview environments\n\nService detail → **Previews**. Create a preview per branch/PR with a TTL,\npromote it to production, or delete it.\n\nCurrent limitation: previews are bookkeeping records — they do not build,\ndeploy, or route a real container. The generated `*.preview.containr.local`\nURL is a placeholder. Real preview runtime is tracked on the roadmap.\n\n## Security scans\n\n**/security** page — pick a project, run a scan (dependency / configuration\n/ comprehensive, optionally scoped to one service).\n\nThe scanner is heuristic config analysis, not Trivy/Grype: it flags\nunpinned image tags, `http://` source/public URLs, `npm install` without\n`npm ci`, `pip install` without hashes, `curl|sh` build steps, missing\nhealth checks, and similar smells. Findings get severity badges and can be\nresolved or ignored; the page also shows a security score, open/resolved\ncounts, scan history, and compliance status.\n\n```\nPOST /api/v1/security/scans {project_id, service_id?, scan_type}\nGET /api/v1/projects/:id/vulnerabilities\nPUT /api/v1/vulnerabilities/:id {status: open|resolved|ignored}\nGET /api/v1/projects/:id/security/metrics\nGET /api/v1/projects/:id/security/history\n```\n\n## High availability\n\n**/ha** page — manager enable/disable, node/health-check/alert status\ncards, manual failover (with confirmation), per-service failover policies\n(project → service picker, strategy, min healthy nodes, max failures),\nactive alerts with resolve, and health check results.\n\nHA state is in-memory like autoscaling — policies and alerts reset on\nbackend restart.\n\n## Node agents\n\n**/usage** page → *Connect node*: issue a per-node token (`cagt_…`, shown\nonce), copy the prefilled install command, revoke tokens when done. Agents\nregister over `POST /api/agents/register` and heartbeat via\n`POST /api/agents/heartbeat`.\n\n## Personal access tokens\n\n**Settings → Personal Access Tokens**: issue `cnp_…` bearer tokens for the\nCLI, MCP server, and scripts — the same privileges as your account, bounded\nby scope. The raw token is shown once at creation; only its SHA-256 hash is\nstored.\n\nScopes:\n\n- `read` — `GET`/`HEAD`/`OPTIONS` only; mutations return `403 SCOPE_INSUFFICIENT`.\n- `write` — everything except admin routes.\n- `admin` — full access; requires a platform admin account, and the token is\n still capped at the owner's privileges.\n\nA token can never outrank its owner: non-admin scopes report\n`is_admin = false` even on admin accounts.\n\n```\nGET /api/v1/user/tokens list (metadata only, never hashes)\nPOST /api/v1/user/tokens {name, scope?, expires_in_days?} → raw token once\nDELETE /api/v1/user/tokens/:id revoke (owner-scoped)\n```\n\nUse as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`.\nRevocation takes effect on the next request; `last_used_at` updates on each\nauthenticated call.\n\n## Deployment queue\n\nDeployments are serialized per service: a deploy while another is active\nlands in `queued` state and promotes when it finishes — deploys, rollbacksLine truncated
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "guides/README_BACKEND.md",
|
"path": "guides/README_BACKEND.md",
|
||||||
|
|||||||
@@ -54,9 +54,37 @@ export type ServiceEntity = {
|
|||||||
domain?: string;
|
domain?: string;
|
||||||
healthcheckPath?: string;
|
healthcheckPath?: string;
|
||||||
restartPolicy?: string;
|
restartPolicy?: string;
|
||||||
|
volumes?: ServiceVolume[];
|
||||||
|
domains?: ServiceDomain[];
|
||||||
|
maintenanceMode?: boolean;
|
||||||
|
basicAuth?: string[];
|
||||||
publicUrl?: string;
|
publicUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ServiceDomain = {
|
||||||
|
id: string;
|
||||||
|
service_id: string;
|
||||||
|
domain: string;
|
||||||
|
is_default: boolean;
|
||||||
|
cert_type?: string;
|
||||||
|
cert_status?: string;
|
||||||
|
last_checked_at?: string;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DomainCheckResult = {
|
||||||
|
domain: string;
|
||||||
|
status: 'ok' | 'wrong-target' | 'pending';
|
||||||
|
resolved?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceVolume = {
|
||||||
|
type?: 'volume' | 'bind';
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
read_only?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type RuntimeContainer = {
|
export type RuntimeContainer = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -552,6 +580,10 @@ function normalizeService(service: RawService): ServiceEntity | null {
|
|||||||
domain: service.domain,
|
domain: service.domain,
|
||||||
healthcheckPath: service.healthcheck_path,
|
healthcheckPath: service.healthcheck_path,
|
||||||
restartPolicy: service.restart_policy,
|
restartPolicy: service.restart_policy,
|
||||||
|
volumes: service.volumes as ServiceVolume[] | undefined,
|
||||||
|
domains: service.domains as ServiceDomain[] | undefined,
|
||||||
|
maintenanceMode: service.maintenance_mode,
|
||||||
|
basicAuth: service.basic_auth as string[] | undefined,
|
||||||
publicUrl: service.public_url,
|
publicUrl: service.public_url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -993,6 +1025,34 @@ export async function revokeAgentToken(id: string): Promise<void> {
|
|||||||
await requestJson(`/agent-tokens/${id}`, { method: 'DELETE' });
|
await requestJson(`/agent-tokens/${id}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserToken = components['schemas']['UserToken'];
|
||||||
|
export type UserTokenCreated = components['schemas']['UserTokenCreated'];
|
||||||
|
export type UserTokenScope = 'read' | 'write' | 'admin';
|
||||||
|
|
||||||
|
export async function createUserToken(input: {
|
||||||
|
name: string;
|
||||||
|
scope?: UserTokenScope;
|
||||||
|
expiresInDays?: number;
|
||||||
|
}): Promise<UserTokenCreated> {
|
||||||
|
return requestJson<UserTokenCreated>(`/user/tokens`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: input.name,
|
||||||
|
scope: input.scope ?? 'write',
|
||||||
|
expires_in_days: input.expiresInDays ?? 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUserTokens(): Promise<UserToken[]> {
|
||||||
|
const payload = await requestJson<{ tokens?: UserToken[] }>(`/user/tokens`);
|
||||||
|
return payload.tokens ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeUserToken(id: string): Promise<void> {
|
||||||
|
await requestJson(`/user/tokens/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> {
|
export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> {
|
||||||
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, {
|
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1063,6 +1123,36 @@ export async function createService(projectId: string, input: CreateServiceInput
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listServiceDomains(serviceId: string): Promise<ServiceDomain[]> {
|
||||||
|
const payload = await requestJson<{ domains?: ServiceDomain[] }>(`/services/${serviceId}/domains`);
|
||||||
|
return payload.domains ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addServiceDomain(serviceId: string, domain: string, isDefault = false): Promise<ServiceDomain> {
|
||||||
|
const payload = await requestJson<{ domain: ServiceDomain }>(`/services/${serviceId}/domains`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ domain, is_default: isDefault }),
|
||||||
|
});
|
||||||
|
return payload.domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeServiceDomain(serviceId: string, domainId: string): Promise<void> {
|
||||||
|
await requestJson(`/services/${serviceId}/domains/${domainId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDefaultServiceDomain(serviceId: string, domainId: string): Promise<ServiceDomain[]> {
|
||||||
|
const payload = await requestJson<{ domains?: ServiceDomain[] }>(
|
||||||
|
`/services/${serviceId}/domains/${domainId}/default`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
return payload.domains ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkServiceDomains(serviceId: string): Promise<DomainCheckResult[]> {
|
||||||
|
const payload = await requestJson<{ domains?: DomainCheckResult[] }>(`/services/${serviceId}/domains/check`);
|
||||||
|
return payload.domains ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateService(serviceId: string, input: UpdateServiceInput): Promise<ServiceEntity> {
|
export async function updateService(serviceId: string, input: UpdateServiceInput): Promise<ServiceEntity> {
|
||||||
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`, {
|
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -1292,6 +1382,12 @@ export async function rollbackDeployment(deploymentId: string): Promise<Rollback
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cancelDeployment(deploymentId: string): Promise<{ status: string }> {
|
||||||
|
return requestJson<{ status: string }>(`/deployments/${deploymentId}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function listBuilds(input: ListBuildsInput = {}): Promise<ListBuildsResult> {
|
export async function listBuilds(input: ListBuildsInput = {}): Promise<ListBuildsResult> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (input.projectId) {
|
if (input.projectId) {
|
||||||
|
|||||||
+573
-5
@@ -8,10 +8,27 @@ info:
|
|||||||
Containr provides a comprehensive platform for deploying, managing, and scaling containerized applications on Proxmox infrastructure. This API allows you to manage projects, services, deployments, databases, and more through a RESTful interface.
|
Containr provides a comprehensive platform for deploying, managing, and scaling containerized applications on Proxmox infrastructure. This API allows you to manage projects, services, deployments, databases, and more through a RESTful interface.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
The API uses JWT-based authentication. Include your JWT token in the Authorization header:
|
The API accepts three credentials in the Authorization header:
|
||||||
```
|
`Authorization: Bearer <jwt>` (UI sessions), a Better Auth session cookie, or a
|
||||||
Authorization: Bearer <your-jwt-token>
|
personal access token `cnp_...` (CLI/MCP/agents). PAT scopes: `read` allows only
|
||||||
```
|
GET/HEAD/OPTIONS; `write` everything except admin routes; `admin` requires a
|
||||||
|
platform-admin owner.
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
Error responses use `{"error": "human message", "code": "MACHINE_CODE"}`. Stable
|
||||||
|
codes: `UNAUTHENTICATED`, `INVALID_TOKEN`, `FORBIDDEN`, `ADMIN_REQUIRED`,
|
||||||
|
`SCOPE_INSUFFICIENT`, `NOT_FOUND`, `VALIDATION`, `CONFLICT`, `INTERNAL`,
|
||||||
|
`DEPENDENCY_UNAVAILABLE`, `IDEMPOTENCY_IN_PROGRESS`.
|
||||||
|
|
||||||
|
## Idempotency
|
||||||
|
POST endpoints accept an `Idempotency-Key` header. The first response is replayed
|
||||||
|
for repeated keys within 24h (scoped to the caller and path); a concurrent duplicate
|
||||||
|
receives `409 IDEMPOTENCY_IN_PROGRESS`. Safe for clients to always send a UUID.
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
List endpoints accept `?limit=` plus either `?page=` (legacy) or `?cursor=`
|
||||||
|
(preferred — pass `pagination.next_cursor` from the previous response verbatim).
|
||||||
|
Invalid cursors fail soft to the first page.
|
||||||
|
|
||||||
## Base URL
|
## Base URL
|
||||||
```
|
```
|
||||||
@@ -152,6 +169,97 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Error'
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
/user/tokens:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- User
|
||||||
|
summary: List personal access tokens
|
||||||
|
description: Returns the caller's non-revoked personal access token metadata. Token values and hashes are never returned.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Token list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
tokens:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/UserToken'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- User
|
||||||
|
summary: Create personal access token
|
||||||
|
description: Issues a `cnp_` bearer token for CLI/MCP/agent authentication. The raw token is returned exactly once and never stored — only its SHA-256 hash persists. Admin-scope tokens require a platform admin account.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateUserTokenRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Token created; raw value returned once
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UserTokenCreated'
|
||||||
|
'400':
|
||||||
|
description: Invalid input
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'403':
|
||||||
|
description: Admin scope requires platform admin
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
/user/tokens/{id}:
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- User
|
||||||
|
summary: Revoke personal access token
|
||||||
|
description: Marks one of the caller's tokens as revoked. Clients presenting it are rejected from then on.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Token revoked
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
'404':
|
||||||
|
description: Token not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
# Projects
|
# Projects
|
||||||
/projects:
|
/projects:
|
||||||
get:
|
get:
|
||||||
@@ -164,7 +272,7 @@ paths:
|
|||||||
parameters:
|
parameters:
|
||||||
- name: page
|
- name: page
|
||||||
in: query
|
in: query
|
||||||
description: Page number for pagination
|
description: Page number for pagination (legacy — prefer cursor)
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
default: 1
|
default: 1
|
||||||
@@ -174,6 +282,11 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
default: 20
|
default: 20
|
||||||
|
- name: cursor
|
||||||
|
in: query
|
||||||
|
description: Opaque cursor from a previous response's pagination.next_cursor
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
- name: search
|
- name: search
|
||||||
in: query
|
in: query
|
||||||
description: Search term to filter projects
|
description: Search term to filter projects
|
||||||
@@ -554,6 +667,163 @@ paths:
|
|||||||
'200':
|
'200':
|
||||||
description: Reconciled runtime state
|
description: Reconciled runtime state
|
||||||
|
|
||||||
|
/services/{id}/domains:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Services
|
||||||
|
summary: List service domains
|
||||||
|
description: All hostnames attached to the service; the default is mirrored to services.domain
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Domain list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
domains:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ServiceDomain'
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Services
|
||||||
|
summary: Attach a domain
|
||||||
|
description: Adds a hostname routed to the service via Traefik. The first domain becomes default automatically.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- domain
|
||||||
|
properties:
|
||||||
|
domain:
|
||||||
|
type: string
|
||||||
|
is_default:
|
||||||
|
type: boolean
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Domain attached
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
domain:
|
||||||
|
$ref: '#/components/schemas/ServiceDomain'
|
||||||
|
'409':
|
||||||
|
description: Domain already attached
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
/services/{id}/domains/check:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Services
|
||||||
|
summary: DNS preflight
|
||||||
|
description: Resolves every attached domain and compares it to the expected target (app_settings.public_ip / PUBLIC_IP, or base_url host)
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Per-domain DNS status
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
domains:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/DomainCheckResult'
|
||||||
|
|
||||||
|
/services/{id}/domains/{domain_id}:
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- Services
|
||||||
|
summary: Detach a domain
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: domain_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Domain removed
|
||||||
|
|
||||||
|
/services/{id}/domains/{domain_id}/default:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Services
|
||||||
|
summary: Set the default domain
|
||||||
|
description: Marks the domain default and mirrors it to services.domain
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: domain_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Updated domain list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
domains:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ServiceDomain'
|
||||||
|
|
||||||
|
/maintenance:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Services
|
||||||
|
summary: Maintenance page
|
||||||
|
description: Public HTML page Traefik redirects to while a service is in maintenance mode
|
||||||
|
|
||||||
/services/{id}/deployments:
|
/services/{id}/deployments:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -569,6 +839,17 @@ paths:
|
|||||||
description: Service ID
|
description: Service ID
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 50
|
||||||
|
maximum: 100
|
||||||
|
- name: cursor
|
||||||
|
in: query
|
||||||
|
description: Opaque cursor from a previous response's pagination.next_cursor
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Deployments retrieved successfully
|
description: Deployments retrieved successfully
|
||||||
@@ -621,6 +902,12 @@ paths:
|
|||||||
type: integer
|
type: integer
|
||||||
default: 10
|
default: 10
|
||||||
maximum: 50
|
maximum: 50
|
||||||
|
- name: cursor
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Opaque cursor from a previous response's pagination.next_cursor
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Recent deployments
|
description: Recent deployments
|
||||||
@@ -681,6 +968,45 @@ paths:
|
|||||||
deployment:
|
deployment:
|
||||||
$ref: '#/components/schemas/Deployment'
|
$ref: '#/components/schemas/Deployment'
|
||||||
|
|
||||||
|
/deployments/{id}/cancel:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Deployments
|
||||||
|
summary: Cancel a deployment
|
||||||
|
description: Cancel a queued or in-flight deployment. Queued deployments are marked cancelled immediately; running ones get their build/reconcile aborted and settle as 'cancelled' shortly after.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Deployment ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Queued deployment cancelled
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status: { type: string, example: cancelled }
|
||||||
|
'202':
|
||||||
|
description: Running deployment is cancelling
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status: { type: string, example: cancelling }
|
||||||
|
'409':
|
||||||
|
description: Deployment already finished
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
/deployments/{id}/rollback:
|
/deployments/{id}/rollback:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -1141,6 +1467,51 @@ paths:
|
|||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
/admin/volumes:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Admin
|
||||||
|
summary: List docker volumes
|
||||||
|
description: Returns the node's docker volume inventory with in-use flags. Admin only.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Volume list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
volumes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/VolumeInfo'
|
||||||
|
|
||||||
|
/admin/volumes/{name}:
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- Admin
|
||||||
|
summary: Delete a docker volume
|
||||||
|
description: Removes an unused docker volume. Docker refuses in-use volumes — returned as 409.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: name
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Volume deleted
|
||||||
|
'409':
|
||||||
|
description: Volume in use or removal failed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
/templates/{id}:
|
/templates/{id}:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -4784,6 +5155,24 @@ components:
|
|||||||
restart_policy:
|
restart_policy:
|
||||||
type: string
|
type: string
|
||||||
description: Docker restart policy (default unless-stopped)
|
description: Docker restart policy (default unless-stopped)
|
||||||
|
volumes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ServiceVolume'
|
||||||
|
description: Volume/bind mounts applied to every replica
|
||||||
|
domains:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ServiceDomain'
|
||||||
|
description: All attached hostnames; `domain` is the derived default
|
||||||
|
maintenance_mode:
|
||||||
|
type: boolean
|
||||||
|
description: Redirect all traffic to the platform maintenance page
|
||||||
|
basic_auth:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Basic-auth usernames gating the route (hashes never returned)
|
||||||
public_url:
|
public_url:
|
||||||
type: string
|
type: string
|
||||||
description: Computed public URL (published port or domain)
|
description: Computed public URL (published port or domain)
|
||||||
@@ -4854,6 +5243,11 @@ components:
|
|||||||
restart_policy:
|
restart_policy:
|
||||||
type: string
|
type: string
|
||||||
description: Docker restart policy
|
description: Docker restart policy
|
||||||
|
volumes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ServiceVolume'
|
||||||
|
description: Volume/bind mounts applied to every replica
|
||||||
|
|
||||||
UpdateServiceRequest:
|
UpdateServiceRequest:
|
||||||
type: object
|
type: object
|
||||||
@@ -4905,6 +5299,114 @@ components:
|
|||||||
restart_policy:
|
restart_policy:
|
||||||
type: string
|
type: string
|
||||||
description: Docker restart policy
|
description: Docker restart policy
|
||||||
|
volumes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ServiceVolume'
|
||||||
|
description: Replaces the entire mount list when present; [] clears all mounts. Takes effect on next deploy/reconcile.
|
||||||
|
maintenance_mode:
|
||||||
|
type: boolean
|
||||||
|
description: Redirect all traffic to the platform maintenance page
|
||||||
|
basic_auth:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/BasicAuthCred'
|
||||||
|
description: Replaces all basic-auth credentials; [] clears the gate
|
||||||
|
|
||||||
|
ServiceDomain:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
service_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
domain:
|
||||||
|
type: string
|
||||||
|
is_default:
|
||||||
|
type: boolean
|
||||||
|
cert_type:
|
||||||
|
type: string
|
||||||
|
cert_status:
|
||||||
|
type: string
|
||||||
|
enum: [pending, issued, failed]
|
||||||
|
last_checked_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
DomainCheckResult:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
domain:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [ok, wrong-target, pending]
|
||||||
|
description: ok = resolves to expected target (or no expectation configured), wrong-target = resolves elsewhere, pending = no DNS records
|
||||||
|
resolved:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: A/AAAA records resolved
|
||||||
|
|
||||||
|
BasicAuthCred:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- username
|
||||||
|
- password
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
description: Plaintext on write — stored as bcrypt htpasswd; never returned
|
||||||
|
|
||||||
|
ServiceVolume:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- source
|
||||||
|
- target
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [volume, bind]
|
||||||
|
default: volume
|
||||||
|
description: volume = docker named volume, bind = host path
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
description: Volume name or host path
|
||||||
|
target:
|
||||||
|
type: string
|
||||||
|
description: Absolute container path
|
||||||
|
read_only:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
VolumeInfo:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
driver:
|
||||||
|
type: string
|
||||||
|
mountpoint:
|
||||||
|
type: string
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
in_use:
|
||||||
|
type: boolean
|
||||||
|
used_by:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Container names mounting this volume
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
Deployment:
|
Deployment:
|
||||||
type: object
|
type: object
|
||||||
@@ -4978,6 +5480,9 @@ components:
|
|||||||
additionalProperties:
|
additionalProperties:
|
||||||
type: string
|
type: string
|
||||||
description: Runtime environment variable overrides
|
description: Runtime environment variable overrides
|
||||||
|
no_cache:
|
||||||
|
type: boolean
|
||||||
|
description: Build without Docker layer cache
|
||||||
|
|
||||||
RollbackDeploymentResponse:
|
RollbackDeploymentResponse:
|
||||||
type: object
|
type: object
|
||||||
@@ -5859,6 +6364,9 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/VariableInput'
|
$ref: '#/components/schemas/VariableInput'
|
||||||
|
redeploy:
|
||||||
|
type: boolean
|
||||||
|
description: Queue a service redeploy after saving so running containers pick up the new variables
|
||||||
|
|
||||||
ConnectGitHubAppRequest:
|
ConnectGitHubAppRequest:
|
||||||
type: object
|
type: object
|
||||||
@@ -6395,6 +6903,66 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: Human-readable label for the token (e.g. node name)
|
description: Human-readable label for the token (e.g. node name)
|
||||||
|
|
||||||
|
UserToken:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
key_prefix:
|
||||||
|
type: string
|
||||||
|
description: First 12 chars of the token, for identification in lists
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
enum: [read, write, admin]
|
||||||
|
expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
last_used_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
- key_prefix
|
||||||
|
- scope
|
||||||
|
- created_at
|
||||||
|
|
||||||
|
UserTokenCreated:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/UserToken'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
description: Raw cnp_ token value, returned exactly once at creation
|
||||||
|
required:
|
||||||
|
- token
|
||||||
|
|
||||||
|
CreateUserTokenRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Human-readable label (e.g. "laptop cli", "ci deploy")
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
enum: [read, write, admin]
|
||||||
|
default: write
|
||||||
|
description: read = GET/HEAD only; write = all non-admin routes; admin = everything (requires admin account)
|
||||||
|
expires_in_days:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 3650
|
||||||
|
description: 0 or omitted = never expires
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
|
||||||
AgentCapabilities:
|
AgentCapabilities:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Containr CLI
|
||||||
|
|
||||||
|
The CLI controls every part of a Containr instance — the same API surface
|
||||||
|
the web UI uses. Build it with `scripts/build-cli.sh` or
|
||||||
|
`go build ./cmd/cli` from `app/backend`.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Create a personal access token in the web UI under
|
||||||
|
**Settings → Personal Access Tokens** (or `containr tokens create`), then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
containr auth login cnp_... # verify + store
|
||||||
|
containr auth login cnp_... --url https://containr.example.com
|
||||||
|
containr auth login cnp_... --name production # named profile
|
||||||
|
containr auth status # show active profile + identity
|
||||||
|
containr auth logout
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolution order for every command: `--token`/`--api-url` flags >
|
||||||
|
`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >
|
||||||
|
`http://localhost:8080/api/v1`. The active profile is `--profile` >
|
||||||
|
`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.
|
||||||
|
|
||||||
|
## Output and scripting
|
||||||
|
|
||||||
|
- `--json` prints the raw API response — safe to pipe and parse.
|
||||||
|
- `--yes` / `-y` skips confirmation on destructive commands (delete,
|
||||||
|
revoke, restore, failover). Non-interactive shells refuse destructive
|
||||||
|
commands without it.
|
||||||
|
- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,
|
||||||
|
`3` not found, `4` validation, `5` unreachable.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```
|
||||||
|
auth login|logout|status token auth and profiles
|
||||||
|
tokens list|create|revoke personal access tokens
|
||||||
|
projects list|get|create|delete
|
||||||
|
services list|get|create|update|delete|start|stop|restart|redeploy
|
||||||
|
services domains list|add|remove|default|check multi-domain + DNS preflight
|
||||||
|
volumes list|delete docker volume inventory + cleanup (admin)
|
||||||
|
deploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)
|
||||||
|
deployments list|get|logs|rollback|cancel
|
||||||
|
logs <service-id> [--follow] runtime logs (SSE follow)
|
||||||
|
exec <service-id> -- <cmd> one-off container command
|
||||||
|
variables list|set|unset env vars (secrets masked; --redeploy applies them)
|
||||||
|
databases list|get|create|delete|action|backup|restore|download-backup
|
||||||
|
cron list|get|create|delete|trigger|executions
|
||||||
|
templates list|get|create|delete|deploy
|
||||||
|
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
|
||||||
|
scaling status|policies|services|scale
|
||||||
|
ha status|alerts|health|policies|enable|disable|failover
|
||||||
|
security scan|vulnerabilities|metrics
|
||||||
|
gateway services|keys|analytics (admin)
|
||||||
|
admin overview|users|settings|audit-logs
|
||||||
|
notifications list|read
|
||||||
|
up deploy the current directory
|
||||||
|
```
|
||||||
|
|
||||||
|
## containr up
|
||||||
|
|
||||||
|
Run inside a git checkout: uses `remote.origin.url` + the current branch
|
||||||
|
as the build source, creates a service named after the directory when none
|
||||||
|
exists, then triggers a deployment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
containr up --project <id>
|
||||||
|
containr up --project <id> --image ghcr.io/example/app:latest # image, no build
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `project_id` on the active profile to omit `--project` (stored in
|
||||||
|
`~/.containr.yaml` under `profiles.<name>.project_id`).
|
||||||
|
|
||||||
|
## Service volumes
|
||||||
|
|
||||||
|
`services update` manages mounts; flags replace the whole mount list:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
containr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro
|
||||||
|
containr services update <id> --clear-volumes
|
||||||
|
```
|
||||||
|
|
||||||
|
`type` is `volume` (docker named volume, auto-created) or `bind` (host
|
||||||
|
path). `target` must be absolute. Mounts apply on the next deploy or
|
||||||
|
redeploy — `containr deploy <id>` to apply immediately.
|
||||||
|
|
||||||
|
`volumes list` shows the node's docker volume inventory with in-use
|
||||||
|
flags; `volumes delete <name>` removes an unused volume (admin scope).
|
||||||
|
|
||||||
|
## Service domains and access
|
||||||
|
|
||||||
|
A service can carry multiple hostnames; the default is mirrored to
|
||||||
|
`services.domain`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
containr services domains add <id> app.example.com --default
|
||||||
|
containr services domains check <id> # DNS preflight per domain
|
||||||
|
containr services domains remove <id> <domain-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
DNS check compares each domain to the expected target — set
|
||||||
|
`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is
|
||||||
|
meaningful; without it any resolvable domain reports `ok`.
|
||||||
|
|
||||||
|
Access gates apply via Traefik middlewares (no extra proxy):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
containr services update <id> --maintenance on
|
||||||
|
containr services update <id> --basic-auth admin:secret --basic-auth ops:pw2
|
||||||
|
```
|
||||||
|
|
||||||
|
Maintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`
|
||||||
|
— set `app_settings.base_url` or `BASE_URL`; without it the service is
|
||||||
|
taken offline with empty responses. Basic-auth credentials are stored
|
||||||
|
bcrypt-hashed (htpasswd) and gate every request.
|
||||||
|
|
||||||
|
## MCP server
|
||||||
|
|
||||||
|
`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools
|
||||||
|
over stdio — every command group above maps to a `containr_*` tool
|
||||||
|
(~60 tools). Destructive tools require the caller to pass
|
||||||
|
`confirm: true`; results are pretty JSON with `isError` on failures.
|
||||||
|
|
||||||
|
Client configuration (Claude Code, Cursor, Devin, any stdio MCP host):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"containr": {
|
||||||
|
"command": "containr-mcp",
|
||||||
|
"env": {
|
||||||
|
"CONTAINR_API_URL": "https://containr.example.com",
|
||||||
|
"CONTAINR_TOKEN": "cnp_..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Scope the token deliberately: `read` is enough for inspection agents,
|
||||||
|
`write` for deploy automation, `admin` only for platform operations.
|
||||||
|
|
||||||
|
Deployments are serialized per service: a second `deploy` on a busy
|
||||||
|
service lands in `queued` state and runs when the active one finishes.
|
||||||
|
`deployments cancel <id>` drops a queued deployment or aborts a running
|
||||||
|
one (builds/reconciles stop via context cancellation).
|
||||||
@@ -90,3 +90,47 @@ backend restart.
|
|||||||
once), copy the prefilled install command, revoke tokens when done. Agents
|
once), copy the prefilled install command, revoke tokens when done. Agents
|
||||||
register over `POST /api/agents/register` and heartbeat via
|
register over `POST /api/agents/register` and heartbeat via
|
||||||
`POST /api/agents/heartbeat`.
|
`POST /api/agents/heartbeat`.
|
||||||
|
|
||||||
|
## Personal access tokens
|
||||||
|
|
||||||
|
**Settings → Personal Access Tokens**: issue `cnp_…` bearer tokens for the
|
||||||
|
CLI, MCP server, and scripts — the same privileges as your account, bounded
|
||||||
|
by scope. The raw token is shown once at creation; only its SHA-256 hash is
|
||||||
|
stored.
|
||||||
|
|
||||||
|
Scopes:
|
||||||
|
|
||||||
|
- `read` — `GET`/`HEAD`/`OPTIONS` only; mutations return `403 SCOPE_INSUFFICIENT`.
|
||||||
|
- `write` — everything except admin routes.
|
||||||
|
- `admin` — full access; requires a platform admin account, and the token is
|
||||||
|
still capped at the owner's privileges.
|
||||||
|
|
||||||
|
A token can never outrank its owner: non-admin scopes report
|
||||||
|
`is_admin = false` even on admin accounts.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/user/tokens list (metadata only, never hashes)
|
||||||
|
POST /api/v1/user/tokens {name, scope?, expires_in_days?} → raw token once
|
||||||
|
DELETE /api/v1/user/tokens/:id revoke (owner-scoped)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`.
|
||||||
|
Revocation takes effect on the next request; `last_used_at` updates on each
|
||||||
|
authenticated call.
|
||||||
|
|
||||||
|
## Deployment queue
|
||||||
|
|
||||||
|
Deployments are serialized per service: a deploy while another is active
|
||||||
|
lands in `queued` state and promotes when it finishes — deploys, rollbacks,
|
||||||
|
redeploys, and variable-apply reconciles can never race on the same service.
|
||||||
|
`POST /deployments/:id/cancel` drops queued work or aborts running builds.
|
||||||
|
Rows left in-flight by a server restart are marked `failed` on boot.
|
||||||
|
Webhook-triggered deploys build with `no_cache` by default.
|
||||||
|
|
||||||
|
## Secret variables
|
||||||
|
|
||||||
|
Variables with `is_secret` are AES-GCM-encrypted at rest (`enc:v1:` prefix).
|
||||||
|
Key derivation: `SECRETS_KEY` env var if set, else `JWT_SECRET`. Existing
|
||||||
|
plaintext secrets are encrypted on boot; reads for deploy/exec decrypt
|
||||||
|
transparently. Rotating the key makes stored secrets unreadable — re-save
|
||||||
|
them after rotation.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Containr
|
||||||
|
|
||||||
|
> Self-hosted Docker PaaS — deploy and manage containerized apps, databases, cron jobs and preview environments. Operable by humans (web UI) and agents (API / CLI / MCP) through one contract.
|
||||||
|
|
||||||
|
Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
|
||||||
|
|
||||||
|
## Access (pick one)
|
||||||
|
|
||||||
|
- **API**: `Authorization: Bearer <pat>` — create a PAT in Settings → Access Tokens or `containr tokens create`. Scopes: `read` (GET/HEAD/OPTIONS only), `write` (everything but admin routes), `admin` (requires platform-admin owner).
|
||||||
|
- **CLI**: `containr auth login --api-url <url>` (or `--token cnp_…`), then e.g. `containr projects list --json`, `containr up`. Global flags: `--json`, `--yes`, `--profile`, `--idempotency-key`.
|
||||||
|
- **MCP**: run `containr-mcp` over stdio with `CONTAINR_API_URL` + `CONTAINR_TOKEN` env (or CLI profile). ~60 tools mirroring the API; destructive tools require `confirm: true`.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Errors**: `{"error": "msg", "code": "MACHINE_CODE"}`. Codes: `UNAUTHENTICATED`, `INVALID_TOKEN`, `FORBIDDEN`, `ADMIN_REQUIRED`, `SCOPE_INSUFFICIENT`, `NOT_FOUND`, `VALIDATION`, `CONFLICT`, `INTERNAL`, `DEPENDENCY_UNAVAILABLE`, `IDEMPOTENCY_IN_PROGRESS`, `NOT_CANCELLABLE`.
|
||||||
|
- **Idempotency**: send `Idempotency-Key: <uuid>` on POSTs — the first response replays for 24h; concurrent duplicates get 409. Always safe to send.
|
||||||
|
- **Pagination**: `?limit=` + `?cursor=` (pass `pagination.next_cursor` verbatim; legacy `?page=` also works). Invalid cursors fall back to page 1.
|
||||||
|
- **Deployments**: serialized per service — a deploy on a busy service returns `status: queued` and runs when the active one finishes. `POST /deployments/{id}/cancel` drops queued (`200 cancelled`) or aborts running (`202 cancelling`) work. `no_cache: true` on create disables the Docker layer cache. `PUT /services/{id}/variables` accepts `redeploy: true` to apply env changes to running containers.
|
||||||
|
- **Volumes**: service mounts via `PUT /services/{id}` `volumes: [{type: volume|bind, source, target, read_only}]` — replaces the whole list (`[]` clears), applied on next deploy. `GET /admin/volumes` inventories node volumes with `in_use` flags; `DELETE /admin/volumes/{name}` removes unused ones (admin).
|
||||||
|
- **Domains**: multiple hostnames per service via `GET|POST /services/{id}/domains`, `DELETE /services/{id}/domains/{domain_id}`, `POST …/{domain_id}/default` (mirrors `services.domain`). `GET /services/{id}/domains/check` returns `ok|wrong-target|pending` per domain vs `app_settings.public_ip`/`PUBLIC_IP`. `PUT` `maintenance_mode` redirects all traffic to `GET /maintenance` (needs `base_url`/`BASE_URL` setting); `basic_auth: [{username,password}]` stores bcrypt htpasswd and gates the route — responses return usernames only.
|
||||||
|
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
|
||||||
|
|
||||||
|
## Core objects
|
||||||
|
|
||||||
|
`projects` → `services` (git/app deploys) → `deployments`; plus `databases`, `templates`, `cron-jobs`, `variables`, `volumes`, `preview-environments`, `nodes`, `scaling`, `security`, `gateway`, `admin`, `notifications`, `user/tokens`.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
- `docs/guides/CLI.md` — full CLI reference
|
||||||
|
- `docs/guides/OPERATIONS.md` — PATs, agent tokens, ops
|
||||||
|
- `docs/api/openapi.yaml` — request/response contract
|
||||||
|
- `ROADMAP2.md` — product roadmap
|
||||||
|
- `AGENTS.md` — repo conventions for coding agents
|
||||||
Reference in new issue
Block a user