mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c15e6514df | ||
|
|
a91e6531f5 | ||
|
|
9f5dcc7296 |
No files matched your search
@@ -1 +0,0 @@
|
|||||||
github: Dvorinka
|
|
||||||
@@ -17,27 +17,6 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Cheap gate so docs-only/roadmap PRs skip the heavyweight jobs. Skipped
|
|
||||||
# required checks still satisfy branch protection ("skipped" = success).
|
|
||||||
changes:
|
|
||||||
name: Changed paths
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
code: ${{ steps.filter.outputs.code }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: dorny/paths-filter@v3
|
|
||||||
id: filter
|
|
||||||
with:
|
|
||||||
filters: |
|
|
||||||
code:
|
|
||||||
- 'app/**'
|
|
||||||
- 'infra/**'
|
|
||||||
- 'docker-compose.yml'
|
|
||||||
- 'install.sh'
|
|
||||||
- '.github/workflows/**'
|
|
||||||
- 'docs/api/**'
|
|
||||||
|
|
||||||
infra:
|
infra:
|
||||||
name: Infra (Compose + Scripts)
|
name: Infra (Compose + Scripts)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -69,8 +48,6 @@ jobs:
|
|||||||
backend:
|
backend:
|
||||||
name: Backend (Go)
|
name: Backend (Go)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: changes
|
|
||||||
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: app/backend
|
working-directory: app/backend
|
||||||
@@ -93,8 +70,6 @@ jobs:
|
|||||||
frontend:
|
frontend:
|
||||||
name: Frontend (React)
|
name: Frontend (React)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: changes
|
|
||||||
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: app/frontend
|
working-directory: app/frontend
|
||||||
@@ -125,8 +100,6 @@ jobs:
|
|||||||
name: Compose boot smoke
|
name: Compose boot smoke
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
needs: changes
|
|
||||||
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -164,7 +137,6 @@ jobs:
|
|||||||
name: Build & publish images
|
name: Build & publish images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [backend, frontend, smoke]
|
needs: [backend, frontend, smoke]
|
||||||
if: needs.backend.result == 'success' && needs.frontend.result == 'success' && needs.smoke.result == 'success'
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -255,7 +227,7 @@ jobs:
|
|||||||
name: Release
|
name: Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [docker]
|
needs: [docker]
|
||||||
if: github.event_name != 'pull_request' && needs.docker.result == 'success'
|
if: github.event_name != 'pull_request'
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
# 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,8 +56,7 @@ 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), personal access tokens with `read`/`write`/`admin` scopes for CLI and agents, audit logs, vulnerability scans
|
- **Auth & security** — Better Auth sessions (email/password self-hosted), 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
@@ -1,396 +0,0 @@
|
|||||||
# 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,5 +15,8 @@ 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))
|
||||||
os.Exit(cli.Execute())
|
if err := cli.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-9
@@ -8,12 +8,11 @@ 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.3.1
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
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
|
||||||
@@ -22,7 +21,6 @@ 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 (
|
||||||
@@ -47,7 +45,6 @@ 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
|
||||||
@@ -66,8 +63,6 @@ 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
|
||||||
@@ -76,7 +71,6 @@ 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
|
||||||
@@ -90,11 +84,10 @@ 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.15.0 // indirect
|
golang.org/x/time v0.11.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
|
||||||
|
|||||||
+4
-18
@@ -65,13 +65,11 @@ 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.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
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=
|
||||||
@@ -107,8 +105,6 @@ 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=
|
||||||
@@ -147,10 +143,6 @@ 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=
|
||||||
@@ -186,8 +178,6 @@ 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=
|
||||||
@@ -223,20 +213,16 @@ 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.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
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,13 +3,10 @@ package api
|
|||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
"containr/internal/deployqueue"
|
|
||||||
"containr/internal/docker"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -38,7 +35,6 @@ 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 {
|
||||||
@@ -78,21 +74,14 @@ 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 $2 OFFSET $3`,
|
LIMIT 50`,
|
||||||
serviceID, limit, offset,
|
serviceID,
|
||||||
)
|
)
|
||||||
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"})
|
||||||
@@ -115,10 +104,7 @@ func handleGetDeployments(c *gin.Context) {
|
|||||||
deployments = append(deployments, d)
|
deployments = append(deployments, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
||||||
"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.
|
||||||
@@ -146,19 +132,11 @@ func handleGetRecentDeployments(c *gin.Context) {
|
|||||||
userID := optionalUserUUID(c)
|
userID := optionalUserUUID(c)
|
||||||
isAdmin := contextIsAdmin(c)
|
isAdmin := contextIsAdmin(c)
|
||||||
|
|
||||||
offset, limit := pageWindow(c, 10, 50)
|
limit := 10
|
||||||
|
if v := c.Query("limit"); v != "" {
|
||||||
var total int
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
|
||||||
if err := db.(*database.DB).QueryRow(
|
limit = n
|
||||||
`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(
|
||||||
@@ -170,8 +148,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 OFFSET $4`,
|
LIMIT $3`,
|
||||||
userID, isAdmin, limit, offset,
|
userID, isAdmin, limit,
|
||||||
)
|
)
|
||||||
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"})
|
||||||
@@ -190,10 +168,7 @@ func handleGetRecentDeployments(c *gin.Context) {
|
|||||||
deployments = append(deployments, d)
|
deployments = append(deployments, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
||||||
"deployments": deployments,
|
|
||||||
"pagination": paginationMeta(offset, limit, total),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCreateDeployment(c *gin.Context) {
|
func handleCreateDeployment(c *gin.Context) {
|
||||||
@@ -234,9 +209,6 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
|
||||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
|
||||||
COALESCE(s.static_dir, ''),
|
|
||||||
s.created_at, s.updated_at, p.owner_id
|
s.created_at, s.updated_at, p.owner_id
|
||||||
FROM services s
|
FROM services s
|
||||||
JOIN projects p ON s.project_id = p.id
|
JOIN projects p ON s.project_id = p.id
|
||||||
@@ -247,9 +219,7 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectOwner,
|
||||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
|
||||||
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -319,23 +289,7 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
engineInstance := engine.(*deployment.DeploymentEngine)
|
engineInstance := engine.(*deployment.DeploymentEngine)
|
||||||
pos := getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
|
go runDeploymentAndSync(context.Background(), db.(*database.DB), engineInstance, &d, service, req, userID.(string))
|
||||||
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{
|
||||||
@@ -408,19 +362,6 @@ 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)
|
||||||
|
|
||||||
publicPort := int32(service.Port)
|
|
||||||
if service.Builder == "static" && publicPort == 0 {
|
|
||||||
// The static builder's image is nginx — route to its default port
|
|
||||||
// unless the user picked another.
|
|
||||||
publicPort = 80
|
|
||||||
}
|
|
||||||
|
|
||||||
maintenanceMode, basicAuthUsers := serviceAccess(db, service.ID)
|
|
||||||
maintURL := ""
|
|
||||||
if maintenanceMode {
|
|
||||||
maintURL = maintenanceURL(db)
|
|
||||||
}
|
|
||||||
|
|
||||||
deployReq := &deployment.DeploymentRequest{
|
deployReq := &deployment.DeploymentRequest{
|
||||||
ProjectID: service.ProjectID.String(),
|
ProjectID: service.ProjectID.String(),
|
||||||
ServiceID: service.ID.String(),
|
ServiceID: service.ID.String(),
|
||||||
@@ -431,22 +372,15 @@ func runDeploymentAndSyncWithImage(
|
|||||||
Command: command,
|
Command: command,
|
||||||
Environment: env,
|
Environment: env,
|
||||||
Replicas: replicas,
|
Replicas: replicas,
|
||||||
PublicPort: publicPort,
|
PublicPort: int32(service.Port),
|
||||||
PublishedPort: publishedPort,
|
PublishedPort: publishedPort,
|
||||||
Domain: service.Domain,
|
Domain: service.Domain,
|
||||||
HealthPath: service.HealthCheckPath,
|
HealthPath: service.HealthCheckPath,
|
||||||
RestartPolicy: service.RestartPolicy,
|
RestartPolicy: service.RestartPolicy,
|
||||||
VolumeMounts: loadServiceVolumes(db, service.ID),
|
|
||||||
Domains: serviceDomainNames(db, service.ID, service.Domain),
|
|
||||||
Resources: deployment.ResourceLimits{
|
Resources: deployment.ResourceLimits{
|
||||||
MemoryBytes: parseMemoryLimit(service.Memory),
|
MemoryBytes: parseMemoryLimit(service.Memory),
|
||||||
MemoryReservation: parseMemoryLimit(service.MemoryReserve),
|
CPUQuota: parseCPULimit(service.CPU),
|
||||||
CPUQuota: parseCPULimit(service.CPU),
|
|
||||||
CPUShares: parseCPUReserveShares(service.CPUReserve),
|
|
||||||
},
|
},
|
||||||
Maintenance: maintenanceMode,
|
|
||||||
MaintenanceURL: maintURL,
|
|
||||||
BasicAuthUsers: basicAuthUsers,
|
|
||||||
},
|
},
|
||||||
Trigger: deployment.TriggerConfig{
|
Trigger: deployment.TriggerConfig{
|
||||||
Type: req.Trigger,
|
Type: req.Trigger,
|
||||||
@@ -456,50 +390,24 @@ func runDeploymentAndSyncWithImage(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if imageOverride != "" || service.GitRepo == "" {
|
if imageOverride != "" {
|
||||||
// Prebuilt image deploy (rollback, redeploy, or image-sourced
|
|
||||||
// service): resolve private-registry credentials by image host.
|
|
||||||
image := imageOverride
|
|
||||||
if image == "" {
|
|
||||||
image = service.Image
|
|
||||||
}
|
|
||||||
var ownerID string
|
|
||||||
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
|
|
||||||
auth := registryAuthFor(db, ownerID, image)
|
|
||||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||||
BuildType: "prebuilt",
|
BuildType: "prebuilt",
|
||||||
PrebuiltImage: image,
|
PrebuiltImage: imageOverride,
|
||||||
PullUsername: auth.Username,
|
}
|
||||||
PullPassword: auth.Password,
|
} else if service.GitRepo == "" {
|
||||||
|
// Image-sourced service: pull the image instead of building.
|
||||||
|
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||||
|
BuildType: "prebuilt",
|
||||||
|
PrebuiltImage: service.Image,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// "auto" leaves BuildType empty so the manager detects it; the static
|
|
||||||
// builder additionally feeds its generated Dockerfile the service's
|
|
||||||
// build command and output dir.
|
|
||||||
buildType := ""
|
|
||||||
if service.Builder != "" && service.Builder != "auto" {
|
|
||||||
buildType = service.Builder
|
|
||||||
}
|
|
||||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||||
BuildType: buildType,
|
BuildType: "nixpacks",
|
||||||
SourcePath: sourcePath,
|
SourcePath: sourcePath,
|
||||||
Branch: req.Branch,
|
Branch: req.Branch,
|
||||||
Commit: req.CommitHash,
|
Commit: req.CommitHash,
|
||||||
NoCache: req.NoCache,
|
|
||||||
}
|
}
|
||||||
if buildType == "static" {
|
|
||||||
deployReq.BuildConfig.BuildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build")
|
|
||||||
deployReq.BuildConfig.BuildArgs = map[string]string{"STATIC_DIR": firstNonEmpty(service.StaticDir, "dist")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg := capacityCheck(parentCtx, db, engine.DockerClient(), service, replicas); msg != "" {
|
|
||||||
failedAt := time.Now()
|
|
||||||
_, _ = db.Exec(
|
|
||||||
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
|
||||||
msg, failedAt, dbDeployment.ID,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
engineDeployment, err := engine.Deploy(ctx, deployReq)
|
engineDeployment, err := engine.Deploy(ctx, deployReq)
|
||||||
@@ -526,19 +434,16 @@ func runDeploymentAndSyncWithImage(
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
failedAt := time.Now()
|
failedAt := time.Now()
|
||||||
finalStatus, finalErr := "failed", "Deployment timed out before completion"
|
timeoutErr := "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 = $1, error = $2, completed_at = $3, updated_at = $3
|
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
|
||||||
WHERE id = $4`,
|
WHERE id = $3`,
|
||||||
finalStatus, finalErr, failedAt, dbDeployment.ID,
|
timeoutErr, failedAt, dbDeployment.ID,
|
||||||
)
|
)
|
||||||
_, _ = db.Exec(
|
_, _ = db.Exec(
|
||||||
`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`,
|
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||||
finalStatus, failedAt, service.ID,
|
failedAt, service.ID,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
case <-syncTicker.C:
|
case <-syncTicker.C:
|
||||||
@@ -605,86 +510,13 @@ func runDeploymentAndSyncWithImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mapEngineStatusToDBStatus(status string) string {
|
func mapEngineStatusToDBStatus(status string) string {
|
||||||
if status == "running" {
|
|
||||||
return "deployed"
|
|
||||||
}
|
|
||||||
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 {
|
switch status {
|
||||||
case "deployed", "failed", "cancelled":
|
case "running":
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "Deployment already finished", "code": "NOT_CANCELLABLE"})
|
return "deployed"
|
||||||
return
|
case "cancelled":
|
||||||
}
|
return "failed"
|
||||||
|
|
||||||
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:
|
default:
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "Deployment is not running on this node", "code": "NOT_CANCELLABLE"})
|
return status
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,9 +695,6 @@ func handleRollbackDeployment(c *gin.Context) {
|
|||||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
|
||||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
|
||||||
COALESCE(s.static_dir, ''),
|
|
||||||
s.created_at, s.updated_at
|
s.created_at, s.updated_at
|
||||||
FROM services s WHERE s.id = $1`, serviceID,
|
FROM services s WHERE s.id = $1`, serviceID,
|
||||||
).Scan(
|
).Scan(
|
||||||
@@ -873,9 +702,7 @@ func handleRollbackDeployment(c *gin.Context) {
|
|||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
||||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
|
||||||
&service.CreatedAt, &service.UpdatedAt,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
rollbackReq := CreateDeploymentRequest{
|
rollbackReq := CreateDeploymentRequest{
|
||||||
@@ -888,12 +715,7 @@ func handleRollbackDeployment(c *gin.Context) {
|
|||||||
Trigger: "rollback",
|
Trigger: "rollback",
|
||||||
}
|
}
|
||||||
// Reuse the stored image directly — no rebuild.
|
// Reuse the stored image directly — no rebuild.
|
||||||
getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
|
go runDeploymentAndSyncWithImage(context.Background(), db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
|
||||||
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{
|
||||||
@@ -909,35 +731,3 @@ func handleRollbackDeployment(c *gin.Context) {
|
|||||||
"message": "Rollback initiated",
|
"message": "Rollback initiated",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// capacityCheck compares the requested per-replica memory (limit, falling
|
|
||||||
// back to reservation) against node capacity. app_settings.capacity_policy
|
|
||||||
// = "block" fails the deployment; the default "warn" only logs. CPU is not
|
|
||||||
// gated — shares are a weight, not a reservation.
|
|
||||||
func capacityCheck(ctx context.Context, db *database.DB, client *docker.Client, service Service, replicas int) string {
|
|
||||||
if client == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
requested := parseMemoryLimit(service.Memory)
|
|
||||||
if requested == 0 {
|
|
||||||
requested = parseMemoryLimit(service.MemoryReserve)
|
|
||||||
}
|
|
||||||
if requested == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
info, err := client.GetSystemInfo(ctx)
|
|
||||||
if err != nil || info.MemTotal <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
need := requested * int64(replicas)
|
|
||||||
if need <= info.MemTotal {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
msg := fmt.Sprintf("requested %d bytes x %d replicas exceeds node memory (%d bytes)",
|
|
||||||
requested, replicas, info.MemTotal)
|
|
||||||
if strings.ToLower(settingValue(db, "capacity_policy", "", "warn")) == "block" {
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
slog.Warn("capacity check exceeded", "service_id", service.ID, "detail", msg)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,375 +0,0 @@
|
|||||||
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>`))
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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,7 +14,6 @@ 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"
|
||||||
@@ -179,24 +178,11 @@ 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,
|
||||||
)
|
)
|
||||||
// Webhook pushes default to a clean build — stale layers are the
|
go runDeploymentAndSync(
|
||||||
// classic silent-rollback failure (dflow convention).
|
context.Background(), db, engine, &d, service,
|
||||||
webhookReq := CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook", NoCache: true}
|
CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook"},
|
||||||
if pos := getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
|
repoUserID,
|
||||||
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++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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,6 +8,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -54,9 +55,18 @@ 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)
|
||||||
|
|
||||||
offset, limit := pageWindow(c, 10, 100)
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
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}
|
||||||
@@ -89,9 +99,15 @@ 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": paginationMeta(offset, limit, int(total)),
|
"pagination": gin.H{
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"total": totalCount,
|
||||||
|
"pages": (totalCount + limit - 1) / limit,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"containr/internal/database"
|
|
||||||
"containr/internal/secrets"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Project-level shared variables — referenced from service env values as
|
|
||||||
// ${{shared.KEY}} and resolved at runtime in resolveServiceEnv.
|
|
||||||
|
|
||||||
type ProjectVariable struct {
|
|
||||||
ID uuid.UUID `json:"id" db:"id"`
|
|
||||||
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
|
|
||||||
Key string `json:"key" db:"key"`
|
|
||||||
Value string `json:"value" db:"value"`
|
|
||||||
IsSecret bool `json:"is_secret" db:"is_secret"`
|
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleGetProjectVariables(c *gin.Context) {
|
|
||||||
db, exists := c.Get("db")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
projectID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := db.(*database.DB).Query(
|
|
||||||
`SELECT id, project_id, key, value, is_secret, created_at, updated_at
|
|
||||||
FROM project_variables WHERE project_id = $1 ORDER BY key ASC`,
|
|
||||||
projectID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
variables := []ProjectVariable{}
|
|
||||||
for rows.Next() {
|
|
||||||
var v ProjectVariable
|
|
||||||
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan variable"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if v.IsSecret {
|
|
||||||
v.Value = maskedSecretValue
|
|
||||||
}
|
|
||||||
variables = append(variables, v)
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"variables": variables})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleUpdateProjectVariables(c *gin.Context) {
|
|
||||||
db, exists := c.Get("db")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
projectID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req UpdateVariablesRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
storedSecrets := map[string]string{}
|
|
||||||
secretRows, err := db.(*database.DB).Query(
|
|
||||||
`SELECT key, value FROM project_variables WHERE project_id = $1 AND is_secret = TRUE`,
|
|
||||||
projectID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load existing variables"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for secretRows.Next() {
|
|
||||||
var k, v string
|
|
||||||
if err := secretRows.Scan(&k, &v); err == nil {
|
|
||||||
storedSecrets[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
secretRows.Close()
|
|
||||||
|
|
||||||
tx, err := db.(*database.DB).Begin()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
if _, err = tx.Exec(`DELETE FROM project_variables WHERE project_id = $1`, projectID); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
for _, v := range req.Variables {
|
|
||||||
value := v.Value
|
|
||||||
if v.IsSecret && value == maskedSecretValue {
|
|
||||||
if stored, ok := storedSecrets[v.Key]; ok {
|
|
||||||
value = stored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v.IsSecret {
|
|
||||||
value = secrets.Encrypt(value)
|
|
||||||
}
|
|
||||||
if _, err = tx.Exec(
|
|
||||||
`INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
||||||
uuid.New(), projectID, v.Key, value, v.IsSecret, now, now,
|
|
||||||
); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err = tx.Commit(); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := db.(*database.DB).Query(
|
|
||||||
`SELECT id, project_id, key, value, is_secret, created_at, updated_at
|
|
||||||
FROM project_variables WHERE project_id = $1 ORDER BY key ASC`,
|
|
||||||
projectID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
variables := []ProjectVariable{}
|
|
||||||
for rows.Next() {
|
|
||||||
var v ProjectVariable
|
|
||||||
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if v.IsSecret {
|
|
||||||
v.Value = maskedSecretValue
|
|
||||||
}
|
|
||||||
variables = append(variables, v)
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Project variables updated successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveSharedVar returns the decrypted value of a project variable.
|
|
||||||
func resolveSharedVar(db *database.DB, projectID uuid.UUID, key string) (string, bool) {
|
|
||||||
var value string
|
|
||||||
err := db.QueryRow(
|
|
||||||
`SELECT value FROM project_variables WHERE project_id = $1 AND key = $2`,
|
|
||||||
projectID, key,
|
|
||||||
).Scan(&value)
|
|
||||||
if err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return secrets.Decrypt(value), true
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"containr/internal/database"
|
|
||||||
"containr/internal/secrets"
|
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/registry"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Registry is a stored credential for pulling private images. Password is
|
|
||||||
// encrypted at rest and never returned — `has_password` signals presence.
|
|
||||||
type Registry struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Host string `json:"host"` // e.g. ghcr.io, registry.example.com; docker.io for Docker Hub
|
|
||||||
Username string `json:"username"`
|
|
||||||
HasPassword bool `json:"has_password"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type registryRequest struct {
|
|
||||||
Name string `json:"name" binding:"required,min=1,max=255"`
|
|
||||||
Host string `json:"host" binding:"required,min=1,max=255"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleListRegistries(c *gin.Context) {
|
|
||||||
db, exists := c.Get("db")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID, _ := c.Get("user_id")
|
|
||||||
rows, err := db.(*database.DB).Query(
|
|
||||||
`SELECT id, name, host, username, password <> '', created_at, updated_at
|
|
||||||
FROM registries WHERE owner_id = $1 ORDER BY created_at DESC`,
|
|
||||||
userID.(string),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list registries"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
registries := []Registry{}
|
|
||||||
for rows.Next() {
|
|
||||||
var r Registry
|
|
||||||
if err := rows.Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt); err == nil {
|
|
||||||
registries = append(registries, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"registries": registries})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleCreateRegistry(c *gin.Context) {
|
|
||||||
db, exists := c.Get("db")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID, _ := c.Get("user_id")
|
|
||||||
|
|
||||||
var req registryRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
|
|
||||||
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
|
|
||||||
req.Host = strings.TrimSuffix(req.Host, "/")
|
|
||||||
|
|
||||||
// Encrypt only a real credential — encrypting "" would still satisfy
|
|
||||||
// `password <> ''` and report has_password for anonymous registries.
|
|
||||||
password := ""
|
|
||||||
if req.Password != "" {
|
|
||||||
password = secrets.Encrypt(req.Password)
|
|
||||||
}
|
|
||||||
var r Registry
|
|
||||||
err := db.(*database.DB).QueryRow(
|
|
||||||
`INSERT INTO registries (owner_id, name, host, username, password)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
|
|
||||||
userID.(string), req.Name, req.Host, req.Username, password,
|
|
||||||
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), "unique") {
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "A registry for this host already exists", "code": "CONFLICT"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create registry"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"registry": r})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleUpdateRegistry(c *gin.Context) {
|
|
||||||
db, exists := c.Get("db")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID, _ := c.Get("user_id")
|
|
||||||
registryID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req registryRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
|
|
||||||
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
|
|
||||||
req.Host = strings.TrimSuffix(req.Host, "/")
|
|
||||||
|
|
||||||
// Empty password keeps the stored credential — there is no "clear"
|
|
||||||
// operation; delete + recreate to remove a password.
|
|
||||||
var passwordArg interface{}
|
|
||||||
if req.Password != "" {
|
|
||||||
passwordArg = secrets.Encrypt(req.Password)
|
|
||||||
}
|
|
||||||
|
|
||||||
var r Registry
|
|
||||||
err = db.(*database.DB).QueryRow(
|
|
||||||
`UPDATE registries SET name = $1, host = $2, username = $3,
|
|
||||||
password = COALESCE($4, password), updated_at = now()
|
|
||||||
WHERE id = $5 AND owner_id = $6
|
|
||||||
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
|
|
||||||
req.Name, req.Host, req.Username, passwordArg, registryID, userID.(string),
|
|
||||||
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"registry": r})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleDeleteRegistry(c *gin.Context) {
|
|
||||||
db, exists := c.Get("db")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID, _ := c.Get("user_id")
|
|
||||||
registryID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
res, err := db.(*database.DB).Exec(
|
|
||||||
`DELETE FROM registries WHERE id = $1 AND owner_id = $2`,
|
|
||||||
registryID, userID.(string),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete registry"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Registry deleted"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// registryAuthFor resolves pull credentials for an image reference: the
|
|
||||||
// caller's registry whose host matches, else anonymous. docker.io covers
|
|
||||||
// bare image names (nginx:latest).
|
|
||||||
func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig {
|
|
||||||
host := registryHost(imageRef)
|
|
||||||
var username, password string
|
|
||||||
err := db.QueryRow(
|
|
||||||
`SELECT username, password FROM registries WHERE owner_id = $1 AND host = $2`,
|
|
||||||
ownerID, host,
|
|
||||||
).Scan(&username, &password)
|
|
||||||
if err != nil {
|
|
||||||
return registry.AuthConfig{}
|
|
||||||
}
|
|
||||||
return registry.AuthConfig{
|
|
||||||
Username: username,
|
|
||||||
Password: secrets.Decrypt(password),
|
|
||||||
ServerAddress: host,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestParseCPUReserveShares(t *testing.T) {
|
|
||||||
cases := map[string]int64{
|
|
||||||
"": 0,
|
|
||||||
"0": 0,
|
|
||||||
"-1": 0,
|
|
||||||
"abc": 0,
|
|
||||||
"0.25": 256,
|
|
||||||
"1": 1024,
|
|
||||||
"2.5": 2560,
|
|
||||||
" 0.5 ": 512,
|
|
||||||
}
|
|
||||||
for in, want := range cases {
|
|
||||||
if got := parseCPUReserveShares(in); got != want {
|
|
||||||
t.Errorf("parseCPUReserveShares(%q) = %d, want %d", in, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateResourceSpecs(t *testing.T) {
|
|
||||||
if err := validateCPUSpec(""); err != nil {
|
|
||||||
t.Error("empty cpu should pass")
|
|
||||||
}
|
|
||||||
if err := validateCPUSpec("0.5"); err != nil {
|
|
||||||
t.Error("0.5 should be valid cpu")
|
|
||||||
}
|
|
||||||
if err := validateCPUSpec("512mb"); err == nil {
|
|
||||||
t.Error("512mb should fail cpu validation")
|
|
||||||
}
|
|
||||||
if err := validateMemorySpec(""); err != nil {
|
|
||||||
t.Error("empty memory should pass")
|
|
||||||
}
|
|
||||||
if err := validateMemorySpec("512Mi"); err != nil {
|
|
||||||
t.Error("512Mi should be valid memory")
|
|
||||||
}
|
|
||||||
if err := validateMemorySpec("1g"); err != nil {
|
|
||||||
t.Error("1g should be valid memory")
|
|
||||||
}
|
|
||||||
if err := validateMemorySpec("0.5"); err == nil {
|
|
||||||
t.Error("0.5 should fail memory validation (parses to <1 byte)")
|
|
||||||
}
|
|
||||||
if err := validateMemorySpec("lots"); err == nil {
|
|
||||||
t.Error("'lots' should fail memory validation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,20 +4,17 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -60,33 +57,6 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
haManager := ha.NewHighAvailabilityManager(scheduler, metricsCollector)
|
haManager := ha.NewHighAvailabilityManager(scheduler, metricsCollector)
|
||||||
if db != nil && db.DB != nil {
|
|
||||||
haManager.WithPersistence(db.DB)
|
|
||||||
if err := haManager.LoadHealthChecks(context.Background()); err != nil {
|
|
||||||
log.Printf("Failed to restore health checks: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if dockerClient != nil && db != nil {
|
|
||||||
// Service-bound checks probe the service DNS name on the project
|
|
||||||
// network — the backend container joins it on demand. The old
|
|
||||||
// 127.0.0.1 fallback stays for node-bound checks.
|
|
||||||
docker := dockerClient
|
|
||||||
haManager.WithServiceResolver(func(ctx context.Context, serviceID string) (string, int, error) {
|
|
||||||
var name, projectID string
|
|
||||||
var port int
|
|
||||||
err := db.QueryRowContext(ctx,
|
|
||||||
`SELECT name, project_id::text, COALESCE(port, 0) FROM services WHERE id = $1`,
|
|
||||||
serviceID,
|
|
||||||
).Scan(&name, &projectID, &port)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(projectID)); err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
return name, port, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
haAPIManager := NewHAManager(haManager)
|
haAPIManager := NewHAManager(haManager)
|
||||||
|
|
||||||
// Initialize scaling handler
|
// Initialize scaling handler
|
||||||
@@ -101,45 +71,6 @@ 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)
|
||||||
|
|
||||||
@@ -157,7 +88,6 @@ 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)
|
||||||
@@ -248,7 +178,6 @@ 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
|
||||||
@@ -269,8 +198,6 @@ 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)
|
||||||
|
|
||||||
@@ -302,16 +229,10 @@ 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)
|
||||||
@@ -329,27 +250,17 @@ 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/clone", handleCloneService)
|
|
||||||
authed.POST("/services/:id/move", handleMoveService)
|
|
||||||
authed.POST("/services/:id/deployments", handleCreateDeployment)
|
authed.POST("/services/:id/deployments", handleCreateDeployment)
|
||||||
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
||||||
authed.POST("/deployments/:id/cancel", handleCancelDeployment)
|
|
||||||
|
|
||||||
// Environment variables — secrets, owner/admin only.
|
// Environment variables — secrets, owner/admin only.
|
||||||
authed.GET("/services/:id/variables", handleGetVariables)
|
authed.GET("/services/:id/variables", handleGetVariables)
|
||||||
authed.PUT("/services/:id/variables", handleUpdateVariables)
|
authed.PUT("/services/:id/variables", handleUpdateVariables)
|
||||||
|
|
||||||
// Project-level shared variables — ${{shared.KEY}} refs.
|
|
||||||
authed.GET("/projects/:id/variables", handleGetProjectVariables)
|
|
||||||
authed.PUT("/projects/:id/variables", handleUpdateProjectVariables)
|
|
||||||
|
|
||||||
// Runtime logs — container stdout routinely echoes secrets,
|
// Runtime logs — container stdout routinely echoes secrets,
|
||||||
// so these stay behind authentication like build logs.
|
// so these stay behind authentication like build logs.
|
||||||
authed.GET("/services/:id/logs", handleGetLogs)
|
authed.GET("/services/:id/logs", handleGetLogs)
|
||||||
@@ -358,12 +269,6 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// One-off exec console (docker exec, 30s ceiling)
|
// One-off exec console (docker exec, 30s ceiling)
|
||||||
authed.POST("/services/:id/exec", handleExecInService)
|
authed.POST("/services/:id/exec", handleExecInService)
|
||||||
|
|
||||||
// Private-image registries — owner-scoped, credentials encrypted at rest.
|
|
||||||
authed.GET("/registries", handleListRegistries)
|
|
||||||
authed.POST("/registries", handleCreateRegistry)
|
|
||||||
authed.PUT("/registries/:id", handleUpdateRegistry)
|
|
||||||
authed.DELETE("/registries/:id", handleDeleteRegistry)
|
|
||||||
|
|
||||||
// Git integration — providers carry per-user credentials.
|
// Git integration — providers carry per-user credentials.
|
||||||
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
|
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
|
||||||
authed.POST("/git/github-app/connect", handleConnectGitHubApp)
|
authed.POST("/git/github-app/connect", handleConnectGitHubApp)
|
||||||
@@ -420,7 +325,6 @@ 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)
|
||||||
@@ -441,8 +345,6 @@ 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,8 +11,6 @@ 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"
|
||||||
@@ -53,19 +51,6 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
|
|||||||
}
|
}
|
||||||
spec.NanoCPUs = parseCPULimit(service.CPU)
|
spec.NanoCPUs = parseCPULimit(service.CPU)
|
||||||
spec.MemoryBytes = parseMemoryLimit(service.Memory)
|
spec.MemoryBytes = parseMemoryLimit(service.Memory)
|
||||||
spec.MemoryReserve = parseMemoryLimit(service.MemoryReserve)
|
|
||||||
spec.CPUShares = parseCPUReserveShares(service.CPUReserve)
|
|
||||||
if service.Builder == "static" && spec.Port == 0 {
|
|
||||||
// The static builder's image is nginx; expose its default port so
|
|
||||||
// Traefik has something to route to without extra config.
|
|
||||||
spec.Port = 80
|
|
||||||
}
|
|
||||||
spec.Volumes = loadServiceVolumes(db, service.ID)
|
|
||||||
spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
|
|
||||||
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
|
|
||||||
if spec.Maintenance {
|
|
||||||
spec.MaintenanceURL = maintenanceURL(db)
|
|
||||||
}
|
|
||||||
if spec.Port > 0 {
|
if spec.Port > 0 {
|
||||||
env["PORT"] = strconv.Itoa(service.Port)
|
env["PORT"] = strconv.Itoa(service.Port)
|
||||||
}
|
}
|
||||||
@@ -93,7 +78,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] = secrets.Decrypt(v)
|
env[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
var refServices []struct{ ID, Name string }
|
var refServices []struct{ ID, Name string }
|
||||||
@@ -119,9 +104,6 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
resolve := func(serviceName, key string) (string, bool) {
|
resolve := func(serviceName, key string) (string, bool) {
|
||||||
if strings.EqualFold(serviceName, "shared") {
|
|
||||||
return resolveSharedVar(db, service.ProjectID, key)
|
|
||||||
}
|
|
||||||
id, ok := byName[strings.ToLower(serviceName)]
|
id, ok := byName[strings.ToLower(serviceName)]
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -144,7 +126,7 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return secrets.Decrypt(value), true
|
return value, true
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range env {
|
for k, v := range env {
|
||||||
@@ -170,32 +152,6 @@ func parseCPULimit(cpu string) int64 {
|
|||||||
return int64(v * 1e9)
|
return int64(v * 1e9)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCPUReserveShares maps a cpu fraction to Docker's relative share
|
|
||||||
// weight: 1024 shares ≈ one CPU.
|
|
||||||
func parseCPUReserveShares(cpu string) int64 {
|
|
||||||
v, err := strconv.ParseFloat(strings.TrimSpace(cpu), 64)
|
|
||||||
if err != nil || v <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return int64(v * 1024)
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateCPUSpec and validateMemorySpec reject values that silently parse
|
|
||||||
// to zero — a typo like "512mb" must fail loudly instead of dropping the limit.
|
|
||||||
func validateCPUSpec(v string) error {
|
|
||||||
if s := strings.TrimSpace(v); s != "" && parseCPULimit(s) <= 0 {
|
|
||||||
return fmt.Errorf("must be a positive cpu fraction (e.g. 0.5, 2)")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateMemorySpec(v string) error {
|
|
||||||
if s := strings.TrimSpace(v); s != "" && parseMemoryLimit(s) <= 0 {
|
|
||||||
return fmt.Errorf("must be bytes with unit (e.g. 256Mi, 1g)")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var memUnits = map[string]int64{"k": 1e3, "m": 1e6, "g": 1e9, "ki": 1 << 10, "mi": 1 << 20, "gi": 1 << 30}
|
var memUnits = map[string]int64{"k": 1e3, "m": 1e6, "g": 1e9, "ki": 1 << 10, "mi": 1 << 20, "gi": 1 << 30}
|
||||||
|
|
||||||
func parseMemoryLimit(mem string) int64 {
|
func parseMemoryLimit(mem string) int64 {
|
||||||
@@ -251,9 +207,6 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
|
|||||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
|
||||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
|
||||||
COALESCE(s.static_dir, ''),
|
|
||||||
s.created_at, s.updated_at, p.owner_id
|
s.created_at, s.updated_at, p.owner_id
|
||||||
FROM services s JOIN projects p ON s.project_id = p.id
|
FROM services s JOIN projects p ON s.project_id = p.id
|
||||||
WHERE s.id = $1`,
|
WHERE s.id = $1`,
|
||||||
@@ -263,9 +216,7 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
|
|||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner,
|
||||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
|
||||||
&service.CreatedAt, &service.UpdatedAt, &owner,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||||
@@ -299,9 +250,6 @@ func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
|
|||||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
|
||||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
|
||||||
COALESCE(s.static_dir, ''),
|
|
||||||
s.created_at, s.updated_at, s.project_id
|
s.created_at, s.updated_at, s.project_id
|
||||||
FROM services s
|
FROM services s
|
||||||
WHERE s.id = $1`,
|
WHERE s.id = $1`,
|
||||||
@@ -311,9 +259,7 @@ func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
|
|||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectID,
|
||||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
|
||||||
&service.CreatedAt, &service.UpdatedAt, &projectID,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||||
@@ -435,7 +381,6 @@ 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 {
|
||||||
@@ -461,78 +406,7 @@ 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
|
||||||
@@ -595,9 +469,6 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
|
|||||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
|
||||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
|
||||||
COALESCE(s.static_dir, ''),
|
|
||||||
s.created_at, s.updated_at, p.owner_id
|
s.created_at, s.updated_at, p.owner_id
|
||||||
FROM services s JOIN projects p ON s.project_id = p.id
|
FROM services s JOIN projects p ON s.project_id = p.id
|
||||||
WHERE s.id = $1`, serviceID,
|
WHERE s.id = $1`, serviceID,
|
||||||
@@ -606,9 +477,7 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
|
|||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner,
|
||||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
|
||||||
&service.CreatedAt, &service.UpdatedAt, &owner,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"containr/internal/database"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CloneServiceRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
ProjectID uuid.UUID `json:"project_id"` // empty = clone within the same project
|
|
||||||
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCloneService duplicates a service's full configuration — image/git
|
|
||||||
// source, volumes, domains, access gates, builder settings, and variables —
|
|
||||||
// into the same or another project. Containers are not cloned; the copy
|
|
||||||
// starts stopped until its first deploy.
|
|
||||||
func handleCloneService(c *gin.Context) {
|
|
||||||
db, exists := c.Get("db")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID, _ := c.Get("user_id")
|
|
||||||
|
|
||||||
var req CloneServiceRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
source, ok := loadOwnedService(c, db.(*database.DB))
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetProjectID := source.ProjectID
|
|
||||||
if req.ProjectID != uuid.Nil {
|
|
||||||
targetProjectID = req.ProjectID
|
|
||||||
}
|
|
||||||
if !ownsProject(db.(*database.DB), targetProjectID, userID.(string), contextIsAdmin(c)) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
name := strings.TrimSpace(req.Name)
|
|
||||||
if name == "" {
|
|
||||||
name = source.Name + "-copy"
|
|
||||||
}
|
|
||||||
var count int
|
|
||||||
_ = db.(*database.DB).QueryRow(
|
|
||||||
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
|
|
||||||
targetProjectID, name,
|
|
||||||
).Scan(&count)
|
|
||||||
if count > 0 {
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in target project", "code": "CONFLICT"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
environment := source.Environment
|
|
||||||
if req.Environment != "" {
|
|
||||||
environment = req.Environment
|
|
||||||
}
|
|
||||||
if environment == "" {
|
|
||||||
environment = "production"
|
|
||||||
}
|
|
||||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), targetProjectID, environment)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
maintenance, basicAuth := serviceAccess(db.(*database.DB), source.ID)
|
|
||||||
newID := uuid.New()
|
|
||||||
now := time.Now()
|
|
||||||
// string() so pq sends text — []byte would go as bytea and fail ::jsonb.
|
|
||||||
volumesJSON := string(loadServiceVolumesJSON(db.(*database.DB), source.ID))
|
|
||||||
|
|
||||||
_, err = db.(*database.DB).Exec(
|
|
||||||
`INSERT INTO services
|
|
||||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
|
||||||
build_command, start_command, type, status, image, command, environment,
|
|
||||||
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
|
||||||
healthcheck_path, restart_policy, volumes, maintenance_mode, basic_auth_users,
|
|
||||||
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
|
||||||
created_at, updated_at)
|
|
||||||
SELECT $1, $2, $3, $4, service_type, source_type, source_url, image_name,
|
|
||||||
build_command, start_command, type, 'stopped', image, command, $5,
|
|
||||||
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
|
||||||
healthcheck_path, restart_policy, $6::jsonb, $7, $8,
|
|
||||||
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
|
||||||
$9, $9
|
|
||||||
FROM services WHERE id = $10`,
|
|
||||||
newID, targetProjectID, name, environmentID, environment, volumesJSON,
|
|
||||||
maintenance, basicAuth, now, source.ID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.(*database.DB).Exec(
|
|
||||||
`INSERT INTO service_domains (service_id, domain, is_default, cert_type)
|
|
||||||
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
|
|
||||||
newID, source.ID,
|
|
||||||
); err != nil {
|
|
||||||
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service domains"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Variable values are copied as stored — ciphertext stays ciphertext.
|
|
||||||
if _, err := db.(*database.DB).Exec(
|
|
||||||
`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
|
|
||||||
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
|
|
||||||
newID, source.ID,
|
|
||||||
); err != nil {
|
|
||||||
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service variables"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
|
|
||||||
}
|
|
||||||
|
|
||||||
type MoveServiceRequest struct {
|
|
||||||
ProjectID uuid.UUID `json:"project_id" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleMoveService re-parents a service to another project owned by the
|
|
||||||
// caller. Variables, domains, and volumes follow via the service id;
|
|
||||||
// running containers keep running (they join the new project's network on
|
|
||||||
// the next deploy).
|
|
||||||
func handleMoveService(c *gin.Context) {
|
|
||||||
db, exists := c.Get("db")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID, _ := c.Get("user_id")
|
|
||||||
serviceID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID", "code": "VALIDATION"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req MoveServiceRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
service, ok := loadOwnedService(c, db.(*database.DB))
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.ProjectID == service.ProjectID {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Service is already in that project", "code": "VALIDATION"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !ownsProject(db.(*database.DB), req.ProjectID, userID.(string), contextIsAdmin(c)) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var count int
|
|
||||||
_ = db.(*database.DB).QueryRow(
|
|
||||||
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
|
|
||||||
req.ProjectID, service.Name,
|
|
||||||
).Scan(&count)
|
|
||||||
if count > 0 {
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "A service with this name already exists in the target project", "code": "CONFLICT"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
envName := service.Environment
|
|
||||||
if envName == "" {
|
|
||||||
envName = "production"
|
|
||||||
}
|
|
||||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), req.ProjectID, envName)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := db.(*database.DB).Exec(
|
|
||||||
`UPDATE services SET project_id = $1, environment_id = $2, updated_at = $3 WHERE id = $4`,
|
|
||||||
req.ProjectID, environmentID, time.Now(), serviceID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to move service"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found", "code": "NOT_FOUND"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
LogAuditWithRequest(c, "service", serviceID.String(), "service.move", map[string]interface{}{"to_project": req.ProjectID.String()})
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Service moved", "project_id": req.ProjectID})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ownsProject is true when the user owns the project or is an admin.
|
|
||||||
func ownsProject(db *database.DB, projectID uuid.UUID, userID string, isAdmin bool) bool {
|
|
||||||
if isAdmin {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
var owner string
|
|
||||||
err := db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, projectID).Scan(&owner)
|
|
||||||
return err == nil && owner == userID
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadServiceVolumesJSON returns the raw stored mounts for a verbatim copy.
|
|
||||||
func loadServiceVolumesJSON(db *database.DB, serviceID uuid.UUID) []byte {
|
|
||||||
var raw []byte
|
|
||||||
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil || len(raw) == 0 {
|
|
||||||
return []byte("[]")
|
|
||||||
}
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
@@ -3,12 +3,8 @@ 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"
|
||||||
@@ -31,129 +27,53 @@ type Service struct {
|
|||||||
CPU string `json:"cpu" db:"cpu"`
|
CPU string `json:"cpu" db:"cpu"`
|
||||||
Memory string `json:"memory" db:"memory"`
|
Memory string `json:"memory" db:"memory"`
|
||||||
// Runtime spec
|
// Runtime spec
|
||||||
Replicas int `json:"replicas" db:"replicas"`
|
Replicas int `json:"replicas" db:"replicas"`
|
||||||
Port int `json:"port" db:"port"` // container port to expose
|
Port int `json:"port" db:"port"` // container port to expose
|
||||||
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
|
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
|
||||||
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
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
MaintenanceMode bool `json:"maintenance_mode" db:"-"`
|
|
||||||
BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes
|
|
||||||
Builder string `json:"builder" db:"builder"` // auto|railpack|nixpacks|dockerfile|static
|
|
||||||
CPUReserve string `json:"cpu_reserve,omitempty" db:"cpu_reserve"`
|
|
||||||
MemoryReserve string `json:"memory_reserve,omitempty" db:"memory_reserve"`
|
|
||||||
StaticBuildCmd string `json:"static_build_cmd,omitempty" db:"static_build_cmd"`
|
|
||||||
StaticDir string `json:"static_dir,omitempty" db:"static_dir"`
|
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceVolume is a volume/bind mount applied to every replica.
|
|
||||||
type ServiceVolume struct {
|
|
||||||
Type string `json:"type"` // volume | bind (empty = volume)
|
|
||||||
Source string `json:"source"` // volume name or host path
|
|
||||||
Target string `json:"target"` // container path (absolute)
|
|
||||||
ReadOnly bool `json:"read_only"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades
|
|
||||||
// to no mounts rather than failing the request.
|
|
||||||
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
|
|
||||||
var raw []byte
|
|
||||||
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var stored []ServiceVolume
|
|
||||||
if err := json.Unmarshal(raw, &stored); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]deployment.VolumeMount, 0, len(stored))
|
|
||||||
for _, v := range stored {
|
|
||||||
out = append(out, deployment.VolumeMount{
|
|
||||||
Type: v.Type,
|
|
||||||
Source: v.Source,
|
|
||||||
Destination: v.Target,
|
|
||||||
ReadOnly: v.ReadOnly,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateServiceVolumes enforces the mount contract — bad paths fail
|
|
||||||
// fast at write time instead of surfacing as docker create errors later.
|
|
||||||
func validateServiceVolumes(vols []ServiceVolume) error {
|
|
||||||
for i, v := range vols {
|
|
||||||
if v.Type == "" {
|
|
||||||
v.Type = "volume"
|
|
||||||
}
|
|
||||||
if v.Type != "volume" && v.Type != "bind" {
|
|
||||||
return fmt.Errorf("volumes[%d].type must be volume or bind", i)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(v.Source) == "" {
|
|
||||||
return fmt.Errorf("volumes[%d].source is required", i)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(v.Target, "/") {
|
|
||||||
return fmt.Errorf("volumes[%d].target must be an absolute container path", i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateServiceRequest represents a request to create a service
|
// 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"`
|
||||||
Name string `json:"name" binding:"required,min=1,max=255"`
|
Name string `json:"name" binding:"required,min=1,max=255"`
|
||||||
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Environment string `json:"environment" binding:"required,oneof=production preview development"`
|
Environment string `json:"environment" binding:"required,oneof=production preview development"`
|
||||||
GitRepo string `json:"git_repo"`
|
GitRepo string `json:"git_repo"`
|
||||||
GitBranch string `json:"git_branch"`
|
GitBranch string `json:"git_branch"`
|
||||||
BuildPath string `json:"build_path"`
|
BuildPath string `json:"build_path"`
|
||||||
CPU string `json:"cpu"`
|
CPU string `json:"cpu"`
|
||||||
Memory string `json:"memory"`
|
Memory string `json:"memory"`
|
||||||
Replicas int `json:"replicas"`
|
Replicas int `json:"replicas"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
HealthCheckPath string `json:"healthcheck_path"`
|
HealthCheckPath string `json:"healthcheck_path"`
|
||||||
RestartPolicy string `json:"restart_policy"`
|
RestartPolicy string `json:"restart_policy"`
|
||||||
Volumes []ServiceVolume `json:"volumes"`
|
|
||||||
MaintenanceMode bool `json:"maintenance_mode"`
|
|
||||||
BasicAuth []BasicAuthCred `json:"basic_auth"`
|
|
||||||
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
|
|
||||||
CPUReserve string `json:"cpu_reserve"`
|
|
||||||
MemoryReserve string `json:"memory_reserve"`
|
|
||||||
StaticBuildCmd string `json:"static_build_cmd"`
|
|
||||||
StaticDir string `json:"static_dir"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateServiceRequest represents a request to update a service
|
// UpdateServiceRequest represents a request to update a service
|
||||||
type UpdateServiceRequest struct {
|
type UpdateServiceRequest struct {
|
||||||
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
||||||
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
||||||
GitRepo string `json:"git_repo"`
|
GitRepo string `json:"git_repo"`
|
||||||
GitBranch string `json:"git_branch"`
|
GitBranch string `json:"git_branch"`
|
||||||
BuildPath string `json:"build_path"`
|
BuildPath string `json:"build_path"`
|
||||||
CPU string `json:"cpu"`
|
CPU string `json:"cpu"`
|
||||||
Memory string `json:"memory"`
|
Memory string `json:"memory"`
|
||||||
Replicas *int `json:"replicas"`
|
Replicas *int `json:"replicas"`
|
||||||
Port *int `json:"port"`
|
Port *int `json:"port"`
|
||||||
Domain *string `json:"domain"`
|
Domain *string `json:"domain"`
|
||||||
HealthCheckPath *string `json:"healthcheck_path"`
|
HealthCheckPath *string `json:"healthcheck_path"`
|
||||||
RestartPolicy string `json:"restart_policy"`
|
RestartPolicy string `json:"restart_policy"`
|
||||||
Volumes *[]ServiceVolume `json:"volumes"`
|
|
||||||
MaintenanceMode *bool `json:"maintenance_mode"`
|
|
||||||
BasicAuth *[]BasicAuthCred `json:"basic_auth"`
|
|
||||||
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
|
|
||||||
CPUReserve *string `json:"cpu_reserve"`
|
|
||||||
MemoryReserve *string `json:"memory_reserve"`
|
|
||||||
StaticBuildCmd *string `json:"static_build_cmd"`
|
|
||||||
StaticDir *string `json:"static_dir"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetServices retrieves all services for a project
|
// handleGetServices retrieves all services for a project
|
||||||
@@ -198,9 +118,6 @@ func handleGetServices(c *gin.Context) {
|
|||||||
COALESCE(replicas, 1), COALESCE(port, 0),
|
COALESCE(replicas, 1), COALESCE(port, 0),
|
||||||
COALESCE(domain, ''), COALESCE(healthcheck_path, ''),
|
COALESCE(domain, ''), COALESCE(healthcheck_path, ''),
|
||||||
COALESCE(restart_policy, 'unless-stopped'),
|
COALESCE(restart_policy, 'unless-stopped'),
|
||||||
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''),
|
|
||||||
COALESCE(memory_reserve, ''), COALESCE(static_build_cmd, ''),
|
|
||||||
COALESCE(static_dir, ''),
|
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM services
|
FROM services
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
@@ -221,9 +138,7 @@ func handleGetServices(c *gin.Context) {
|
|||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
||||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
|
||||||
&service.CreatedAt, &service.UpdatedAt,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
|
||||||
@@ -334,38 +249,12 @@ func handleCreateService(c *gin.Context) {
|
|||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default values if not provided; app_settings.default_cpu /
|
// Set default values if not provided
|
||||||
// default_memory override the hardcoded fallbacks.
|
|
||||||
if service.CPU == "" {
|
if service.CPU == "" {
|
||||||
service.CPU = firstNonEmpty(settingValue(db.(*database.DB), "default_cpu", "", ""), "0.5")
|
service.CPU = "0.5"
|
||||||
}
|
}
|
||||||
if service.Memory == "" {
|
if service.Memory == "" {
|
||||||
service.Memory = firstNonEmpty(settingValue(db.(*database.DB), "default_memory", "", ""), "512Mi")
|
service.Memory = "512Mi"
|
||||||
}
|
|
||||||
if req.Builder == "" {
|
|
||||||
service.Builder = "auto"
|
|
||||||
} else {
|
|
||||||
service.Builder = req.Builder
|
|
||||||
}
|
|
||||||
service.CPUReserve = req.CPUReserve
|
|
||||||
service.MemoryReserve = req.MemoryReserve
|
|
||||||
service.StaticBuildCmd = req.StaticBuildCmd
|
|
||||||
service.StaticDir = req.StaticDir
|
|
||||||
if service.Builder == "static" && service.StaticDir == "" {
|
|
||||||
service.StaticDir = "dist"
|
|
||||||
}
|
|
||||||
for label, check := range map[string]func(string) error{
|
|
||||||
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
|
||||||
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
|
||||||
} {
|
|
||||||
v := map[string]string{
|
|
||||||
"cpu": service.CPU, "cpu_reserve": service.CPUReserve,
|
|
||||||
"memory": service.Memory, "memory_reserve": service.MemoryReserve,
|
|
||||||
}[label]
|
|
||||||
if err := check(v); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if service.Replicas < 1 {
|
if service.Replicas < 1 {
|
||||||
service.Replicas = 1
|
service.Replicas = 1
|
||||||
@@ -373,15 +262,6 @@ 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 {
|
||||||
@@ -397,18 +277,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, volumes,
|
healthcheck_path, restart_policy, created_at, updated_at)
|
||||||
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
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)`,
|
||||||
created_at, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
|
|
||||||
$27, $28, $29, $30, $31, $32, $33)`,
|
|
||||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||||
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
||||||
volumesJSON, service.Builder, service.CPUReserve, service.MemoryReserve,
|
service.CreatedAt, service.UpdatedAt,
|
||||||
service.StaticBuildCmd, service.StaticDir, service.CreatedAt, service.UpdatedAt,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -416,26 +292,6 @@ 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})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,9 +349,6 @@ func handleGetService(c *gin.Context) {
|
|||||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
|
||||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
|
||||||
COALESCE(s.static_dir, ''),
|
|
||||||
s.created_at, s.updated_at
|
s.created_at, s.updated_at
|
||||||
FROM services s
|
FROM services s
|
||||||
JOIN projects p ON s.project_id = p.id
|
JOIN projects p ON s.project_id = p.id
|
||||||
@@ -507,9 +360,7 @@ func handleGetService(c *gin.Context) {
|
|||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
||||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
|
||||||
&service.CreatedAt, &service.UpdatedAt,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -518,15 +369,6 @@ 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})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,9 +417,6 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
|
||||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
|
||||||
COALESCE(s.static_dir, ''),
|
|
||||||
s.created_at, s.updated_at
|
s.created_at, s.updated_at
|
||||||
FROM services s
|
FROM services s
|
||||||
JOIN projects p ON s.project_id = p.id
|
JOIN projects p ON s.project_id = p.id
|
||||||
@@ -590,9 +429,6 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
&existingService.BuildPath, &existingService.CPU, &existingService.Memory,
|
&existingService.BuildPath, &existingService.CPU, &existingService.Memory,
|
||||||
&existingService.Replicas, &existingService.Port, &existingService.Domain,
|
&existingService.Replicas, &existingService.Port, &existingService.Domain,
|
||||||
&existingService.HealthCheckPath, &existingService.RestartPolicy,
|
&existingService.HealthCheckPath, &existingService.RestartPolicy,
|
||||||
&existingService.Builder, &existingService.CPUReserve,
|
|
||||||
&existingService.MemoryReserve, &existingService.StaticBuildCmd,
|
|
||||||
&existingService.StaticDir,
|
|
||||||
&existingService.CreatedAt, &existingService.UpdatedAt,
|
&existingService.CreatedAt, &existingService.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -647,49 +483,6 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
if req.RestartPolicy != "" {
|
if req.RestartPolicy != "" {
|
||||||
existingService.RestartPolicy = req.RestartPolicy
|
existingService.RestartPolicy = req.RestartPolicy
|
||||||
}
|
}
|
||||||
if req.Builder != "" {
|
|
||||||
existingService.Builder = req.Builder
|
|
||||||
}
|
|
||||||
if req.CPUReserve != nil {
|
|
||||||
existingService.CPUReserve = *req.CPUReserve
|
|
||||||
}
|
|
||||||
if req.MemoryReserve != nil {
|
|
||||||
existingService.MemoryReserve = *req.MemoryReserve
|
|
||||||
}
|
|
||||||
if req.StaticBuildCmd != nil {
|
|
||||||
existingService.StaticBuildCmd = *req.StaticBuildCmd
|
|
||||||
}
|
|
||||||
if req.StaticDir != nil {
|
|
||||||
existingService.StaticDir = *req.StaticDir
|
|
||||||
}
|
|
||||||
if existingService.Builder == "static" && existingService.StaticDir == "" {
|
|
||||||
existingService.StaticDir = "dist"
|
|
||||||
}
|
|
||||||
for label, check := range map[string]func(string) error{
|
|
||||||
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
|
||||||
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
|
||||||
} {
|
|
||||||
v := map[string]string{
|
|
||||||
"cpu": existingService.CPU, "cpu_reserve": existingService.CPUReserve,
|
|
||||||
"memory": existingService.Memory, "memory_reserve": existingService.MemoryReserve,
|
|
||||||
}[label]
|
|
||||||
if err := check(v); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// lib/pq sends a nil []byte as an empty bytea literal rather than NULL,
|
|
||||||
// so the COALESCE fallback needs an untyped nil when volumes aren't sent.
|
|
||||||
var volumesArg interface{}
|
|
||||||
if req.Volumes != nil {
|
|
||||||
if err := validateServiceVolumes(*req.Volumes); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
existingService.Volumes = *req.Volumes
|
|
||||||
volumesJSON, _ := json.Marshal(*req.Volumes)
|
|
||||||
volumesArg = volumesJSON
|
|
||||||
}
|
|
||||||
|
|
||||||
existingService.UpdatedAt = time.Now()
|
existingService.UpdatedAt = time.Now()
|
||||||
|
|
||||||
@@ -699,18 +492,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, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
|
restart_policy = $15, updated_at = $16
|
||||||
builder = $19, cpu_reserve = $20, memory_reserve = $21,
|
WHERE id = $17`,
|
||||||
static_build_cmd = $22, static_dir = $23
|
|
||||||
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, volumesArg, existingService.ID,
|
existingService.UpdatedAt, existingService.ID,
|
||||||
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
|
|
||||||
existingService.StaticBuildCmd, existingService.StaticDir,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -718,65 +507,23 @@ 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
|
||||||
dbCopy := db.(*database.DB)
|
go func() {
|
||||||
getDeployQueue(c).Enqueue(serviceCopy.ID, deployqueue.Job{Run: func(jctx context.Context) {
|
spec, err := serviceRuntimeSpec(db.(*database.DB), serviceCopy)
|
||||||
spec, err := serviceRuntimeSpec(dbCopy, serviceCopy)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if state, err := engine.ReconcileService(jctx, spec); err == nil && state != nil {
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
persistPublishedPort(dbCopy, serviceCopy.ID, state.Ports)
|
defer cancel()
|
||||||
|
if state, err := engine.ReconcileService(ctx, spec); err == nil && state != nil {
|
||||||
|
persistPublishedPort(db.(*database.DB), serviceCopy.ID, state.Ports)
|
||||||
}
|
}
|
||||||
}})
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ type hostMonitoringResponse struct {
|
|||||||
Docker map[string]any `json:"docker,omitempty"`
|
Docker map[string]any `json:"docker,omitempty"`
|
||||||
// Scope names what the telemetry actually measures: the physical host for
|
// Scope names what the telemetry actually measures: the physical host for
|
||||||
// native dockerd, or the Docker Desktop VM when the daemon runs in one.
|
// native dockerd, or the Docker Desktop VM when the daemon runs in one.
|
||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
CollectedAt string `json:"collected_at"`
|
CollectedAt string `json:"collected_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type hostCPU struct {
|
type hostCPU struct {
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
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,7 +2,6 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/secrets"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -26,7 +25,6 @@ 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 {
|
||||||
@@ -161,9 +159,6 @@ 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)
|
||||||
@@ -209,24 +204,5 @@ func handleUpdateVariables(c *gin.Context) {
|
|||||||
variables = append(variables, v)
|
variables = append(variables, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := gin.H{"variables": variables, "message": "Environment variables updated successfully"}
|
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Environment variables updated successfully"})
|
||||||
if req.Redeploy {
|
|
||||||
// Load the service spec inputs and queue a reconcile behind any
|
|
||||||
// in-flight deployment — new env lands in the recreated containers.
|
|
||||||
var svc Service
|
|
||||||
svcErr := db.(*database.DB).QueryRow(
|
|
||||||
`SELECT id, project_id, name, COALESCE(image, ''), COALESCE(command, ''),
|
|
||||||
COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(domain, ''),
|
|
||||||
COALESCE(healthcheck_path, ''), COALESCE(restart_policy, 'unless-stopped'),
|
|
||||||
COALESCE(cpu, ''), COALESCE(memory, ''),
|
|
||||||
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''), COALESCE(memory_reserve, '')
|
|
||||||
FROM services WHERE id = $1`,
|
|
||||||
serviceID,
|
|
||||||
).Scan(&svc.ID, &svc.ProjectID, &svc.Name, &svc.Image, &svc.Command,
|
|
||||||
&svc.Replicas, &svc.Port, &svc.Domain, &svc.HealthCheckPath,
|
|
||||||
&svc.RestartPolicy, &svc.CPU, &svc.Memory,
|
|
||||||
&svc.Builder, &svc.CPUReserve, &svc.MemoryReserve)
|
|
||||||
resp["redeploy_queued"] = svcErr == nil && enqueueServiceRedeploy(c, db.(*database.DB), svc)
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, resp)
|
|
||||||
}
|
}
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"containr/internal/docker"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// VolumeInfo is one docker volume plus whether any container mounts it.
|
|
||||||
type VolumeInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Driver string `json:"driver"`
|
|
||||||
Mountpoint string `json:"mountpoint"`
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
InUse bool `json:"in_use"`
|
|
||||||
UsedBy []string `json:"used_by,omitempty"`
|
|
||||||
Labels map[string]string `json:"labels,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func dockerClientOr500(c *gin.Context) (*docker.Client, bool) {
|
|
||||||
v, exists := c.Get("docker_client")
|
|
||||||
if !exists || v == nil {
|
|
||||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "Docker client not available")
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return v.(*docker.Client), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAdminListVolumes returns the node's docker volume inventory with
|
|
||||||
// in-use flags. Admin-only: mountpoints reveal host filesystem layout.
|
|
||||||
func handleAdminListVolumes(c *gin.Context) {
|
|
||||||
client, ok := dockerClientOr500(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
vols, err := client.ListVolumes(ctx)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, http.StatusBadGateway, "DEPENDENCY_UNAVAILABLE", "Failed to list volumes: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
containers, err := client.ListContainers(ctx, true)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, http.StatusBadGateway, "DEPENDENCY_UNAVAILABLE", "Failed to list containers: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
inUse := map[string][]string{}
|
|
||||||
for _, cont := range containers {
|
|
||||||
for _, m := range cont.Mounts {
|
|
||||||
if m.Type == "volume" {
|
|
||||||
name := cont.ID[:12]
|
|
||||||
if len(cont.Names) > 0 {
|
|
||||||
name = cont.Names[0]
|
|
||||||
}
|
|
||||||
inUse[m.Name] = append(inUse[m.Name], name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]VolumeInfo, 0, len(vols.Volumes))
|
|
||||||
for _, v := range vols.Volumes {
|
|
||||||
if v == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info := VolumeInfo{
|
|
||||||
Name: v.Name,
|
|
||||||
Driver: v.Driver,
|
|
||||||
Mountpoint: v.Mountpoint,
|
|
||||||
Scope: v.Scope,
|
|
||||||
Labels: v.Labels,
|
|
||||||
UsedBy: inUse[v.Name],
|
|
||||||
}
|
|
||||||
info.InUse = len(info.UsedBy) > 0
|
|
||||||
if t, err := time.Parse(time.RFC3339Nano, v.CreatedAt); err == nil {
|
|
||||||
info.CreatedAt = t
|
|
||||||
}
|
|
||||||
out = append(out, info)
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"volumes": out})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAdminDeleteVolume removes a docker volume. Docker refuses removal
|
|
||||||
// of in-use volumes — that error is surfaced as a conflict.
|
|
||||||
func handleAdminDeleteVolume(c *gin.Context) {
|
|
||||||
client, ok := dockerClientOr500(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
name := c.Param("name")
|
|
||||||
if name == "" {
|
|
||||||
respondError(c, http.StatusBadRequest, "VALIDATION", "Volume name is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := client.RemoveVolume(c.Request.Context(), name, false); err != nil {
|
|
||||||
respondError(c, http.StatusConflict, "CONFLICT", "Failed to remove volume: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"deleted": name})
|
|
||||||
}
|
|
||||||
@@ -211,12 +211,8 @@ func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest)
|
|||||||
// Build Docker image
|
// Build Docker image
|
||||||
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
||||||
|
|
||||||
// createBuildContext wants the Dockerfile contents, not its path.
|
// Create build context tar
|
||||||
dockerfileContent, err := os.ReadFile(optimizedDockerfile)
|
buildCtx, err := createBuildContext(req.SourcePath, optimizedDockerfile)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read Dockerfile: %w", err)
|
|
||||||
}
|
|
||||||
buildCtx, err := createBuildContext(req.SourcePath, string(dockerfileContent))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create build context: %w", err)
|
return nil, fmt.Errorf("failed to create build context: %w", err)
|
||||||
}
|
}
|
||||||
@@ -226,7 +222,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ type BuildManager struct {
|
|||||||
railpackBuilder *RailpackBuilder
|
railpackBuilder *RailpackBuilder
|
||||||
nixpacksBuilder *NixpacksBuilder
|
nixpacksBuilder *NixpacksBuilder
|
||||||
dockerfileBuilder *DockerfileBuilder
|
dockerfileBuilder *DockerfileBuilder
|
||||||
staticBuilder *StaticBuilder
|
|
||||||
dockerClient *docker.Client
|
dockerClient *docker.Client
|
||||||
cacheManager *CacheManager
|
cacheManager *CacheManager
|
||||||
parallelBuilder *ParallelBuilder
|
parallelBuilder *ParallelBuilder
|
||||||
@@ -29,7 +28,6 @@ const (
|
|||||||
BuildTypeRailpack BuildType = "railpack"
|
BuildTypeRailpack BuildType = "railpack"
|
||||||
BuildTypeNixpacks BuildType = "nixpacks"
|
BuildTypeNixpacks BuildType = "nixpacks"
|
||||||
BuildTypeDockerfile BuildType = "dockerfile"
|
BuildTypeDockerfile BuildType = "dockerfile"
|
||||||
BuildTypeStatic BuildType = "static"
|
|
||||||
BuildTypePrebuilt BuildType = "prebuilt"
|
BuildTypePrebuilt BuildType = "prebuilt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,7 +39,6 @@ func NewBuildManager(workDir string, dockerClient *docker.Client) *BuildManager
|
|||||||
railpackBuilder: NewRailpackBuilder(workDir, dockerClient),
|
railpackBuilder: NewRailpackBuilder(workDir, dockerClient),
|
||||||
nixpacksBuilder: NewNixpacksBuilder(workDir, dockerClient),
|
nixpacksBuilder: NewNixpacksBuilder(workDir, dockerClient),
|
||||||
dockerfileBuilder: NewDockerfileBuilder(workDir, dockerClient),
|
dockerfileBuilder: NewDockerfileBuilder(workDir, dockerClient),
|
||||||
staticBuilder: NewStaticBuilder(dockerClient),
|
|
||||||
dockerClient: dockerClient,
|
dockerClient: dockerClient,
|
||||||
cacheManager: cacheManager,
|
cacheManager: cacheManager,
|
||||||
parallelBuilder: parallelBuilder,
|
parallelBuilder: parallelBuilder,
|
||||||
@@ -83,8 +80,6 @@ func (bm *BuildManager) Build(ctx context.Context, req *types.BuildRequest) (*ty
|
|||||||
return bm.nixpacksBuilder.Build(ctx, req)
|
return bm.nixpacksBuilder.Build(ctx, req)
|
||||||
case BuildTypeDockerfile:
|
case BuildTypeDockerfile:
|
||||||
return bm.dockerfileBuilder.Build(ctx, req)
|
return bm.dockerfileBuilder.Build(ctx, req)
|
||||||
case BuildTypeStatic:
|
|
||||||
return bm.staticBuilder.Build(ctx, req)
|
|
||||||
case BuildTypePrebuilt:
|
case BuildTypePrebuilt:
|
||||||
return bm.buildPrebuilt(ctx, req)
|
return bm.buildPrebuilt(ctx, req)
|
||||||
default:
|
default:
|
||||||
@@ -100,7 +95,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
|
|||||||
|
|
||||||
// Pull the prebuilt image — must drain the pull stream or the daemon
|
// Pull the prebuilt image — must drain the pull stream or the daemon
|
||||||
// aborts it and the tag below races a missing image.
|
// aborts it and the tag below races a missing image.
|
||||||
auth := registry.AuthConfig{Username: req.PullUsername, Password: req.PullPassword}
|
auth := registry.AuthConfig{}
|
||||||
if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil {
|
if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil {
|
||||||
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
|
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,8 +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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = n.dockerClient.BuildImage(ctx, buildCtx, buildOptions)
|
_, err = n.dockerClient.BuildImage(ctx, buildCtx, buildOptions)
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
package build
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"containr/internal/docker"
|
|
||||||
"containr/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StaticBuilder produces an nginx-served image from a repo's built assets.
|
|
||||||
// STATIC_BUILD_CMD runs inside a node build stage; STATIC_DIR names the
|
|
||||||
// output directory copied into the nginx image. Both are overridable via
|
|
||||||
// build args so repos with non-node toolchains can swap the build image too.
|
|
||||||
type StaticBuilder struct {
|
|
||||||
dockerClient *docker.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStaticBuilder(dockerClient *docker.Client) *StaticBuilder {
|
|
||||||
return &StaticBuilder{dockerClient: dockerClient}
|
|
||||||
}
|
|
||||||
|
|
||||||
const staticDockerfile = `ARG BUILD_IMAGE=node:20-alpine
|
|
||||||
FROM ${BUILD_IMAGE} AS assets
|
|
||||||
WORKDIR /app
|
|
||||||
COPY . .
|
|
||||||
ARG STATIC_BUILD_CMD
|
|
||||||
RUN sh -c "${STATIC_BUILD_CMD}"
|
|
||||||
|
|
||||||
FROM nginx:alpine
|
|
||||||
ARG STATIC_DIR=dist
|
|
||||||
COPY --from=assets /app/${STATIC_DIR} /usr/share/nginx/html
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
`
|
|
||||||
|
|
||||||
func (s *StaticBuilder) Build(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
|
|
||||||
buildCmd := req.BuildCommand
|
|
||||||
if buildCmd == "" {
|
|
||||||
buildCmd = "npm ci && npm run build"
|
|
||||||
}
|
|
||||||
staticDir := req.BuildArgs["STATIC_DIR"]
|
|
||||||
if staticDir == "" {
|
|
||||||
staticDir = "dist"
|
|
||||||
}
|
|
||||||
|
|
||||||
buildArgs := map[string]*string{}
|
|
||||||
for k, v := range req.BuildArgs {
|
|
||||||
v := v
|
|
||||||
buildArgs[k] = &v
|
|
||||||
}
|
|
||||||
for k, v := range req.Environment {
|
|
||||||
v := v
|
|
||||||
buildArgs[k] = &v
|
|
||||||
}
|
|
||||||
buildArgs["STATIC_BUILD_CMD"] = &buildCmd
|
|
||||||
buildArgs["STATIC_DIR"] = &staticDir
|
|
||||||
|
|
||||||
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
|
||||||
buildCtx, err := createBuildContext(req.SourcePath, staticDockerfile)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create build context: %w", err)
|
|
||||||
}
|
|
||||||
defer buildCtx.Close()
|
|
||||||
|
|
||||||
if _, err := s.dockerClient.BuildImage(ctx, buildCtx, docker.BuildOptions{
|
|
||||||
Dockerfile: "Dockerfile",
|
|
||||||
Tags: []string{imageName},
|
|
||||||
BuildArgs: buildArgs,
|
|
||||||
NoCache: req.NoCache,
|
|
||||||
Remove: true,
|
|
||||||
}); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to build static image: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.RegistryURL != "" {
|
|
||||||
full := fmt.Sprintf("%s/%s", req.RegistryURL, imageName)
|
|
||||||
if err := s.dockerClient.PushImage(ctx, imageName, req.RegistryURL); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to push image: %w", err)
|
|
||||||
}
|
|
||||||
imageName = full
|
|
||||||
}
|
|
||||||
|
|
||||||
imageInfo, err := s.dockerClient.GetImageInfo(ctx, imageName)
|
|
||||||
if err != nil {
|
|
||||||
imageInfo = &docker.ImageInfo{Size: 0}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &types.BuildResponse{
|
|
||||||
ImageName: imageName,
|
|
||||||
ImageTag: req.ImageTag,
|
|
||||||
Size: imageInfo.Size,
|
|
||||||
Digest: imageInfo.Digest,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
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,150 +2,139 @@ package commands
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/term"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthCmd groups authentication commands.
|
// AuthCmd represents the auth command
|
||||||
var AuthCmd = &cobra.Command{
|
var AuthCmd = &cobra.Command{
|
||||||
Use: "auth",
|
Use: "auth",
|
||||||
Short: "Authenticate with a Containr instance",
|
Short: "Authenticate with Containr API",
|
||||||
Long: `Login with a personal access token (cnp_...) created under
|
Long: `Manage authentication with the Containr API.
|
||||||
Settings → Personal Access Tokens, check status, or switch profiles.`,
|
You can login, logout, and check your current authentication status.`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loginCmd represents the login command
|
||||||
var loginCmd = &cobra.Command{
|
var loginCmd = &cobra.Command{
|
||||||
Use: "login [token]",
|
Use: "login [token]",
|
||||||
Short: "Store a personal access token",
|
Short: "Login to Containr",
|
||||||
Long: `Login with a cnp_ token. Reads interactively when no argument is
|
Long: `Login to Containr using your API token.
|
||||||
given. The token is verified against the API before it is stored.`,
|
You can get your token from the Containr web interface.`,
|
||||||
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: "Remove the stored token",
|
Short: "Logout from Containr",
|
||||||
|
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: "Show the active profile and verify the token",
|
Short: "Check authentication status",
|
||||||
|
Long: `Check if you are currently authenticated with Containr.`,
|
||||||
RunE: runStatus,
|
RunE: runStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
loginCmd.Flags().String("url", "", "API URL to store in the profile (e.g. https://containr.example.com)")
|
AuthCmd.AddCommand(loginCmd)
|
||||||
loginCmd.Flags().String("name", "", "profile name to save under (default: active profile)")
|
AuthCmd.AddCommand(logoutCmd)
|
||||||
AuthCmd.AddCommand(loginCmd, logoutCmd, statusCmd)
|
AuthCmd.AddCommand(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 if isTerminal() {
|
} else {
|
||||||
fmt.Fprint(os.Stderr, "Token: ")
|
// Prompt for token
|
||||||
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
fmt.Print("Enter your Containr API token: ")
|
||||||
fmt.Fprintln(os.Stderr)
|
fmt.Scanln(&token)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
token = string(raw)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return &APIError{Message: "token is required — pass it as an argument or pipe it via stdin", ExitCode: ExitValidation}
|
return fmt.Errorf("token is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify before saving so a mistyped token fails loudly.
|
// Store token in config
|
||||||
urlFlag, _ := cmd.Flags().GetString("url")
|
viper.Set("token", token)
|
||||||
probe := &Client{BaseURL: ResolveAPIURL(), Token: token}
|
if err := viper.WriteConfig(); err != nil {
|
||||||
if urlFlag != "" {
|
return fmt.Errorf("failed to save token: %w", err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
name, _ := cmd.Flags().GetString("name")
|
fmt.Println("✓ Successfully logged in to Containr")
|
||||||
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 {
|
||||||
name := ActiveProfile()
|
// Remove token from config
|
||||||
if err := ClearProfileToken(name); err != nil {
|
viper.Set("token", "")
|
||||||
return fmt.Errorf("failed to update config: %w", err)
|
if err := viper.WriteConfig(); err != nil {
|
||||||
}
|
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 {
|
||||||
client := NewClient()
|
token := viper.GetString("token")
|
||||||
profile := ActiveProfile()
|
if token == "" {
|
||||||
|
fmt.Println("❌ Not authenticated")
|
||||||
if JSONMode() {
|
fmt.Println("Run 'containr auth login <token>' to authenticate")
|
||||||
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
|
|
||||||
if err := client.DoJSON("GET", "/user/profile", nil, &p); err != nil {
|
fmt.Println("✓ Authenticated with Containr")
|
||||||
fmt.Printf("Token: present but rejected (%v)\n", err)
|
apiURL := buildAPIURL("/user/profile")
|
||||||
|
fmt.Printf("API URL: %s\n", strings.TrimSuffix(buildAPIURL(""), "/"))
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create verification request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("⚠ Token verification unavailable: %v\n", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fmt.Printf("Identity: %s <%s>\n", p.Name, p.Email)
|
defer resp.Body.Close()
|
||||||
if p.IsAdmin {
|
|
||||||
fmt.Println("Role: platform admin")
|
switch resp.StatusCode {
|
||||||
|
case http.StatusOK:
|
||||||
|
fmt.Println("✓ Token verified against API")
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
fmt.Println("❌ Token rejected by API (expired/invalid)")
|
||||||
|
default:
|
||||||
|
fmt.Printf("⚠ Token verification returned unexpected status: %s\n", resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
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,52 +6,16 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveAPIURLDefaultsAndTrimming(t *testing.T) {
|
func TestBuildAPIURLDefaultsAndTrimming(t *testing.T) {
|
||||||
viper.Set("api-url", "")
|
viper.Set("api-url", "")
|
||||||
viper.Set("current_profile", "")
|
got := buildAPIURL("/user/profile")
|
||||||
viper.Set("profiles.default.api_url", "")
|
if got != "http://localhost:8080/api/v1/user/profile" {
|
||||||
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/")
|
||||||
if got := ResolveAPIURL(); got != "https://api.example.com/api/v1" {
|
got = buildAPIURL("user/profile")
|
||||||
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
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,123 +1,219 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProjectsCmd manages projects.
|
// Project represents a Containr project
|
||||||
|
type Project struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
OwnerID string `json:"owner_id"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectsCmd represents the projects command
|
||||||
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.`,
|
||||||
}
|
}
|
||||||
|
|
||||||
var projectsListCmd = &cobra.Command{
|
// listProjectsCmd represents the list command
|
||||||
|
var listProjectsCmd = &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List projects",
|
Short: "List all projects",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Long: `List all your Containr projects.`,
|
||||||
c, err := client()
|
RunE: runListProjects,
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := c.Do("GET", "/projects", nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
items, err := unwrapList(data, "projects")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rows := make([][]string, 0, len(items))
|
|
||||||
for _, p := range items {
|
|
||||||
rows = append(rows, []string{
|
|
||||||
str(p, "id"), str(p, "name"), str(p, "description"), relTime(p, "created_at"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
printRows(data, []string{"ID", "NAME", "DESCRIPTION", "CREATED"}, rows)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var projectsGetCmd = &cobra.Command{
|
// createProjectCmd represents the create command
|
||||||
Use: "get <id>",
|
var createProjectCmd = &cobra.Command{
|
||||||
Short: "Show a project",
|
Use: "create [name]",
|
||||||
Args: cobra.ExactArgs(1),
|
Short: "Create a new project",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Long: `Create a new Containr project.
|
||||||
c, err := client()
|
Provide a name and optional description.`,
|
||||||
if err != nil {
|
Args: cobra.ExactArgs(1),
|
||||||
return err
|
RunE: runCreateProject,
|
||||||
}
|
|
||||||
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{
|
// deleteProjectCmd represents the delete command
|
||||||
Use: "create <name>",
|
var deleteProjectCmd = &cobra.Command{
|
||||||
Short: "Create a project",
|
Use: "delete [project-id]",
|
||||||
|
Short: "Delete a project",
|
||||||
|
Long: `Delete a Containr project by ID.`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: runDeleteProject,
|
||||||
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{
|
var projectDescription string
|
||||||
Use: "delete <id>",
|
|
||||||
Short: "Delete a project and all its services",
|
// getAPIURL constructs the full API URL for a given endpoint
|
||||||
Args: cobra.ExactArgs(1),
|
func getAPIURL(endpoint string) string {
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
baseURL := viper.GetString("api-url")
|
||||||
c, err := client()
|
if baseURL == "" {
|
||||||
if err != nil {
|
baseURL = "http://localhost:8080/api/v1" // Default for development
|
||||||
return err
|
}
|
||||||
}
|
|
||||||
if !Confirm(fmt.Sprintf("Delete project %s and all its services?", args[0])) {
|
// Ensure baseURL doesn't end with / and endpoint starts with /
|
||||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||||
}
|
if !strings.HasPrefix(endpoint, "/") {
|
||||||
if _, err := c.Do("DELETE", "/projects/"+args[0], nil); err != nil {
|
endpoint = "/" + endpoint
|
||||||
return err
|
}
|
||||||
}
|
|
||||||
if JSONMode() {
|
return baseURL + endpoint
|
||||||
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
|
||||||
}
|
|
||||||
fmt.Printf("Deleted project %s\n", args[0])
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
projectsCreateCmd.Flags().StringP("description", "d", "", "project description")
|
ProjectsCmd.AddCommand(listProjectsCmd)
|
||||||
ProjectsCmd.AddCommand(projectsListCmd, projectsGetCmd, projectsCreateCmd, projectsDeleteCmd)
|
ProjectsCmd.AddCommand(createProjectCmd)
|
||||||
|
ProjectsCmd.AddCommand(deleteProjectCmd)
|
||||||
|
|
||||||
|
// Add flags
|
||||||
|
createProjectCmd.Flags().StringVarP(&projectDescription, "description", "d", "", "Project description")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runListProjects(cmd *cobra.Command, args []string) error {
|
||||||
|
apiURL := getAPIURL("/projects")
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to make request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var projects []Project
|
||||||
|
if err := json.Unmarshal(body, &projects); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(projects) == 0 {
|
||||||
|
fmt.Println("No projects found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Your Projects:")
|
||||||
|
fmt.Println()
|
||||||
|
for _, project := range projects {
|
||||||
|
fmt.Printf("📦 %s (%s)\n", project.Name, project.ID)
|
||||||
|
if project.Description != "" {
|
||||||
|
fmt.Printf(" %s\n", project.Description)
|
||||||
|
}
|
||||||
|
fmt.Printf(" Created: %s\n", utils.FormatTime(project.CreatedAt))
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCreateProject(cmd *cobra.Command, args []string) error {
|
||||||
|
name := args[0]
|
||||||
|
|
||||||
|
projectData := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if projectDescription != "" {
|
||||||
|
projectData["description"] = projectDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(projectData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal project data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := getAPIURL("/projects")
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to make request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var project Project
|
||||||
|
if err := json.Unmarshal(body, &project); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ Project '%s' created successfully!\n", project.Name)
|
||||||
|
fmt.Printf("ID: %s\n", project.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDeleteProject(cmd *cobra.Command, args []string) error {
|
||||||
|
projectID := args[0]
|
||||||
|
|
||||||
|
apiURL := getAPIURL("/projects/" + projectID)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("DELETE", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to make request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ Project '%s' deleted successfully!\n", projectID)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
package commands
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RegistriesCmd manages private-image registry credentials.
|
|
||||||
var RegistriesCmd = &cobra.Command{
|
|
||||||
Use: "registries",
|
|
||||||
Aliases: []string{"registry"},
|
|
||||||
Short: "Manage registry credentials",
|
|
||||||
}
|
|
||||||
|
|
||||||
var registriesListCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List registry credentials",
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := c.Do("GET", "/registries", nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
items, err := unwrapList(data, "registries")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rows := make([][]string, 0, len(items))
|
|
||||||
for _, r := range items {
|
|
||||||
hasPw := ""
|
|
||||||
if b, ok := r["has_password"].(bool); ok && b {
|
|
||||||
hasPw = "yes"
|
|
||||||
}
|
|
||||||
rows = append(rows, []string{str(r, "id"), str(r, "name"), str(r, "host"), str(r, "username"), hasPw})
|
|
||||||
}
|
|
||||||
printRows(data, []string{"ID", "NAME", "HOST", "USERNAME", "PASSWORD"}, rows)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var registriesAddCmd = &cobra.Command{
|
|
||||||
Use: "add <name> <host>",
|
|
||||||
Short: "Add a registry credential (e.g. ghcr.io, registry.example.com, docker.io)",
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
body := map[string]interface{}{"name": args[0], "host": args[1]}
|
|
||||||
if v, _ := cmd.Flags().GetString("username"); v != "" {
|
|
||||||
body["username"] = v
|
|
||||||
}
|
|
||||||
if v, _ := cmd.Flags().GetString("password"); v != "" {
|
|
||||||
body["password"] = v
|
|
||||||
}
|
|
||||||
data, err := c.Do("POST", "/registries", body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if JSONMode() {
|
|
||||||
PrintRaw(data)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
r, _ := unwrapObject(data, "registry")
|
|
||||||
fmt.Printf("Added registry %s (%s)\n", str(r, "name"), str(r, "host"))
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var registriesUpdateCmd = &cobra.Command{
|
|
||||||
Use: "update <id>",
|
|
||||||
Short: "Update a registry credential (empty --password keeps the stored one)",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
body := map[string]interface{}{}
|
|
||||||
for _, f := range []string{"name", "host", "username", "password"} {
|
|
||||||
if cmd.Flags().Changed(f) {
|
|
||||||
v, _ := cmd.Flags().GetString(f)
|
|
||||||
body[f] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(body) == 0 {
|
|
||||||
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
// The API requires name+host — backfill them from the stored row
|
|
||||||
// when the caller didn't supply both.
|
|
||||||
_, hasName := body["name"]
|
|
||||||
_, hasHost := body["host"]
|
|
||||||
if !hasName || !hasHost {
|
|
||||||
data, err := c.Do("GET", "/registries", nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
items, _ := unwrapList(data, "registries")
|
|
||||||
for _, r := range items {
|
|
||||||
if str(r, "id") == args[0] {
|
|
||||||
if !hasName {
|
|
||||||
body["name"] = str(r, "name")
|
|
||||||
}
|
|
||||||
if !hasHost {
|
|
||||||
body["host"] = str(r, "host")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data, err := c.Do("PUT", "/registries/"+args[0], body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if JSONMode() {
|
|
||||||
PrintRaw(data)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fmt.Printf("Updated registry %s\n", args[0])
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var registriesRemoveCmd = &cobra.Command{
|
|
||||||
Use: "remove <id>",
|
|
||||||
Short: "Remove a registry credential",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !Confirm(fmt.Sprintf("Remove registry %s?", args[0])) {
|
|
||||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
if _, err := c.Do("DELETE", "/registries/"+args[0], nil); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if JSONMode() {
|
|
||||||
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
|
||||||
}
|
|
||||||
fmt.Printf("Removed registry %s\n", args[0])
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
registriesAddCmd.Flags().String("username", "", "registry username")
|
|
||||||
registriesAddCmd.Flags().String("password", "", "registry password/token (stored encrypted)")
|
|
||||||
registriesUpdateCmd.Flags().String("name", "", "display name")
|
|
||||||
registriesUpdateCmd.Flags().String("host", "", "registry host")
|
|
||||||
registriesUpdateCmd.Flags().String("username", "", "registry username")
|
|
||||||
registriesUpdateCmd.Flags().String("password", "", "registry password/token")
|
|
||||||
RegistriesCmd.AddCommand(registriesListCmd, registriesAddCmd, registriesUpdateCmd, registriesRemoveCmd)
|
|
||||||
}
|
|
||||||
@@ -1,524 +0,0 @@
|
|||||||
package commands
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ServicesCmd manages services.
|
|
||||||
var ServicesCmd = &cobra.Command{
|
|
||||||
Use: "services",
|
|
||||||
Aliases: []string{"service", "svc"},
|
|
||||||
Short: "Manage services",
|
|
||||||
}
|
|
||||||
|
|
||||||
var servicesListCmd = &cobra.Command{
|
|
||||||
Use: "list [project-id]",
|
|
||||||
Short: "List services in a project",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := c.Do("GET", "/projects/"+args[0]+"/services", nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
items, err := unwrapList(data, "services")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rows := make([][]string, 0, len(items))
|
|
||||||
for _, s := range items {
|
|
||||||
rows = append(rows, []string{
|
|
||||||
str(s, "id"), str(s, "name"), str(s, "type"), str(s, "status"), str(s, "environment"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
printRows(data, []string{"ID", "NAME", "TYPE", "STATUS", "ENV"}, rows)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var servicesGetCmd = &cobra.Command{
|
|
||||||
Use: "get <id>",
|
|
||||||
Short: "Show a service",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := c.Do("GET", "/services/"+args[0], nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if JSONMode() {
|
|
||||||
PrintRaw(data)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
s, _ := unwrapObject(data, "service")
|
|
||||||
for _, k := range []string{"id", "name", "type", "status", "environment", "image", "git_repo", "git_branch", "domain", "port"} {
|
|
||||||
fmt.Printf("%-14s %s\n", k+":", str(s, k))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var servicesCreateCmd = &cobra.Command{
|
|
||||||
Use: "create <project-id> <name>",
|
|
||||||
Short: "Create a service (image- or git-based)",
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
body := map[string]interface{}{
|
|
||||||
"name": args[1],
|
|
||||||
}
|
|
||||||
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
|
|
||||||
if v, _ := cmd.Flags().GetString(f); v != "" {
|
|
||||||
key := f
|
|
||||||
// JSON uses snake_case keys matching the API schema.
|
|
||||||
switch f {
|
|
||||||
case "git-repo":
|
|
||||||
key = "git_repo"
|
|
||||||
case "git-branch":
|
|
||||||
key = "git_branch"
|
|
||||||
case "build-path":
|
|
||||||
key = "build_path"
|
|
||||||
case "restart-policy":
|
|
||||||
key = "restart_policy"
|
|
||||||
case "healthcheck-path":
|
|
||||||
key = "healthcheck_path"
|
|
||||||
case "cpu-reserve":
|
|
||||||
key = "cpu_reserve"
|
|
||||||
case "memory-reserve":
|
|
||||||
key = "memory_reserve"
|
|
||||||
case "static-cmd":
|
|
||||||
key = "static_build_cmd"
|
|
||||||
case "static-dir":
|
|
||||||
key = "static_dir"
|
|
||||||
}
|
|
||||||
body[key] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v, _ := cmd.Flags().GetInt("port"); v > 0 {
|
|
||||||
body["port"] = v
|
|
||||||
}
|
|
||||||
if v, _ := cmd.Flags().GetInt("replicas"); v > 0 {
|
|
||||||
body["replicas"] = v
|
|
||||||
}
|
|
||||||
data, err := c.Do("POST", "/projects/"+args[0]+"/services", body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if JSONMode() {
|
|
||||||
PrintRaw(data)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
s, _ := unwrapObject(data, "service")
|
|
||||||
fmt.Printf("Created service %s (%s)\n", str(s, "name"), str(s, "id"))
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var servicesDeleteCmd = &cobra.Command{
|
|
||||||
Use: "delete <id>",
|
|
||||||
Short: "Delete a service",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !Confirm(fmt.Sprintf("Delete service %s?", args[0])) {
|
|
||||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
if _, err := c.Do("DELETE", "/services/"+args[0], nil); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if JSONMode() {
|
|
||||||
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
|
||||||
}
|
|
||||||
fmt.Printf("Deleted service %s\n", args[0])
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var servicesUpdateCmd = &cobra.Command{
|
|
||||||
Use: "update <id>",
|
|
||||||
Short: "Update a service (volumes, replicas, domain, ...)",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
body := map[string]interface{}{}
|
|
||||||
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
|
|
||||||
if cmd.Flags().Changed(f) {
|
|
||||||
key := f
|
|
||||||
switch f {
|
|
||||||
case "restart-policy":
|
|
||||||
key = "restart_policy"
|
|
||||||
case "healthcheck-path":
|
|
||||||
key = "healthcheck_path"
|
|
||||||
case "cpu-reserve":
|
|
||||||
key = "cpu_reserve"
|
|
||||||
case "memory-reserve":
|
|
||||||
key = "memory_reserve"
|
|
||||||
case "static-cmd":
|
|
||||||
key = "static_build_cmd"
|
|
||||||
case "static-dir":
|
|
||||||
key = "static_dir"
|
|
||||||
}
|
|
||||||
v, _ := cmd.Flags().GetString(f)
|
|
||||||
body[key] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("replicas") {
|
|
||||||
v, _ := cmd.Flags().GetInt("replicas")
|
|
||||||
body["replicas"] = v
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("port") {
|
|
||||||
v, _ := cmd.Flags().GetInt("port")
|
|
||||||
body["port"] = v
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("volume") || cmd.Flags().Changed("bind") || cmd.Flags().Changed("clear-volumes") {
|
|
||||||
volumes := []map[string]interface{}{}
|
|
||||||
for _, spec := range volumeFlagValues(cmd, "volume", "bind") {
|
|
||||||
volumes = append(volumes, spec)
|
|
||||||
}
|
|
||||||
body["volumes"] = volumes
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("maintenance") {
|
|
||||||
v, _ := cmd.Flags().GetString("maintenance")
|
|
||||||
body["maintenance_mode"] = v == "on" || v == "true"
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("basic-auth") {
|
|
||||||
creds := []map[string]interface{}{}
|
|
||||||
pairs, _ := cmd.Flags().GetStringArray("basic-auth")
|
|
||||||
for _, p := range pairs {
|
|
||||||
user, pass, ok := strings.Cut(p, ":")
|
|
||||||
if ok {
|
|
||||||
creds = append(creds, map[string]interface{}{"username": user, "password": pass})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body["basic_auth"] = creds
|
|
||||||
}
|
|
||||||
if len(body) == 0 {
|
|
||||||
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
|
|
||||||
}
|
|
||||||
data, err := c.Do("PUT", "/services/"+args[0], body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if JSONMode() {
|
|
||||||
PrintRaw(data)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fmt.Printf("Updated service %s\n", args[0])
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var servicesCloneCmd = &cobra.Command{
|
|
||||||
Use: "clone <id>",
|
|
||||||
Short: "Clone a service (config, volumes, domains, variables)",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
body := map[string]interface{}{}
|
|
||||||
for _, f := range []string{"name", "project", "environment"} {
|
|
||||||
if v, _ := cmd.Flags().GetString(f); v != "" {
|
|
||||||
key := f
|
|
||||||
if f == "project" {
|
|
||||||
key = "project_id"
|
|
||||||
}
|
|
||||||
body[key] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data, err := c.Do("POST", "/services/"+args[0]+"/clone", body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if JSONMode() {
|
|
||||||
PrintRaw(data)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
m, _ := unwrapObject(data)
|
|
||||||
fmt.Printf("Cloned to service %s (%s)\n", str(m, "service_id"), str(m, "name"))
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var servicesMoveCmd = &cobra.Command{
|
|
||||||
Use: "move <id> <project-id>",
|
|
||||||
Short: "Move a service to another project",
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := c.Do("POST", "/services/"+args[0]+"/move", map[string]interface{}{"project_id": args[1]})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if JSONMode() {
|
|
||||||
PrintRaw(data)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fmt.Printf("Moved service %s to project %s\n", args[0], args[1])
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// volumeFlagValues parses repeated "src:target[:ro]" flags into API mount
|
|
||||||
// objects. Bind mounts are type=bind, named volumes type=volume.
|
|
||||||
func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} {
|
|
||||||
var out []map[string]interface{}
|
|
||||||
for _, f := range flags {
|
|
||||||
vals, _ := cmd.Flags().GetStringArray(f)
|
|
||||||
for _, v := range vals {
|
|
||||||
parts := strings.SplitN(v, ":", 3)
|
|
||||||
if len(parts) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
m := map[string]interface{}{
|
|
||||||
"type": f,
|
|
||||||
"source": parts[0],
|
|
||||||
"target": parts[1],
|
|
||||||
}
|
|
||||||
if len(parts) == 3 && parts[2] == "ro" {
|
|
||||||
m["read_only"] = true
|
|
||||||
}
|
|
||||||
out = append(out, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
var servicesDomainsCmd = &cobra.Command{
|
|
||||||
Use: "domains",
|
|
||||||
Short: "Manage service domains",
|
|
||||||
}
|
|
||||||
|
|
||||||
var domainsListCmd = &cobra.Command{
|
|
||||||
Use: "list <service-id>",
|
|
||||||
Short: "List domains attached to a service",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := c.Do("GET", "/services/"+args[0]+"/domains", nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
items, err := unwrapList(data, "domains")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rows := make([][]string, 0, len(items))
|
|
||||||
for _, d := range items {
|
|
||||||
def := ""
|
|
||||||
if b, ok := d["is_default"].(bool); ok && b {
|
|
||||||
def = "*"
|
|
||||||
}
|
|
||||||
rows = append(rows, []string{str(d, "id"), str(d, "domain"), def, str(d, "cert_status")})
|
|
||||||
}
|
|
||||||
printRows(data, []string{"ID", "DOMAIN", "DEFAULT", "CERT"}, rows)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var domainsAddCmd = &cobra.Command{
|
|
||||||
Use: "add <service-id> <domain>",
|
|
||||||
Short: "Attach a domain to a service",
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
def, _ := cmd.Flags().GetBool("default")
|
|
||||||
data, err := c.Do("POST", "/services/"+args[0]+"/domains", map[string]interface{}{
|
|
||||||
"domain": args[1], "is_default": def,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if JSONMode() {
|
|
||||||
PrintRaw(data)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fmt.Printf("Added domain %s\n", args[1])
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var domainsRemoveCmd = &cobra.Command{
|
|
||||||
Use: "remove <service-id> <domain-id>",
|
|
||||||
Short: "Detach a domain",
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := c.Do("DELETE", fmt.Sprintf("/services/%s/domains/%s", args[0], args[1]), nil); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if JSONMode() {
|
|
||||||
return PrintJSON(map[string]string{"status": "deleted", "id": args[1]})
|
|
||||||
}
|
|
||||||
fmt.Printf("Removed domain %s\n", args[1])
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var domainsDefaultCmd = &cobra.Command{
|
|
||||||
Use: "default <service-id> <domain-id>",
|
|
||||||
Short: "Set the default domain",
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := c.Do("POST", fmt.Sprintf("/services/%s/domains/%s/default", args[0], args[1]), map[string]interface{}{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if JSONMode() {
|
|
||||||
PrintRaw(data)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fmt.Printf("Default domain updated\n")
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var domainsCheckCmd = &cobra.Command{
|
|
||||||
Use: "check <service-id>",
|
|
||||||
Short: "DNS preflight for all service domains",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := c.Do("GET", "/services/"+args[0]+"/domains/check", nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
items, err := unwrapList(data, "domains")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rows := make([][]string, 0, len(items))
|
|
||||||
for _, d := range items {
|
|
||||||
resolved, _ := d["resolved"].([]interface{})
|
|
||||||
addrs := make([]string, 0, len(resolved))
|
|
||||||
for _, a := range resolved {
|
|
||||||
if s, ok := a.(string); ok {
|
|
||||||
addrs = append(addrs, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows = append(rows, []string{str(d, "domain"), str(d, "status"), strings.Join(addrs, ", ")})
|
|
||||||
}
|
|
||||||
printRows(data, []string{"DOMAIN", "STATUS", "RESOLVED"}, rows)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// serviceAction returns a RunE for the simple POST action endpoints.
|
|
||||||
func serviceAction(verb string) func(*cobra.Command, []string) error {
|
|
||||||
return func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := c.Do("POST", fmt.Sprintf("/services/%s/%s", args[0], verb), map[string]interface{}{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if JSONMode() {
|
|
||||||
PrintRaw(data)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fmt.Printf("%s: %s\n", verb, args[0])
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
f := servicesCreateCmd.Flags()
|
|
||||||
f.String("type", "web", "service type: web|worker|database|cron")
|
|
||||||
f.String("image", "", "container image (image-based service)")
|
|
||||||
f.String("git-repo", "", "git repository URL (build-from-source service)")
|
|
||||||
f.String("git-branch", "main", "git branch")
|
|
||||||
f.String("build-path", "", "build context subdirectory")
|
|
||||||
f.String("command", "", "container start command override")
|
|
||||||
f.String("environment", "production", "production|preview|development")
|
|
||||||
f.String("domain", "", "public domain")
|
|
||||||
f.String("restart-policy", "unless-stopped", "docker restart policy")
|
|
||||||
f.String("healthcheck-path", "", "HTTP health check path")
|
|
||||||
f.String("cpu", "", "CPU limit (e.g. 0.5)")
|
|
||||||
f.String("memory", "", "memory limit (e.g. 512m)")
|
|
||||||
f.Int("port", 0, "container port")
|
|
||||||
f.Int("replicas", 0, "replica count")
|
|
||||||
f.String("builder", "", "build strategy: auto|railpack|nixpacks|dockerfile|static")
|
|
||||||
f.String("cpu-reserve", "", "soft CPU reservation (e.g. 0.25)")
|
|
||||||
f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)")
|
|
||||||
f.String("static-cmd", "", "build command for the static builder")
|
|
||||||
f.String("static-dir", "", "output dir for the static builder (default dist)")
|
|
||||||
|
|
||||||
uf := servicesUpdateCmd.Flags()
|
|
||||||
uf.String("name", "", "service name")
|
|
||||||
uf.String("image", "", "container image")
|
|
||||||
uf.String("command", "", "container start command")
|
|
||||||
uf.String("domain", "", "public domain (empty string clears)")
|
|
||||||
uf.String("restart-policy", "", "docker restart policy")
|
|
||||||
uf.String("healthcheck-path", "", "HTTP health check path")
|
|
||||||
uf.String("cpu", "", "CPU limit (e.g. 0.5)")
|
|
||||||
uf.String("memory", "", "memory limit (e.g. 512m)")
|
|
||||||
uf.Int("replicas", 0, "replica count")
|
|
||||||
uf.Int("port", 0, "container port")
|
|
||||||
uf.StringArray("volume", nil, "named volume mount name:path[:ro] (repeatable, replaces all mounts)")
|
|
||||||
uf.StringArray("bind", nil, "bind mount src:path[:ro] (repeatable)")
|
|
||||||
uf.Bool("clear-volumes", false, "remove all volume mounts")
|
|
||||||
uf.String("maintenance", "", "maintenance mode: on|off")
|
|
||||||
uf.StringArray("basic-auth", nil, "basic-auth credential user:password (repeatable, replaces all)")
|
|
||||||
uf.String("builder", "", "build strategy: auto|railpack|nixpacks|dockerfile|static")
|
|
||||||
uf.String("cpu-reserve", "", "soft CPU reservation (empty clears)")
|
|
||||||
uf.String("memory-reserve", "", "soft memory reservation (empty clears)")
|
|
||||||
uf.String("static-cmd", "", "build command for the static builder (empty clears)")
|
|
||||||
uf.String("static-dir", "", "output dir for the static builder (empty clears)")
|
|
||||||
|
|
||||||
for _, a := range []string{"start", "stop", "restart", "redeploy"} {
|
|
||||||
verb := a
|
|
||||||
ServicesCmd.AddCommand(&cobra.Command{
|
|
||||||
Use: verb + " <id>",
|
|
||||||
Short: verb + " a service",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: serviceAction(verb),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
servicesCloneCmd.Flags().String("name", "", "name for the clone (default <name>-copy)")
|
|
||||||
servicesCloneCmd.Flags().String("project", "", "target project id (default: same project)")
|
|
||||||
servicesCloneCmd.Flags().String("environment", "", "target environment (default: source's)")
|
|
||||||
|
|
||||||
domainsAddCmd.Flags().Bool("default", false, "set as the default domain")
|
|
||||||
servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd)
|
|
||||||
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd)
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
package commands
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// VariablesCmd manages service environment variables.
|
|
||||||
var VariablesCmd = &cobra.Command{
|
|
||||||
Use: "variables",
|
|
||||||
Aliases: []string{"vars", "env"},
|
|
||||||
Short: "Manage service environment variables",
|
|
||||||
}
|
|
||||||
|
|
||||||
var varsListCmd = &cobra.Command{
|
|
||||||
Use: "list <service-id>",
|
|
||||||
Short: "List variables (secrets shown masked)",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := c.Do("GET", "/services/"+args[0]+"/variables", nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
items, err := unwrapList(data, "variables")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rows := make([][]string, 0, len(items))
|
|
||||||
for _, v := range items {
|
|
||||||
rows = append(rows, []string{str(v, "key"), str(v, "value"), str(v, "is_secret")})
|
|
||||||
}
|
|
||||||
printRows(data, []string{"KEY", "VALUE", "SECRET"}, rows)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Variables are replaced wholesale server-side — set/unset fetch the
|
|
||||||
// current list, mutate it, and PUT it back. Masked secret placeholders are
|
|
||||||
// preserved by the server.
|
|
||||||
var varsSetCmd = &cobra.Command{
|
|
||||||
Use: "set <service-id> KEY=VALUE [KEY=VALUE...]",
|
|
||||||
Short: "Set one or more variables",
|
|
||||||
Args: cobra.MinimumNArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
secret, _ := cmd.Flags().GetBool("secret")
|
|
||||||
redeploy, _ := cmd.Flags().GetBool("redeploy")
|
|
||||||
return mutateVars(serviceVarsPath(args[0]), args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
|
|
||||||
for _, pair := range args[1:] {
|
|
||||||
k, v, found := strings.Cut(pair, "=")
|
|
||||||
if !found || k == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
vars = upsertVar(vars, k, v, secret)
|
|
||||||
}
|
|
||||||
return vars
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var varsUnsetCmd = &cobra.Command{
|
|
||||||
Use: "unset <service-id> KEY [KEY...]",
|
|
||||||
Short: "Remove variables",
|
|
||||||
Args: cobra.MinimumNArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
drop := map[string]bool{}
|
|
||||||
for _, k := range args[1:] {
|
|
||||||
drop[k] = true
|
|
||||||
}
|
|
||||||
redeploy, _ := cmd.Flags().GetBool("redeploy")
|
|
||||||
return mutateVars(serviceVarsPath(args[0]), args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
|
|
||||||
out := vars[:0]
|
|
||||||
for _, v := range vars {
|
|
||||||
if !drop[str(v, "key")] {
|
|
||||||
out = append(out, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared variables — project scope, referenced as ${{shared.KEY}}.
|
|
||||||
|
|
||||||
var varsSharedCmd = &cobra.Command{
|
|
||||||
Use: "shared",
|
|
||||||
Short: "Manage project-level shared variables (${{shared.KEY}})",
|
|
||||||
}
|
|
||||||
|
|
||||||
var sharedVarsListCmd = &cobra.Command{
|
|
||||||
Use: "list <project-id>",
|
|
||||||
Short: "List shared project variables (secrets shown masked)",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := c.Do("GET", projectVarsPath(args[0]), nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
items, err := unwrapList(data, "variables")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rows := make([][]string, 0, len(items))
|
|
||||||
for _, v := range items {
|
|
||||||
rows = append(rows, []string{str(v, "key"), str(v, "value"), str(v, "is_secret")})
|
|
||||||
}
|
|
||||||
printRows(data, []string{"KEY", "VALUE", "SECRET"}, rows)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var sharedVarsSetCmd = &cobra.Command{
|
|
||||||
Use: "set <project-id> KEY=VALUE [KEY=VALUE...]",
|
|
||||||
Short: "Set shared project variables",
|
|
||||||
Args: cobra.MinimumNArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
secret, _ := cmd.Flags().GetBool("secret")
|
|
||||||
return mutateVars(projectVarsPath(args[0]), args[0], false, func(vars []map[string]interface{}) []map[string]interface{} {
|
|
||||||
for _, pair := range args[1:] {
|
|
||||||
k, v, found := strings.Cut(pair, "=")
|
|
||||||
if !found || k == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
vars = upsertVar(vars, k, v, secret)
|
|
||||||
}
|
|
||||||
return vars
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var sharedVarsUnsetCmd = &cobra.Command{
|
|
||||||
Use: "unset <project-id> KEY [KEY...]",
|
|
||||||
Short: "Remove shared project variables",
|
|
||||||
Args: cobra.MinimumNArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
drop := map[string]bool{}
|
|
||||||
for _, k := range args[1:] {
|
|
||||||
drop[k] = true
|
|
||||||
}
|
|
||||||
return mutateVars(projectVarsPath(args[0]), args[0], false, func(vars []map[string]interface{}) []map[string]interface{} {
|
|
||||||
out := vars[:0]
|
|
||||||
for _, v := range vars {
|
|
||||||
if !drop[str(v, "key")] {
|
|
||||||
out = append(out, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func serviceVarsPath(serviceID string) string { return "/services/" + serviceID + "/variables" }
|
|
||||||
func projectVarsPath(projectID string) string { return "/projects/" + projectID + "/variables" }
|
|
||||||
|
|
||||||
func mutateVars(path, label string, redeploy bool, mutate func([]map[string]interface{}) []map[string]interface{}) error {
|
|
||||||
c, err := client()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := c.Do("GET", path, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
vars, err := unwrapList(data, "variables")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
vars = mutate(vars)
|
|
||||||
payload := make([]map[string]interface{}, 0, len(vars))
|
|
||||||
for _, v := range vars {
|
|
||||||
payload = append(payload, map[string]interface{}{
|
|
||||||
"key": str(v, "key"),
|
|
||||||
"value": str(v, "value"),
|
|
||||||
"is_secret": v["is_secret"] == true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
body := map[string]interface{}{"variables": payload}
|
|
||||||
if redeploy {
|
|
||||||
body["redeploy"] = true
|
|
||||||
}
|
|
||||||
data, err = c.Do("PUT", path, body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if JSONMode() {
|
|
||||||
PrintRaw(data)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
obj, _ := unwrapObject(data)
|
|
||||||
if obj["redeploy_queued"] == true {
|
|
||||||
fmt.Printf("Variables updated on %s — redeploy queued\n", label)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Variables updated on %s — redeploy to apply to running containers\n", label)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func upsertVar(vars []map[string]interface{}, key, value string, secret bool) []map[string]interface{} {
|
|
||||||
for _, v := range vars {
|
|
||||||
if str(v, "key") == key {
|
|
||||||
v["value"] = value
|
|
||||||
v["is_secret"] = secret
|
|
||||||
return vars
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return append(vars, map[string]interface{}{"key": key, "value": value, "is_secret": secret})
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
varsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
|
|
||||||
varsSetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
|
|
||||||
varsUnsetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
|
|
||||||
sharedVarsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
|
|
||||||
varsSharedCmd.AddCommand(sharedVarsListCmd, sharedVarsSetCmd, sharedVarsUnsetCmd)
|
|
||||||
VariablesCmd.AddCommand(varsListCmd, varsSetCmd, varsUnsetCmd, varsSharedCmd)
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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,35 +12,23 @@ 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 controls every part of a Containr instance: projects,
|
Long: `Containr CLI is a command-line interface for managing your Containr platform.
|
||||||
services, deployments, databases, cron jobs, templates, nodes, scaling,
|
You can manage projects, services, deployments, databases, and more from your terminal.`,
|
||||||
HA, security, and the API gateway.
|
Version: "1.0.0",
|
||||||
|
|
||||||
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 runs the root command and returns the process exit code.
|
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||||
func Execute() int {
|
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||||
if err := rootCmd.Execute(); err != nil {
|
func Execute() {
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
cobra.CheckErr(rootCmd.Execute())
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -48,58 +36,46 @@ func SetVersion(v string) {
|
|||||||
func init() {
|
func init() {
|
||||||
cobra.OnInitialize(initConfig)
|
cobra.OnInitialize(initConfig)
|
||||||
|
|
||||||
pf := rootCmd.PersistentFlags()
|
// Global flags
|
||||||
pf.StringVar(&cfgFile, "config", "", "config file (default $HOME/.containr.yaml)")
|
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.containr.yaml)")
|
||||||
pf.String("api-url", "", "Containr API URL (overrides profile and CONTAINR_API_URL)")
|
rootCmd.PersistentFlags().String("api-url", "", "Containr API URL (default is https://api.containr.dev)")
|
||||||
pf.String("token", "", "auth token (overrides profile and CONTAINR_TOKEN)")
|
rootCmd.PersistentFlags().String("token", "", "Authentication 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)")
|
|
||||||
|
|
||||||
viper.BindPFlag("api-url", pf.Lookup("api-url"))
|
// Bind flags to viper
|
||||||
viper.BindPFlag("token", pf.Lookup("token"))
|
viper.BindPFlag("api-url", rootCmd.PersistentFlags().Lookup("api-url"))
|
||||||
viper.BindPFlag("profile", pf.Lookup("profile"))
|
viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token"))
|
||||||
viper.BindPFlag("json", pf.Lookup("json"))
|
|
||||||
viper.BindPFlag("yes", pf.Lookup("yes"))
|
|
||||||
viper.BindPFlag("idempotency-key", pf.Lookup("idempotency-key"))
|
|
||||||
|
|
||||||
|
// Add command groups
|
||||||
rootCmd.AddCommand(commands.AuthCmd)
|
rootCmd.AddCommand(commands.AuthCmd)
|
||||||
rootCmd.AddCommand(commands.TokensCmd)
|
|
||||||
rootCmd.AddCommand(commands.ProjectsCmd)
|
rootCmd.AddCommand(commands.ProjectsCmd)
|
||||||
rootCmd.AddCommand(commands.ServicesCmd)
|
|
||||||
rootCmd.AddCommand(commands.DeployCmd)
|
|
||||||
rootCmd.AddCommand(commands.DeploymentsCmd)
|
|
||||||
rootCmd.AddCommand(commands.LogsCmd)
|
|
||||||
rootCmd.AddCommand(commands.ExecCmd)
|
|
||||||
rootCmd.AddCommand(commands.VariablesCmd)
|
|
||||||
rootCmd.AddCommand(commands.VolumesCmd)
|
|
||||||
rootCmd.AddCommand(commands.RegistriesCmd)
|
|
||||||
rootCmd.AddCommand(commands.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()
|
|
||||||
// CONTAINR_TOKEN / CONTAINR_API_URL are read explicitly in config.go;
|
viper.AutomaticEnv() // read in environment variables that match
|
||||||
// AutomaticEnv covers them only when bound, so this is belt-and-braces.
|
|
||||||
viper.ReadInConfig()
|
// If a config file is found, read it in.
|
||||||
|
if err := viper.ReadInConfig(); err == nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the CLI
|
||||||
|
func Run() error {
|
||||||
|
rootCmd.Execute()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -168,53 +168,33 @@ type ProjectMember struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description sql.NullString `json:"description"`
|
Description sql.NullString `json:"description"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
EnvironmentID uuid.UUID `json:"environment_id"`
|
EnvironmentID uuid.UUID `json:"environment_id"`
|
||||||
ServiceType string `json:"service_type"`
|
ServiceType string `json:"service_type"`
|
||||||
SourceType string `json:"source_type"`
|
SourceType string `json:"source_type"`
|
||||||
SourceUrl sql.NullString `json:"source_url"`
|
SourceUrl sql.NullString `json:"source_url"`
|
||||||
ImageName sql.NullString `json:"image_name"`
|
ImageName sql.NullString `json:"image_name"`
|
||||||
BuildCommand sql.NullString `json:"build_command"`
|
BuildCommand sql.NullString `json:"build_command"`
|
||||||
StartCommand sql.NullString `json:"start_command"`
|
StartCommand sql.NullString `json:"start_command"`
|
||||||
CpuLimit sql.NullInt32 `json:"cpu_limit"`
|
CpuLimit sql.NullInt32 `json:"cpu_limit"`
|
||||||
MemoryLimit sql.NullInt32 `json:"memory_limit"`
|
MemoryLimit sql.NullInt32 `json:"memory_limit"`
|
||||||
PublicUrl sql.NullString `json:"public_url"`
|
PublicUrl sql.NullString `json:"public_url"`
|
||||||
HealthCheckUrl sql.NullString `json:"health_check_url"`
|
HealthCheckUrl sql.NullString `json:"health_check_url"`
|
||||||
Status sql.NullString `json:"status"`
|
Status sql.NullString `json:"status"`
|
||||||
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 sql.NullString `json:"type"`
|
Type sql.NullString `json:"type"`
|
||||||
Image sql.NullString `json:"image"`
|
Image sql.NullString `json:"image"`
|
||||||
Command sql.NullString `json:"command"`
|
Command sql.NullString `json:"command"`
|
||||||
Environment sql.NullString `json:"environment"`
|
Environment sql.NullString `json:"environment"`
|
||||||
GitRepo sql.NullString `json:"git_repo"`
|
GitRepo sql.NullString `json:"git_repo"`
|
||||||
GitBranch sql.NullString `json:"git_branch"`
|
GitBranch sql.NullString `json:"git_branch"`
|
||||||
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 {
|
||||||
@@ -231,16 +211,3 @@ 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,7 +26,6 @@ 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
|
||||||
@@ -47,7 +46,6 @@ 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)
|
||||||
@@ -68,11 +66,9 @@ 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
|
||||||
@@ -81,7 +77,6 @@ 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
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
// 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,11 +57,6 @@ 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
|
||||||
@@ -84,11 +79,10 @@ type VolumeMount struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ResourceLimits struct {
|
type ResourceLimits struct {
|
||||||
MemoryBytes int64 `json:"memory_bytes,omitempty"`
|
MemoryBytes int64 `json:"memory_bytes,omitempty"`
|
||||||
MemoryReservation int64 `json:"memory_reservation,omitempty"`
|
CPUQuota int64 `json:"cpu_quota,omitempty"`
|
||||||
CPUQuota int64 `json:"cpu_quota,omitempty"`
|
CPUPeriod int64 `json:"cpu_period,omitempty"`
|
||||||
CPUPeriod int64 `json:"cpu_period,omitempty"`
|
CPUShares int64 `json:"cpu_shares,omitempty"`
|
||||||
CPUShares int64 `json:"cpu_shares,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type HealthCheck struct {
|
type HealthCheck struct {
|
||||||
@@ -153,14 +147,9 @@ type BuildConfig struct {
|
|||||||
PrebuiltImage string `json:"prebuilt_image"`
|
PrebuiltImage string `json:"prebuilt_image"`
|
||||||
BuildCommand string `json:"build_command"`
|
BuildCommand string `json:"build_command"`
|
||||||
StartCommand string `json:"start_command"`
|
StartCommand string `json:"start_command"`
|
||||||
BuildArgs map[string]string `json:"build_args,omitempty"`
|
|
||||||
Environment map[string]string `json:"environment"`
|
Environment map[string]string `json:"environment"`
|
||||||
// Pull credentials for private prebuilt images.
|
Branch string `json:"branch"`
|
||||||
PullUsername string `json:"pull_username,omitempty"`
|
Commit string `json:"commit"`
|
||||||
PullPassword string `json:"pull_password,omitempty"`
|
|
||||||
Branch string `json:"branch"`
|
|
||||||
Commit string `json:"commit"`
|
|
||||||
NoCache bool `json:"no_cache"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TriggerConfig struct {
|
type TriggerConfig struct {
|
||||||
@@ -171,12 +160,6 @@ type TriggerConfig struct {
|
|||||||
Timestamp time.Time `json:"timestamp"` // When trigger occurred
|
Timestamp time.Time `json:"timestamp"` // When trigger occurred
|
||||||
}
|
}
|
||||||
|
|
||||||
// DockerClient exposes the engine's docker handle for read-only queries
|
|
||||||
// (capacity checks, image info).
|
|
||||||
func (de *DeploymentEngine) DockerClient() *docker.Client {
|
|
||||||
return de.dockerClient
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDeploymentEngine(buildManager *build.BuildManager, dockerClient *docker.Client) *DeploymentEngine {
|
func NewDeploymentEngine(buildManager *build.BuildManager, dockerClient *docker.Client) *DeploymentEngine {
|
||||||
return &DeploymentEngine{
|
return &DeploymentEngine{
|
||||||
buildManager: buildManager,
|
buildManager: buildManager,
|
||||||
@@ -301,17 +284,13 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme
|
|||||||
ImageTag: deployment.ID,
|
ImageTag: deployment.ID,
|
||||||
BuildCommand: buildConfig.BuildCommand,
|
BuildCommand: buildConfig.BuildCommand,
|
||||||
StartCommand: buildConfig.StartCommand,
|
StartCommand: buildConfig.StartCommand,
|
||||||
BuildArgs: buildConfig.BuildArgs,
|
|
||||||
Environment: buildConfig.Environment,
|
Environment: buildConfig.Environment,
|
||||||
PullUsername: buildConfig.PullUsername,
|
|
||||||
PullPassword: buildConfig.PullPassword,
|
|
||||||
ProjectID: deployment.ProjectID,
|
ProjectID: deployment.ProjectID,
|
||||||
ServiceID: deployment.ServiceID,
|
ServiceID: deployment.ServiceID,
|
||||||
DeploymentID: deployment.ID,
|
DeploymentID: deployment.ID,
|
||||||
TriggeredBy: "deployment_engine",
|
TriggeredBy: "deployment_engine",
|
||||||
Branch: buildConfig.Branch,
|
Branch: buildConfig.Branch,
|
||||||
Commit: buildConfig.Commit,
|
Commit: buildConfig.Commit,
|
||||||
NoCache: buildConfig.NoCache,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := de.buildManager.Build(ctx, buildReq)
|
response, err := de.buildManager.Build(ctx, buildReq)
|
||||||
@@ -331,27 +310,20 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
|||||||
cfg := deployment.Config
|
cfg := deployment.Config
|
||||||
|
|
||||||
spec := RuntimeSpec{
|
spec := RuntimeSpec{
|
||||||
ProjectID: deployment.ProjectID,
|
ProjectID: deployment.ProjectID,
|
||||||
ServiceID: deployment.ServiceID,
|
ServiceID: deployment.ServiceID,
|
||||||
Name: cfg.Name,
|
Name: cfg.Name,
|
||||||
Image: deployment.ImageName,
|
Image: deployment.ImageName,
|
||||||
Command: cfg.Command,
|
Command: cfg.Command,
|
||||||
Env: cfg.Environment,
|
Env: cfg.Environment,
|
||||||
Replicas: cfg.Replicas,
|
Replicas: cfg.Replicas,
|
||||||
Port: cfg.PublicPort,
|
Port: cfg.PublicPort,
|
||||||
PublishedPort: cfg.PublishedPort,
|
PublishedPort: cfg.PublishedPort,
|
||||||
Domain: cfg.Domain,
|
Domain: cfg.Domain,
|
||||||
HealthPath: cfg.HealthPath,
|
HealthPath: cfg.HealthPath,
|
||||||
RestartPolicy: cfg.RestartPolicy,
|
RestartPolicy: cfg.RestartPolicy,
|
||||||
MemoryBytes: cfg.Resources.MemoryBytes,
|
MemoryBytes: cfg.Resources.MemoryBytes,
|
||||||
MemoryReserve: cfg.Resources.MemoryReservation,
|
NanoCPUs: cfg.Resources.CPUQuota,
|
||||||
NanoCPUs: cfg.Resources.CPUQuota,
|
|
||||||
CPUShares: cfg.Resources.CPUShares,
|
|
||||||
Volumes: cfg.VolumeMounts,
|
|
||||||
Domains: cfg.Domains,
|
|
||||||
Maintenance: cfg.Maintenance,
|
|
||||||
MaintenanceURL: cfg.MaintenanceURL,
|
|
||||||
BasicAuthUsers: cfg.BasicAuthUsers,
|
|
||||||
}
|
}
|
||||||
for _, pm := range cfg.PortMappings {
|
for _, pm := range cfg.PortMappings {
|
||||||
if spec.Port == 0 {
|
if spec.Port == 0 {
|
||||||
@@ -362,6 +334,8 @@ 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,7 +17,6 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -34,27 +33,20 @@ const (
|
|||||||
|
|
||||||
// RuntimeSpec is everything needed to run a service's containers.
|
// RuntimeSpec is everything needed to run a service's containers.
|
||||||
type RuntimeSpec struct {
|
type RuntimeSpec struct {
|
||||||
ProjectID string
|
ProjectID string
|
||||||
ServiceID string
|
ServiceID string
|
||||||
Name string // DNS alias on the project network
|
Name string // DNS alias on the project network
|
||||||
Image string
|
Image string
|
||||||
Command []string
|
Command []string
|
||||||
Env map[string]string // already resolved
|
Env map[string]string // already resolved
|
||||||
Replicas int
|
Replicas int
|
||||||
Port int32 // container port to expose publicly; 0 = none
|
Port int32 // container port to expose publicly; 0 = none
|
||||||
PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral
|
PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral
|
||||||
Domain string // public hostname routed via Traefik; empty = none
|
Domain string // public hostname routed via Traefik; empty = none
|
||||||
HealthPath string // http path probed on Port for container healthcheck
|
HealthPath string // http path probed on Port for container healthcheck
|
||||||
RestartPolicy string
|
RestartPolicy string
|
||||||
MemoryBytes int64
|
MemoryBytes int64
|
||||||
MemoryReserve int64 // soft reservation; scheduler hint, not a hard cap
|
NanoCPUs int64
|
||||||
NanoCPUs int64
|
|
||||||
CPUShares int64 // relative weight (1024 ≈ 1 cpu), not a cap
|
|
||||||
Volumes []VolumeMount // applied to every replica
|
|
||||||
Domains []string // all public hostnames; Domain is the default
|
|
||||||
Maintenance bool // redirect traffic to MaintenanceURL
|
|
||||||
MaintenanceURL string // absolute URL; empty = serve empty response
|
|
||||||
BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuntimeContainer describes one live replica.
|
// RuntimeContainer describes one live replica.
|
||||||
@@ -75,9 +67,7 @@ type RuntimeState struct {
|
|||||||
Status string `json:"status"` // running, degraded, stopped
|
Status string `json:"status"` // running, degraded, stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectNetworkName is the deterministic docker network every service in a
|
func projectNetworkName(projectID string) string {
|
||||||
// project joins — containr-proj-<first 12 hex chars of the project uuid>.
|
|
||||||
func ProjectNetworkName(projectID string) string {
|
|
||||||
short := strings.ReplaceAll(projectID, "-", "")
|
short := strings.ReplaceAll(projectID, "-", "")
|
||||||
if len(short) > 12 {
|
if len(short) > 12 {
|
||||||
short = short[:12]
|
short = short[:12]
|
||||||
@@ -92,7 +82,7 @@ func serviceNamePrefix(serviceID string) string {
|
|||||||
// EnsureProjectNetwork creates the per-project bridge network if missing.
|
// EnsureProjectNetwork creates the per-project bridge network if missing.
|
||||||
// Containers on it resolve each other by service name (private networking).
|
// Containers on it resolve each other by service name (private networking).
|
||||||
func (de *DeploymentEngine) EnsureProjectNetwork(ctx context.Context, projectID string) (string, error) {
|
func (de *DeploymentEngine) EnsureProjectNetwork(ctx context.Context, projectID string) (string, error) {
|
||||||
name := ProjectNetworkName(projectID)
|
name := projectNetworkName(projectID)
|
||||||
|
|
||||||
networks, err := de.dockerClient.ListNetworks(ctx)
|
networks, err := de.dockerClient.ListNetworks(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -233,72 +223,24 @@ 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."+router+".rule"] = strings.Join(rules, " || ")
|
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".rule"] = "Host(`" + spec.Domain + "`)"
|
||||||
labels["traefik.http.routers."+router+".entrypoints"] = "web"
|
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".entrypoints"] = "web"
|
||||||
labels["traefik.http.services."+router+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
|
labels["traefik.http.services.svc-"+spec.ServiceID[:8]+".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{
|
||||||
Name: fmt.Sprintf("%s%d", serviceNamePrefix(spec.ServiceID), index),
|
Name: fmt.Sprintf("%s%d", serviceNamePrefix(spec.ServiceID), index),
|
||||||
Image: spec.Image,
|
Image: spec.Image,
|
||||||
Cmd: spec.Command,
|
Cmd: spec.Command,
|
||||||
Env: env,
|
Env: env,
|
||||||
Labels: labels,
|
Labels: labels,
|
||||||
RestartPolicy: spec.RestartPolicy,
|
RestartPolicy: spec.RestartPolicy,
|
||||||
Memory: spec.MemoryBytes,
|
Memory: spec.MemoryBytes,
|
||||||
MemoryReservation: spec.MemoryReserve,
|
NanoCPUs: spec.NanoCPUs,
|
||||||
NanoCPUs: spec.NanoCPUs,
|
Networks: endpoints,
|
||||||
CPUShares: spec.CPUShares,
|
|
||||||
Networks: endpoints,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, v := range spec.Volumes {
|
|
||||||
mountType := mount.TypeVolume
|
|
||||||
if v.Type == "bind" {
|
|
||||||
mountType = mount.TypeBind
|
|
||||||
}
|
|
||||||
cfg.Mounts = append(cfg.Mounts, mount.Mount{
|
|
||||||
Type: mountType,
|
|
||||||
Source: v.Source,
|
|
||||||
Target: v.Destination,
|
|
||||||
ReadOnly: v.ReadOnly,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var wantPort nat.Port
|
var wantPort nat.Port
|
||||||
@@ -422,7 +364,7 @@ func (de *DeploymentEngine) ProbeServiceHealth(ctx context.Context, state *Runti
|
|||||||
}
|
}
|
||||||
|
|
||||||
probeURL := ""
|
probeURL := ""
|
||||||
if networkName := ProjectNetworkName(projectID); networkName != "" {
|
if networkName := projectNetworkName(projectID); networkName != "" {
|
||||||
if err := de.dockerClient.ConnectSelfToNetwork(ctx, networkName); err == nil {
|
if err := de.dockerClient.ConnectSelfToNetwork(ctx, networkName); err == nil {
|
||||||
probeURL = fmt.Sprintf("http://%s:%d%s", serviceName, port, healthPath)
|
probeURL = fmt.Sprintf("http://%s:%d%s", serviceName, port, healthPath)
|
||||||
}
|
}
|
||||||
@@ -514,7 +456,7 @@ func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, project
|
|||||||
|
|
||||||
// RemoveProjectNetwork removes the per-project bridge network if present.
|
// RemoveProjectNetwork removes the per-project bridge network if present.
|
||||||
func (de *DeploymentEngine) RemoveProjectNetwork(ctx context.Context, projectID string) error {
|
func (de *DeploymentEngine) RemoveProjectNetwork(ctx context.Context, projectID string) error {
|
||||||
return de.dockerClient.RemoveNetwork(ctx, ProjectNetworkName(projectID))
|
return de.dockerClient.RemoveNetwork(ctx, projectNetworkName(projectID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func replicaIndex(c container.Summary) int {
|
func replicaIndex(c container.Summary) int {
|
||||||
@@ -530,15 +472,8 @@ func specHash(spec RuntimeSpec) string {
|
|||||||
Env map[string]string
|
Env map[string]string
|
||||||
Port int32
|
Port int32
|
||||||
MemoryBytes, NanoCPUs int64
|
MemoryBytes, NanoCPUs int64
|
||||||
MemoryReserve, CPUShares int64
|
|
||||||
Volumes []VolumeMount
|
|
||||||
Domains []string
|
|
||||||
Maintenance bool
|
|
||||||
BasicAuth string
|
|
||||||
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
|
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
|
||||||
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs,
|
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs})
|
||||||
spec.MemoryReserve, spec.CPUShares, spec.Volumes,
|
|
||||||
spec.Domains, spec.Maintenance, spec.BasicAuthUsers})
|
|
||||||
sum := sha256.Sum256(payload)
|
sum := sha256.Sum256(payload)
|
||||||
return hex.EncodeToString(sum[:8])
|
return hex.EncodeToString(sum[:8])
|
||||||
}
|
}
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
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():
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -156,10 +156,8 @@ func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (s
|
|||||||
PortBindings: config.PortBindings,
|
PortBindings: config.PortBindings,
|
||||||
Mounts: config.Mounts,
|
Mounts: config.Mounts,
|
||||||
Resources: container.Resources{
|
Resources: container.Resources{
|
||||||
Memory: config.Memory,
|
Memory: config.Memory,
|
||||||
MemoryReservation: config.MemoryReservation,
|
NanoCPUs: config.NanoCPUs,
|
||||||
NanoCPUs: config.NanoCPUs,
|
|
||||||
CPUShares: config.CPUShares,
|
|
||||||
},
|
},
|
||||||
NetworkMode: container.NetworkMode(config.NetworkMode),
|
NetworkMode: container.NetworkMode(config.NetworkMode),
|
||||||
}
|
}
|
||||||
@@ -293,42 +291,13 @@ func (c *Client) PullImageWait(ctx context.Context, ref string, auth registry.Au
|
|||||||
|
|
||||||
// BuildImage builds an image from a Dockerfile
|
// BuildImage builds an image from a Dockerfile
|
||||||
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
|
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
|
||||||
resp, err := c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
|
return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
|
||||||
Dockerfile: options.Dockerfile,
|
Dockerfile: options.Dockerfile,
|
||||||
Tags: options.Tags,
|
Tags: options.Tags,
|
||||||
BuildArgs: options.BuildArgs,
|
BuildArgs: options.BuildArgs,
|
||||||
Labels: options.Labels,
|
Labels: options.Labels,
|
||||||
Remove: options.Remove,
|
Remove: options.Remove,
|
||||||
NoCache: options.NoCache,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
// The daemon aborts the build if the response stream isn't consumed,
|
|
||||||
// and reports failures only inside that stream.
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var buildErr string
|
|
||||||
dec := json.NewDecoder(resp.Body)
|
|
||||||
for {
|
|
||||||
var line struct {
|
|
||||||
ErrorDetail struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"errorDetail"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
if err := dec.Decode(&line); err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if line.ErrorDetail.Message != "" {
|
|
||||||
buildErr = line.ErrorDetail.Message
|
|
||||||
} else if line.Error != "" {
|
|
||||||
buildErr = line.Error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if buildErr != "" {
|
|
||||||
return resp, fmt.Errorf("docker build failed: %s", buildErr)
|
|
||||||
}
|
|
||||||
return resp, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveImage removes an image
|
// RemoveImage removes an image
|
||||||
|
|||||||
@@ -12,22 +12,20 @@ import (
|
|||||||
|
|
||||||
// ContainerConfig represents the configuration for creating a container
|
// ContainerConfig represents the configuration for creating a container
|
||||||
type ContainerConfig struct {
|
type ContainerConfig struct {
|
||||||
Name string
|
Name string
|
||||||
Image string
|
Image string
|
||||||
Cmd []string
|
Cmd []string
|
||||||
Env []string
|
Env []string
|
||||||
Labels map[string]string
|
Labels map[string]string
|
||||||
RestartPolicy string
|
RestartPolicy string
|
||||||
ExposedPorts nat.PortSet
|
ExposedPorts nat.PortSet
|
||||||
PortBindings nat.PortMap
|
PortBindings nat.PortMap
|
||||||
Mounts []mount.Mount
|
Mounts []mount.Mount
|
||||||
Memory int64
|
Memory int64
|
||||||
MemoryReservation int64
|
NanoCPUs int64
|
||||||
NanoCPUs int64
|
NetworkMode string
|
||||||
CPUShares int64
|
Networks map[string]*network.EndpointSettings
|
||||||
NetworkMode string
|
Healthcheck *container.HealthConfig
|
||||||
Networks map[string]*network.EndpointSettings
|
|
||||||
Healthcheck *container.HealthConfig
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogOptions represents options for retrieving container logs
|
// LogOptions represents options for retrieving container logs
|
||||||
@@ -46,7 +44,6 @@ 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
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package ha
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -76,13 +75,11 @@ type HealthCheckConfig struct {
|
|||||||
|
|
||||||
// HealthChecker performs health checks on services and nodes
|
// HealthChecker performs health checks on services and nodes
|
||||||
type HealthChecker struct {
|
type HealthChecker struct {
|
||||||
scheduler *deployment.Scheduler
|
scheduler *deployment.Scheduler
|
||||||
checks map[string]*HealthCheck
|
checks map[string]*HealthCheck
|
||||||
results map[string]*HealthCheckResult
|
results map[string]*HealthCheckResult
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
checkInterval time.Duration
|
checkInterval time.Duration
|
||||||
serviceResolver func(ctx context.Context, serviceID string) (host string, port int, err error)
|
|
||||||
db *sql.DB
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HealthCheck represents a health check configuration
|
// HealthCheck represents a health check configuration
|
||||||
@@ -599,11 +596,7 @@ func (hc *HealthChecker) performHealthChecks(ctx context.Context) error {
|
|||||||
result := hc.performHealthCheck(ctx, check)
|
result := hc.performHealthCheck(ctx, check)
|
||||||
hc.mu.Lock()
|
hc.mu.Lock()
|
||||||
hc.results[check.ID] = result
|
hc.results[check.ID] = result
|
||||||
check.Status = result.Status
|
|
||||||
check.LastCheck = result.Timestamp
|
|
||||||
hc.mu.Unlock()
|
hc.mu.Unlock()
|
||||||
// Persist last-known state so a restart resumes with honest history.
|
|
||||||
hc.persistCheck(check)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -624,20 +617,12 @@ func (hc *HealthChecker) performHealthCheck(ctx context.Context, check *HealthCh
|
|||||||
checkCtx, cancel := context.WithTimeout(ctx, timeout)
|
checkCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
host, port, err := hc.resolveTarget(ctx, check)
|
var err error
|
||||||
if err != nil {
|
|
||||||
result.Status = HealthStatusUnhealthy
|
|
||||||
result.Message = err.Error()
|
|
||||||
result.ErrorCode = "TARGET_UNRESOLVED"
|
|
||||||
result.Latency = time.Since(start)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
switch check.Type {
|
switch check.Type {
|
||||||
case HealthCheckTypeHTTP:
|
case HealthCheckTypeHTTP:
|
||||||
err = hc.performHTTPCheck(checkCtx, check, host, port)
|
err = hc.performHTTPCheck(checkCtx, check)
|
||||||
case HealthCheckTypeTCP:
|
case HealthCheckTypeTCP:
|
||||||
err = hc.performTCPCheck(checkCtx, check, host, port)
|
err = hc.performTCPCheck(checkCtx, check)
|
||||||
case HealthCheckTypeCommand:
|
case HealthCheckTypeCommand:
|
||||||
err = hc.performCommandCheck(checkCtx, check)
|
err = hc.performCommandCheck(checkCtx, check)
|
||||||
default:
|
default:
|
||||||
@@ -657,11 +642,13 @@ func (hc *HealthChecker) performHealthCheck(ctx context.Context, check *HealthCh
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthCheck, host string, port int) error {
|
func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthCheck) error {
|
||||||
|
host := hc.resolveHealthCheckHost(check)
|
||||||
protocol := strings.ToLower(strings.TrimSpace(check.Config.Protocol))
|
protocol := strings.ToLower(strings.TrimSpace(check.Config.Protocol))
|
||||||
if protocol == "" {
|
if protocol == "" {
|
||||||
protocol = "http"
|
protocol = "http"
|
||||||
}
|
}
|
||||||
|
port := check.Config.Port
|
||||||
if port <= 0 {
|
if port <= 0 {
|
||||||
if protocol == "https" {
|
if protocol == "https" {
|
||||||
port = 443
|
port = 443
|
||||||
@@ -697,7 +684,9 @@ func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthChec
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hc *HealthChecker) performTCPCheck(ctx context.Context, check *HealthCheck, host string, port int) error {
|
func (hc *HealthChecker) performTCPCheck(ctx context.Context, check *HealthCheck) error {
|
||||||
|
host := hc.resolveHealthCheckHost(check)
|
||||||
|
port := check.Config.Port
|
||||||
if port <= 0 {
|
if port <= 0 {
|
||||||
port = 80
|
port = 80
|
||||||
}
|
}
|
||||||
@@ -734,27 +723,6 @@ func (hc *HealthChecker) performCommandCheck(ctx context.Context, check *HealthC
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithServiceResolver lets the API layer plug in service-aware targeting: a
|
|
||||||
// ServiceID-bound check resolves to the service's DNS name on its project
|
|
||||||
// network instead of the backend container's loopback, where dialing
|
|
||||||
// 127.0.0.1:<host port> can never succeed.
|
|
||||||
func (m *HighAvailabilityManager) WithServiceResolver(fn func(ctx context.Context, serviceID string) (host string, port int, err error)) *HighAvailabilityManager {
|
|
||||||
m.healthChecker.serviceResolver = fn
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithPersistence attaches Postgres so configured checks survive restarts.
|
|
||||||
// Call LoadHealthChecks once during startup to hydrate state.
|
|
||||||
func (m *HighAvailabilityManager) WithPersistence(db *sql.DB) *HighAvailabilityManager {
|
|
||||||
m.healthChecker.db = db
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadHealthChecks hydrates persisted health checks during startup.
|
|
||||||
func (m *HighAvailabilityManager) LoadHealthChecks(ctx context.Context) error {
|
|
||||||
return m.healthChecker.LoadHealthChecks(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string {
|
func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string {
|
||||||
if strings.TrimSpace(check.NodeID) != "" && hc.scheduler != nil {
|
if strings.TrimSpace(check.NodeID) != "" && hc.scheduler != nil {
|
||||||
nodes := hc.scheduler.GetNodes()
|
nodes := hc.scheduler.GetNodes()
|
||||||
@@ -771,109 +739,19 @@ func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string {
|
|||||||
return "127.0.0.1"
|
return "127.0.0.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveTarget picks the dial target for a check: service-bound checks go to
|
|
||||||
// the service DNS name on the project network; node-bound and free-standing
|
|
||||||
// checks keep the old host behavior. A service-bound resolver failure is
|
|
||||||
// surfaced as an error — falling back to loopback would only produce a
|
|
||||||
// misleading "service down" result.
|
|
||||||
func (hc *HealthChecker) resolveTarget(ctx context.Context, check *HealthCheck) (string, int, error) {
|
|
||||||
port := check.Config.Port
|
|
||||||
if strings.TrimSpace(check.NodeID) == "" && check.ServiceID != "" && hc.serviceResolver != nil {
|
|
||||||
resolveCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
h, p, err := hc.serviceResolver(resolveCtx, check.ServiceID)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, fmt.Errorf("resolve service target: %w", err)
|
|
||||||
}
|
|
||||||
if h == "" {
|
|
||||||
return "", 0, fmt.Errorf("resolve service target: service %s not found", check.ServiceID)
|
|
||||||
}
|
|
||||||
if port <= 0 {
|
|
||||||
port = p
|
|
||||||
}
|
|
||||||
return h, port, nil
|
|
||||||
}
|
|
||||||
return hc.resolveHealthCheckHost(check), port, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddHealthCheck adds a new health check
|
// AddHealthCheck adds a new health check
|
||||||
func (hc *HealthChecker) AddHealthCheck(check *HealthCheck) {
|
func (hc *HealthChecker) AddHealthCheck(check *HealthCheck) {
|
||||||
hc.mu.Lock()
|
hc.mu.Lock()
|
||||||
|
defer hc.mu.Unlock()
|
||||||
hc.checks[check.ID] = check
|
hc.checks[check.ID] = check
|
||||||
hc.mu.Unlock()
|
|
||||||
hc.persistCheck(check)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveHealthCheck removes a health check
|
// RemoveHealthCheck removes a health check
|
||||||
func (hc *HealthChecker) RemoveHealthCheck(checkID string) {
|
func (hc *HealthChecker) RemoveHealthCheck(checkID string) {
|
||||||
hc.mu.Lock()
|
hc.mu.Lock()
|
||||||
|
defer hc.mu.Unlock()
|
||||||
delete(hc.checks, checkID)
|
delete(hc.checks, checkID)
|
||||||
delete(hc.results, checkID)
|
delete(hc.results, checkID)
|
||||||
hc.mu.Unlock()
|
|
||||||
if hc.db != nil {
|
|
||||||
_, _ = hc.db.ExecContext(context.Background(), `DELETE FROM ha_health_checks WHERE id = $1`, checkID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// persistCheck upserts one check. Best effort — a write failure never blocks
|
|
||||||
// the in-memory path, matching the scaling-policy persistence style.
|
|
||||||
func (hc *HealthChecker) persistCheck(check *HealthCheck) {
|
|
||||||
if hc.db == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg, _ := json.Marshal(check.Config)
|
|
||||||
var lastCheck interface{}
|
|
||||||
if !check.LastCheck.IsZero() {
|
|
||||||
lastCheck = check.LastCheck
|
|
||||||
}
|
|
||||||
_, _ = hc.db.ExecContext(context.Background(),
|
|
||||||
`INSERT INTO ha_health_checks (id, service_id, node_id, type, config, status, last_check, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
|
||||||
ON CONFLICT (id) DO UPDATE
|
|
||||||
SET service_id = EXCLUDED.service_id, node_id = EXCLUDED.node_id,
|
|
||||||
type = EXCLUDED.type, config = EXCLUDED.config,
|
|
||||||
status = EXCLUDED.status, last_check = EXCLUDED.last_check,
|
|
||||||
updated_at = now()`,
|
|
||||||
check.ID, check.ServiceID, check.NodeID, string(check.Type), cfg, string(check.Status), lastCheck,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadHealthChecks hydrates checks persisted across restarts.
|
|
||||||
func (hc *HealthChecker) LoadHealthChecks(ctx context.Context) error {
|
|
||||||
if hc.db == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rows, err := hc.db.QueryContext(ctx,
|
|
||||||
`SELECT id, service_id, node_id, type, config, status, last_check FROM ha_health_checks`)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
loaded := 0
|
|
||||||
for rows.Next() {
|
|
||||||
var check HealthCheck
|
|
||||||
var cfg []byte
|
|
||||||
var status, checkType string
|
|
||||||
var lastCheck sql.NullTime
|
|
||||||
if err := rows.Scan(&check.ID, &check.ServiceID, &check.NodeID, &checkType, &cfg, &status, &lastCheck); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
check.Type = HealthCheckType(checkType)
|
|
||||||
check.Status = HealthStatus(status)
|
|
||||||
if lastCheck.Valid {
|
|
||||||
check.LastCheck = lastCheck.Time
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(cfg, &check.Config)
|
|
||||||
hc.mu.Lock()
|
|
||||||
hc.checks[check.ID] = &check
|
|
||||||
hc.mu.Unlock()
|
|
||||||
loaded++
|
|
||||||
}
|
|
||||||
if loaded > 0 {
|
|
||||||
log.Printf("Restored %d health checks", loaded)
|
|
||||||
}
|
|
||||||
return rows.Err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHealthCheck returns one configured health check.
|
// GetHealthCheck returns one configured health check.
|
||||||
|
|||||||
@@ -1,301 +0,0 @@
|
|||||||
// Package mcpserver exposes the Containr API as MCP tools over stdio.
|
|
||||||
// It reuses the CLI's thin client — same PAT auth, same endpoints —
|
|
||||||
// so agents reach everything the UI and CLI do.
|
|
||||||
package mcpserver
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"containr/internal/cli/commands"
|
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// spec describes one MCP tool as an HTTP call. {placeholder} segments in
|
|
||||||
// Path are filled from arguments; remaining string/number/bool args become
|
|
||||||
// the JSON body for POST/PUT/PATCH.
|
|
||||||
type spec struct {
|
|
||||||
name string
|
|
||||||
description string
|
|
||||||
method string
|
|
||||||
path string
|
|
||||||
// params are the arguments that fill path placeholders (required).
|
|
||||||
params []string
|
|
||||||
// bodyKeys restrict which args go into the request body; when empty all
|
|
||||||
// non-param args are sent.
|
|
||||||
bodyKeys []string
|
|
||||||
// destructive tools refuse unless args["confirm"] == true.
|
|
||||||
destructive bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var tools []spec
|
|
||||||
|
|
||||||
func register(s spec) { tools = append(tools, s) }
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
register(spec{"containr_auth_status", "Show which profile/token is active and the authenticated identity", "GET", "/user/profile", nil, nil, false})
|
|
||||||
|
|
||||||
// Personal access tokens
|
|
||||||
register(spec{"containr_tokens_list", "List personal access tokens (metadata only, never values)", "GET", "/user/tokens", nil, nil, false})
|
|
||||||
register(spec{"containr_tokens_create", "Create a personal access token. Args: name (required), scope (read|write|admin, default write), expires_in_days (0=never). Returns the raw token once.", "POST", "/user/tokens", nil, []string{"name", "scope", "expires_in_days"}, false})
|
|
||||||
register(spec{"containr_tokens_revoke", "Revoke a personal access token by id. Requires confirm:true.", "DELETE", "/user/tokens/{id}", []string{"id"}, nil, true})
|
|
||||||
|
|
||||||
// Projects
|
|
||||||
register(spec{"containr_projects_list", "List projects", "GET", "/projects", nil, nil, false})
|
|
||||||
register(spec{"containr_projects_get", "Get a project by id", "GET", "/projects/{id}", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_projects_create", "Create a project. Args: name (required), description.", "POST", "/projects", nil, []string{"name", "description"}, false})
|
|
||||||
register(spec{"containr_projects_delete", "Delete a project and all its services. Requires confirm:true.", "DELETE", "/projects/{id}", []string{"id"}, nil, true})
|
|
||||||
|
|
||||||
// Services
|
|
||||||
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
|
||||||
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir.", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
|
||||||
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
|
|
||||||
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_services_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_services_clone", "Clone a service (config, volumes, domains, variables) into the same or another project. Args: id, name, project_id, environment.", "POST", "/services/{id}/clone", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false})
|
|
||||||
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
|
|
||||||
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields).", "PUT", "/services/{id}", []string{"id"}, nil, false})
|
|
||||||
|
|
||||||
// Domains
|
|
||||||
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_domains_add", "Attach a domain to a service. Args: id, domain (required), is_default.", "POST", "/services/{id}/domains", []string{"id"}, []string{"domain", "is_default"}, false})
|
|
||||||
register(spec{"containr_domains_remove", "Detach a domain. Args: id (service), domain_id.", "DELETE", "/services/{id}/domains/{domain_id}", []string{"id", "domain_id"}, nil, false})
|
|
||||||
register(spec{"containr_domains_default", "Set the default domain. Args: id (service), domain_id.", "POST", "/services/{id}/domains/{domain_id}/default", []string{"id", "domain_id"}, nil, false})
|
|
||||||
register(spec{"containr_domains_check", "DNS preflight for all service domains — returns ok|wrong-target|pending per domain", "GET", "/services/{id}/domains/check", []string{"id"}, nil, false})
|
|
||||||
|
|
||||||
// Registries — private-image pull credentials, owner-scoped.
|
|
||||||
register(spec{"containr_registries_list", "List registry credentials (passwords never returned)", "GET", "/registries", nil, nil, false})
|
|
||||||
register(spec{"containr_registries_create", "Add a registry credential. Args: name (required), host (required — e.g. ghcr.io, docker.io), username, password (stored encrypted).", "POST", "/registries", nil, []string{"name", "host"}, false})
|
|
||||||
register(spec{"containr_registries_update", "Update a registry credential. Args: id, name, host (required), username, password (empty keeps stored).", "PUT", "/registries/{id}", []string{"id"}, []string{"name", "host"}, false})
|
|
||||||
register(spec{"containr_registries_delete", "Remove a registry credential. Requires confirm:true.", "DELETE", "/registries/{id}", []string{"id"}, nil, true})
|
|
||||||
|
|
||||||
// Docker volumes (admin)
|
|
||||||
register(spec{"containr_volumes_list", "List docker volumes on the node with in-use flags (admin)", "GET", "/admin/volumes", nil, nil, false})
|
|
||||||
register(spec{"containr_volumes_delete", "Delete an unused docker volume. Requires confirm:true. (admin)", "DELETE", "/admin/volumes/{name}", []string{"name"}, nil, true})
|
|
||||||
|
|
||||||
// Variables
|
|
||||||
register(spec{"containr_variables_list", "List service environment variables (secrets masked)", "GET", "/services/{id}/variables", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_variables_set", "Replace all variables on a service. Args: id, variables = [{key, value, is_secret}], redeploy (queue a redeploy to apply). Fetch the current list first and merge — the server replaces wholesale.", "PUT", "/services/{id}/variables", []string{"id"}, []string{"variables", "redeploy"}, false})
|
|
||||||
register(spec{"containr_shared_variables_list", "List project-level shared variables, referenced from service env as ${{shared.KEY}} (secrets masked). Args: id (project).", "GET", "/projects/{id}/variables", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_shared_variables_set", "Replace all shared project variables. Args: id (project), variables = [{key, value, is_secret}]. Services reference them as ${{shared.KEY}} — resolved at deploy/refresh, not live-updated.", "PUT", "/projects/{id}/variables", []string{"id"}, []string{"variables"}, false})
|
|
||||||
|
|
||||||
// Deployments
|
|
||||||
register(spec{"containr_deploy", "Trigger a deployment for a service. Args: id (service), commit_hash, branch, no_cache.", "POST", "/services/{id}/deployments", []string{"id"}, []string{"commit_hash", "branch", "trigger", "no_cache"}, false})
|
|
||||||
register(spec{"containr_deployments_list", "List recent deployments across services", "GET", "/deployments", nil, nil, false})
|
|
||||||
register(spec{"containr_services_deployments", "List deployments for one service", "GET", "/services/{id}/deployments", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_deployments_get", "Get a deployment", "GET", "/deployments/{id}", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_deployments_logs", "Get persisted build/runtime logs for a deployment. Args: id, type (all|build|runtime).", "GET", "/deployments/{id}/logs", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_deployments_rollback", "Roll back to a deployment. Requires confirm:true.", "POST", "/deployments/{id}/rollback", []string{"id"}, nil, true})
|
|
||||||
register(spec{"containr_deployments_cancel", "Cancel a queued or running deployment", "POST", "/deployments/{id}/cancel", []string{"id"}, nil, false})
|
|
||||||
|
|
||||||
// Databases
|
|
||||||
register(spec{"containr_databases_list", "List managed databases", "GET", "/databases", nil, nil, false})
|
|
||||||
register(spec{"containr_databases_get", "Get a database (connection URL included)", "GET", "/databases/{id}", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_databases_create", "Provision a managed database. Args: name, type (postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse).", "POST", "/databases", nil, nil, false})
|
|
||||||
register(spec{"containr_databases_action", "Start/stop/restart a database. Args: id, action.", "POST", "/databases/{id}/action", []string{"id"}, []string{"action"}, false})
|
|
||||||
register(spec{"containr_databases_backup", "Take a manual backup snapshot", "POST", "/databases/{id}/backup", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_databases_restore", "Restore a database from a backup — overwrites current data. Requires confirm:true.", "POST", "/databases/{id}/restore", []string{"id"}, []string{"backup_id"}, true})
|
|
||||||
register(spec{"containr_databases_delete", "Delete a database and its data. Requires confirm:true.", "DELETE", "/databases/{id}", []string{"id"}, nil, true})
|
|
||||||
|
|
||||||
// Cron
|
|
||||||
register(spec{"containr_cron_list", "List cron jobs", "GET", "/cron-jobs", nil, nil, false})
|
|
||||||
register(spec{"containr_cron_create", "Create a cron job. Args: service_id, schedule (5-field), command, name, timezone.", "POST", "/cron-jobs", nil, nil, false})
|
|
||||||
register(spec{"containr_cron_trigger", "Run a cron job now", "POST", "/cron-jobs/{id}/trigger", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_cron_executions", "List a cron job's execution history", "GET", "/cron-jobs/{id}/executions", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_cron_delete", "Delete a cron job. Requires confirm:true.", "DELETE", "/cron-jobs/{id}", []string{"id"}, nil, true})
|
|
||||||
|
|
||||||
// Templates
|
|
||||||
register(spec{"containr_templates_list", "List templates", "GET", "/templates", nil, nil, false})
|
|
||||||
register(spec{"containr_templates_get", "Get a template definition", "GET", "/templates/{id}", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
|
|
||||||
|
|
||||||
// Notifications
|
|
||||||
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
|
|
||||||
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
|
|
||||||
|
|
||||||
// Nodes / agent tokens (admin)
|
|
||||||
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false})
|
|
||||||
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
|
|
||||||
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
|
|
||||||
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
|
|
||||||
|
|
||||||
// Scaling / HA / security
|
|
||||||
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
|
|
||||||
register(spec{"containr_scaling_policies", "List scaling policies", "GET", "/scaling/policies", nil, nil, false})
|
|
||||||
register(spec{"containr_scaling_scale", "Manually scale a service. Args: service_id, replicas. (admin)", "POST", "/scaling/services/{service_id}/scale", []string{"service_id"}, []string{"replicas"}, false})
|
|
||||||
register(spec{"containr_ha_status", "HA manager status", "GET", "/ha/status", nil, nil, false})
|
|
||||||
register(spec{"containr_ha_alerts", "Active HA alerts", "GET", "/ha/alerts/active", nil, nil, false})
|
|
||||||
register(spec{"containr_ha_failover", "Trigger a manual failover — reschedules services. Requires confirm:true. (admin)", "POST", "/ha/failover", nil, nil, true})
|
|
||||||
register(spec{"containr_security_scan", "Run a security scan. Args: project_id (required), scan_type, service_id.", "POST", "/security/scans", nil, nil, false})
|
|
||||||
register(spec{"containr_security_vulnerabilities", "List a project's vulnerabilities", "GET", "/projects/{project_id}/vulnerabilities", []string{"project_id"}, nil, false})
|
|
||||||
|
|
||||||
// Admin
|
|
||||||
register(spec{"containr_admin_overview", "Platform overview (admin)", "GET", "/admin/overview", nil, nil, false})
|
|
||||||
register(spec{"containr_admin_users", "List all users (admin)", "GET", "/admin/users", nil, nil, false})
|
|
||||||
register(spec{"containr_admin_audit_logs", "List audit log entries (admin)", "GET", "/audit-logs", nil, nil, false})
|
|
||||||
register(spec{"containr_gateway_services", "List gateway services (admin)", "GET", "/gateway/services", nil, nil, false})
|
|
||||||
register(spec{"containr_gateway_keys", "List gateway API keys (admin)", "GET", "/gateway/keys", nil, nil, false})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run starts the stdio MCP server. apiURL/token come from env or config —
|
|
||||||
// the caller resolves them before calling.
|
|
||||||
func Run(ctx context.Context, apiURL, token, version string) error {
|
|
||||||
if token == "" {
|
|
||||||
return fmt.Errorf("CONTAINR_TOKEN is required (create a cnp_ token under Settings → Personal Access Tokens)")
|
|
||||||
}
|
|
||||||
client := &commands.Client{BaseURL: apiURL, Token: token}
|
|
||||||
|
|
||||||
server := mcp.NewServer(&mcp.Implementation{
|
|
||||||
Name: "containr",
|
|
||||||
Version: version,
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
for _, s := range tools {
|
|
||||||
s := s
|
|
||||||
server.AddTool(&mcp.Tool{
|
|
||||||
Name: s.name,
|
|
||||||
Description: s.description,
|
|
||||||
InputSchema: schemaFor(s),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
return call(ctx, client, s, req)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return server.Run(ctx, &mcp.StdioTransport{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// schemaFor builds a JSON Schema for the tool's arguments: path params +
|
|
||||||
// body keys + confirm for destructive tools. Properties accept any JSON
|
|
||||||
// type — the API validates values.
|
|
||||||
func schemaFor(s spec) map[string]interface{} {
|
|
||||||
props := map[string]interface{}{}
|
|
||||||
var required []string
|
|
||||||
for _, p := range s.params {
|
|
||||||
props[p] = map[string]interface{}{"type": "string", "description": "path parameter"}
|
|
||||||
required = append(required, p)
|
|
||||||
}
|
|
||||||
bodySrc := s.bodyKeys
|
|
||||||
if len(bodySrc) == 0 && s.method != "GET" && s.method != "DELETE" {
|
|
||||||
// Unrestricted body: advertise nothing, accept anything.
|
|
||||||
}
|
|
||||||
for _, k := range bodySrc {
|
|
||||||
props[k] = map[string]interface{}{"description": "request body field"}
|
|
||||||
}
|
|
||||||
if s.destructive {
|
|
||||||
props["confirm"] = map[string]interface{}{"type": "boolean", "description": "must be true — this operation is destructive"}
|
|
||||||
required = append(required, "confirm")
|
|
||||||
}
|
|
||||||
schema := map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": props,
|
|
||||||
"additionalProperties": true,
|
|
||||||
}
|
|
||||||
if len(required) > 0 {
|
|
||||||
schema["required"] = required
|
|
||||||
}
|
|
||||||
return schema
|
|
||||||
}
|
|
||||||
|
|
||||||
func call(ctx context.Context, c *commands.Client, s spec, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
var args map[string]interface{}
|
|
||||||
if len(req.Params.Arguments) > 0 {
|
|
||||||
if err := json.Unmarshal(req.Params.Arguments, &args); err != nil {
|
|
||||||
return errorResult("invalid arguments: " + err.Error()), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if args == nil {
|
|
||||||
args = map[string]interface{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.destructive && args["confirm"] != true {
|
|
||||||
return errorResult("destructive operation — pass confirm:true to proceed"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
path := s.path
|
|
||||||
for _, p := range s.params {
|
|
||||||
v, ok := args[p]
|
|
||||||
if !ok || fmt.Sprint(v) == "" {
|
|
||||||
return errorResult(fmt.Sprintf("missing required argument %q", p)), nil
|
|
||||||
}
|
|
||||||
path = strings.ReplaceAll(path, "{"+p+"}", fmt.Sprint(v))
|
|
||||||
}
|
|
||||||
|
|
||||||
var body interface{}
|
|
||||||
var query []string
|
|
||||||
for k, v := range args {
|
|
||||||
if k == "confirm" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
inParams := false
|
|
||||||
for _, p := range s.params {
|
|
||||||
if k == p {
|
|
||||||
inParams = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if inParams {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if s.method == "GET" || s.method == "DELETE" {
|
|
||||||
// Non-path args on reads become query parameters.
|
|
||||||
query = append(query, url.QueryEscape(k)+"="+url.QueryEscape(fmt.Sprint(v)))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(s.bodyKeys) > 0 {
|
|
||||||
allowed := false
|
|
||||||
for _, b := range s.bodyKeys {
|
|
||||||
if k == b {
|
|
||||||
allowed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !allowed {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if body == nil {
|
|
||||||
body = map[string]interface{}{}
|
|
||||||
}
|
|
||||||
body.(map[string]interface{})[k] = v
|
|
||||||
}
|
|
||||||
if len(query) > 0 {
|
|
||||||
sep := "?"
|
|
||||||
if strings.Contains(path, "?") {
|
|
||||||
sep = "&"
|
|
||||||
}
|
|
||||||
path += sep + strings.Join(query, "&")
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := c.Do(s.method, path, body)
|
|
||||||
if err != nil {
|
|
||||||
return errorResult(err.Error()), nil
|
|
||||||
}
|
|
||||||
return textResult(data), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func textResult(data []byte) *mcp.CallToolResult {
|
|
||||||
var v interface{}
|
|
||||||
if json.Unmarshal(data, &v) != nil {
|
|
||||||
v = string(data)
|
|
||||||
}
|
|
||||||
out, _ := json.MarshalIndent(v, "", " ")
|
|
||||||
return &mcp.CallToolResult{
|
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: string(out)}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func errorResult(msg string) *mcp.CallToolResult {
|
|
||||||
return &mcp.CallToolResult{
|
|
||||||
IsError: true,
|
|
||||||
Content: []mcp.Content{&mcp.TextContent{Text: msg}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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,10 +2,7 @@ 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"
|
||||||
@@ -78,14 +75,6 @@ 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 {
|
||||||
@@ -112,67 +101,12 @@ 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)
|
||||||
isAdmin := loadIsAdmin(c, userID)
|
c.Set("is_admin", 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 {
|
||||||
@@ -199,28 +133,20 @@ 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, "code": "INVALID_TOKEN"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasToken {
|
if hasToken {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token", "code": "INVALID_TOKEN"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||||
} else {
|
} else {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
}
|
}
|
||||||
c.Abort()
|
c.Abort()
|
||||||
}
|
}
|
||||||
@@ -245,12 +171,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", "code": "UNAUTHENTICATED"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
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", "code": "ADMIN_REQUIRED"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,61 +131,6 @@ 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)
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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,11 +28,6 @@ type BuildRequest struct {
|
|||||||
TriggeredBy string `json:"triggered_by"` // Who triggered the build
|
TriggeredBy string `json:"triggered_by"` // Who triggered the build
|
||||||
Branch string `json:"branch"` // Git branch
|
Branch string `json:"branch"` // Git branch
|
||||||
Commit string `json:"commit"` // Git commit SHA
|
Commit string `json:"commit"` // Git commit SHA
|
||||||
NoCache bool `json:"no_cache"` // Disable Docker layer cache
|
|
||||||
|
|
||||||
// Registry credentials for pulling a private prebuilt image.
|
|
||||||
PullUsername string `json:"pull_username,omitempty"`
|
|
||||||
PullPassword string `json:"pull_password,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildResponse represents the response from a build operation
|
// BuildResponse represents the response from a build operation
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- Persisted HA health checks. Previously checks lived only in memory, so a
|
|
||||||
-- backend restart silently deleted every configured check while services kept
|
|
||||||
-- running unmonitored.
|
|
||||||
CREATE TABLE IF NOT EXISTS ha_health_checks (
|
|
||||||
id uuid PRIMARY KEY,
|
|
||||||
service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
|
||||||
node_id varchar(255) NOT NULL DEFAULT '',
|
|
||||||
type varchar(20) NOT NULL,
|
|
||||||
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
status varchar(20) NOT NULL DEFAULT 'unknown',
|
|
||||||
last_check timestamptz,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
DROP TABLE IF EXISTS ha_health_checks;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
-- +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;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
-- +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;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
-- +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;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- +goose StatementBegin
|
|
||||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS builder varchar(32) NOT NULL DEFAULT 'auto';
|
|
||||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS cpu_reserve varchar(20) NOT NULL DEFAULT '';
|
|
||||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS memory_reserve varchar(20) NOT NULL DEFAULT '';
|
|
||||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS static_build_cmd varchar(255) NOT NULL DEFAULT '';
|
|
||||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS static_dir varchar(255) NOT NULL DEFAULT '';
|
|
||||||
-- +goose StatementEnd
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
-- +goose StatementBegin
|
|
||||||
ALTER TABLE services DROP COLUMN IF EXISTS static_dir;
|
|
||||||
ALTER TABLE services DROP COLUMN IF EXISTS static_build_cmd;
|
|
||||||
ALTER TABLE services DROP COLUMN IF EXISTS memory_reserve;
|
|
||||||
ALTER TABLE services DROP COLUMN IF EXISTS cpu_reserve;
|
|
||||||
ALTER TABLE services DROP COLUMN IF EXISTS builder;
|
|
||||||
-- +goose StatementEnd
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- +goose StatementBegin
|
|
||||||
CREATE TABLE IF NOT EXISTS registries (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
host VARCHAR(255) NOT NULL,
|
|
||||||
username VARCHAR(255) NOT NULL DEFAULT '',
|
|
||||||
password TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (owner_id, host)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_registries_owner ON registries(owner_id);
|
|
||||||
-- +goose StatementEnd
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
-- +goose StatementBegin
|
|
||||||
DROP TABLE IF EXISTS registries;
|
|
||||||
-- +goose StatementEnd
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- Project-level shared variables (Railway-style). Services reference them via
|
|
||||||
-- ${{shared.KEY}} in environment values; secret entries are encrypted at rest
|
|
||||||
-- and masked in API responses exactly like service variables.
|
|
||||||
CREATE TABLE IF NOT EXISTS project_variables (
|
|
||||||
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
|
|
||||||
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
||||||
key varchar(255) NOT NULL,
|
|
||||||
value text NOT NULL DEFAULT '',
|
|
||||||
is_secret boolean NOT NULL DEFAULT false,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (project_id, key)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_project_variables_project ON project_variables(project_id);
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
DROP TABLE IF EXISTS project_variables;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
-- name: CreateUserToken :one
|
|
||||||
INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: ListUserTokens :many
|
|
||||||
SELECT * FROM user_tokens
|
|
||||||
WHERE user_id = $1 AND revoked_at IS NULL
|
|
||||||
ORDER BY created_at DESC;
|
|
||||||
|
|
||||||
-- name: GetUserTokenByHash :one
|
|
||||||
SELECT * FROM user_tokens
|
|
||||||
WHERE token_hash = $1
|
|
||||||
AND revoked_at IS NULL
|
|
||||||
AND (expires_at IS NULL OR expires_at > NOW())
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- name: TouchUserToken :exec
|
|
||||||
UPDATE user_tokens SET last_used_at = NOW()
|
|
||||||
WHERE id = $1 AND revoked_at IS NULL;
|
|
||||||
|
|
||||||
-- name: RevokeUserToken :execrows
|
|
||||||
UPDATE user_tokens SET revoked_at = NOW()
|
|
||||||
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL;
|
|
||||||
@@ -52,45 +52,7 @@ CREATE TABLE services (
|
|||||||
git_branch VARCHAR(100),
|
git_branch VARCHAR(100),
|
||||||
build_path VARCHAR(500),
|
build_path VARCHAR(500),
|
||||||
cpu VARCHAR(50),
|
cpu VARCHAR(50),
|
||||||
memory VARCHAR(50),
|
memory VARCHAR(50)
|
||||||
replicas INTEGER NOT NULL DEFAULT 1,
|
|
||||||
port INTEGER NOT NULL DEFAULT 0,
|
|
||||||
domain VARCHAR(255) NOT NULL DEFAULT '',
|
|
||||||
healthcheck_path VARCHAR(255) NOT NULL DEFAULT '',
|
|
||||||
restart_policy VARCHAR(50) NOT NULL DEFAULT 'unless-stopped',
|
|
||||||
published_port INTEGER NOT NULL DEFAULT 0,
|
|
||||||
volumes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
||||||
maintenance_mode BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
basic_auth_users TEXT NOT NULL DEFAULT '',
|
|
||||||
builder VARCHAR(32) NOT NULL DEFAULT 'auto',
|
|
||||||
cpu_reserve VARCHAR(20) NOT NULL DEFAULT '',
|
|
||||||
memory_reserve VARCHAR(20) NOT NULL DEFAULT '',
|
|
||||||
static_build_cmd VARCHAR(255) NOT NULL DEFAULT '',
|
|
||||||
static_dir VARCHAR(255) NOT NULL DEFAULT ''
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE registries (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
host VARCHAR(255) NOT NULL,
|
|
||||||
username VARCHAR(255) NOT NULL DEFAULT '',
|
|
||||||
password TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (owner_id, host)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE service_domains (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
|
||||||
domain VARCHAR(255) NOT NULL,
|
|
||||||
is_default BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
cert_type VARCHAR(32) NOT NULL DEFAULT 'letsencrypt',
|
|
||||||
cert_status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
|
||||||
last_checked_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (service_id, domain)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE deployments (
|
CREATE TABLE deployments (
|
||||||
@@ -236,16 +198,3 @@ 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,13 +17,6 @@ import {
|
|||||||
createAgentToken,
|
createAgentToken,
|
||||||
listAgentTokens,
|
listAgentTokens,
|
||||||
revokeAgentToken,
|
revokeAgentToken,
|
||||||
createUserToken,
|
|
||||||
listUserTokens,
|
|
||||||
revokeUserToken,
|
|
||||||
listRegistries,
|
|
||||||
createRegistry,
|
|
||||||
deleteRegistry,
|
|
||||||
type Registry,
|
|
||||||
listDatabases,
|
listDatabases,
|
||||||
databaseAction,
|
databaseAction,
|
||||||
updateDatabaseBackupSchedule,
|
updateDatabaseBackupSchedule,
|
||||||
@@ -53,8 +46,6 @@ 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,
|
||||||
@@ -1276,293 +1267,6 @@ function PlatformSettingsSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAT_EXPIRY_OPTIONS: { label: string; days: number }[] = [
|
|
||||||
{ label: 'Never', days: 0 },
|
|
||||||
{ label: '30 days', days: 30 },
|
|
||||||
{ label: '90 days', days: 90 },
|
|
||||||
{ label: '1 year', days: 365 },
|
|
||||||
];
|
|
||||||
|
|
||||||
function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const tokensQuery = useQuery({
|
|
||||||
queryKey: ['user-tokens'],
|
|
||||||
queryFn: listUserTokens,
|
|
||||||
});
|
|
||||||
const [tokenName, setTokenName] = useState('');
|
|
||||||
const [tokenScope, setTokenScope] = useState<UserTokenScope>('write');
|
|
||||||
const [expiryDays, setExpiryDays] = useState(0);
|
|
||||||
const [issuedToken, setIssuedToken] = useState<UserTokenCreated | null>(null);
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
createUserToken({
|
|
||||||
name: tokenName.trim(),
|
|
||||||
scope: tokenScope,
|
|
||||||
expiresInDays: expiryDays,
|
|
||||||
}),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setIssuedToken(data);
|
|
||||||
setTokenName('');
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['user-tokens'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const revokeMutation = useMutation({
|
|
||||||
mutationFn: (id: string) => revokeUserToken(id),
|
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['user-tokens'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokens = tokensQuery.data ?? [];
|
|
||||||
const scopeOptions: { value: UserTokenScope; label: string }[] = isAdmin
|
|
||||||
? [
|
|
||||||
{ value: 'read', label: 'Read - GET requests only' },
|
|
||||||
{ value: 'write', label: 'Write - read and manage resources' },
|
|
||||||
{ value: 'admin', label: 'Admin - everything, incl. platform settings' },
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{ value: 'read', label: 'Read - GET requests only' },
|
|
||||||
{ value: 'write', label: 'Write - read and manage resources' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="panel p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
|
||||||
<Key size={18} className="text-[var(--accent-primary)]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Personal Access Tokens</h2>
|
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<input
|
|
||||||
value={tokenName}
|
|
||||||
onChange={(e) => setTokenName(e.target.value)}
|
|
||||||
placeholder="Token name (e.g. laptop cli)"
|
|
||||||
className="h-9 flex-1 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={tokenScope}
|
|
||||||
onChange={(e) => setTokenScope(e.target.value as UserTokenScope)}
|
|
||||||
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
|
|
||||||
>
|
|
||||||
{scopeOptions.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={expiryDays}
|
|
||||||
onChange={(e) => setExpiryDays(Number(e.target.value))}
|
|
||||||
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
|
|
||||||
>
|
|
||||||
{PAT_EXPIRY_OPTIONS.map((option) => (
|
|
||||||
<option key={option.days} value={option.days}>{option.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={() => createMutation.mutate()}
|
|
||||||
disabled={createMutation.isPending || !tokenName.trim()}
|
|
||||||
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
|
||||||
style={{ background: 'var(--accent-primary)' }}
|
|
||||||
>
|
|
||||||
{createMutation.isPending ? 'Creating…' : 'Create token'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{issuedToken ? (
|
|
||||||
<div className="rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
|
|
||||||
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
|
|
||||||
>
|
|
||||||
<Copy size={11} />
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-[var(--text-tertiary)]">
|
|
||||||
Shown once - store it now. Use with <span className="mono">containr auth login --token</span> or as a Bearer token.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{createMutation.isError ? (
|
|
||||||
<p className="text-xs text-[var(--error)]">
|
|
||||||
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create token'}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{tokensQuery.isLoading ? (
|
|
||||||
<div className="py-4 text-center">
|
|
||||||
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
|
||||||
</div>
|
|
||||||
) : tokens.length === 0 ? (
|
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">No tokens yet. Create one to connect the CLI or an agent.</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
|
|
||||||
{tokens.map((token) => (
|
|
||||||
<div key={token.id} className="flex items-center justify-between gap-2 text-xs">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="text-[var(--text-primary)]">{token.name}</span>
|
|
||||||
<span className="ml-2 mono text-[var(--text-muted)]">{token.key_prefix}…</span>
|
|
||||||
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">{token.scope}</span>
|
|
||||||
<span className="ml-2 text-[var(--text-muted)]">
|
|
||||||
{token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'}
|
|
||||||
{token.expires_at ? ` · expires ${formatRelative(token.expires_at)}` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (window.confirm(`Revoke token "${token.name}"? Clients using it lose access immediately.`)) {
|
|
||||||
revokeMutation.mutate(token.id ?? '');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={revokeMutation.isPending}
|
|
||||||
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
|
|
||||||
>
|
|
||||||
Revoke
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RegistriesSection() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const registriesQuery = useQuery({
|
|
||||||
queryKey: ['registries'],
|
|
||||||
queryFn: listRegistries,
|
|
||||||
});
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [host, setHost] = useState('');
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
createRegistry({
|
|
||||||
name: name.trim(),
|
|
||||||
host: host.trim(),
|
|
||||||
username: username.trim() || undefined,
|
|
||||||
password: password || undefined,
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
setName('');
|
|
||||||
setHost('');
|
|
||||||
setUsername('');
|
|
||||||
setPassword('');
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['registries'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: (id: string) => deleteRegistry(id),
|
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['registries'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const registries = registriesQuery.data ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="panel p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
|
||||||
<Archive size={18} className="text-[var(--accent-primary)]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Registry credentials</h2>
|
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">Pull private images on deploy - matched by image host, stored encrypted</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="Name (e.g. ghcr)"
|
|
||||||
className="h-9 flex-1 min-w-[120px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={host}
|
|
||||||
onChange={(e) => setHost(e.target.value)}
|
|
||||||
placeholder="Host (ghcr.io, docker.io)"
|
|
||||||
className="h-9 flex-1 min-w-[160px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
placeholder="Username"
|
|
||||||
className="h-9 w-40 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="Password / token"
|
|
||||||
className="h-9 w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => createMutation.mutate()}
|
|
||||||
disabled={createMutation.isPending || !name.trim() || !host.trim()}
|
|
||||||
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
|
||||||
style={{ background: 'var(--accent-primary)' }}
|
|
||||||
>
|
|
||||||
{createMutation.isPending ? 'Adding…' : 'Add registry'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{createMutation.isError ? (
|
|
||||||
<p className="text-xs text-[var(--error)]">
|
|
||||||
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add registry'}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{registriesQuery.isLoading ? (
|
|
||||||
<div className="py-4 text-center">
|
|
||||||
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
|
||||||
</div>
|
|
||||||
) : registries.length === 0 ? (
|
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">
|
|
||||||
No credentials. Image pulls are anonymous unless a registry matching the image host exists here.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
|
|
||||||
{registries.map((r: Registry) => (
|
|
||||||
<div key={r.id} className="flex items-center justify-between gap-2 text-xs">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="text-[var(--text-primary)]">{r.name}</span>
|
|
||||||
<span className="ml-2 mono text-[var(--text-muted)]">{r.host}</span>
|
|
||||||
{r.username ? <span className="ml-2 text-[var(--text-secondary)]">{r.username}</span> : null}
|
|
||||||
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
|
|
||||||
{r.has_password ? 'password set' : 'anonymous'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (window.confirm(`Remove credentials for ${r.host}?`)) {
|
|
||||||
deleteMutation.mutate(r.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -1734,10 +1438,6 @@ export function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Personal Access Tokens */}
|
|
||||||
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
|
||||||
<RegistriesSection />
|
|
||||||
|
|
||||||
{/* Platform Settings - owner only */}
|
{/* Platform Settings - owner only */}
|
||||||
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
||||||
|
|
||||||
|
|||||||
@@ -28,24 +28,12 @@ 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,
|
|
||||||
cloneService,
|
|
||||||
moveService,
|
|
||||||
listProjects,
|
|
||||||
} 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';
|
||||||
@@ -78,7 +66,6 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Box,
|
Box,
|
||||||
Ban,
|
|
||||||
Play,
|
Play,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
@@ -117,9 +104,7 @@ 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 };
|
||||||
|
|
||||||
@@ -389,14 +374,6 @@ 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: () => {
|
||||||
@@ -415,36 +392,6 @@ export function ServiceDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectsQuery = useQuery({
|
|
||||||
queryKey: ['projects'],
|
|
||||||
queryFn: () => listProjects({ limit: 100 }),
|
|
||||||
enabled: !isDemoMode && activeSection === 'settings',
|
|
||||||
});
|
|
||||||
|
|
||||||
const cloneMutation = useMutation({
|
|
||||||
mutationFn: (input: { name?: string; project_id?: string }) => cloneService(serviceId, input),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
|
||||||
const targetProject = lifecycleForm?.cloneProject || projectId;
|
|
||||||
navigate(`/projects/${targetProject}/services/${data.service_id}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const moveMutation = useMutation({
|
|
||||||
mutationFn: (targetProjectId: string) => moveService(serviceId, targetProjectId),
|
|
||||||
onSuccess: (_data, targetProjectId) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
|
||||||
navigate(`/projects/${targetProjectId}/services/${serviceId}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [lifecycleForm, setLifecycleForm] = useState<{
|
|
||||||
cloneName: string;
|
|
||||||
cloneProject: string;
|
|
||||||
moveTarget: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const invalidateService = () => {
|
const invalidateService = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
||||||
@@ -477,27 +424,6 @@ 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 [buildForm, setBuildForm] = useState<{
|
|
||||||
builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const domainsQuery = useQuery({
|
|
||||||
queryKey: ['service-domains', serviceId],
|
|
||||||
queryFn: () => listServiceDomains(serviceId),
|
|
||||||
enabled: Boolean(serviceId) && !isDemoMode,
|
|
||||||
});
|
|
||||||
|
|
||||||
const domainMutation = useMutation({
|
|
||||||
mutationFn: (fn: () => Promise<unknown>) => fn(),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['service-domains', serviceId] });
|
|
||||||
invalidateService();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
|
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
|
||||||
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
|
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
|
||||||
@@ -588,7 +514,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) =>
|
||||||
['queued', 'pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status),
|
['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;
|
||||||
|
|
||||||
@@ -1255,9 +1181,8 @@ export function ServiceDetailPage() {
|
|||||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
Runtime values injected at deploy. Reference another service's variable with{' '}
|
Runtime values injected at deploy. Reference another service's variable with{' '}
|
||||||
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> or a project
|
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> — e.g.{' '}
|
||||||
shared variable with{' '}
|
<code className="mono text-[var(--accent-primary)]">{'${{postgres.POSTGRES_PASSWORD}}'}</code>
|
||||||
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2129,442 +2054,6 @@ 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 && (
|
|
||||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-sm font-medium text-[var(--text-primary)]">Build & Resources</h3>
|
|
||||||
{buildForm === null && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setBuildForm({
|
|
||||||
builder: service.builder ?? 'auto',
|
|
||||||
cpuReserve: service.cpuReserve ?? '',
|
|
||||||
memoryReserve: service.memoryReserve ?? '',
|
|
||||||
staticCmd: service.staticBuildCmd ?? '',
|
|
||||||
staticDir: service.staticDir ?? '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{buildForm === null ? (
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
|
||||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
|
||||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Builder</p>
|
|
||||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.builder ?? 'auto'}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
|
||||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Reservations</p>
|
|
||||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
|
||||||
{[service.cpuReserve && `cpu ${service.cpuReserve}`, service.memoryReserve && `mem ${service.memoryReserve}`]
|
|
||||||
.filter(Boolean).join(' · ') || 'none'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{service.builder === 'static' && (
|
|
||||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3 col-span-2">
|
|
||||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Static build</p>
|
|
||||||
<p className="mt-1 text-sm font-medium mono text-[var(--text-primary)]">
|
|
||||||
{service.staticBuildCmd || 'npm ci && npm run build'} → {service.staticDir || 'dist'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Builder</label>
|
|
||||||
<select
|
|
||||||
value={buildForm.builder}
|
|
||||||
onChange={(e) => setBuildForm({ ...buildForm, builder: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
|
||||||
>
|
|
||||||
{['auto', 'railpack', 'nixpacks', 'dockerfile', 'static'].map((b) => (
|
|
||||||
<option key={b} value={b}>{b}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">CPU reservation (e.g. 0.25)</label>
|
|
||||||
<input
|
|
||||||
value={buildForm.cpuReserve}
|
|
||||||
onChange={(e) => setBuildForm({ ...buildForm, cpuReserve: e.target.value })}
|
|
||||||
placeholder="0.25"
|
|
||||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Memory reservation (e.g. 128Mi)</label>
|
|
||||||
<input
|
|
||||||
value={buildForm.memoryReserve}
|
|
||||||
onChange={(e) => setBuildForm({ ...buildForm, memoryReserve: e.target.value })}
|
|
||||||
placeholder="128Mi"
|
|
||||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{buildForm.builder === 'static' && (
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Build command</label>
|
|
||||||
<input
|
|
||||||
value={buildForm.staticCmd}
|
|
||||||
onChange={(e) => setBuildForm({ ...buildForm, staticCmd: e.target.value })}
|
|
||||||
placeholder="npm ci && npm run build"
|
|
||||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Output directory</label>
|
|
||||||
<input
|
|
||||||
value={buildForm.staticDir}
|
|
||||||
onChange={(e) => setBuildForm({ ...buildForm, staticDir: e.target.value })}
|
|
||||||
placeholder="dist"
|
|
||||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={updateServiceMutation.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
updateServiceMutation.mutate(
|
|
||||||
{
|
|
||||||
builder: buildForm.builder as 'auto' | 'railpack' | 'nixpacks' | 'dockerfile' | 'static',
|
|
||||||
cpu_reserve: buildForm.cpuReserve,
|
|
||||||
memory_reserve: buildForm.memoryReserve,
|
|
||||||
static_build_cmd: buildForm.staticCmd,
|
|
||||||
static_dir: buildForm.staticDir,
|
|
||||||
},
|
|
||||||
{ onSuccess: () => setBuildForm(null) },
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setBuildForm(null)}
|
|
||||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<p className="text-[10px] text-[var(--text-tertiary)]">Builder applies to the next source build; reservations on next deploy</p>
|
|
||||||
</div>
|
|
||||||
{updateServiceMutation.isError && (
|
|
||||||
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isDemoMode && (
|
{!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">
|
||||||
@@ -2632,19 +2121,6 @@ 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)}?`)) {
|
||||||
@@ -2666,135 +2142,6 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isDemoMode && signedIn && (
|
|
||||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-sm font-medium text-[var(--text-primary)]">Lifecycle</h3>
|
|
||||||
{lifecycleForm === null && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setLifecycleForm({ cloneName: `${service.name}-copy`, cloneProject: '', moveTarget: '' })}
|
|
||||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
|
||||||
>
|
|
||||||
Manage
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{lifecycleForm === null ? (
|
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">
|
|
||||||
Duplicate this service into the same or another project, move it between projects, or delete it.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Clone</label>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input
|
|
||||||
value={lifecycleForm.cloneName}
|
|
||||||
onChange={(e) => setLifecycleForm({ ...lifecycleForm, cloneName: e.target.value })}
|
|
||||||
placeholder={`${service.name}-copy`}
|
|
||||||
className="w-52 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={lifecycleForm.cloneProject}
|
|
||||||
onChange={(e) => setLifecycleForm({ ...lifecycleForm, cloneProject: e.target.value })}
|
|
||||||
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
|
||||||
>
|
|
||||||
<option value="">same project</option>
|
|
||||||
{(projectsQuery.data ?? [])
|
|
||||||
.filter((p) => p.id !== projectId)
|
|
||||||
.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={cloneMutation.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
cloneMutation.mutate({
|
|
||||||
name: lifecycleForm.cloneName.trim() || undefined,
|
|
||||||
project_id: lifecycleForm.cloneProject || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{cloneMutation.isPending ? 'Cloning…' : 'Clone'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
|
|
||||||
Copies config, volumes, domains, and variables. The clone starts stopped.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Move to project</label>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={lifecycleForm.moveTarget}
|
|
||||||
onChange={(e) => setLifecycleForm({ ...lifecycleForm, moveTarget: e.target.value })}
|
|
||||||
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
|
||||||
>
|
|
||||||
<option value="">choose a project…</option>
|
|
||||||
{(projectsQuery.data ?? [])
|
|
||||||
.filter((p) => p.id !== projectId)
|
|
||||||
.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={moveMutation.isPending || !lifecycleForm.moveTarget}
|
|
||||||
onClick={() => {
|
|
||||||
if (window.confirm(`Move ${service.name} to another project?`)) {
|
|
||||||
moveMutation.mutate(lifecycleForm.moveTarget);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{moveMutation.isPending ? 'Moving…' : 'Move'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
|
|
||||||
Variables, domains, and volumes move with the service. Running containers keep running.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-3 border-t border-[var(--border-subtle)]">
|
|
||||||
<label className="block text-xs text-[var(--error)] mb-1">Delete service</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={deleteServiceMutation.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
if (window.confirm(`Delete ${service.name}? This removes its containers and cannot be undone.`)) {
|
|
||||||
deleteServiceMutation.mutate();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{deleteServiceMutation.isPending ? 'Deleting…' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setLifecycleForm(null)}
|
|
||||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
{(cloneMutation.isError || moveMutation.isError) && (
|
|
||||||
<p className="text-xs text-[var(--error)]">
|
|
||||||
{((cloneMutation.error ?? moveMutation.error) as Error)?.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { listProjectVariables, updateProjectVariables } from '@/lib/api-client';
|
|
||||||
import { Loader2, Plus, Save, Trash2, KeyRound } from 'lucide-react';
|
|
||||||
|
|
||||||
type Row = { key: string; value: string; isSecret: boolean };
|
|
||||||
|
|
||||||
// SharedVariablesSection edits project-level variables referenced from
|
|
||||||
// service env values as ${{shared.KEY}}.
|
|
||||||
export function SharedVariablesSection({ projectId }: { projectId: string }) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [drafts, setDrafts] = useState<Row[] | null>(null);
|
|
||||||
|
|
||||||
const varsQuery = useQuery({
|
|
||||||
queryKey: ['project-variables', projectId],
|
|
||||||
queryFn: () => listProjectVariables(projectId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
|
||||||
mutationFn: (rows: Row[]) =>
|
|
||||||
updateProjectVariables(
|
|
||||||
projectId,
|
|
||||||
rows
|
|
||||||
.filter((r) => r.key.trim())
|
|
||||||
.map((r) => ({ key: r.key.trim(), value: r.value, is_secret: r.isSecret })),
|
|
||||||
),
|
|
||||||
onSuccess: () => {
|
|
||||||
setDrafts(null);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['project-variables', projectId] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows: Row[] = drafts ?? (varsQuery.data ?? []).map((v) => ({ key: v.key, value: v.value, isSecret: v.isSecret }));
|
|
||||||
const dirty = drafts !== null;
|
|
||||||
|
|
||||||
const updateRow = (i: number, patch: Partial<Row>) => {
|
|
||||||
const next = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
|
|
||||||
setDrafts(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Shared Variables</h3>
|
|
||||||
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
|
||||||
Reference from any service as{' '}
|
|
||||||
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next
|
|
||||||
deploy/redeploy — not live-updated.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}
|
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
|
||||||
>
|
|
||||||
<Plus size={12} />
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => saveMutation.mutate(rows)}
|
|
||||||
disabled={!dirty || saveMutation.isPending}
|
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-50 transition-all"
|
|
||||||
style={{ background: 'var(--accent-primary)' }}
|
|
||||||
>
|
|
||||||
{saveMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{varsQuery.isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-6 text-[var(--text-muted)]">
|
|
||||||
<Loader2 size={16} className="animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : rows.length === 0 ? (
|
|
||||||
<p className="text-xs text-[var(--text-muted)]">No shared variables.</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{rows.map((row, i) => (
|
|
||||||
<div key={i} className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
|
|
||||||
<input
|
|
||||||
value={row.key}
|
|
||||||
onChange={(e) => updateRow(i, { key: e.target.value })}
|
|
||||||
placeholder="KEY"
|
|
||||||
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={row.value}
|
|
||||||
onChange={(e) => updateRow(i, { value: e.target.value })}
|
|
||||||
type={row.isSecret ? 'password' : 'text'}
|
|
||||||
placeholder="value"
|
|
||||||
autoComplete="off"
|
|
||||||
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={row.isSecret ? 'Secret — masked on save' : 'Mark as secret'}
|
|
||||||
onClick={() => updateRow(i, { isSecret: !row.isSecret })}
|
|
||||||
className={`w-9 h-9 rounded-[var(--radius-md)] border flex items-center justify-center transition-colors ${
|
|
||||||
row.isSecret
|
|
||||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
|
||||||
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-default)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<KeyRound size={12} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDrafts(rows.filter((_, idx) => idx !== i))}
|
|
||||||
className="w-9 h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{saveMutation.isError && (
|
|
||||||
<p className="mt-2 text-xs text-[var(--error)]">
|
|
||||||
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save shared variables'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,6 @@ import type { ServiceVariable } from '../auto-connections';
|
|||||||
import { ProjectCanvas } from '../canvas/ProjectCanvas';
|
import { ProjectCanvas } from '../canvas/ProjectCanvas';
|
||||||
import { canvasStorageKey, clearCanvasMetadata } from '../storage';
|
import { canvasStorageKey, clearCanvasMetadata } from '../storage';
|
||||||
import { MetricsDashboard } from '../components/MetricsDashboard';
|
import { MetricsDashboard } from '../components/MetricsDashboard';
|
||||||
import { SharedVariablesSection } from '../components/SharedVariablesSection';
|
|
||||||
import { CommandPalette, StatusBadge, LiveIndicator, useToast } from '@/shared/components';
|
import { CommandPalette, StatusBadge, LiveIndicator, useToast } from '@/shared/components';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -936,8 +935,6 @@ export function ProjectWorkspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isDemoMode && signedIn && <SharedVariablesSection projectId={project.id} />}
|
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3>
|
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3>
|
||||||
<button
|
<button
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -36,12 +36,6 @@ 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",
|
||||||
@@ -58,7 +52,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\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
|
"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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "guides/README_BACKEND.md",
|
"path": "guides/README_BACKEND.md",
|
||||||
|
|||||||
@@ -1523,7 +1523,6 @@ textarea {
|
|||||||
background: var(--accent-primary); color: var(--accent-on);
|
background: var(--accent-primary); color: var(--accent-on);
|
||||||
border: none; border-radius: 8px; padding: 9px 17px;
|
border: none; border-radius: 8px; padding: 9px 17px;
|
||||||
font: 700 12px 'JetBrains Mono', monospace; cursor: pointer;
|
font: 700 12px 'JetBrains Mono', monospace; cursor: pointer;
|
||||||
white-space: nowrap; flex-shrink: 0;
|
|
||||||
transition: background 0.15s, transform 0.15s;
|
transition: background 0.15s, transform 0.15s;
|
||||||
box-shadow: 0 2px 12px rgba(180, 227, 74, 0.22);
|
box-shadow: 0 2px 12px rgba(180, 227, 74, 0.22);
|
||||||
}
|
}
|
||||||
@@ -1535,7 +1534,6 @@ textarea {
|
|||||||
background: var(--surface-card); color: var(--text-secondary);
|
background: var(--surface-card); color: var(--text-secondary);
|
||||||
border: 1px solid var(--border-subtle); border-radius: 8px; padding: 8px 16px;
|
border: 1px solid var(--border-subtle); border-radius: 8px; padding: 8px 16px;
|
||||||
font: 600 12px 'JetBrains Mono', monospace; cursor: pointer; transition: 0.15s;
|
font: 600 12px 'JetBrains Mono', monospace; cursor: pointer; transition: 0.15s;
|
||||||
white-space: nowrap; flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
.v-btn-ghost:hover { color: var(--text-primary); border-color: var(--border-default); }
|
.v-btn-ghost:hover { color: var(--text-primary); border-color: var(--border-default); }
|
||||||
|
|
||||||
|
|||||||
@@ -54,42 +54,9 @@ export type ServiceEntity = {
|
|||||||
domain?: string;
|
domain?: string;
|
||||||
healthcheckPath?: string;
|
healthcheckPath?: string;
|
||||||
restartPolicy?: string;
|
restartPolicy?: string;
|
||||||
volumes?: ServiceVolume[];
|
|
||||||
domains?: ServiceDomain[];
|
|
||||||
maintenanceMode?: boolean;
|
|
||||||
basicAuth?: string[];
|
|
||||||
builder?: 'auto' | 'railpack' | 'nixpacks' | 'dockerfile' | 'static';
|
|
||||||
cpuReserve?: string;
|
|
||||||
memoryReserve?: string;
|
|
||||||
staticBuildCmd?: string;
|
|
||||||
staticDir?: string;
|
|
||||||
publicUrl?: string;
|
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;
|
||||||
@@ -585,15 +552,6 @@ 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,
|
|
||||||
builder: service.builder as ServiceEntity['builder'],
|
|
||||||
cpuReserve: service.cpu_reserve,
|
|
||||||
memoryReserve: service.memory_reserve,
|
|
||||||
staticBuildCmd: service.static_build_cmd,
|
|
||||||
staticDir: service.static_dir,
|
|
||||||
publicUrl: service.public_url,
|
publicUrl: service.public_url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1035,34 +993,6 @@ 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',
|
||||||
@@ -1133,36 +1063,6 @@ 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',
|
||||||
@@ -1185,68 +1085,6 @@ export async function deleteService(serviceId: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CloneServiceInput = {
|
|
||||||
name?: string;
|
|
||||||
project_id?: string;
|
|
||||||
environment?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function cloneService(serviceId: string, input: CloneServiceInput): Promise<{ service_id: string; name: string }> {
|
|
||||||
return requestJson<{ service_id: string; name: string }>(`/services/${serviceId}/clone`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function moveService(serviceId: string, projectId: string): Promise<void> {
|
|
||||||
await requestJson(`/services/${serviceId}/move`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ project_id: projectId }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Registry = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
host: string;
|
|
||||||
username?: string;
|
|
||||||
has_password: boolean;
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RegistryInput = {
|
|
||||||
name: string;
|
|
||||||
host: string;
|
|
||||||
username?: string;
|
|
||||||
password?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function listRegistries(): Promise<Registry[]> {
|
|
||||||
const payload = await requestJson<{ registries?: Registry[] }>('/registries');
|
|
||||||
return payload.registries ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createRegistry(input: RegistryInput): Promise<Registry> {
|
|
||||||
const payload = await requestJson<{ registry: Registry }>('/registries', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
return payload.registry;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateRegistry(id: string, input: RegistryInput): Promise<Registry> {
|
|
||||||
const payload = await requestJson<{ registry: Registry }>(`/registries/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
return payload.registry;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteRegistry(id: string): Promise<void> {
|
|
||||||
await requestJson(`/registries/${id}`, { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRuntime(runtime?: ServiceRuntime): ServiceRuntime {
|
function normalizeRuntime(runtime?: ServiceRuntime): ServiceRuntime {
|
||||||
const state = runtime ?? { desired: 0, status: 'stopped' as const, urls: [], ports: [], containers: [] };
|
const state = runtime ?? { desired: 0, status: 'stopped' as const, urls: [], ports: [], containers: [] };
|
||||||
// The API emits null (not []) for empty collections — coerce at the boundary.
|
// The API emits null (not []) for empty collections — coerce at the boundary.
|
||||||
@@ -1310,22 +1148,6 @@ export async function updateServiceVariables(
|
|||||||
return normalizeServiceVariables(payload.variables);
|
return normalizeServiceVariables(payload.variables);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listProjectVariables(projectId: string): Promise<ServiceVariable[]> {
|
|
||||||
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(`/projects/${projectId}/variables`);
|
|
||||||
return normalizeServiceVariables(payload.variables);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateProjectVariables(
|
|
||||||
projectId: string,
|
|
||||||
variables: UpdateServiceVariableInput[],
|
|
||||||
): Promise<ServiceVariable[]> {
|
|
||||||
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(
|
|
||||||
`/projects/${projectId}/variables`,
|
|
||||||
{ method: 'PUT', body: JSON.stringify({ variables }) },
|
|
||||||
);
|
|
||||||
return normalizeServiceVariables(payload.variables);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listAuditLogs(input: ListAuditLogsInput = {}): Promise<AuditLogEntity[]> {
|
export async function listAuditLogs(input: ListAuditLogsInput = {}): Promise<AuditLogEntity[]> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (input.resource) {
|
if (input.resource) {
|
||||||
@@ -1470,12 +1292,6 @@ 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) {
|
||||||
|
|||||||
+5
-904
File diff suppressed because it is too large.
Load diff
@@ -1,202 +0,0 @@
|
|||||||
# Containr CLI
|
|
||||||
|
|
||||||
The CLI controls every part of a Containr instance — the same API surface
|
|
||||||
the web UI uses. Build it with `scripts/build-cli.sh` or
|
|
||||||
`go build ./cmd/cli` from `app/backend`.
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
Create a personal access token in the web UI under
|
|
||||||
**Settings → Personal Access Tokens** (or `containr tokens create`), then:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
containr auth login cnp_... # verify + store
|
|
||||||
containr auth login cnp_... --url https://containr.example.com
|
|
||||||
containr auth login cnp_... --name production # named profile
|
|
||||||
containr auth status # show active profile + identity
|
|
||||||
containr auth logout
|
|
||||||
```
|
|
||||||
|
|
||||||
Resolution order for every command: `--token`/`--api-url` flags >
|
|
||||||
`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >
|
|
||||||
`http://localhost:8080/api/v1`. The active profile is `--profile` >
|
|
||||||
`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.
|
|
||||||
|
|
||||||
## Output and scripting
|
|
||||||
|
|
||||||
- `--json` prints the raw API response — safe to pipe and parse.
|
|
||||||
- `--yes` / `-y` skips confirmation on destructive commands (delete,
|
|
||||||
revoke, restore, failover). Non-interactive shells refuse destructive
|
|
||||||
commands without it.
|
|
||||||
- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,
|
|
||||||
`3` not found, `4` validation, `5` unreachable.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```
|
|
||||||
auth login|logout|status token auth and profiles
|
|
||||||
tokens list|create|revoke personal access tokens
|
|
||||||
projects list|get|create|delete
|
|
||||||
services list|get|create|update|delete|start|stop|restart|redeploy
|
|
||||||
services clone|move duplicate config or re-parent a service
|
|
||||||
services domains list|add|remove|default|check multi-domain + DNS preflight
|
|
||||||
registries list|add|update|remove private-image pull credentials
|
|
||||||
volumes list|delete docker volume inventory + cleanup (admin)
|
|
||||||
deploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)
|
|
||||||
deployments list|get|logs|rollback|cancel
|
|
||||||
logs <service-id> [--follow] runtime logs (SSE follow)
|
|
||||||
exec <service-id> -- <cmd> one-off container command
|
|
||||||
variables list|set|unset env vars (secrets masked; --redeploy applies them)
|
|
||||||
variables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}
|
|
||||||
databases list|get|create|delete|action|backup|restore|download-backup
|
|
||||||
cron list|get|create|delete|trigger|executions
|
|
||||||
templates list|get|create|delete|deploy
|
|
||||||
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
|
|
||||||
scaling status|policies|services|scale
|
|
||||||
ha status|alerts|health|policies|enable|disable|failover
|
|
||||||
security scan|vulnerabilities|metrics
|
|
||||||
gateway services|keys|analytics (admin)
|
|
||||||
admin overview|users|settings|audit-logs
|
|
||||||
notifications list|read
|
|
||||||
up deploy the current directory
|
|
||||||
```
|
|
||||||
|
|
||||||
## containr up
|
|
||||||
|
|
||||||
Run inside a git checkout: uses `remote.origin.url` + the current branch
|
|
||||||
as the build source, creates a service named after the directory when none
|
|
||||||
exists, then triggers a deployment.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
containr up --project <id>
|
|
||||||
containr up --project <id> --image ghcr.io/example/app:latest # image, no build
|
|
||||||
```
|
|
||||||
|
|
||||||
Set `project_id` on the active profile to omit `--project` (stored in
|
|
||||||
`~/.containr.yaml` under `profiles.<name>.project_id`).
|
|
||||||
|
|
||||||
## Service volumes
|
|
||||||
|
|
||||||
`services update` manages mounts; flags replace the whole mount list:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
containr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro
|
|
||||||
containr services update <id> --clear-volumes
|
|
||||||
```
|
|
||||||
|
|
||||||
`type` is `volume` (docker named volume, auto-created) or `bind` (host
|
|
||||||
path). `target` must be absolute. Mounts apply on the next deploy or
|
|
||||||
redeploy — `containr deploy <id>` to apply immediately.
|
|
||||||
|
|
||||||
`volumes list` shows the node's docker volume inventory with in-use
|
|
||||||
flags; `volumes delete <name>` removes an unused volume (admin scope).
|
|
||||||
|
|
||||||
## Service domains and access
|
|
||||||
|
|
||||||
A service can carry multiple hostnames; the default is mirrored to
|
|
||||||
`services.domain`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
containr services domains add <id> app.example.com --default
|
|
||||||
containr services domains check <id> # DNS preflight per domain
|
|
||||||
containr services domains remove <id> <domain-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
DNS check compares each domain to the expected target — set
|
|
||||||
`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is
|
|
||||||
meaningful; without it any resolvable domain reports `ok`.
|
|
||||||
|
|
||||||
Access gates apply via Traefik middlewares (no extra proxy):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
containr services update <id> --maintenance on
|
|
||||||
containr services update <id> --basic-auth admin:secret --basic-auth ops:pw2
|
|
||||||
```
|
|
||||||
|
|
||||||
Maintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`
|
|
||||||
— set `app_settings.base_url` or `BASE_URL`; without it the service is
|
|
||||||
taken offline with empty responses. Basic-auth credentials are stored
|
|
||||||
bcrypt-hashed (htpasswd) and gate every request.
|
|
||||||
|
|
||||||
## Build strategy and resources
|
|
||||||
|
|
||||||
Source-built services pick a builder per service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
containr services update <id> --builder railpack # auto|railpack|nixpacks|dockerfile|static
|
|
||||||
containr services update <id> --builder static --static-cmd "npm run build" --static-dir dist
|
|
||||||
```
|
|
||||||
|
|
||||||
`auto` detects from repo contents (Dockerfile → railpack → nixpacks).
|
|
||||||
`static` builds assets in a node stage and serves them from nginx —
|
|
||||||
`--static-cmd` runs inside the build stage (default
|
|
||||||
`npm ci && npm run build`), `--static-dir` is copied into the nginx image
|
|
||||||
(default `dist`, exposed on port 80).
|
|
||||||
|
|
||||||
Resource reservations complement the hard `cpu`/`memory` limits:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
containr services update <id> --cpu-reserve 0.25 --memory-reserve 128Mi
|
|
||||||
```
|
|
||||||
|
|
||||||
`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/
|
|
||||||
`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty
|
|
||||||
string clears. `app_settings.default_cpu`/`default_memory` change the
|
|
||||||
instance-level defaults used at service creation;
|
|
||||||
`app_settings.capacity_policy=block` fails deployments whose requested
|
|
||||||
memory exceeds node capacity (default `warn` logs only).
|
|
||||||
|
|
||||||
## Clone, move, registries
|
|
||||||
|
|
||||||
`services clone` duplicates config, volumes, domains, access gates,
|
|
||||||
builder settings, and variables — ciphertext secret values are copied
|
|
||||||
verbatim (no decrypt/re-encrypt). The clone starts stopped:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
containr services clone <id> --name staging-api --project <other-project-id> --environment preview
|
|
||||||
containr services move <id> <project-id> # name conflicts → 409
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: cloned domains are verbatim copies — the same hostname attached to
|
|
||||||
two services makes Traefik routing ambiguous. Re-domain or undeploy one.
|
|
||||||
|
|
||||||
`registries` stores private-image pull credentials, owner-scoped and
|
|
||||||
encrypted at rest. Matched by image host on every image-sourced deploy:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
containr registries add ghcr ghcr.io --username <user> --password <pat>
|
|
||||||
containr registries add hub docker.io --username <user> --password <pw> # Docker Hub
|
|
||||||
containr registries update <id> --username <new-user> # empty --password keeps stored
|
|
||||||
containr registries remove <id>
|
|
||||||
```
|
|
||||||
|
|
||||||
## MCP server
|
|
||||||
|
|
||||||
`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools
|
|
||||||
over stdio — every command group above maps to a `containr_*` tool
|
|
||||||
(~60 tools). Destructive tools require the caller to pass
|
|
||||||
`confirm: true`; results are pretty JSON with `isError` on failures.
|
|
||||||
|
|
||||||
Client configuration (Claude Code, Cursor, Devin, any stdio MCP host):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"containr": {
|
|
||||||
"command": "containr-mcp",
|
|
||||||
"env": {
|
|
||||||
"CONTAINR_API_URL": "https://containr.example.com",
|
|
||||||
"CONTAINR_TOKEN": "cnp_..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Scope the token deliberately: `read` is enough for inspection agents,
|
|
||||||
`write` for deploy automation, `admin` only for platform operations.
|
|
||||||
|
|
||||||
Deployments are serialized per service: a second `deploy` on a busy
|
|
||||||
service lands in `queued` state and runs when the active one finishes.
|
|
||||||
`deployments cancel <id>` drops a queued deployment or aborts a running
|
|
||||||
one (builds/reconciles stop via context cancellation).
|
|
||||||
@@ -90,47 +90,3 @@ 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.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# Containr
|
|
||||||
|
|
||||||
> Self-hosted Docker PaaS — deploy and manage containerized apps, databases, cron jobs and preview environments. Operable by humans (web UI) and agents (API / CLI / MCP) through one contract.
|
|
||||||
|
|
||||||
Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
|
|
||||||
|
|
||||||
## Access (pick one)
|
|
||||||
|
|
||||||
- **API**: `Authorization: Bearer <pat>` — create a PAT in Settings → Access Tokens or `containr tokens create`. Scopes: `read` (GET/HEAD/OPTIONS only), `write` (everything but admin routes), `admin` (requires platform-admin owner).
|
|
||||||
- **CLI**: `containr auth login --api-url <url>` (or `--token cnp_…`), then e.g. `containr projects list --json`, `containr up`. Global flags: `--json`, `--yes`, `--profile`, `--idempotency-key`.
|
|
||||||
- **MCP**: run `containr-mcp` over stdio with `CONTAINR_API_URL` + `CONTAINR_TOKEN` env (or CLI profile). ~60 tools mirroring the API; destructive tools require `confirm: true`.
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- **Errors**: `{"error": "msg", "code": "MACHINE_CODE"}`. Codes: `UNAUTHENTICATED`, `INVALID_TOKEN`, `FORBIDDEN`, `ADMIN_REQUIRED`, `SCOPE_INSUFFICIENT`, `NOT_FOUND`, `VALIDATION`, `CONFLICT`, `INTERNAL`, `DEPENDENCY_UNAVAILABLE`, `IDEMPOTENCY_IN_PROGRESS`, `NOT_CANCELLABLE`.
|
|
||||||
- **Idempotency**: send `Idempotency-Key: <uuid>` on POSTs — the first response replays for 24h; concurrent duplicates get 409. Always safe to send.
|
|
||||||
- **Pagination**: `?limit=` + `?cursor=` (pass `pagination.next_cursor` verbatim; legacy `?page=` also works). Invalid cursors fall back to page 1.
|
|
||||||
- **Deployments**: serialized per service — a deploy on a busy service returns `status: queued` and runs when the active one finishes. `POST /deployments/{id}/cancel` drops queued (`200 cancelled`) or aborts running (`202 cancelling`) work. `no_cache: true` on create disables the Docker layer cache. `PUT /services/{id}/variables` accepts `redeploy: true` to apply env changes to running containers.
|
|
||||||
- **Variables**: service env values support `${{service.KEY}}` cross-service refs (builtins HOST/PORT) and `${{shared.KEY}}` project shared variables (`GET|PUT /projects/{id}/variables`, CLI `variables shared …`). Resolved at deploy/reconcile, not live-updated.
|
|
||||||
- **Volumes**: service mounts via `PUT /services/{id}` `volumes: [{type: volume|bind, source, target, read_only}]` — replaces the whole list (`[]` clears), applied on next deploy. `GET /admin/volumes` inventories node volumes with `in_use` flags; `DELETE /admin/volumes/{name}` removes unused ones (admin).
|
|
||||||
- **Domains**: multiple hostnames per service via `GET|POST /services/{id}/domains`, `DELETE /services/{id}/domains/{domain_id}`, `POST …/{domain_id}/default` (mirrors `services.domain`). `GET /services/{id}/domains/check` returns `ok|wrong-target|pending` per domain vs `app_settings.public_ip`/`PUBLIC_IP`. `PUT` `maintenance_mode` redirects all traffic to `GET /maintenance` (needs `base_url`/`BASE_URL` setting); `basic_auth: [{username,password}]` stores bcrypt htpasswd and gates the route — responses return usernames only.
|
|
||||||
- **Builders & resources**: service field `builder` = `auto|railpack|nixpacks|dockerfile|static` (auto detects; static builds assets with `static_build_cmd` and serves `static_dir` via nginx:80). `cpu`/`memory` are hard caps; `cpu_reserve`/`memory_reserve` are soft reservations (CPU shares / MemoryReservation) — empty string clears. `app_settings.default_cpu`/`default_memory` set instance defaults; `app_settings.capacity_policy=block` rejects deploys exceeding node memory (default `warn`).
|
|
||||||
- **Clone & move**: `POST /services/{id}/clone` `{name?, project_id?, environment?}` copies config, volumes, domains, access gates, and variables (secret ciphertext verbatim; clone starts stopped). `POST /services/{id}/move` `{project_id}` re-parents a service — both require ownership of source and target; name conflicts → `CONFLICT`. Cloned domains are verbatim copies: the same hostname on two services makes routing ambiguous.
|
|
||||||
- **Registries**: `GET|POST /registries`, `PUT|DELETE /registries/{id}` — owner-scoped private-image pull credentials, encrypted at rest, never returned (`has_password` signals presence). `host` matched against the image reference on image deploys (`docker.io` for Docker Hub; scheme/case normalized). Empty `password` on update keeps the stored credential.
|
|
||||||
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
|
|
||||||
|
|
||||||
## Core objects
|
|
||||||
|
|
||||||
`projects` → `services` (git/app deploys) → `deployments`; plus `databases`, `templates`, `cron-jobs`, `variables`, `volumes`, `domains`, `registries`, `preview-environments`, `nodes`, `scaling`, `security`, `gateway`, `admin`, `notifications`, `user/tokens`.
|
|
||||||
|
|
||||||
## Docs
|
|
||||||
|
|
||||||
- `docs/guides/CLI.md` — full CLI reference
|
|
||||||
- `docs/guides/OPERATIONS.md` — PATs, agent tokens, ops
|
|
||||||
- `docs/api/openapi.yaml` — request/response contract
|
|
||||||
- `ROADMAP2.md` — product roadmap
|
|
||||||
- `AGENTS.md` — repo conventions for coding agents
|
|
||||||
+3
-10
@@ -21,25 +21,18 @@ for platform in $PLATFORMS; do
|
|||||||
GOARCH=${platform#*/}
|
GOARCH=${platform#*/}
|
||||||
|
|
||||||
OUTPUT_NAME="containr-${GOOS}-${GOARCH}"
|
OUTPUT_NAME="containr-${GOOS}-${GOARCH}"
|
||||||
MCP_NAME="containr-mcp-${GOOS}-${GOARCH}"
|
|
||||||
if [ "$GOOS" = "windows" ]; then
|
if [ "$GOOS" = "windows" ]; then
|
||||||
OUTPUT_NAME="${OUTPUT_NAME}.exe"
|
OUTPUT_NAME="${OUTPUT_NAME}.exe"
|
||||||
MCP_NAME="${MCP_NAME}.exe"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📦 Building CLI for ${platform}..."
|
echo "📦 Building for ${platform}..."
|
||||||
|
|
||||||
GOOS=$GOOS GOARCH=$GOARCH go build \
|
GOOS=$GOOS GOARCH=$GOARCH go build \
|
||||||
-ldflags "$LDFLAGS" \
|
-ldflags "$LDFLAGS" \
|
||||||
-o "bin/${OUTPUT_NAME}" \
|
-o "bin/${OUTPUT_NAME}" \
|
||||||
./cmd/cli
|
./cmd/cli
|
||||||
|
|
||||||
echo "📦 Building MCP server for ${platform}..."
|
echo "✅ Built bin/${OUTPUT_NAME}"
|
||||||
GOOS=$GOOS GOARCH=$GOARCH go build \
|
|
||||||
-ldflags "$LDFLAGS" \
|
|
||||||
-o "bin/${MCP_NAME}" \
|
|
||||||
./cmd/mcp
|
|
||||||
|
|
||||||
echo "✅ Built bin/${OUTPUT_NAME} + bin/${MCP_NAME}"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "🎉 CLI build complete!"
|
echo "🎉 CLI build complete!"
|
||||||
|
|||||||
Reference in new issue
Block a user