mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e3994e937 | ||
|
|
355eb2c9ab | ||
|
|
2be1715015 | ||
|
|
e67a39436c | ||
|
|
4734be0d9d | ||
|
|
4e8d364911 | ||
|
|
f53695be4f | ||
|
|
925199633b | ||
|
|
1054679ca2 | ||
|
|
57f8b4f56e | ||
|
|
dd1bbaf5a2 | ||
|
|
58424ed0ab | ||
|
|
25fa8c47e6 | ||
|
|
b3da014d26 | ||
|
|
f83ad81f73 | ||
|
|
2049087ccd | ||
|
|
01edb677e5 | ||
|
|
b22ac401c8 | ||
|
|
ea96929435 | ||
|
|
971773f80c | ||
|
|
7e99e41f8d | ||
|
|
6c5ff7be41 | ||
|
|
7b93506e2d | ||
|
|
f34fba165c | ||
|
|
6949c01464 | ||
|
|
f22ae22375 | ||
|
|
a4b6ea4bd2 | ||
|
|
5dc9b5f115 | ||
|
|
3664eaed1a | ||
|
|
912f6f5142 | ||
|
|
e995c98d05 | ||
|
|
2f8f595d6e | ||
|
|
747b714a02 | ||
|
|
7839dc48a3 | ||
|
|
84d58cf808 | ||
|
|
18f78f00a3 | ||
|
|
485e41a817 | ||
|
|
e51b20b2d4 | ||
|
|
17e429b86b | ||
|
|
e881747388 | ||
|
|
f9ae5b962c | ||
|
|
15b5b0e6e7 |
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
github: Dvorinka
|
||||||
@@ -17,6 +17,30 @@ 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 }}
|
||||||
|
mobile: ${{ steps.filter.outputs.mobile }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dorny/paths-filter@v3
|
||||||
|
id: filter
|
||||||
|
with:
|
||||||
|
filters: |
|
||||||
|
code:
|
||||||
|
- 'app/**'
|
||||||
|
- 'infra/**'
|
||||||
|
- 'docker-compose.yml'
|
||||||
|
- 'install.sh'
|
||||||
|
- '.github/workflows/**'
|
||||||
|
- 'docs/api/**'
|
||||||
|
mobile:
|
||||||
|
- 'app/mobile/**'
|
||||||
|
|
||||||
infra:
|
infra:
|
||||||
name: Infra (Compose + Scripts)
|
name: Infra (Compose + Scripts)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -48,6 +72,8 @@ 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
|
||||||
@@ -70,6 +96,8 @@ 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
|
||||||
@@ -96,10 +124,40 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
mobile:
|
||||||
|
name: Mobile (Expo)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.mobile == 'true' || github.event_name != 'pull_request'
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: app/mobile
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: app/mobile/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npx expo lint
|
||||||
|
|
||||||
smoke:
|
smoke:
|
||||||
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
|
||||||
@@ -137,6 +195,7 @@ 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
|
||||||
@@ -227,7 +286,7 @@ jobs:
|
|||||||
name: Release
|
name: Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [docker]
|
needs: [docker]
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' && needs.docker.result == 'success'
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# AGENTS.md — Containr
|
||||||
|
|
||||||
|
Containr is a self-hosted Docker PaaS (Railway-style). Backend: Go + Gin + PostgreSQL (sqlc) + Redis-compatible cache (Dragonfly) + Docker API. Frontend: React + Vite + TypeScript + React Query. API contract: `docs/api/openapi.yaml` (generated types: `app/frontend/src/generated/api-types.ts`).
|
||||||
|
|
||||||
|
This file is for **coding agents working on the repo**. For operating Containr as a deployed product, see `llms.txt`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `app/backend` — Go monolith. Entrypoints: `cmd/server` (API), `cmd/cli` (`containr` CLI), `cmd/mcp` (`containr-mcp` MCP server).
|
||||||
|
- `app/backend/internal/api` — Gin handlers + route groups (`routes.go`: public / `read` OptionalAuth / `authed` / `admin`).
|
||||||
|
- `app/backend/internal/middleware` — `Auth` (JWT + Better Auth session + PAT), `RequireAdmin`, `Idempotency`.
|
||||||
|
- `app/backend/internal/cli/commands` — all CLI commands; shared `Client`, `Output`, `confirm` helpers live here.
|
||||||
|
- `app/backend/internal/mcpserver` — declarative MCP tool registry mirroring the API.
|
||||||
|
- `app/backend/sqlc` — `schema.sql` + `queries/*.sql`; generate via `sqlc generate` into `internal/database/sqlcdb`.
|
||||||
|
- `app/backend/migrations_goose` — goose migrations (`YYYYMMDDHHMMSS_name.sql`).
|
||||||
|
- `app/frontend` — React app. Pages under `src/features/`, API calls via `src/lib/api-client.ts`.
|
||||||
|
|
||||||
|
## Build / test / verify
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd app/backend && go build ./... && go vet ./... && go test ./...
|
||||||
|
cd app/frontend && npx tsc --noEmit && npm run lint && npm run build
|
||||||
|
cd app/frontend && npm run generate:api # after any openapi.yaml change
|
||||||
|
```
|
||||||
|
|
||||||
|
Dev stack: Postgres on `:15432` (`containr-postgres-1`), Dragonfly on `:16379` (`containr-dragonfly-1`). Server needs `DATABASE_URL`, `REDIS_URL`, `JWT_SECRET`, `PORT=8080`.
|
||||||
|
|
||||||
|
## Conventions that matter
|
||||||
|
|
||||||
|
- New endpoints go in the right route group; mutations on `authed`/`admin` get idempotency for free.
|
||||||
|
- Deploy work goes through `deploy_queue` (`internal/deployqueue`) — per-service FIFO; enqueue jobs instead of `go`-ing deployment/reconcile goroutines so they never race a build.
|
||||||
|
- New list endpoints use `pageWindow`/`paginationMeta` (`internal/api/pagination.go`) — cursor + page, `next_cursor` in the envelope.
|
||||||
|
- New errors use stable codes via `respondError(c, status, code, msg)` in `internal/api/errors.go`.
|
||||||
|
- New CLI command → also register an MCP tool in `internal/mcpserver/server.go` and document in OpenAPI. All three surfaces ship together.
|
||||||
|
- Destructive CLI commands must gate on `--yes`/`confirm` via `commands.confirm`; destructive MCP tools require `confirm: true`.
|
||||||
|
- sqlc: edit `schema.sql` + `queries/*.sql`, run `sqlc generate`, add the goose migration.
|
||||||
|
- Don't commit secrets. PATs (`cnp_…`) and agent tokens (`cagt_…`) are sha256-hashed at rest; raw values are shown once.
|
||||||
@@ -56,7 +56,8 @@ dedicated console.
|
|||||||
- **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete
|
- **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete
|
||||||
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
|
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
|
||||||
- **HA & scaling** — failover policies, node agents, autoscaling rules
|
- **HA & scaling** — failover policies, node agents, autoscaling rules
|
||||||
- **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vulnerability scans
|
- **Auth & security** — Better Auth sessions (email/password self-hosted), personal access tokens with `read`/`write`/`admin` scopes for CLI and agents, audit logs, vulnerability scans
|
||||||
|
- **CLI** — `containr` covers the full API: projects, services, deployments, logs, exec, variables, databases, cron, templates, nodes, scaling, HA, security, gateway, admin — with `--json`, `--yes`, named profiles, and `containr up` for current-directory deploys
|
||||||
- **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline
|
- **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline
|
||||||
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
|
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
|
||||||
|
|
||||||
|
|||||||
+432
@@ -0,0 +1,432 @@
|
|||||||
|
# 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) ✅ SHIPPED (PRs #14–#18)
|
||||||
|
|
||||||
|
- [x] **Personal access tokens.** `user_tokens` table — `id`, `user_id`,
|
||||||
|
`name`, `key_prefix` (`cnp_…`), `key_hash` (sha256), `scopes`
|
||||||
|
(`read`/`write`/`admin`, later resource-scoped), `expires_at`,
|
||||||
|
`last_used_at`. Model on `agent_auth_tokens` (raw shown once, hash stored).
|
||||||
|
`POST/GET/DELETE /api/v1/user/tokens`; `middleware.Auth` accepts
|
||||||
|
`Bearer cnp_…`; Settings → Access tokens UI with one-time display +
|
||||||
|
copyable CLI/MCP config snippets.
|
||||||
|
- [x] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client:
|
||||||
|
- `auth login [--token] [--url] [--profile]` (multi-instance profiles).
|
||||||
|
- Resources: `projects services deploy logs exec variables databases
|
||||||
|
cron templates nodes scaling ha security gateway notifications
|
||||||
|
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.
|
||||||
|
- [x] **MCP server** (`cmd/mcp`, same module, stdio). Tools mirror the
|
||||||
|
CLI: `list_projects create_service deploy get_logs set_variable
|
||||||
|
restart rollback create_database trigger_cron deploy_template exec
|
||||||
|
get_metrics security_scan …`. Auth: `CONTAINR_URL` + `CONTAINR_TOKEN`
|
||||||
|
or shared `~/.containr/config`. `containr mcp serve` + install snippets
|
||||||
|
for Devin/Claude/Cursor.
|
||||||
|
- [x] **API agent ergonomics.** `Idempotency-Key` on mutating endpoints;
|
||||||
|
uniform `{error, code, details}` envelope (audit bare-string returns);
|
||||||
|
cursor pagination on growing lists; name-or-id lookup everywhere.
|
||||||
|
- [x] **Agent docs.** Root `AGENTS.md`, `/docs` serves `llms.txt` +
|
||||||
|
`llms-full.txt`, MCP tool list generated from OpenAPI where possible.
|
||||||
|
|
||||||
|
**Gate**: an agent holding only a PAT registers a project, deploys from a
|
||||||
|
git repo, streams logs, sets vars, rolls back — zero UI. Same via CLI
|
||||||
|
for a human.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Phase B — Deployment pipeline maturity ✅ SHIPPED (PR #19)
|
||||||
|
|
||||||
|
dflow's one real architectural edge. Containr deploys are fire-and-forget
|
||||||
|
goroutines; this fixes correctness *and* unlocks the ops surface.
|
||||||
|
|
||||||
|
- [x] **Deploy job queue.** Lightweight internal queue — per-service
|
||||||
|
FIFO in `internal/deployqueue`; status gains `queued`.
|
||||||
|
- [x] **Cancel deployments.** `POST /deployments/:id/cancel` — queued
|
||||||
|
jobs cancel immediately, active jobs cancel context; `cancelled` status.
|
||||||
|
- [x] **Cache modes.** `no_cache` on deploys; webhook-triggered deploys
|
||||||
|
default to no-cache (dflow convention — prevents stale-layer surprises).
|
||||||
|
- [x] **Queued→building→running status + persisted step logs.** `queued`
|
||||||
|
state lands; streaming over existing log surface.
|
||||||
|
- [x] **Env var apply → redeploy.** `--redeploy` flag on variable writes;
|
||||||
|
backend requeues honoring running state.
|
||||||
|
- [x] **Encrypt variables at rest.** `is_secret` values encrypted
|
||||||
|
AES-GCM `enc:v1:` (key from `SECRETS_KEY`); masked-echo preserves
|
||||||
|
ciphertext.
|
||||||
|
|
||||||
|
## 4. Phase C — Service configuration parity ✅ SHIPPED (PRs #20–#23)
|
||||||
|
|
||||||
|
- [x] **Volumes on services** — `services.volumes` jsonb
|
||||||
|
`[{volume|host_path, container_path, read_only}]`; Volumes tab +
|
||||||
|
admin volume inventory.
|
||||||
|
- [x] **Multi-domain** — `service_domains(service_id, domain, is_default,
|
||||||
|
cert_type, status, last_checked_at)`. Domains UI: add/remove/
|
||||||
|
set-default/DNS-status badge. `services.domain` stays derived default.
|
||||||
|
- [x] **DNS preflight** — `GET /services/:id/domains/check` resolves and
|
||||||
|
compares vs edge IP → `ok|wrong-target|pending`.
|
||||||
|
- [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
|
||||||
|
no proxy code needed.
|
||||||
|
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with
|
||||||
|
allowlist validation (middlewares, headers, redirects).
|
||||||
|
- [x] **Builder override** — `services.builder` enum
|
||||||
|
`auto|railpack|nixpacks|dockerfile|static`; static builder = build +
|
||||||
|
nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug.
|
||||||
|
- [x] **Resource limits + reserves** — `cpu`/`memory` caps +
|
||||||
|
`cpu_reserve`/`memory_reserve` → NanoCPUs/Memory/MemoryReservation;
|
||||||
|
instance defaults in `app_settings`.
|
||||||
|
- [x] **Pre-deploy capacity check** — reservations vs node capacity;
|
||||||
|
`app_settings.capacity_policy=block` rejects, default warns.
|
||||||
|
- [x] **Service clone** + **move between projects** — copies volumes,
|
||||||
|
domains, access gates, vars (ciphertext verbatim); audit-logged.
|
||||||
|
- [x] **Private registries** — `registry_credentials`
|
||||||
|
(server/username/secret, encrypted); pull auth resolved at deploy;
|
||||||
|
per-service registry link.
|
||||||
|
- [x] **Service detail tabs to match dflow's surface**: Domains,
|
||||||
|
Volumes, access-controls sections added to the service page.
|
||||||
|
|
||||||
|
## 5. Phase D — Data layer maturity
|
||||||
|
|
||||||
|
- [x] **External DB registration** — `provider='managed'|'external'`,
|
||||||
|
conn fields, `POST /databases/test-connection`, same bind flow. (PR #24)
|
||||||
|
- [x] **Public DB port expose** — `public_port` opt-in `0.0.0.0` host
|
||||||
|
bind; default stays loopback. (PR #25)
|
||||||
|
- [x] **Offsite backups** — `backup_targets` (S3-compatible:
|
||||||
|
endpoint/bucket/keys encrypted); per-DB destination; archives shipped
|
||||||
|
via holder container + minio-go; restore-from-S3 fallback. (PR #26)
|
||||||
|
- [ ] **Cross-node DB migration** — `POST /databases/:id/migrate
|
||||||
|
{target_node_id}`: dump → stream transfer → provision → restore →
|
||||||
|
re-point bound services. Deferred until Phase F lands real second
|
||||||
|
nodes — nothing to migrate between today.
|
||||||
|
- [ ] **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.
|
||||||
|
- [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) —
|
||||||
|
`services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper
|
||||||
|
polls container NetworkIO deltas; idle past threshold → workload
|
||||||
|
removed, status `sleeping`. Wake-on-traffic via per-service
|
||||||
|
placeholder container on the edge network carrying the same Traefik
|
||||||
|
host rule at higher priority — serves a waking page, calls
|
||||||
|
`/internal/wake/:id`, exits; Traefik falls back once the app is up.
|
||||||
|
Verified `running → sleeping → waking → running` end to end.
|
||||||
|
- [x] **Env doctor** — `GET /services/:id/env-check` surfaces
|
||||||
|
unresolved `${{…}}` refs, empty values, and unreadable
|
||||||
|
(rotated-key) secrets; template `plan` endpoint reports missing
|
||||||
|
required vars before deploy (PR #30). Frontend Variables section
|
||||||
|
shows the banner.
|
||||||
|
- [x] **Shared project variables** — `project_variables` table,
|
||||||
|
`GET/PUT /projects/:id/variables`, `${{shared.KEY}}` refs resolved
|
||||||
|
at deploy/reconcile, secrets `enc:v1:` at rest and masked (PR #32).
|
||||||
|
- [x] **One-click install** — `install.sh` (curl|sh): detects docker,
|
||||||
|
writes compose + .env with generated secrets incl. `SECRETS_KEY`,
|
||||||
|
pulls images, runs migrations, prints admin URL. GHCR images mean
|
||||||
|
installs never build from source.
|
||||||
|
- [x] **CI/CD v2** — required checks (Go test/vet, frontend
|
||||||
|
lint/build/test, compose boot smoke), `dorny/paths-filter` gate so
|
||||||
|
docs-only PRs skip heavyweight jobs, and release on main/tag:
|
||||||
|
conventional-commit version bump → tag → GHCR images (backend,
|
||||||
|
frontend) → cross-platform CLI + MCP binaries → SHA-256 checksums
|
||||||
|
→ GitHub Release → Trivy scans (PR #33).
|
||||||
|
- [ ] **Custom roles/RBAC** — defer until demanded; PAT scopes designed
|
||||||
|
forward-compatible now.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
## 9b. Phase I — Companion surfaces
|
||||||
|
|
||||||
|
- [x] **Phone app (v1)** — `app/mobile`, Expo/React Native thin client
|
||||||
|
over the agent-shaped API; dark UI mirroring the design system. PAT
|
||||||
|
auth against any instance URL, stored in platform secure storage.
|
||||||
|
- **Dashboard** — service status counts (running/sleeping/failed/
|
||||||
|
stopped), per-project service health dots, recent deployments feed.
|
||||||
|
- **Operate** — start/stop/restart/redeploy, sleep/wake, rollback to
|
||||||
|
a prior deployment, database start/stop/restart/backup-now.
|
||||||
|
- **Observe** — polling log tail (300 lines, 5s refresh), per-service
|
||||||
|
deployment history, env-doctor issues surfaced on the service view.
|
||||||
|
- [ ] **env var edit + apply-redeploy, domain management, replica
|
||||||
|
scaling, cron execution history** — next iteration.
|
||||||
|
- [ ] **Push notifications** — zero-infra path = self-hosted
|
||||||
|
ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional.
|
||||||
|
- [ ] **Offline resilience** — cached last-known state + unreachable
|
||||||
|
banner.
|
||||||
|
- [ ] **Status/offline resilience** — app + CLI degrade gracefully when
|
||||||
|
the instance is unreachable (cached last-known state, clear banner).
|
||||||
|
|
||||||
|
## 10. Carried-over debt (ROADMAP.md items still open)
|
||||||
|
|
||||||
|
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit →
|
||||||
|
`gateway/proxy.go` → metrics. Management API exists; proxy unwired.
|
||||||
|
- [ ] **Preview environments runtime** — real build+deploy+subdomain+TTL
|
||||||
|
(bookkeeping only now). Pairs with clone + multi-domain.
|
||||||
|
- [ ] **Environments** — env switcher, per-env vars/domains, promote.
|
||||||
|
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`.
|
||||||
|
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
|
||||||
|
webhook table tests, Playwright e2e golden path.
|
||||||
|
- [ ] **First-run wizard** — admin → GitHub App (manifest flow from
|
||||||
|
Phase G makes this self-serve) → Tunnel → done.
|
||||||
|
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release.
|
||||||
|
|
||||||
|
## 11. Deliberately not imported from dflow
|
||||||
|
|
||||||
|
- Dokku-over-SSH runtime — our Docker API + agent model is strictly
|
||||||
|
better.
|
||||||
|
- Per-server-named BullMQ queues — our queue stays a single ordered
|
||||||
|
table.
|
||||||
|
- Payload/Mongo/Next.js stack, multi-tenant plugin machinery.
|
||||||
|
- Ansible playbook runner — arbitrary code-on-node is a security
|
||||||
|
surface; SSH bootstrap + agent commands cover the real needs.
|
||||||
|
Revisit only with a concrete demand.
|
||||||
|
- `isAdmin`-only collection access — our owner/admin/public model is
|
||||||
|
richer already.
|
||||||
|
- Netdata install flow — our agent telemetry + optional Beszel covers
|
||||||
|
monitoring; don't ship a third dashboard stack.
|
||||||
|
|
||||||
|
## 12. Recommended order
|
||||||
|
|
||||||
|
```
|
||||||
|
A (agent surface) ──► B (deploy pipeline) ──► C/D (service+data depth)
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
CLI/MCP dogfood every feature E (templates v2)
|
||||||
|
│
|
||||||
|
F (nodes/infra) ──► H (migrators)
|
||||||
|
│
|
||||||
|
G (polish) + debt
|
||||||
|
```
|
||||||
|
|
||||||
|
Why this order: PATs+CLI+MCP first = every later feature ships
|
||||||
|
user-and-agent-visible at once. The queue (B) is prerequisite for
|
||||||
|
the ops surface and correct concurrency. Templates v2 needs volumes,
|
||||||
|
multi-domain, clone. Migrators last — they're launch marketing, not
|
||||||
|
foundation.
|
||||||
|
|
||||||
|
## 13. Design rules
|
||||||
|
|
||||||
|
- **API is the contract.** UI, CLI, MCP are thin clients over
|
||||||
|
`openapi.yaml`. A feature isn't done until all three reach it.
|
||||||
|
- **Everything scriptable.** JSON in/out, `--json`/`--yes`, uniform
|
||||||
|
pagination, name-or-id addressing.
|
||||||
|
- **Same powers, same position.** Nothing user-facing that isn't
|
||||||
|
API-facing; agent-visible state (tokens, jobs) visible to users too.
|
||||||
|
- **dflow is a donor, not a dependency.** Port semantics, not code.
|
||||||
|
- **Containr branding is the default**; white-label is instance config.
|
||||||
|
- **Self-hosted first** — no required external service, no phone-home.
|
||||||
@@ -15,8 +15,5 @@ var (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime))
|
cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime))
|
||||||
if err := cli.Run(); err != nil {
|
os.Exit(cli.Execute())
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"containr/internal/mcpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Injected at build time via -ldflags.
|
||||||
|
var Version = "dev"
|
||||||
|
|
||||||
|
// containr-mcp exposes the whole Containr API as MCP tools over stdio.
|
||||||
|
// Configure it in an MCP client with:
|
||||||
|
//
|
||||||
|
// command: containr-mcp
|
||||||
|
// env: CONTAINR_API_URL, CONTAINR_TOKEN (a cnp_ personal access token)
|
||||||
|
func main() {
|
||||||
|
apiURL := os.Getenv("CONTAINR_API_URL")
|
||||||
|
if apiURL == "" {
|
||||||
|
apiURL = "http://localhost:8080/api/v1"
|
||||||
|
}
|
||||||
|
apiURL = strings.TrimSuffix(apiURL, "/")
|
||||||
|
if !strings.HasSuffix(apiURL, "/api/v1") {
|
||||||
|
apiURL += "/api/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
if err := mcpserver.Run(ctx, apiURL, os.Getenv("CONTAINR_TOKEN"), Version); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "containr-mcp: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-2
@@ -8,11 +8,12 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/go-playground/validator/v10 v10.30.1
|
github.com/go-playground/validator/v10 v10.30.1
|
||||||
github.com/go-redis/redis/v8 v8.11.5
|
github.com/go-redis/redis/v8 v8.11.5
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/miekg/dns v1.1.72
|
github.com/miekg/dns v1.1.72
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.7.0
|
||||||
github.com/pressly/goose/v3 v3.24.1
|
github.com/pressly/goose/v3 v3.24.1
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/rs/cors v1.10.1
|
github.com/rs/cors v1.10.1
|
||||||
@@ -21,6 +22,7 @@ require (
|
|||||||
github.com/sqlc-dev/pqtype v0.3.0
|
github.com/sqlc-dev/pqtype v0.3.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
|
golang.org/x/term v0.46.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -45,6 +47,7 @@ require (
|
|||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
@@ -63,6 +66,8 @@ require (
|
|||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
@@ -71,6 +76,7 @@ require (
|
|||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||||
@@ -84,10 +90,11 @@ require (
|
|||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/mod v0.41.0 // indirect
|
golang.org/x/mod v0.41.0 // indirect
|
||||||
golang.org/x/net v0.59.0 // indirect
|
golang.org/x/net v0.59.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.35.0 // indirect
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
golang.org/x/time v0.11.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/tools v0.49.0 // indirect
|
golang.org/x/tools v0.49.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
+18
-4
@@ -65,11 +65,13 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
|
|||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||||
|
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
@@ -105,6 +107,8 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z
|
|||||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -143,6 +147,10 @@ github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU
|
|||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||||
|
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
@@ -178,6 +186,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||||
@@ -213,16 +223,20 @@ golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
|||||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
|
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
|
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||||
|
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"archive/tar"
|
"archive/tar"
|
||||||
"containr/internal/database/sqlcdb"
|
"containr/internal/database/sqlcdb"
|
||||||
"containr/internal/docker"
|
"containr/internal/docker"
|
||||||
|
"containr/internal/secrets"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
@@ -51,12 +52,30 @@ type DatabaseService struct {
|
|||||||
Region string `json:"region" db:"region"`
|
Region string `json:"region" db:"region"`
|
||||||
BackupSchedule string `json:"backup_schedule,omitempty"`
|
BackupSchedule string `json:"backup_schedule,omitempty"`
|
||||||
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
|
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
|
||||||
|
Provider string `json:"provider"` // managed | external
|
||||||
|
External *DatabaseExternalRef `json:"external,omitempty"` // connection target for external DBs
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
ConnectionURL string `json:"connection_url"`
|
ConnectionURL string `json:"connection_url"`
|
||||||
Metrics DatabaseMetrics `json:"metrics"`
|
Metrics DatabaseMetrics `json:"metrics"`
|
||||||
Backups DatabaseBackupConfig `json:"backups"`
|
Backups DatabaseBackupConfig `json:"backups"`
|
||||||
Settings DatabaseSettings `json:"settings"`
|
Settings DatabaseSettings `json:"settings"`
|
||||||
|
|
||||||
|
// externalPasswordEnc is the stored ciphertext — needed to compose the
|
||||||
|
// connection URL on privileged reads, never serialized.
|
||||||
|
externalPasswordEnc string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseExternalRef describes a registered external database's connection
|
||||||
|
// target. The password is stored encrypted and never returned — has_password
|
||||||
|
// signals presence.
|
||||||
|
type DatabaseExternalRef struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
HasPassword bool `json:"has_password"`
|
||||||
|
SSL bool `json:"ssl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseMetrics represents database performance metrics
|
// DatabaseMetrics represents database performance metrics
|
||||||
@@ -467,21 +486,20 @@ func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) {
|
|||||||
databaseID := c.Param("id")
|
databaseID := c.Param("id")
|
||||||
userID = h.effectiveUserID(c, userID, databaseID)
|
userID = h.effectiveUserID(c, userID, databaseID)
|
||||||
|
|
||||||
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
|
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||||
ID: databaseID,
|
ID: databaseID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if row.Provider != "external" && h.dockerClient != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.dockerClient != nil {
|
|
||||||
if err := h.deleteManagedDatabaseRuntime(databaseID); err != nil {
|
if err := h.deleteManagedDatabaseRuntime(databaseID); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete runtime database"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete runtime database"})
|
||||||
return
|
return
|
||||||
@@ -515,17 +533,24 @@ func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
|
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||||
ID: databaseID,
|
ID: databaseID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if row.Provider == "external" {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "Lifecycle actions apply to managed databases only — external databases are run elsewhere",
|
||||||
|
"code": "VALIDATION",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,6 +644,13 @@ func (h *DatabaseHandler) CreateBackup(c *gin.Context) {
|
|||||||
c.JSON(http.StatusConflict, gin.H{"error": "Database is still provisioning"})
|
c.JSON(http.StatusConflict, gin.H{"error": "Database is still provisioning"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if row.Provider == "external" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "Backups run against the managed dump pipeline — external database backups are not supported yet",
|
||||||
|
"code": "VALIDATION",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
backupID := generateDatabaseBackupID(databaseID)
|
backupID := generateDatabaseBackupID(databaseID)
|
||||||
archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID))
|
archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID))
|
||||||
@@ -659,17 +691,23 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
|
dbRow, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||||
ID: databaseID,
|
ID: databaseID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if dbRow.Provider == "external" {
|
||||||
if !exists {
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
"error": "Restores run against the managed dump pipeline — external database restores are not supported yet",
|
||||||
|
"code": "VALIDATION",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,7 +825,7 @@ func (h *DatabaseHandler) DownloadBackup(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics {
|
func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics {
|
||||||
if h.dockerClient == nil {
|
if h.dockerClient == nil || db.Provider == "external" {
|
||||||
return h.generateMockMetrics()
|
return h.generateMockMetrics()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,7 +983,11 @@ func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
|
|||||||
|
|
||||||
// resolveConnectionURL returns the stored connection URL for privileged
|
// resolveConnectionURL returns the stored connection URL for privileged
|
||||||
// viewers, or "" when the runtime has not produced one (not provisioned yet).
|
// viewers, or "" when the runtime has not produced one (not provisioned yet).
|
||||||
|
// External databases compose it from the registered fields on demand.
|
||||||
func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string {
|
func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string {
|
||||||
|
if db.Provider == "external" && db.External != nil {
|
||||||
|
return externalConnectionURL(db.Type, db.External, secrets.Decrypt(db.externalPasswordEnc))
|
||||||
|
}
|
||||||
return strings.TrimSpace(db.ConnectionURL)
|
return strings.TrimSpace(db.ConnectionURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -987,7 +1029,7 @@ func rewriteLoopbackURL(rawURL, host string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService {
|
func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService {
|
||||||
if h.dockerClient == nil {
|
if h.dockerClient == nil || db.Provider == "external" {
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1054,7 +1096,7 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
|
func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
|
||||||
return DatabaseService{
|
svc := DatabaseService{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
OwnerID: row.UserID,
|
OwnerID: row.UserID,
|
||||||
Name: row.Name,
|
Name: row.Name,
|
||||||
@@ -1065,10 +1107,23 @@ func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
|
|||||||
Region: row.Region,
|
Region: row.Region,
|
||||||
BackupSchedule: databaseNullString(row.BackupSchedule),
|
BackupSchedule: databaseNullString(row.BackupSchedule),
|
||||||
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
|
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
|
||||||
|
Provider: row.Provider,
|
||||||
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
||||||
CreatedAt: databaseNullTime(row.CreatedAt),
|
CreatedAt: databaseNullTime(row.CreatedAt),
|
||||||
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
||||||
}
|
}
|
||||||
|
if svc.Provider == "external" {
|
||||||
|
svc.External = &DatabaseExternalRef{
|
||||||
|
Host: databaseNullString(row.ExternalHost),
|
||||||
|
Port: int(row.ExternalPort.Int32),
|
||||||
|
Database: databaseNullString(row.ExternalName),
|
||||||
|
Username: databaseNullString(row.ExternalUsername),
|
||||||
|
HasPassword: row.ExternalPassword != "",
|
||||||
|
SSL: row.ExternalSsl,
|
||||||
|
}
|
||||||
|
svc.externalPasswordEnc = row.ExternalPassword
|
||||||
|
}
|
||||||
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
func databaseNullTime(value sql.NullTime) time.Time {
|
func databaseNullTime(value sql.NullTime) time.Time {
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database/sqlcdb"
|
||||||
|
"containr/internal/secrets"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// externalDatabaseRequest is the shared payload for registering an external
|
||||||
|
// database and for ad-hoc connection tests.
|
||||||
|
type externalDatabaseRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type" binding:"required"`
|
||||||
|
Host string `json:"host" binding:"required"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
SSL bool `json:"ssl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var externalDefaultPorts = map[string]int{
|
||||||
|
"postgresql": 5432,
|
||||||
|
"mysql": 3306,
|
||||||
|
"mariadb": 3306,
|
||||||
|
"mongodb": 27017,
|
||||||
|
"redis": 6379,
|
||||||
|
"dragonfly": 6379,
|
||||||
|
"clickhouse": 9000,
|
||||||
|
}
|
||||||
|
|
||||||
|
// externalConnectionURL composes a DSN for display/bind use. The password is
|
||||||
|
// embedded only on privileged reads — the value is decrypted at that point.
|
||||||
|
func externalConnectionURL(dbType string, ext *DatabaseExternalRef, password string) string {
|
||||||
|
host := net.JoinHostPort(ext.Host, fmt.Sprintf("%d", ext.Port))
|
||||||
|
creds := ""
|
||||||
|
if ext.Username != "" {
|
||||||
|
creds = url.QueryEscape(ext.Username)
|
||||||
|
if password != "" {
|
||||||
|
creds += ":" + url.QueryEscape(password)
|
||||||
|
}
|
||||||
|
creds += "@"
|
||||||
|
}
|
||||||
|
switch dbType {
|
||||||
|
case "postgresql":
|
||||||
|
u := fmt.Sprintf("postgres://%s%s/%s", creds, host, ext.Database)
|
||||||
|
if ext.SSL {
|
||||||
|
u += "?sslmode=require"
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
case "mysql", "mariadb":
|
||||||
|
return fmt.Sprintf("mysql://%s%s/%s", creds, host, ext.Database)
|
||||||
|
case "mongodb":
|
||||||
|
u := fmt.Sprintf("mongodb://%s%s/%s", creds, host, ext.Database)
|
||||||
|
if ext.SSL {
|
||||||
|
u += "?tls=true"
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
case "redis", "dragonfly":
|
||||||
|
scheme := "redis"
|
||||||
|
if ext.SSL {
|
||||||
|
scheme = "rediss"
|
||||||
|
}
|
||||||
|
// Redis auth is password-only; username support is ACL-era.
|
||||||
|
if password != "" {
|
||||||
|
creds = ":" + url.QueryEscape(password) + "@"
|
||||||
|
if ext.Username != "" {
|
||||||
|
creds = url.QueryEscape(ext.Username) + creds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s%s", scheme, creds, host)
|
||||||
|
case "clickhouse":
|
||||||
|
return fmt.Sprintf("clickhouse://%s%s/%s", creds, host, ext.Database)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s%s", dbType, creds, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeExternalDatabase verifies reachability. postgres and redis-family get
|
||||||
|
// real protocol checks via the vendored drivers; everything else gets a TCP
|
||||||
|
// dial — enough to catch typos and firewall issues without new dependencies.
|
||||||
|
func probeExternalDatabase(ctx context.Context, req externalDatabaseRequest) (time.Duration, error) {
|
||||||
|
addr := net.JoinHostPort(req.Host, fmt.Sprintf("%d", req.Port))
|
||||||
|
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
switch req.Type {
|
||||||
|
case "postgresql":
|
||||||
|
sslmode := "disable"
|
||||||
|
if req.SSL {
|
||||||
|
sslmode = "require"
|
||||||
|
}
|
||||||
|
dsn := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s&connect_timeout=5",
|
||||||
|
url.QueryEscape(req.Username), url.QueryEscape(req.Password), addr,
|
||||||
|
url.QueryEscape(req.Database), sslmode)
|
||||||
|
conn, err := sql.Open("postgres", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if err := conn.PingContext(ctx); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
case "redis", "dragonfly":
|
||||||
|
client := redis.NewClient(&redis.Options{
|
||||||
|
Addr: addr,
|
||||||
|
Username: req.Username,
|
||||||
|
Password: req.Password,
|
||||||
|
DialTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 5 * time.Second,
|
||||||
|
TLSConfig: redisTLSConfig(req.SSL, req.Host),
|
||||||
|
})
|
||||||
|
defer client.Close()
|
||||||
|
if err := client.Ping(ctx).Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
return time.Since(start), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func redisTLSConfig(enabled bool, host string) *tls.Config {
|
||||||
|
if !enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTestDatabaseConnection probes arbitrary connection parameters — used
|
||||||
|
// by the register form's "test" button and by agents before registering.
|
||||||
|
func (h *DatabaseHandler) TestDatabaseConnection(c *gin.Context) {
|
||||||
|
if _, ok := requireAuthenticatedUserID(c); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req externalDatabaseRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := normalizeExternalRequest(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
latency, err := probeExternalDatabase(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDatabaseConnectionByID re-probes a registered external database using
|
||||||
|
// its stored (decrypted) credentials.
|
||||||
|
func (h *DatabaseHandler) TestDatabaseConnectionByID(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
databaseID := c.Param("id")
|
||||||
|
userID = h.effectiveUserID(c, userID, databaseID)
|
||||||
|
|
||||||
|
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||||
|
ID: databaseID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.Provider != "external" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "test-connection applies to external databases", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := externalDatabaseRequest{
|
||||||
|
Type: row.Type,
|
||||||
|
Host: databaseNullString(row.ExternalHost),
|
||||||
|
Port: int(row.ExternalPort.Int32),
|
||||||
|
Database: databaseNullString(row.ExternalName),
|
||||||
|
Username: databaseNullString(row.ExternalUsername),
|
||||||
|
Password: secrets.Decrypt(row.ExternalPassword),
|
||||||
|
SSL: row.ExternalSsl,
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
latency, probeErr := probeExternalDatabase(ctx, req)
|
||||||
|
if probeErr != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": false, "error": probeErr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeExternalRequest(req *externalDatabaseRequest) error {
|
||||||
|
req.Type = normalizeDatabaseType(req.Type)
|
||||||
|
if _, ok := supportedDatabaseTypes[req.Type]; !ok {
|
||||||
|
return fmt.Errorf("%w: %s", errUnsupportedDatabaseType, req.Type)
|
||||||
|
}
|
||||||
|
req.Host = strings.TrimSpace(req.Host)
|
||||||
|
if req.Host == "" {
|
||||||
|
return errors.New("host is required")
|
||||||
|
}
|
||||||
|
if req.Port <= 0 {
|
||||||
|
req.Port = externalDefaultPorts[req.Type]
|
||||||
|
}
|
||||||
|
if req.Port <= 0 || req.Port > 65535 {
|
||||||
|
return errors.New("port must be 1-65535")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterExternalDatabase records a database hosted elsewhere — same list,
|
||||||
|
// same bind surface, no container. The connection is probed first so typos
|
||||||
|
// fail at registration, not at the first service bind.
|
||||||
|
func (h *DatabaseHandler) RegisterExternalDatabase(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req externalDatabaseRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := normalizeExternalRequest(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := probeExternalDatabase(probeCtx, req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": fmt.Sprintf("Connection check failed: %v", err),
|
||||||
|
"code": "DEPENDENCY_UNAVAILABLE",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := h.queries.CountDatabaseServicesByUserAndName(c.Request.Context(), sqlcdb.CountDatabaseServicesByUserAndNameParams{
|
||||||
|
UserID: userID,
|
||||||
|
Name: req.Name,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate database name"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "database name already exists", "code": "CONFLICT"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseID := generateDatabaseID(req.Name)
|
||||||
|
now := sql.NullTime{Time: time.Now(), Valid: true}
|
||||||
|
encPassword := ""
|
||||||
|
if req.Password != "" {
|
||||||
|
encPassword = secrets.Encrypt(req.Password)
|
||||||
|
}
|
||||||
|
if err := h.queries.CreateExternalDatabaseService(c.Request.Context(), sqlcdb.CreateExternalDatabaseServiceParams{
|
||||||
|
ID: databaseID,
|
||||||
|
UserID: userID,
|
||||||
|
Name: req.Name,
|
||||||
|
Type: req.Type,
|
||||||
|
Version: "",
|
||||||
|
Plan: "external",
|
||||||
|
Region: "external",
|
||||||
|
ExternalHost: sql.NullString{String: req.Host, Valid: true},
|
||||||
|
ExternalPort: sql.NullInt32{Int32: int32(req.Port), Valid: true},
|
||||||
|
ExternalName: sql.NullString{String: req.Database, Valid: req.Database != ""},
|
||||||
|
ExternalUsername: sql.NullString{String: req.Username, Valid: req.Username != ""},
|
||||||
|
ExternalPassword: encPassword,
|
||||||
|
ExternalSsl: req.SSL,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register database"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogAudit(userID, "database", databaseID, "register-external", map[string]interface{}{
|
||||||
|
"name": req.Name,
|
||||||
|
"type": req.Type,
|
||||||
|
"host": req.Host,
|
||||||
|
})
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"id": databaseID,
|
||||||
|
"message": "External database registered",
|
||||||
|
"status": "running",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeExternalRequestDefaults(t *testing.T) {
|
||||||
|
req := externalDatabaseRequest{Type: "PostgreSQL", Host: " db.example.com ", Port: 0}
|
||||||
|
if err := normalizeExternalRequest(&req); err != nil {
|
||||||
|
t.Fatalf("normalize failed: %v", err)
|
||||||
|
}
|
||||||
|
if req.Type != "postgresql" {
|
||||||
|
t.Fatalf("type not normalized: %q", req.Type)
|
||||||
|
}
|
||||||
|
if req.Host != "db.example.com" {
|
||||||
|
t.Fatalf("host not trimmed: %q", req.Host)
|
||||||
|
}
|
||||||
|
if req.Port != 5432 {
|
||||||
|
t.Fatalf("default port = %d, want 5432", req.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeExternalRequestRejects(t *testing.T) {
|
||||||
|
cases := []externalDatabaseRequest{
|
||||||
|
{Type: "sqlite", Host: "x"}, // unsupported type
|
||||||
|
{Type: "postgresql", Host: " "}, // empty host
|
||||||
|
{Type: "postgresql", Host: "h", Port: 70000}, // out of range
|
||||||
|
}
|
||||||
|
for i, req := range cases {
|
||||||
|
if err := normalizeExternalRequest(&req); err == nil {
|
||||||
|
t.Fatalf("case %d: expected error", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalConnectionURLMasking(t *testing.T) {
|
||||||
|
ext := &DatabaseExternalRef{
|
||||||
|
Host: "db.example.com",
|
||||||
|
Port: 5432,
|
||||||
|
Database: "appdb",
|
||||||
|
Username: "svc",
|
||||||
|
SSL: true,
|
||||||
|
}
|
||||||
|
u := externalConnectionURL("postgresql", ext, "s3cret")
|
||||||
|
if u != "postgres://svc:[email protected]:5432/appdb?sslmode=require" {
|
||||||
|
t.Fatalf("unexpected pg url: %s", u)
|
||||||
|
}
|
||||||
|
u = externalConnectionURL("postgresql", ext, "")
|
||||||
|
if u != "postgres://[email protected]:5432/appdb?sslmode=require" {
|
||||||
|
t.Fatalf("unexpected passwordless url: %s", u)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &DatabaseExternalRef{Host: "cache.internal", Port: 6379, SSL: true}
|
||||||
|
u = externalConnectionURL("redis", r, "pw")
|
||||||
|
if u != "rediss://:[email protected]:6379" {
|
||||||
|
t.Fatalf("unexpected redis url: %s", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeExternalDatabaseTCP(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
// TCP fallback path — mysql uses a plain dial.
|
||||||
|
latency, err := probeExternalDatabase(context.Background(), externalDatabaseRequest{
|
||||||
|
Type: "mysql", Host: "127.0.0.1", Port: port,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("probe should succeed against open listener: %v", err)
|
||||||
|
}
|
||||||
|
if latency <= 0 {
|
||||||
|
t.Fatalf("latency not recorded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closed port must fail fast.
|
||||||
|
if _, err := probeExternalDatabase(context.Background(), externalDatabaseRequest{
|
||||||
|
Type: "mysql", Host: "127.0.0.1", Port: 1,
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatalf("probe should fail against closed port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedisTLSConfig(t *testing.T) {
|
||||||
|
if redisTLSConfig(false, "h") != nil {
|
||||||
|
t.Fatalf("disabled ssl must produce nil tls config")
|
||||||
|
}
|
||||||
|
cfg := redisTLSConfig(true, "db.example.com")
|
||||||
|
if cfg == nil || cfg.ServerName != "db.example.com" {
|
||||||
|
t.Fatalf("expected ServerName passthrough, got %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,13 @@ package api
|
|||||||
import (
|
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"
|
||||||
|
|
||||||
@@ -35,6 +38,7 @@ type CreateDeploymentRequest struct {
|
|||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
Trigger string `json:"trigger"`
|
Trigger string `json:"trigger"`
|
||||||
EnvVars map[string]string `json:"env_vars"`
|
EnvVars map[string]string `json:"env_vars"`
|
||||||
|
NoCache bool `json:"no_cache"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeploymentResponse struct {
|
type DeploymentResponse struct {
|
||||||
@@ -74,14 +78,21 @@ func handleGetDeployments(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
offset, limit := pageWindow(c, 50, 100)
|
||||||
|
|
||||||
|
var total int
|
||||||
|
_ = db.(*database.DB).QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM deployments WHERE service_id = $1`, serviceID,
|
||||||
|
).Scan(&total)
|
||||||
|
|
||||||
rows, err := db.(*database.DB).Query(
|
rows, err := db.(*database.DB).Query(
|
||||||
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
|
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
|
||||||
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
|
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
|
||||||
FROM deployments
|
FROM deployments
|
||||||
WHERE service_id = $1
|
WHERE service_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 50`,
|
LIMIT $2 OFFSET $3`,
|
||||||
serviceID,
|
serviceID, limit, offset,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
||||||
@@ -104,7 +115,10 @@ func handleGetDeployments(c *gin.Context) {
|
|||||||
deployments = append(deployments, d)
|
deployments = append(deployments, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"deployments": deployments,
|
||||||
|
"pagination": paginationMeta(offset, limit, total),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecentDeployment is a deployment row joined with its service and project names.
|
// RecentDeployment is a deployment row joined with its service and project names.
|
||||||
@@ -132,11 +146,19 @@ func handleGetRecentDeployments(c *gin.Context) {
|
|||||||
userID := optionalUserUUID(c)
|
userID := optionalUserUUID(c)
|
||||||
isAdmin := contextIsAdmin(c)
|
isAdmin := contextIsAdmin(c)
|
||||||
|
|
||||||
limit := 10
|
offset, limit := pageWindow(c, 10, 50)
|
||||||
if v := c.Query("limit"); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
|
var total int
|
||||||
limit = n
|
if err := db.(*database.DB).QueryRow(
|
||||||
}
|
`SELECT COUNT(*)
|
||||||
|
FROM deployments d
|
||||||
|
JOIN services s ON s.id = d.service_id
|
||||||
|
JOIN projects p ON p.id = s.project_id
|
||||||
|
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
|
||||||
|
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`,
|
||||||
|
userID, isAdmin,
|
||||||
|
).Scan(&total); err != nil {
|
||||||
|
total = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := db.(*database.DB).Query(
|
rows, err := db.(*database.DB).Query(
|
||||||
@@ -148,8 +170,8 @@ func handleGetRecentDeployments(c *gin.Context) {
|
|||||||
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
|
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
|
||||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
|
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
|
||||||
ORDER BY d.created_at DESC
|
ORDER BY d.created_at DESC
|
||||||
LIMIT $3`,
|
LIMIT $3 OFFSET $4`,
|
||||||
userID, isAdmin, limit,
|
userID, isAdmin, limit, offset,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
||||||
@@ -168,7 +190,10 @@ func handleGetRecentDeployments(c *gin.Context) {
|
|||||||
deployments = append(deployments, d)
|
deployments = append(deployments, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"deployments": deployments,
|
||||||
|
"pagination": paginationMeta(offset, limit, total),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCreateDeployment(c *gin.Context) {
|
func handleCreateDeployment(c *gin.Context) {
|
||||||
@@ -209,6 +234,9 @@ 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
|
||||||
@@ -219,7 +247,9 @@ 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.CreatedAt, &service.UpdatedAt, &projectOwner,
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
|
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||||
|
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -289,7 +319,23 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
engineInstance := engine.(*deployment.DeploymentEngine)
|
engineInstance := engine.(*deployment.DeploymentEngine)
|
||||||
go runDeploymentAndSync(context.Background(), db.(*database.DB), engineInstance, &d, service, req, userID.(string))
|
pos := getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
|
||||||
|
DeploymentID: d.ID,
|
||||||
|
Run: func(jctx context.Context) {
|
||||||
|
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, service, req, userID.(string))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if pos > 0 {
|
||||||
|
d.Status = "queued"
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), d.ID,
|
||||||
|
)
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), serviceID,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, DeploymentResponse{
|
c.JSON(http.StatusCreated, DeploymentResponse{
|
||||||
@@ -362,6 +408,19 @@ 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(),
|
||||||
@@ -372,15 +431,22 @@ func runDeploymentAndSyncWithImage(
|
|||||||
Command: command,
|
Command: command,
|
||||||
Environment: env,
|
Environment: env,
|
||||||
Replicas: replicas,
|
Replicas: replicas,
|
||||||
PublicPort: int32(service.Port),
|
PublicPort: publicPort,
|
||||||
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,
|
||||||
@@ -390,24 +456,50 @@ func runDeploymentAndSyncWithImage(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if imageOverride != "" {
|
if imageOverride != "" || service.GitRepo == "" {
|
||||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
// Prebuilt image deploy (rollback, redeploy, or image-sourced
|
||||||
BuildType: "prebuilt",
|
// service): resolve private-registry credentials by image host.
|
||||||
PrebuiltImage: imageOverride,
|
image := imageOverride
|
||||||
|
if image == "" {
|
||||||
|
image = service.Image
|
||||||
}
|
}
|
||||||
} else if service.GitRepo == "" {
|
var ownerID string
|
||||||
// Image-sourced service: pull the image instead of building.
|
_ = 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: service.Image,
|
PrebuiltImage: image,
|
||||||
|
PullUsername: auth.Username,
|
||||||
|
PullPassword: auth.Password,
|
||||||
}
|
}
|
||||||
} 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: "nixpacks",
|
BuildType: buildType,
|
||||||
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)
|
||||||
@@ -434,16 +526,19 @@ func runDeploymentAndSyncWithImage(
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
failedAt := time.Now()
|
failedAt := time.Now()
|
||||||
timeoutErr := "Deployment timed out before completion"
|
finalStatus, finalErr := "failed", "Deployment timed out before completion"
|
||||||
|
if errors.Is(ctx.Err(), context.Canceled) {
|
||||||
|
finalStatus, finalErr = "cancelled", "Deployment cancelled"
|
||||||
|
}
|
||||||
_, _ = db.Exec(
|
_, _ = db.Exec(
|
||||||
`UPDATE deployments
|
`UPDATE deployments
|
||||||
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
|
SET status = $1, error = $2, completed_at = $3, updated_at = $3
|
||||||
WHERE id = $3`,
|
WHERE id = $4`,
|
||||||
timeoutErr, failedAt, dbDeployment.ID,
|
finalStatus, finalErr, failedAt, dbDeployment.ID,
|
||||||
)
|
)
|
||||||
_, _ = db.Exec(
|
_, _ = db.Exec(
|
||||||
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`,
|
||||||
failedAt, service.ID,
|
finalStatus, failedAt, service.ID,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
case <-syncTicker.C:
|
case <-syncTicker.C:
|
||||||
@@ -510,13 +605,86 @@ func runDeploymentAndSyncWithImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mapEngineStatusToDBStatus(status string) string {
|
func mapEngineStatusToDBStatus(status string) string {
|
||||||
switch status {
|
if status == "running" {
|
||||||
case "running":
|
|
||||||
return "deployed"
|
return "deployed"
|
||||||
case "cancelled":
|
}
|
||||||
return "failed"
|
|
||||||
default:
|
|
||||||
return status
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDeployQueue returns the per-service deploy queue. SetupRoutes always
|
||||||
|
// sets it; the fallback keeps handler tests that skip SetupRoutes working.
|
||||||
|
func getDeployQueue(c *gin.Context) *deployqueue.Queue {
|
||||||
|
if v, exists := c.Get("deploy_queue"); exists {
|
||||||
|
if q, ok := v.(*deployqueue.Queue); ok && q != nil {
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deployqueue.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCancelDeployment cancels a queued or in-flight deployment. Queued
|
||||||
|
// jobs are dropped and marked cancelled immediately; active jobs get their
|
||||||
|
// context cancelled so the build/reconcile aborts and the sync loop writes
|
||||||
|
// the terminal 'cancelled' status.
|
||||||
|
func handleCancelDeployment(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available", "code": "DEPENDENCY_UNAVAILABLE"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deploymentID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated", "code": "UNAUTHENTICATED"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var status, owner string
|
||||||
|
var serviceID uuid.UUID
|
||||||
|
err = db.(*database.DB).QueryRow(
|
||||||
|
`SELECT d.status, d.service_id, p.owner_id
|
||||||
|
FROM deployments d
|
||||||
|
JOIN services s ON d.service_id = s.id
|
||||||
|
JOIN projects p ON s.project_id = p.id
|
||||||
|
WHERE d.id = $1`,
|
||||||
|
deploymentID,
|
||||||
|
).Scan(&status, &serviceID, &owner)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if owner != userID.(string) && !contextIsAdmin(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied", "code": "FORBIDDEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case "deployed", "failed", "cancelled":
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Deployment already finished", "code": "NOT_CANCELLABLE"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch getDeployQueue(c).Cancel(serviceID, deploymentID) {
|
||||||
|
case deployqueue.WasQueued:
|
||||||
|
now := time.Now()
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE deployments SET status = 'cancelled', error = 'Deployment cancelled', completed_at = $1, updated_at = $1 WHERE id = $2`,
|
||||||
|
now, deploymentID,
|
||||||
|
)
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE services SET status = 'cancelled', updated_at = $1 WHERE id = $2`,
|
||||||
|
now, serviceID,
|
||||||
|
)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
|
||||||
|
case deployqueue.WasActive:
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"status": "cancelling"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Deployment is not running on this node", "code": "NOT_CANCELLABLE"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,6 +863,9 @@ 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(
|
||||||
@@ -702,7 +873,9 @@ 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.CreatedAt, &service.UpdatedAt,
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
|
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||||
|
&service.CreatedAt, &service.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
rollbackReq := CreateDeploymentRequest{
|
rollbackReq := CreateDeploymentRequest{
|
||||||
@@ -715,7 +888,12 @@ func handleRollbackDeployment(c *gin.Context) {
|
|||||||
Trigger: "rollback",
|
Trigger: "rollback",
|
||||||
}
|
}
|
||||||
// Reuse the stored image directly — no rebuild.
|
// Reuse the stored image directly — no rebuild.
|
||||||
go runDeploymentAndSyncWithImage(context.Background(), db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
|
getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
|
||||||
|
DeploymentID: rollback.ID,
|
||||||
|
Run: func(jctx context.Context) {
|
||||||
|
runDeploymentAndSyncWithImage(jctx, db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
@@ -731,3 +909,35 @@ 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 ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServiceDomain is one hostname routed to a service. services.domain stays
|
||||||
|
// the derived default; this table is the source of truth for the full set.
|
||||||
|
type ServiceDomain struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ServiceID uuid.UUID `json:"service_id"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
CertType string `json:"cert_type"`
|
||||||
|
CertStatus string `json:"cert_status"`
|
||||||
|
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasicAuthCred is a plaintext credential the server encodes to htpasswd.
|
||||||
|
type BasicAuthCred struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostnameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`)
|
||||||
|
|
||||||
|
func validHostname(d string) bool {
|
||||||
|
return len(d) <= 253 && hostnameRE.MatchString(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadServiceDomains(db *database.DB, serviceID uuid.UUID) []ServiceDomain {
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT id, service_id, domain, is_default, cert_type, cert_status, last_checked_at, created_at
|
||||||
|
FROM service_domains WHERE service_id = $1 ORDER BY is_default DESC, created_at ASC`, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []ServiceDomain{}
|
||||||
|
for rows.Next() {
|
||||||
|
var d ServiceDomain
|
||||||
|
if err := rows.Scan(&d.ID, &d.ServiceID, &d.Domain, &d.IsDefault, &d.CertType, &d.CertStatus, &d.LastCheckedAt, &d.CreatedAt); err == nil {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) []string {
|
||||||
|
domains := loadServiceDomains(db, serviceID)
|
||||||
|
out := make([]string, 0, len(domains))
|
||||||
|
for _, d := range domains {
|
||||||
|
out = append(out, d.Domain)
|
||||||
|
}
|
||||||
|
if len(out) == 0 && fallback != "" {
|
||||||
|
out = []string{fallback}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncDefaultDomain writes the is_default row back to services.domain so
|
||||||
|
// legacy reads (CLI tables, public_url) keep working.
|
||||||
|
func syncDefaultDomain(db *database.DB, serviceID uuid.UUID) {
|
||||||
|
var domain string
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT domain FROM service_domains WHERE service_id = $1 AND is_default`, serviceID).Scan(&domain)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
err = db.QueryRow(
|
||||||
|
`SELECT domain FROM service_domains WHERE service_id = $1 ORDER BY created_at ASC LIMIT 1`, serviceID).Scan(&domain)
|
||||||
|
}
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
domain = ""
|
||||||
|
}
|
||||||
|
_, _ = db.Exec(`UPDATE services SET domain = $1 WHERE id = $2`, domain, serviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceAccess loads the maintenance/basic-auth columns for the runtime spec.
|
||||||
|
func serviceAccess(db *database.DB, serviceID uuid.UUID) (maintenance bool, basicAuth string) {
|
||||||
|
_ = db.QueryRow(`SELECT maintenance_mode, basic_auth_users FROM services WHERE id = $1`, serviceID).
|
||||||
|
Scan(&maintenance, &basicAuth)
|
||||||
|
return maintenance, basicAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
// maintenanceURL is the redirect target for maintenance mode — the public
|
||||||
|
// base URL of this API plus the maintenance page path.
|
||||||
|
func maintenanceURL(db *database.DB) string {
|
||||||
|
base := strings.TrimRight(settingValue(db, "base_url", "BASE_URL", ""), "/")
|
||||||
|
if base == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return base + "/api/v1/maintenance"
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeBasicAuth turns plaintext creds into Traefik's htpasswd format
|
||||||
|
// (user:bcrypt hash, comma-separated). Bcrypt is what Traefik expects.
|
||||||
|
func encodeBasicAuth(creds []BasicAuthCred) (string, error) {
|
||||||
|
pairs := make([]string, 0, len(creds))
|
||||||
|
for _, cred := range creds {
|
||||||
|
if cred.Username == "" || cred.Password == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(cred.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
pairs = append(pairs, cred.Username+":"+string(hash))
|
||||||
|
}
|
||||||
|
return strings.Join(pairs, ","), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// basicAuthUsernames returns just the usernames for API responses — never
|
||||||
|
// the hashes.
|
||||||
|
func basicAuthUsernames(encoded string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, pair := range strings.Split(encoded, ",") {
|
||||||
|
if u, _, ok := strings.Cut(pair, ":"); ok && u != "" {
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceWriteAccess mirrors the service update handler's access check.
|
||||||
|
func serviceWriteAccess(c *gin.Context, db *database.DB, serviceID uuid.UUID) bool {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
var ok bool
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM services s JOIN projects p ON s.project_id = p.id
|
||||||
|
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool))`,
|
||||||
|
serviceID, userID, contextIsAdmin(c)).Scan(&ok)
|
||||||
|
return err == nil && ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListServiceDomains(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serviceID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"domains": loadServiceDomains(db.(*database.DB), serviceID)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAddServiceDomain(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serviceID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
|
||||||
|
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Domain string `json:"domain" binding:"required"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domain := strings.ToLower(strings.TrimSpace(req.Domain))
|
||||||
|
if !validHostname(domain) {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid hostname")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// First domain on a service becomes the default automatically.
|
||||||
|
var count int
|
||||||
|
_ = db.(*database.DB).QueryRow(`SELECT COUNT(*) FROM service_domains WHERE service_id = $1`, serviceID).Scan(&count)
|
||||||
|
makeDefault := req.IsDefault || count == 0
|
||||||
|
|
||||||
|
tx, err := db.(*database.DB).Begin()
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if makeDefault {
|
||||||
|
if _, err := tx.Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID); err != nil {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var inserted ServiceDomain
|
||||||
|
err = tx.QueryRow(
|
||||||
|
`INSERT INTO service_domains (service_id, domain, is_default)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, service_id, domain, is_default, cert_type, cert_status, created_at`,
|
||||||
|
serviceID, domain, makeDefault).
|
||||||
|
Scan(&inserted.ID, &inserted.ServiceID, &inserted.Domain, &inserted.IsDefault, &inserted.CertType, &inserted.CertStatus, &inserted.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
|
||||||
|
respondError(c, http.StatusConflict, "CONFLICT", "Domain already attached to this service")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncDefaultDomain(db.(*database.DB), serviceID)
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"domain": inserted})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteServiceDomain(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serviceID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
|
||||||
|
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domainID, err := uuid.Parse(c.Param("domain_id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := db.(*database.DB).Exec(
|
||||||
|
`DELETE FROM service_domains WHERE id = $1 AND service_id = $2`, domainID, serviceID)
|
||||||
|
affected, _ := res.RowsAffected()
|
||||||
|
if err != nil || affected == 0 {
|
||||||
|
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncDefaultDomain(db.(*database.DB), serviceID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deleted": domainID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSetDefaultDomain(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serviceID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
|
||||||
|
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domainID, err := uuid.Parse(c.Param("domain_id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := db.(*database.DB).Exec(
|
||||||
|
`UPDATE service_domains SET is_default = (id = $1) WHERE service_id = $2`,
|
||||||
|
domainID, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to set default domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
affected, _ := res.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncDefaultDomain(db.(*database.DB), serviceID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"domains": loadServiceDomains(db.(*database.DB), serviceID)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCheckServiceDomains resolves each domain's DNS and compares it to
|
||||||
|
// the configured expected target (app_settings.public_ip / PUBLIC_IP, or the
|
||||||
|
// base_url host). Statuses: ok | wrong-target | pending.
|
||||||
|
func handleCheckServiceDomains(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbh := db.(*database.DB)
|
||||||
|
serviceID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedIP := settingValue(dbh, "public_ip", "PUBLIC_IP", "")
|
||||||
|
expectedHost := ""
|
||||||
|
if base := settingValue(dbh, "base_url", "BASE_URL", ""); base != "" {
|
||||||
|
if u := strings.TrimPrefix(strings.TrimPrefix(base, "https://"), "http://"); u != "" {
|
||||||
|
expectedHost, _, _ = strings.Cut(u, "/")
|
||||||
|
expectedHost, _, _ = strings.Cut(expectedHost, ":")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Status string `json:"status"` // ok | wrong-target | pending
|
||||||
|
Resolved []string `json:"resolved"`
|
||||||
|
}
|
||||||
|
results := []result{}
|
||||||
|
resolver := net.DefaultResolver
|
||||||
|
for _, d := range loadServiceDomains(dbh, serviceID) {
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 4*time.Second)
|
||||||
|
addrs, _ := resolver.LookupHost(ctx, d.Domain)
|
||||||
|
cname, _ := resolver.LookupCNAME(ctx, d.Domain)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
r := result{Domain: d.Domain, Resolved: addrs}
|
||||||
|
cname = strings.TrimSuffix(cname, ".")
|
||||||
|
switch {
|
||||||
|
case len(addrs) == 0:
|
||||||
|
r.Status = "pending"
|
||||||
|
case expectedIP != "":
|
||||||
|
r.Status = "wrong-target"
|
||||||
|
for _, a := range addrs {
|
||||||
|
if a == expectedIP {
|
||||||
|
r.Status = "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case expectedHost != "":
|
||||||
|
r.Status = "ok" // resolves; CNAME match is advisory only
|
||||||
|
if cname != "" && cname != d.Domain && cname != expectedHost {
|
||||||
|
r.Status = "wrong-target"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
r.Status = "ok"
|
||||||
|
}
|
||||||
|
results = append(results, r)
|
||||||
|
_, _ = dbh.Exec(`UPDATE service_domains SET last_checked_at = NOW() WHERE id = $1`, d.ID)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"domains": results})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMaintenancePage serves the maintenance-mode landing page. Public:
|
||||||
|
// browsers hit it via the Traefik redirect before any auth.
|
||||||
|
func handleMaintenancePage(c *gin.Context) {
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(`<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Maintenance</title><style>
|
||||||
|
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0b0e14;color:#e6e8ee;font-family:system-ui,sans-serif}
|
||||||
|
.card{text-align:center;padding:2rem}.title{font-size:1.4rem;font-weight:600;margin-bottom:.5rem}
|
||||||
|
.sub{color:#9aa3b2;font-size:.95rem}.brand{margin-top:2rem;color:#5b6472;font-size:.75rem;letter-spacing:.12em;text-transform:uppercase}
|
||||||
|
</style></head><body><div class="card">
|
||||||
|
<div class="title">This service is under maintenance</div>
|
||||||
|
<div class="sub">Please check back shortly.</div>
|
||||||
|
<div class="brand">Containr</div>
|
||||||
|
</div></body></html>`))
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stable machine-readable error codes for agent clients. The "error" field
|
||||||
|
// stays human-readable; "code" is the contract the CLI/MCP rely on.
|
||||||
|
const (
|
||||||
|
CodeUnauthorized = "UNAUTHENTICATED"
|
||||||
|
CodeInvalidToken = "INVALID_TOKEN"
|
||||||
|
CodeForbidden = "FORBIDDEN"
|
||||||
|
CodeAdminRequired = "ADMIN_REQUIRED"
|
||||||
|
CodeScopeInsufficient = "SCOPE_INSUFFICIENT"
|
||||||
|
CodeNotFound = "NOT_FOUND"
|
||||||
|
CodeValidation = "VALIDATION"
|
||||||
|
CodeConflict = "CONFLICT"
|
||||||
|
CodeInternal = "INTERNAL"
|
||||||
|
CodeUnavailable = "DEPENDENCY_UNAVAILABLE"
|
||||||
|
CodeIdempotencyRunning = "IDEMPOTENCY_IN_PROGRESS"
|
||||||
|
)
|
||||||
|
|
||||||
|
// respondError emits the standard error envelope. Existing handlers return
|
||||||
|
// bare {"error": msg} — this adds the stable code for new and
|
||||||
|
// agent-facing paths.
|
||||||
|
func respondError(c *gin.Context, status int, code, message string) {
|
||||||
|
c.JSON(status, gin.H{"error": message, "code": code})
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/deployqueue"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -178,11 +179,24 @@ func handleGitWebhookPush(c *gin.Context) {
|
|||||||
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
||||||
time.Now(), service.ID,
|
time.Now(), service.ID,
|
||||||
)
|
)
|
||||||
go runDeploymentAndSync(
|
// Webhook pushes default to a clean build — stale layers are the
|
||||||
context.Background(), db, engine, &d, service,
|
// classic silent-rollback failure (dflow convention).
|
||||||
CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook"},
|
webhookReq := CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook", NoCache: true}
|
||||||
repoUserID,
|
if pos := getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
|
||||||
|
DeploymentID: d.ID,
|
||||||
|
Run: func(jctx context.Context) {
|
||||||
|
runDeploymentAndSync(jctx, db, engine, &d, service, webhookReq, repoUserID)
|
||||||
|
},
|
||||||
|
}); pos > 0 {
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), d.ID,
|
||||||
)
|
)
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), service.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
enqueued++
|
enqueued++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pageWindow parses pagination from either ?page=&limit= (legacy) or
|
||||||
|
// ?cursor=&limit= (agent-friendly). The cursor is an opaque base64 offset —
|
||||||
|
// cheap to implement over the existing offset queries while the public
|
||||||
|
// contract stays cursor-shaped for clients.
|
||||||
|
func pageWindow(c *gin.Context, defaultLimit, maxLimit int) (offset, limit int) {
|
||||||
|
limit = defaultLimit
|
||||||
|
if v := c.Query("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= maxLimit {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cur := c.Query("cursor"); cur != "" {
|
||||||
|
if raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(cur, "o_")); err == nil {
|
||||||
|
if n, err := strconv.Atoi(string(raw)); err == nil && n >= 0 {
|
||||||
|
return n, limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, limit // invalid cursor → first page, fail soft
|
||||||
|
}
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
return (page - 1) * limit, limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// paginationMeta renders the standard envelope: legacy page fields plus
|
||||||
|
// next_cursor when another page exists.
|
||||||
|
func paginationMeta(offset, limit, total int) gin.H {
|
||||||
|
page := offset/limit + 1
|
||||||
|
meta := gin.H{
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"total": total,
|
||||||
|
"pages": (total + limit - 1) / limit,
|
||||||
|
}
|
||||||
|
if offset+limit < total {
|
||||||
|
meta["next_cursor"] = "o_" + base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprint(offset+limit)))
|
||||||
|
}
|
||||||
|
return meta
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ctxWithQuery(q string) *gin.Context {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
c.Request = httptest.NewRequest("GET", "/x?"+q, nil)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPageWindowDefaults(t *testing.T) {
|
||||||
|
offset, limit := pageWindow(ctxWithQuery(""), 10, 100)
|
||||||
|
if offset != 0 || limit != 10 {
|
||||||
|
t.Fatalf("expected 0/10, got %d/%d", offset, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPageWindowLegacyPage(t *testing.T) {
|
||||||
|
offset, limit := pageWindow(ctxWithQuery("page=3&limit=20"), 10, 100)
|
||||||
|
if offset != 40 || limit != 20 {
|
||||||
|
t.Fatalf("expected 40/20, got %d/%d", offset, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPageWindowCursor(t *testing.T) {
|
||||||
|
// cursor for offset 50 produced by paginationMeta
|
||||||
|
meta := paginationMeta(0, 50, 200)
|
||||||
|
cursor := meta["next_cursor"].(string)
|
||||||
|
offset, limit := pageWindow(ctxWithQuery("cursor="+cursor+"&limit=50"), 10, 100)
|
||||||
|
if offset != 50 || limit != 50 {
|
||||||
|
t.Fatalf("expected 50/50, got %d/%d", offset, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPageWindowInvalidCursorFailsSoft(t *testing.T) {
|
||||||
|
offset, _ := pageWindow(ctxWithQuery("cursor=garbage"), 10, 100)
|
||||||
|
if offset != 0 {
|
||||||
|
t.Fatalf("expected offset 0 on invalid cursor, got %d", offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaginationMetaNextCursorOnlyWhenMore(t *testing.T) {
|
||||||
|
if _, ok := paginationMeta(0, 10, 10)["next_cursor"]; ok {
|
||||||
|
t.Fatal("no next page should omit next_cursor")
|
||||||
|
}
|
||||||
|
if _, ok := paginationMeta(0, 10, 25)["next_cursor"]; !ok {
|
||||||
|
t.Fatal("expected next_cursor when more pages exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -55,18 +54,9 @@ func handleGetProjects(c *gin.Context) {
|
|||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
queries := sqlcdb.New(db.DB)
|
queries := sqlcdb.New(db.DB)
|
||||||
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
offset, limit := pageWindow(c, 10, 100)
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
|
||||||
search := strings.TrimSpace(c.DefaultQuery("search", ""))
|
search := strings.TrimSpace(c.DefaultQuery("search", ""))
|
||||||
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if limit > 100 || limit < 1 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
offset := (page - 1) * limit
|
|
||||||
|
|
||||||
searchParam := sql.NullString{}
|
searchParam := sql.NullString{}
|
||||||
if search != "" {
|
if search != "" {
|
||||||
searchParam = sql.NullString{String: search, Valid: true}
|
searchParam = sql.NullString{String: search, Valid: true}
|
||||||
@@ -99,15 +89,9 @@ func handleGetProjects(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
totalCount := int(total)
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"projects": projects,
|
"projects": projects,
|
||||||
"pagination": gin.H{
|
"pagination": paginationMeta(offset, limit, int(total)),
|
||||||
"page": page,
|
|
||||||
"limit": limit,
|
|
||||||
"total": totalCount,
|
|
||||||
"pages": (totalCount + limit - 1) / limit,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/secrets"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Project-level shared variables — referenced from service env values as
|
||||||
|
// ${{shared.KEY}} and resolved at runtime in resolveServiceEnv.
|
||||||
|
|
||||||
|
type ProjectVariable struct {
|
||||||
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
|
||||||
|
Key string `json:"key" db:"key"`
|
||||||
|
Value string `json:"value" db:"value"`
|
||||||
|
IsSecret bool `json:"is_secret" db:"is_secret"`
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetProjectVariables(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.(*database.DB).Query(
|
||||||
|
`SELECT id, project_id, key, value, is_secret, created_at, updated_at
|
||||||
|
FROM project_variables WHERE project_id = $1 ORDER BY key ASC`,
|
||||||
|
projectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
variables := []ProjectVariable{}
|
||||||
|
for rows.Next() {
|
||||||
|
var v ProjectVariable
|
||||||
|
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan variable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.IsSecret {
|
||||||
|
v.Value = maskedSecretValue
|
||||||
|
}
|
||||||
|
variables = append(variables, v)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"variables": variables})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateProjectVariables(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateVariablesRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storedSecrets := map[string]string{}
|
||||||
|
secretRows, err := db.(*database.DB).Query(
|
||||||
|
`SELECT key, value FROM project_variables WHERE project_id = $1 AND is_secret = TRUE`,
|
||||||
|
projectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load existing variables"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for secretRows.Next() {
|
||||||
|
var k, v string
|
||||||
|
if err := secretRows.Scan(&k, &v); err == nil {
|
||||||
|
storedSecrets[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secretRows.Close()
|
||||||
|
|
||||||
|
tx, err := db.(*database.DB).Begin()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err = tx.Exec(`DELETE FROM project_variables WHERE project_id = $1`, projectID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for _, v := range req.Variables {
|
||||||
|
value := v.Value
|
||||||
|
if v.IsSecret && value == maskedSecretValue {
|
||||||
|
if stored, ok := storedSecrets[v.Key]; ok {
|
||||||
|
value = stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v.IsSecret {
|
||||||
|
value = secrets.Encrypt(value)
|
||||||
|
}
|
||||||
|
if _, err = tx.Exec(
|
||||||
|
`INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
uuid.New(), projectID, v.Key, value, v.IsSecret, now, now,
|
||||||
|
); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.(*database.DB).Query(
|
||||||
|
`SELECT id, project_id, key, value, is_secret, created_at, updated_at
|
||||||
|
FROM project_variables WHERE project_id = $1 ORDER BY key ASC`,
|
||||||
|
projectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
variables := []ProjectVariable{}
|
||||||
|
for rows.Next() {
|
||||||
|
var v ProjectVariable
|
||||||
|
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v.IsSecret {
|
||||||
|
v.Value = maskedSecretValue
|
||||||
|
}
|
||||||
|
variables = append(variables, v)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Project variables updated successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSharedVar returns the decrypted value of a project variable.
|
||||||
|
func resolveSharedVar(db *database.DB, projectID uuid.UUID, key string) (string, bool) {
|
||||||
|
var value string
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT value FROM project_variables WHERE project_id = $1 AND key = $2`,
|
||||||
|
projectID, key,
|
||||||
|
).Scan(&value)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return secrets.Decrypt(value), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/secrets"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/registry"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registry is a stored credential for pulling private images. Password is
|
||||||
|
// encrypted at rest and never returned — `has_password` signals presence.
|
||||||
|
type Registry struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"` // e.g. ghcr.io, registry.example.com; docker.io for Docker Hub
|
||||||
|
Username string `json:"username"`
|
||||||
|
HasPassword bool `json:"has_password"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type registryRequest struct {
|
||||||
|
Name string `json:"name" binding:"required,min=1,max=255"`
|
||||||
|
Host string `json:"host" binding:"required,min=1,max=255"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListRegistries(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
rows, err := db.(*database.DB).Query(
|
||||||
|
`SELECT id, name, host, username, password <> '', created_at, updated_at
|
||||||
|
FROM registries WHERE owner_id = $1 ORDER BY created_at DESC`,
|
||||||
|
userID.(string),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list registries"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
registries := []Registry{}
|
||||||
|
for rows.Next() {
|
||||||
|
var r Registry
|
||||||
|
if err := rows.Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt); err == nil {
|
||||||
|
registries = append(registries, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"registries": registries})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateRegistry(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
|
||||||
|
var req registryRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
|
||||||
|
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
|
||||||
|
req.Host = strings.TrimSuffix(req.Host, "/")
|
||||||
|
|
||||||
|
// Encrypt only a real credential — encrypting "" would still satisfy
|
||||||
|
// `password <> ''` and report has_password for anonymous registries.
|
||||||
|
password := ""
|
||||||
|
if req.Password != "" {
|
||||||
|
password = secrets.Encrypt(req.Password)
|
||||||
|
}
|
||||||
|
var r Registry
|
||||||
|
err := db.(*database.DB).QueryRow(
|
||||||
|
`INSERT INTO registries (owner_id, name, host, username, password)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
|
||||||
|
userID.(string), req.Name, req.Host, req.Username, password,
|
||||||
|
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "unique") {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "A registry for this host already exists", "code": "CONFLICT"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create registry"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"registry": r})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateRegistry(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
registryID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req registryRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
|
||||||
|
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
|
||||||
|
req.Host = strings.TrimSuffix(req.Host, "/")
|
||||||
|
|
||||||
|
// Empty password keeps the stored credential — there is no "clear"
|
||||||
|
// operation; delete + recreate to remove a password.
|
||||||
|
var passwordArg interface{}
|
||||||
|
if req.Password != "" {
|
||||||
|
passwordArg = secrets.Encrypt(req.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
var r Registry
|
||||||
|
err = db.(*database.DB).QueryRow(
|
||||||
|
`UPDATE registries SET name = $1, host = $2, username = $3,
|
||||||
|
password = COALESCE($4, password), updated_at = now()
|
||||||
|
WHERE id = $5 AND owner_id = $6
|
||||||
|
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
|
||||||
|
req.Name, req.Host, req.Username, passwordArg, registryID, userID.(string),
|
||||||
|
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"registry": r})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteRegistry(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
registryID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := db.(*database.DB).Exec(
|
||||||
|
`DELETE FROM registries WHERE id = $1 AND owner_id = $2`,
|
||||||
|
registryID, userID.(string),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete registry"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Registry deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// registryAuthFor resolves pull credentials for an image reference: the
|
||||||
|
// caller's registry whose host matches, else anonymous. docker.io covers
|
||||||
|
// bare image names (nginx:latest).
|
||||||
|
func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig {
|
||||||
|
host := registryHost(imageRef)
|
||||||
|
var username, password string
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT username, password FROM registries WHERE owner_id = $1 AND host = $2`,
|
||||||
|
ownerID, host,
|
||||||
|
).Scan(&username, &password)
|
||||||
|
if err != nil {
|
||||||
|
return registry.AuthConfig{}
|
||||||
|
}
|
||||||
|
return registry.AuthConfig{
|
||||||
|
Username: username,
|
||||||
|
Password: secrets.Decrypt(password),
|
||||||
|
ServerAddress: host,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseCPUReserveShares(t *testing.T) {
|
||||||
|
cases := map[string]int64{
|
||||||
|
"": 0,
|
||||||
|
"0": 0,
|
||||||
|
"-1": 0,
|
||||||
|
"abc": 0,
|
||||||
|
"0.25": 256,
|
||||||
|
"1": 1024,
|
||||||
|
"2.5": 2560,
|
||||||
|
" 0.5 ": 512,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := parseCPUReserveShares(in); got != want {
|
||||||
|
t.Errorf("parseCPUReserveShares(%q) = %d, want %d", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateResourceSpecs(t *testing.T) {
|
||||||
|
if err := validateCPUSpec(""); err != nil {
|
||||||
|
t.Error("empty cpu should pass")
|
||||||
|
}
|
||||||
|
if err := validateCPUSpec("0.5"); err != nil {
|
||||||
|
t.Error("0.5 should be valid cpu")
|
||||||
|
}
|
||||||
|
if err := validateCPUSpec("512mb"); err == nil {
|
||||||
|
t.Error("512mb should fail cpu validation")
|
||||||
|
}
|
||||||
|
if err := validateMemorySpec(""); err != nil {
|
||||||
|
t.Error("empty memory should pass")
|
||||||
|
}
|
||||||
|
if err := validateMemorySpec("512Mi"); err != nil {
|
||||||
|
t.Error("512Mi should be valid memory")
|
||||||
|
}
|
||||||
|
if err := validateMemorySpec("1g"); err != nil {
|
||||||
|
t.Error("1g should be valid memory")
|
||||||
|
}
|
||||||
|
if err := validateMemorySpec("0.5"); err == nil {
|
||||||
|
t.Error("0.5 should fail memory validation (parses to <1 byte)")
|
||||||
|
}
|
||||||
|
if err := validateMemorySpec("lots"); err == nil {
|
||||||
|
t.Error("'lots' should fail memory validation")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,17 +4,20 @@ import (
|
|||||||
"context"
|
"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"
|
||||||
)
|
)
|
||||||
@@ -57,6 +60,33 @@ 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
|
||||||
@@ -71,6 +101,45 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// Initialize database handler
|
// Initialize database handler
|
||||||
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
||||||
|
|
||||||
|
// Per-service serialized deploy queue — one active deployment per
|
||||||
|
// service, the rest wait as 'queued'.
|
||||||
|
deployQueue := deployqueue.New()
|
||||||
|
|
||||||
|
// Encrypt-at-rest for secret env vars. SECRETS_KEY wins, JWT_SECRET is
|
||||||
|
// the fallback so existing installs need no new config.
|
||||||
|
secrets.Configure(os.Getenv("SECRETS_KEY"), cfg.JWTSecret)
|
||||||
|
|
||||||
|
// Boot sweep: in-memory engine state dies on restart, so anything that
|
||||||
|
// looked in-flight is actually orphaned — mark it failed rather than
|
||||||
|
// leaving phantom 'building'/'queued' rows.
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back')`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Boot sweep: encrypt any plaintext secret values left over from before
|
||||||
|
// encryption-at-rest landed. Transparent and idempotent.
|
||||||
|
if encRows, err := db.Query(`SELECT id, value FROM environment_variables WHERE is_secret = TRUE`); err == nil {
|
||||||
|
type encRow struct {
|
||||||
|
id string
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
var toEncrypt []encRow
|
||||||
|
for encRows.Next() {
|
||||||
|
var r encRow
|
||||||
|
if err := encRows.Scan(&r.id, &r.value); err == nil && !secrets.IsEncrypted(r.value) {
|
||||||
|
toEncrypt = append(toEncrypt, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encRows.Close()
|
||||||
|
for _, r := range toEncrypt {
|
||||||
|
_, _ = db.Exec(`UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2`, secrets.Encrypt(r.value), r.id)
|
||||||
|
}
|
||||||
|
if len(toEncrypt) > 0 {
|
||||||
|
log.Printf("secrets: encrypted %d plaintext secret variable(s)", len(toEncrypt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize security handler
|
// Initialize security handler
|
||||||
securityHandler := NewSecurityHandler(db, cfg.JWTSecret)
|
securityHandler := NewSecurityHandler(db, cfg.JWTSecret)
|
||||||
|
|
||||||
@@ -88,6 +157,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
if deploymentEngine != nil {
|
if deploymentEngine != nil {
|
||||||
c.Set("deployment_engine", deploymentEngine)
|
c.Set("deployment_engine", deploymentEngine)
|
||||||
}
|
}
|
||||||
|
c.Set("deploy_queue", deployQueue)
|
||||||
c.Set("scheduler", scheduler)
|
c.Set("scheduler", scheduler)
|
||||||
c.Set("metrics_collector", metricsCollector)
|
c.Set("metrics_collector", metricsCollector)
|
||||||
c.Set("auto_scaler", autoScaler)
|
c.Set("auto_scaler", autoScaler)
|
||||||
@@ -178,6 +248,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
public.GET("/auth/bootstrap", handleAuthBootstrap)
|
public.GET("/auth/bootstrap", handleAuthBootstrap)
|
||||||
public.POST("/auth/login", handleLogin)
|
public.POST("/auth/login", handleLogin)
|
||||||
public.POST("/auth/register", handleRegister)
|
public.POST("/auth/register", handleRegister)
|
||||||
|
public.GET("/maintenance", handleMaintenancePage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read routes — public browsing. OptionalAuth resolves a session when
|
// Read routes — public browsing. OptionalAuth resolves a session when
|
||||||
@@ -198,6 +269,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
read.GET("/services/:id/metrics", handleGetServiceMetrics)
|
read.GET("/services/:id/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)
|
||||||
|
|
||||||
@@ -229,10 +302,16 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// enforce ownership; platform admins additionally pass every check.
|
// enforce ownership; platform admins additionally pass every check.
|
||||||
authed := v1.Group("/")
|
authed := v1.Group("/")
|
||||||
authed.Use(middleware.Auth(cfg.JWTSecret))
|
authed.Use(middleware.Auth(cfg.JWTSecret))
|
||||||
|
authed.Use(middleware.Idempotency())
|
||||||
{
|
{
|
||||||
authed.GET("/user/profile", handleGetProfile)
|
authed.GET("/user/profile", handleGetProfile)
|
||||||
authed.PUT("/user/profile", handleUpdateProfile)
|
authed.PUT("/user/profile", handleUpdateProfile)
|
||||||
|
|
||||||
|
// Personal access tokens for CLI/MCP/agent auth.
|
||||||
|
authed.GET("/user/tokens", handleListUserTokens)
|
||||||
|
authed.POST("/user/tokens", handleCreateUserToken)
|
||||||
|
authed.DELETE("/user/tokens/:id", handleDeleteUserToken)
|
||||||
|
|
||||||
authed.GET("/notifications", handleListNotifications)
|
authed.GET("/notifications", handleListNotifications)
|
||||||
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
|
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
|
||||||
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
|
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
|
||||||
@@ -250,17 +329,27 @@ 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)
|
||||||
@@ -269,6 +358,12 @@ 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)
|
||||||
@@ -292,6 +387,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
authed.POST("/builds/plan", buildHandler.GetBuildPlan)
|
authed.POST("/builds/plan", buildHandler.GetBuildPlan)
|
||||||
|
|
||||||
authed.POST("/databases", databaseHandler.CreateDatabase)
|
authed.POST("/databases", databaseHandler.CreateDatabase)
|
||||||
|
authed.POST("/databases/register-external", databaseHandler.RegisterExternalDatabase)
|
||||||
|
authed.POST("/databases/test-connection", databaseHandler.TestDatabaseConnection)
|
||||||
|
authed.POST("/databases/:id/test-connection", databaseHandler.TestDatabaseConnectionByID)
|
||||||
authed.PUT("/databases/:id", databaseHandler.UpdateDatabase)
|
authed.PUT("/databases/:id", databaseHandler.UpdateDatabase)
|
||||||
authed.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
|
authed.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
|
||||||
authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
|
authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
|
||||||
@@ -325,6 +423,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// Admin routes — platform-wide controls and cross-user management.
|
// Admin routes — platform-wide controls and cross-user management.
|
||||||
admin := v1.Group("/")
|
admin := v1.Group("/")
|
||||||
admin.Use(middleware.Auth(cfg.JWTSecret))
|
admin.Use(middleware.Auth(cfg.JWTSecret))
|
||||||
|
admin.Use(middleware.Idempotency())
|
||||||
admin.Use(middleware.RequireAdmin())
|
admin.Use(middleware.RequireAdmin())
|
||||||
{
|
{
|
||||||
admin.GET("/admin/overview", handleAdminOverview)
|
admin.GET("/admin/overview", handleAdminOverview)
|
||||||
@@ -345,6 +444,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
scalingHandler.RegisterAdminRoutes(admin)
|
scalingHandler.RegisterAdminRoutes(admin)
|
||||||
agentHandler.SetupAdminRoutes(admin)
|
agentHandler.SetupAdminRoutes(admin)
|
||||||
|
|
||||||
|
admin.GET("/admin/volumes", handleAdminListVolumes)
|
||||||
|
admin.DELETE("/admin/volumes/:name", handleAdminDeleteVolume)
|
||||||
admin.GET("/audit-logs", handleGetAuditLogs)
|
admin.GET("/audit-logs", handleGetAuditLogs)
|
||||||
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
|
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/deployqueue"
|
||||||
|
"containr/internal/secrets"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -51,6 +53,19 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -78,7 +93,7 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
|
|||||||
if err := rows.Scan(&k, &v); err != nil {
|
if err := rows.Scan(&k, &v); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
env[k] = v
|
env[k] = secrets.Decrypt(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
var refServices []struct{ ID, Name string }
|
var refServices []struct{ ID, Name string }
|
||||||
@@ -104,6 +119,9 @@ 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
|
||||||
@@ -126,7 +144,7 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return value, true
|
return secrets.Decrypt(value), true
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range env {
|
for k, v := range env {
|
||||||
@@ -152,6 +170,32 @@ 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 {
|
||||||
@@ -207,6 +251,9 @@ 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`,
|
||||||
@@ -216,7 +263,9 @@ 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.CreatedAt, &service.UpdatedAt, &owner,
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
|
&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"})
|
||||||
@@ -250,6 +299,9 @@ 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`,
|
||||||
@@ -259,7 +311,9 @@ 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.CreatedAt, &service.UpdatedAt, &projectID,
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
|
&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"})
|
||||||
@@ -381,6 +435,7 @@ func handleServiceRestart(c *gin.Context) {
|
|||||||
|
|
||||||
// handleServiceRedeploy re-applies the current spec: pulls the image for
|
// handleServiceRedeploy re-applies the current spec: pulls the image for
|
||||||
// image-sourced services and reconciles containers (env, ports, replicas).
|
// image-sourced services and reconciles containers (env, ports, replicas).
|
||||||
|
// Runs through the per-service deploy queue so it never overlaps a build.
|
||||||
func handleServiceRedeploy(c *gin.Context) {
|
func handleServiceRedeploy(c *gin.Context) {
|
||||||
engine, db, ok := getRuntimeEngine(c)
|
engine, db, ok := getRuntimeEngine(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -406,7 +461,78 @@ func handleServiceRedeploy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
service.Image = image
|
service.Image = image
|
||||||
reconcileNow(c, engine, db, service)
|
|
||||||
|
finished := make(chan struct{})
|
||||||
|
var recErr error
|
||||||
|
var state *deployment.RuntimeState
|
||||||
|
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{Run: func(jctx context.Context) {
|
||||||
|
defer close(finished)
|
||||||
|
state, recErr = reconcileServiceJob(jctx, db, engine, service)
|
||||||
|
}})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-finished:
|
||||||
|
if recErr != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": recErr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"runtime": state})
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"status": "queued"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileServiceJob is the shared queue-job body for redeploy paths:
|
||||||
|
// build the spec from current DB state, reconcile containers, persist the
|
||||||
|
// resulting status and published port.
|
||||||
|
func reconcileServiceJob(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, service Service) (*deployment.RuntimeState, error) {
|
||||||
|
spec, err := serviceRuntimeSpec(db, service)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
state, err := engine.ReconcileService(ctx, spec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if state != nil {
|
||||||
|
persistPublishedPort(db, service.ID, state.Ports)
|
||||||
|
status := "stopped"
|
||||||
|
if state.Status != "" {
|
||||||
|
status = state.Status
|
||||||
|
}
|
||||||
|
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueueServiceRedeploy resolves a deployable image (service.Image, else
|
||||||
|
// the last successful deployment's tag) and queues a reconcile. Returns
|
||||||
|
// false when no image is available or the engine is missing.
|
||||||
|
func enqueueServiceRedeploy(c *gin.Context, db *database.DB, service Service) bool {
|
||||||
|
engineValue, exists := c.Get("deployment_engine")
|
||||||
|
if !exists || engineValue == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
engine, ok := engineValue.(*deployment.DeploymentEngine)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
image := service.Image
|
||||||
|
if image == "" {
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT image_name || ':' || image_tag FROM deployments
|
||||||
|
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
|
||||||
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
service.ID,
|
||||||
|
).Scan(&image); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service.Image = image
|
||||||
|
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{Run: func(jctx context.Context) {
|
||||||
|
_, _ = reconcileServiceJob(jctx, db, engine, service)
|
||||||
|
}})
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// persistPublishedPort records the host port Docker actually bound so later
|
// persistPublishedPort records the host port Docker actually bound so later
|
||||||
@@ -469,6 +595,9 @@ 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,
|
||||||
@@ -477,7 +606,9 @@ 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.CreatedAt, &service.UpdatedAt, &owner,
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
|
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||||
|
&service.CreatedAt, &service.UpdatedAt, &owner,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CloneServiceRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"` // empty = clone within the same project
|
||||||
|
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCloneService duplicates a service's full configuration — image/git
|
||||||
|
// source, volumes, domains, access gates, builder settings, and variables —
|
||||||
|
// into the same or another project. Containers are not cloned; the copy
|
||||||
|
// starts stopped until its first deploy.
|
||||||
|
func handleCloneService(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
|
||||||
|
var req CloneServiceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
source, ok := loadOwnedService(c, db.(*database.DB))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetProjectID := source.ProjectID
|
||||||
|
if req.ProjectID != uuid.Nil {
|
||||||
|
targetProjectID = req.ProjectID
|
||||||
|
}
|
||||||
|
if !ownsProject(db.(*database.DB), targetProjectID, userID.(string), contextIsAdmin(c)) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = source.Name + "-copy"
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
_ = db.(*database.DB).QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
|
||||||
|
targetProjectID, name,
|
||||||
|
).Scan(&count)
|
||||||
|
if count > 0 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in target project", "code": "CONFLICT"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
environment := source.Environment
|
||||||
|
if req.Environment != "" {
|
||||||
|
environment = req.Environment
|
||||||
|
}
|
||||||
|
if environment == "" {
|
||||||
|
environment = "production"
|
||||||
|
}
|
||||||
|
environmentID, err := getProjectEnvironmentID(db.(*database.DB), targetProjectID, environment)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maintenance, basicAuth := serviceAccess(db.(*database.DB), source.ID)
|
||||||
|
newID := uuid.New()
|
||||||
|
now := time.Now()
|
||||||
|
// string() so pq sends text — []byte would go as bytea and fail ::jsonb.
|
||||||
|
volumesJSON := string(loadServiceVolumesJSON(db.(*database.DB), source.ID))
|
||||||
|
|
||||||
|
_, err = db.(*database.DB).Exec(
|
||||||
|
`INSERT INTO services
|
||||||
|
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||||
|
build_command, start_command, type, status, image, command, environment,
|
||||||
|
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||||
|
healthcheck_path, restart_policy, volumes, maintenance_mode, basic_auth_users,
|
||||||
|
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
||||||
|
created_at, updated_at)
|
||||||
|
SELECT $1, $2, $3, $4, service_type, source_type, source_url, image_name,
|
||||||
|
build_command, start_command, type, 'stopped', image, command, $5,
|
||||||
|
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||||
|
healthcheck_path, restart_policy, $6::jsonb, $7, $8,
|
||||||
|
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
||||||
|
$9, $9
|
||||||
|
FROM services WHERE id = $10`,
|
||||||
|
newID, targetProjectID, name, environmentID, environment, volumesJSON,
|
||||||
|
maintenance, basicAuth, now, source.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.(*database.DB).Exec(
|
||||||
|
`INSERT INTO service_domains (service_id, domain, is_default, cert_type)
|
||||||
|
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
|
||||||
|
newID, source.ID,
|
||||||
|
); err != nil {
|
||||||
|
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service domains"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Variable values are copied as stored — ciphertext stays ciphertext.
|
||||||
|
if _, err := db.(*database.DB).Exec(
|
||||||
|
`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
|
||||||
|
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
|
||||||
|
newID, source.ID,
|
||||||
|
); err != nil {
|
||||||
|
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service variables"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type MoveServiceRequest struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMoveService re-parents a service to another project owned by the
|
||||||
|
// caller. Variables, domains, and volumes follow via the service id;
|
||||||
|
// running containers keep running (they join the new project's network on
|
||||||
|
// the next deploy).
|
||||||
|
func handleMoveService(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
serviceID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req MoveServiceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
service, ok := loadOwnedService(c, db.(*database.DB))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ProjectID == service.ProjectID {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Service is already in that project", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ownsProject(db.(*database.DB), req.ProjectID, userID.(string), contextIsAdmin(c)) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
_ = db.(*database.DB).QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
|
||||||
|
req.ProjectID, service.Name,
|
||||||
|
).Scan(&count)
|
||||||
|
if count > 0 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "A service with this name already exists in the target project", "code": "CONFLICT"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
envName := service.Environment
|
||||||
|
if envName == "" {
|
||||||
|
envName = "production"
|
||||||
|
}
|
||||||
|
environmentID, err := getProjectEnvironmentID(db.(*database.DB), req.ProjectID, envName)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.(*database.DB).Exec(
|
||||||
|
`UPDATE services SET project_id = $1, environment_id = $2, updated_at = $3 WHERE id = $4`,
|
||||||
|
req.ProjectID, environmentID, time.Now(), serviceID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to move service"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogAuditWithRequest(c, "service", serviceID.String(), "service.move", map[string]interface{}{"to_project": req.ProjectID.String()})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Service moved", "project_id": req.ProjectID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownsProject is true when the user owns the project or is an admin.
|
||||||
|
func ownsProject(db *database.DB, projectID uuid.UUID, userID string, isAdmin bool) bool {
|
||||||
|
if isAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var owner string
|
||||||
|
err := db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, projectID).Scan(&owner)
|
||||||
|
return err == nil && owner == userID
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadServiceVolumesJSON returns the raw stored mounts for a verbatim copy.
|
||||||
|
func loadServiceVolumesJSON(db *database.DB, serviceID uuid.UUID) []byte {
|
||||||
|
var raw []byte
|
||||||
|
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil || len(raw) == 0 {
|
||||||
|
return []byte("[]")
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
@@ -3,8 +3,12 @@ package api
|
|||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/deployqueue"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -33,10 +37,70 @@ type Service struct {
|
|||||||
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
|
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
|
||||||
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
|
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
|
||||||
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
|
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
|
||||||
|
Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB
|
||||||
|
Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
|
||||||
|
MaintenanceMode bool `json:"maintenance_mode" db:"-"`
|
||||||
|
BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes
|
||||||
|
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"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServiceVolume is a volume/bind mount applied to every replica.
|
||||||
|
type ServiceVolume struct {
|
||||||
|
Type string `json:"type"` // volume | bind (empty = volume)
|
||||||
|
Source string `json:"source"` // volume name or host path
|
||||||
|
Target string `json:"target"` // container path (absolute)
|
||||||
|
ReadOnly bool `json:"read_only"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades
|
||||||
|
// to no mounts rather than failing the request.
|
||||||
|
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
|
||||||
|
var raw []byte
|
||||||
|
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var stored []ServiceVolume
|
||||||
|
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]deployment.VolumeMount, 0, len(stored))
|
||||||
|
for _, v := range stored {
|
||||||
|
out = append(out, deployment.VolumeMount{
|
||||||
|
Type: v.Type,
|
||||||
|
Source: v.Source,
|
||||||
|
Destination: v.Target,
|
||||||
|
ReadOnly: v.ReadOnly,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateServiceVolumes enforces the mount contract — bad paths fail
|
||||||
|
// fast at write time instead of surfacing as docker create errors later.
|
||||||
|
func validateServiceVolumes(vols []ServiceVolume) error {
|
||||||
|
for i, v := range vols {
|
||||||
|
if v.Type == "" {
|
||||||
|
v.Type = "volume"
|
||||||
|
}
|
||||||
|
if v.Type != "volume" && v.Type != "bind" {
|
||||||
|
return fmt.Errorf("volumes[%d].type must be volume or bind", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(v.Source) == "" {
|
||||||
|
return fmt.Errorf("volumes[%d].source is required", i)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(v.Target, "/") {
|
||||||
|
return fmt.Errorf("volumes[%d].target must be an absolute container path", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateServiceRequest represents a request to create a service
|
// CreateServiceRequest represents a request to create a service
|
||||||
type CreateServiceRequest struct {
|
type CreateServiceRequest struct {
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
@@ -55,6 +119,14 @@ type CreateServiceRequest struct {
|
|||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
HealthCheckPath string `json:"healthcheck_path"`
|
HealthCheckPath string `json:"healthcheck_path"`
|
||||||
RestartPolicy string `json:"restart_policy"`
|
RestartPolicy string `json:"restart_policy"`
|
||||||
|
Volumes []ServiceVolume `json:"volumes"`
|
||||||
|
MaintenanceMode bool `json:"maintenance_mode"`
|
||||||
|
BasicAuth []BasicAuthCred `json:"basic_auth"`
|
||||||
|
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
|
||||||
@@ -74,6 +146,14 @@ type UpdateServiceRequest struct {
|
|||||||
Domain *string `json:"domain"`
|
Domain *string `json:"domain"`
|
||||||
HealthCheckPath *string `json:"healthcheck_path"`
|
HealthCheckPath *string `json:"healthcheck_path"`
|
||||||
RestartPolicy string `json:"restart_policy"`
|
RestartPolicy string `json:"restart_policy"`
|
||||||
|
Volumes *[]ServiceVolume `json:"volumes"`
|
||||||
|
MaintenanceMode *bool `json:"maintenance_mode"`
|
||||||
|
BasicAuth *[]BasicAuthCred `json:"basic_auth"`
|
||||||
|
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
|
||||||
@@ -118,6 +198,9 @@ 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
|
||||||
@@ -138,7 +221,9 @@ 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.CreatedAt, &service.UpdatedAt,
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
|
&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"})
|
||||||
@@ -249,12 +334,38 @@ func handleCreateService(c *gin.Context) {
|
|||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default values if not provided
|
// Set default values if not provided; app_settings.default_cpu /
|
||||||
|
// default_memory override the hardcoded fallbacks.
|
||||||
if service.CPU == "" {
|
if service.CPU == "" {
|
||||||
service.CPU = "0.5"
|
service.CPU = firstNonEmpty(settingValue(db.(*database.DB), "default_cpu", "", ""), "0.5")
|
||||||
}
|
}
|
||||||
if service.Memory == "" {
|
if service.Memory == "" {
|
||||||
service.Memory = "512Mi"
|
service.Memory = firstNonEmpty(settingValue(db.(*database.DB), "default_memory", "", ""), "512Mi")
|
||||||
|
}
|
||||||
|
if req.Builder == "" {
|
||||||
|
service.Builder = "auto"
|
||||||
|
} else {
|
||||||
|
service.Builder = req.Builder
|
||||||
|
}
|
||||||
|
service.CPUReserve = req.CPUReserve
|
||||||
|
service.MemoryReserve = req.MemoryReserve
|
||||||
|
service.StaticBuildCmd = req.StaticBuildCmd
|
||||||
|
service.StaticDir = req.StaticDir
|
||||||
|
if service.Builder == "static" && service.StaticDir == "" {
|
||||||
|
service.StaticDir = "dist"
|
||||||
|
}
|
||||||
|
for label, check := range map[string]func(string) error{
|
||||||
|
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
||||||
|
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
||||||
|
} {
|
||||||
|
v := map[string]string{
|
||||||
|
"cpu": service.CPU, "cpu_reserve": service.CPUReserve,
|
||||||
|
"memory": service.Memory, "memory_reserve": service.MemoryReserve,
|
||||||
|
}[label]
|
||||||
|
if err := check(v); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if service.Replicas < 1 {
|
if service.Replicas < 1 {
|
||||||
service.Replicas = 1
|
service.Replicas = 1
|
||||||
@@ -262,6 +373,15 @@ func handleCreateService(c *gin.Context) {
|
|||||||
if service.RestartPolicy == "" {
|
if service.RestartPolicy == "" {
|
||||||
service.RestartPolicy = "unless-stopped"
|
service.RestartPolicy = "unless-stopped"
|
||||||
}
|
}
|
||||||
|
if err := validateServiceVolumes(req.Volumes); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service.Volumes = req.Volumes
|
||||||
|
volumesJSON, _ := json.Marshal(req.Volumes)
|
||||||
|
if len(req.Volumes) == 0 {
|
||||||
|
volumesJSON = []byte("[]")
|
||||||
|
}
|
||||||
|
|
||||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -277,14 +397,18 @@ func handleCreateService(c *gin.Context) {
|
|||||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||||
build_command, start_command, type, status, image, command, environment,
|
build_command, start_command, type, status, image, command, environment,
|
||||||
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||||
healthcheck_path, restart_policy, created_at, updated_at)
|
healthcheck_path, restart_policy, volumes,
|
||||||
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)`,
|
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
|
||||||
|
$27, $28, $29, $30, $31, $32, $33)`,
|
||||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||||
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
||||||
service.CreatedAt, service.UpdatedAt,
|
volumesJSON, service.Builder, service.CPUReserve, service.MemoryReserve,
|
||||||
|
service.StaticBuildCmd, service.StaticDir, service.CreatedAt, service.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -292,6 +416,26 @@ func handleCreateService(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Domains live in service_domains; services.domain is the derived default.
|
||||||
|
if req.Domain != "" && validHostname(strings.ToLower(req.Domain)) {
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true)
|
||||||
|
ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`,
|
||||||
|
service.ID, strings.ToLower(req.Domain))
|
||||||
|
}
|
||||||
|
if req.MaintenanceMode || len(req.BasicAuth) > 0 {
|
||||||
|
basicAuthUsers := ""
|
||||||
|
if encoded, encErr := encodeBasicAuth(req.BasicAuth); encErr == nil {
|
||||||
|
basicAuthUsers = encoded
|
||||||
|
}
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`,
|
||||||
|
req.MaintenanceMode, basicAuthUsers, service.ID)
|
||||||
|
service.MaintenanceMode = req.MaintenanceMode
|
||||||
|
service.BasicAuth = basicAuthUsernames(basicAuthUsers)
|
||||||
|
}
|
||||||
|
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"service": service})
|
c.JSON(http.StatusCreated, gin.H{"service": service})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +493,9 @@ 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
|
||||||
@@ -360,7 +507,9 @@ 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.CreatedAt, &service.UpdatedAt,
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
|
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||||
|
&service.CreatedAt, &service.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -369,6 +518,15 @@ func handleGetService(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
liveServiceStatus(c, db.(*database.DB), &service)
|
liveServiceStatus(c, db.(*database.DB), &service)
|
||||||
|
for _, v := range loadServiceVolumes(db.(*database.DB), service.ID) {
|
||||||
|
service.Volumes = append(service.Volumes, ServiceVolume{
|
||||||
|
Type: v.Type, Source: v.Source, Target: v.Destination, ReadOnly: v.ReadOnly,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
|
||||||
|
maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
|
||||||
|
service.MaintenanceMode = maintenance
|
||||||
|
service.BasicAuth = basicAuthUsernames(basicAuth)
|
||||||
c.JSON(http.StatusOK, gin.H{"service": service})
|
c.JSON(http.StatusOK, gin.H{"service": service})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +575,9 @@ 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
|
||||||
@@ -429,6 +590,9 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -483,6 +647,49 @@ 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()
|
||||||
|
|
||||||
@@ -492,14 +699,18 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
|
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
|
||||||
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
|
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
|
||||||
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
|
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
|
||||||
restart_policy = $15, updated_at = $16
|
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
|
||||||
WHERE id = $17`,
|
builder = $19, cpu_reserve = $20, memory_reserve = $21,
|
||||||
|
static_build_cmd = $22, static_dir = $23
|
||||||
|
WHERE id = $18`,
|
||||||
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
||||||
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
||||||
existingService.BuildPath, existingService.CPU, existingService.Memory,
|
existingService.BuildPath, existingService.CPU, existingService.Memory,
|
||||||
existingService.Replicas, existingService.Port, existingService.Domain,
|
existingService.Replicas, existingService.Port, existingService.Domain,
|
||||||
existingService.HealthCheckPath, existingService.RestartPolicy,
|
existingService.HealthCheckPath, existingService.RestartPolicy,
|
||||||
existingService.UpdatedAt, existingService.ID,
|
existingService.UpdatedAt, volumesArg, existingService.ID,
|
||||||
|
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
|
||||||
|
existingService.StaticBuildCmd, existingService.StaticDir,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -507,23 +718,65 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Domain writes route through service_domains — services.domain is the
|
||||||
|
// derived default and gets rewritten by syncDefaultDomain.
|
||||||
|
if req.Domain != nil {
|
||||||
|
domain := strings.ToLower(strings.TrimSpace(*req.Domain))
|
||||||
|
if domain == "" {
|
||||||
|
_, _ = db.(*database.DB).Exec(`DELETE FROM service_domains WHERE service_id = $1`, serviceID)
|
||||||
|
} else if validHostname(domain) {
|
||||||
|
_, _ = db.(*database.DB).Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID)
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true)
|
||||||
|
ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`,
|
||||||
|
serviceID, domain)
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid hostname", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncDefaultDomain(db.(*database.DB), serviceID)
|
||||||
|
_ = db.(*database.DB).QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&existingService.Domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.MaintenanceMode != nil || req.BasicAuth != nil {
|
||||||
|
maintenance, basicAuth := serviceAccess(db.(*database.DB), serviceID)
|
||||||
|
if req.MaintenanceMode != nil {
|
||||||
|
maintenance = *req.MaintenanceMode
|
||||||
|
}
|
||||||
|
if req.BasicAuth != nil {
|
||||||
|
encoded, encErr := encodeBasicAuth(*req.BasicAuth)
|
||||||
|
if encErr != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encode basic auth", "code": "INTERNAL"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
basicAuth = encoded
|
||||||
|
}
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`,
|
||||||
|
maintenance, basicAuth, serviceID)
|
||||||
|
existingService.MaintenanceMode = maintenance
|
||||||
|
}
|
||||||
|
existingService.Domains = loadServiceDomains(db.(*database.DB), serviceID)
|
||||||
|
_, storedAuth := serviceAccess(db.(*database.DB), serviceID)
|
||||||
|
existingService.BasicAuth = basicAuthUsernames(storedAuth)
|
||||||
|
|
||||||
// A replica change applies immediately when the service has live
|
// A replica change applies immediately when the service has live
|
||||||
// containers; other spec fields take effect on the next deploy.
|
// containers; other spec fields take effect on the next deploy.
|
||||||
|
// Runs through the deploy queue so it can't overlap a build.
|
||||||
if req.Replicas != nil && existingService.Status == "running" {
|
if req.Replicas != nil && existingService.Status == "running" {
|
||||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||||
engine := engineValue.(*deployment.DeploymentEngine)
|
engine := engineValue.(*deployment.DeploymentEngine)
|
||||||
serviceCopy := existingService
|
serviceCopy := existingService
|
||||||
go func() {
|
dbCopy := db.(*database.DB)
|
||||||
spec, err := serviceRuntimeSpec(db.(*database.DB), serviceCopy)
|
getDeployQueue(c).Enqueue(serviceCopy.ID, deployqueue.Job{Run: func(jctx context.Context) {
|
||||||
|
spec, err := serviceRuntimeSpec(dbCopy, serviceCopy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
if state, err := engine.ReconcileService(jctx, spec); err == nil && state != nil {
|
||||||
defer cancel()
|
persistPublishedPort(dbCopy, serviceCopy.ID, state.Ports)
|
||||||
if state, err := engine.ReconcileService(ctx, spec); err == nil && state != nil {
|
|
||||||
persistPublishedPort(db.(*database.DB), serviceCopy.ID, state.Ports)
|
|
||||||
}
|
}
|
||||||
}()
|
}})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
sqlcdb "containr/internal/database/sqlcdb"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Personal access tokens let the CLI, MCP server, and third-party agents
|
||||||
|
// authenticate without a browser session. The raw token (cnp_…) is
|
||||||
|
// returned exactly once at creation; only its sha256 hash persists.
|
||||||
|
|
||||||
|
var userTokenScopes = map[string]bool{"read": true, "write": true, "admin": true}
|
||||||
|
|
||||||
|
func generateUserToken() (token string, prefix string, hash string, err error) {
|
||||||
|
buf := make([]byte, 24)
|
||||||
|
if _, err = rand.Read(buf); err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
token = "cnp_" + hex.EncodeToString(buf)
|
||||||
|
prefix = token[:12] // cnp_ + 8 hex chars, enough to identify in a list
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return token, prefix, hex.EncodeToString(sum[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userTokenJSON(t sqlcdb.UserToken) gin.H {
|
||||||
|
row := gin.H{
|
||||||
|
"id": t.ID.String(),
|
||||||
|
"name": t.Name,
|
||||||
|
"key_prefix": t.KeyPrefix,
|
||||||
|
"scope": t.Scope,
|
||||||
|
}
|
||||||
|
if t.CreatedAt.Valid {
|
||||||
|
row["created_at"] = t.CreatedAt.Time
|
||||||
|
}
|
||||||
|
if t.LastUsedAt.Valid {
|
||||||
|
row["last_used_at"] = t.LastUsedAt.Time
|
||||||
|
}
|
||||||
|
if t.ExpiresAt.Valid {
|
||||||
|
row["expires_at"] = t.ExpiresAt.Time
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateUserToken(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
queries := sqlcdb.New(db.DB)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
ExpiresInDays int `json:"expires_in_days"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Scope == "" {
|
||||||
|
req.Scope = "write"
|
||||||
|
}
|
||||||
|
if !userTokenScopes[req.Scope] {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be one of: read, write, admin", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A token can never outrank its owner: non-admin users cannot mint
|
||||||
|
// admin-scope tokens even for themselves.
|
||||||
|
if req.Scope == "admin" && !c.GetBool("is_admin") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "admin scope requires a platform admin account", "code": "FORBIDDEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ExpiresInDays < 0 || req.ExpiresInDays > 3650 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "expires_in_days must be between 0 (never) and 3650", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, prefix, hash, err := generateUserToken()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token", "code": "INTERNAL"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := sql.NullTime{}
|
||||||
|
if req.ExpiresInDays > 0 {
|
||||||
|
expiresAt = sql.NullTime{Time: time.Now().Add(time.Duration(req.ExpiresInDays) * 24 * time.Hour), Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := queries.CreateUserToken(c.Request.Context(), sqlcdb.CreateUserTokenParams{
|
||||||
|
UserID: ownerUUID,
|
||||||
|
Name: req.Name,
|
||||||
|
KeyPrefix: prefix,
|
||||||
|
TokenHash: hash,
|
||||||
|
Scope: req.Scope,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token", "code": "INTERNAL"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogAudit(userID, "user_token", row.ID.String(), "create", map[string]interface{}{"name": req.Name, "scope": req.Scope})
|
||||||
|
|
||||||
|
payload := userTokenJSON(row)
|
||||||
|
payload["token"] = token
|
||||||
|
c.JSON(http.StatusCreated, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListUserTokens(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
queries := sqlcdb.New(db.DB)
|
||||||
|
|
||||||
|
ownerUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := queries.ListUserTokens(c.Request.Context(), ownerUUID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens", "code": "INTERNAL"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]gin.H, 0, len(rows))
|
||||||
|
for _, t := range rows {
|
||||||
|
out = append(out, userTokenJSON(t))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"tokens": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteUserToken(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
queries := sqlcdb.New(db.DB)
|
||||||
|
|
||||||
|
ownerUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenUUID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := queries.RevokeUserToken(c.Request.Context(), sqlcdb.RevokeUserTokenParams{
|
||||||
|
ID: tokenUUID,
|
||||||
|
UserID: ownerUUID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token", "code": "INTERNAL"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogAudit(userID, "user_token", tokenUUID.String(), "revoke", nil)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Token revoked"})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
|
"containr/internal/secrets"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ type EnvironmentVariable struct {
|
|||||||
|
|
||||||
type UpdateVariablesRequest struct {
|
type UpdateVariablesRequest struct {
|
||||||
Variables []VariableInput `json:"variables" binding:"required"`
|
Variables []VariableInput `json:"variables" binding:"required"`
|
||||||
|
Redeploy bool `json:"redeploy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VariableInput struct {
|
type VariableInput struct {
|
||||||
@@ -159,6 +161,9 @@ func handleUpdateVariables(c *gin.Context) {
|
|||||||
value = stored
|
value = stored
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if v.IsSecret {
|
||||||
|
value = secrets.Encrypt(value)
|
||||||
|
}
|
||||||
varID := uuid.New()
|
varID := uuid.New()
|
||||||
_, err = tx.Exec(
|
_, err = tx.Exec(
|
||||||
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
||||||
@@ -204,5 +209,24 @@ func handleUpdateVariables(c *gin.Context) {
|
|||||||
variables = append(variables, v)
|
variables = append(variables, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Environment variables updated successfully"})
|
resp := gin.H{"variables": variables, "message": "Environment variables updated successfully"}
|
||||||
|
if req.Redeploy {
|
||||||
|
// Load the service spec inputs and queue a reconcile behind any
|
||||||
|
// in-flight deployment — new env lands in the recreated containers.
|
||||||
|
var svc Service
|
||||||
|
svcErr := db.(*database.DB).QueryRow(
|
||||||
|
`SELECT id, project_id, name, COALESCE(image, ''), COALESCE(command, ''),
|
||||||
|
COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(domain, ''),
|
||||||
|
COALESCE(healthcheck_path, ''), COALESCE(restart_policy, 'unless-stopped'),
|
||||||
|
COALESCE(cpu, ''), COALESCE(memory, ''),
|
||||||
|
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''), COALESCE(memory_reserve, '')
|
||||||
|
FROM services WHERE id = $1`,
|
||||||
|
serviceID,
|
||||||
|
).Scan(&svc.ID, &svc.ProjectID, &svc.Name, &svc.Image, &svc.Command,
|
||||||
|
&svc.Replicas, &svc.Port, &svc.Domain, &svc.HealthCheckPath,
|
||||||
|
&svc.RestartPolicy, &svc.CPU, &svc.Memory,
|
||||||
|
&svc.Builder, &svc.CPUReserve, &svc.MemoryReserve)
|
||||||
|
resp["redeploy_queued"] = svcErr == nil && enqueueServiceRedeploy(c, db.(*database.DB), svc)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/docker"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VolumeInfo is one docker volume plus whether any container mounts it.
|
||||||
|
type VolumeInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Driver string `json:"driver"`
|
||||||
|
Mountpoint string `json:"mountpoint"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
InUse bool `json:"in_use"`
|
||||||
|
UsedBy []string `json:"used_by,omitempty"`
|
||||||
|
Labels map[string]string `json:"labels,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerClientOr500(c *gin.Context) (*docker.Client, bool) {
|
||||||
|
v, exists := c.Get("docker_client")
|
||||||
|
if !exists || v == nil {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "Docker client not available")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return v.(*docker.Client), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminListVolumes returns the node's docker volume inventory with
|
||||||
|
// in-use flags. Admin-only: mountpoints reveal host filesystem layout.
|
||||||
|
func handleAdminListVolumes(c *gin.Context) {
|
||||||
|
client, ok := dockerClientOr500(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
vols, err := client.ListVolumes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadGateway, "DEPENDENCY_UNAVAILABLE", "Failed to list volumes: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
containers, err := client.ListContainers(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadGateway, "DEPENDENCY_UNAVAILABLE", "Failed to list containers: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inUse := map[string][]string{}
|
||||||
|
for _, cont := range containers {
|
||||||
|
for _, m := range cont.Mounts {
|
||||||
|
if m.Type == "volume" {
|
||||||
|
name := cont.ID[:12]
|
||||||
|
if len(cont.Names) > 0 {
|
||||||
|
name = cont.Names[0]
|
||||||
|
}
|
||||||
|
inUse[m.Name] = append(inUse[m.Name], name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]VolumeInfo, 0, len(vols.Volumes))
|
||||||
|
for _, v := range vols.Volumes {
|
||||||
|
if v == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info := VolumeInfo{
|
||||||
|
Name: v.Name,
|
||||||
|
Driver: v.Driver,
|
||||||
|
Mountpoint: v.Mountpoint,
|
||||||
|
Scope: v.Scope,
|
||||||
|
Labels: v.Labels,
|
||||||
|
UsedBy: inUse[v.Name],
|
||||||
|
}
|
||||||
|
info.InUse = len(info.UsedBy) > 0
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, v.CreatedAt); err == nil {
|
||||||
|
info.CreatedAt = t
|
||||||
|
}
|
||||||
|
out = append(out, info)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"volumes": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminDeleteVolume removes a docker volume. Docker refuses removal
|
||||||
|
// of in-use volumes — that error is surfaced as a conflict.
|
||||||
|
func handleAdminDeleteVolume(c *gin.Context) {
|
||||||
|
client, ok := dockerClientOr500(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := c.Param("name")
|
||||||
|
if name == "" {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Volume name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := client.RemoveVolume(c.Request.Context(), name, false); err != nil {
|
||||||
|
respondError(c, http.StatusConflict, "CONFLICT", "Failed to remove volume: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deleted": name})
|
||||||
|
}
|
||||||
@@ -211,8 +211,12 @@ func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest)
|
|||||||
// Build Docker image
|
// Build Docker image
|
||||||
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
||||||
|
|
||||||
// Create build context tar
|
// createBuildContext wants the Dockerfile contents, not its path.
|
||||||
buildCtx, err := createBuildContext(req.SourcePath, optimizedDockerfile)
|
dockerfileContent, err := os.ReadFile(optimizedDockerfile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read Dockerfile: %w", err)
|
||||||
|
}
|
||||||
|
buildCtx, err := createBuildContext(req.SourcePath, string(dockerfileContent))
|
||||||
if err != nil {
|
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)
|
||||||
}
|
}
|
||||||
@@ -222,6 +226,7 @@ func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest)
|
|||||||
Dockerfile: "Dockerfile",
|
Dockerfile: "Dockerfile",
|
||||||
Tags: []string{imageName},
|
Tags: []string{imageName},
|
||||||
BuildArgs: buildArgs,
|
BuildArgs: buildArgs,
|
||||||
|
NoCache: req.NoCache,
|
||||||
Remove: true,
|
Remove: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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
|
||||||
@@ -28,6 +29,7 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ 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,
|
||||||
@@ -80,6 +83,8 @@ 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:
|
||||||
@@ -95,7 +100,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
|
|||||||
|
|
||||||
// Pull the prebuilt image — must drain the pull stream or the daemon
|
// 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{}
|
auth := registry.AuthConfig{Username: req.PullUsername, Password: req.PullPassword}
|
||||||
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,6 +498,7 @@ func (n *NixpacksBuilder) Build(ctx context.Context, req *types.BuildRequest) (*
|
|||||||
BuildArgs: map[string]*string{
|
BuildArgs: map[string]*string{
|
||||||
"BUILDKIT_INLINE_CACHE": strPtr("1"),
|
"BUILDKIT_INLINE_CACHE": strPtr("1"),
|
||||||
},
|
},
|
||||||
|
NoCache: req.NoCache,
|
||||||
Remove: true,
|
Remove: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ func (rb *RailpackBuilder) Build(ctx context.Context, req *types.BuildRequest) (
|
|||||||
Tags: []string{imageName},
|
Tags: []string{imageName},
|
||||||
BuildArgs: buildArgs,
|
BuildArgs: buildArgs,
|
||||||
Labels: req.Labels,
|
Labels: req.Labels,
|
||||||
|
NoCache: req.NoCache,
|
||||||
Remove: true,
|
Remove: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package build
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"containr/internal/docker"
|
||||||
|
"containr/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StaticBuilder produces an nginx-served image from a repo's built assets.
|
||||||
|
// STATIC_BUILD_CMD runs inside a node build stage; STATIC_DIR names the
|
||||||
|
// output directory copied into the nginx image. Both are overridable via
|
||||||
|
// build args so repos with non-node toolchains can swap the build image too.
|
||||||
|
type StaticBuilder struct {
|
||||||
|
dockerClient *docker.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStaticBuilder(dockerClient *docker.Client) *StaticBuilder {
|
||||||
|
return &StaticBuilder{dockerClient: dockerClient}
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticDockerfile = `ARG BUILD_IMAGE=node:20-alpine
|
||||||
|
FROM ${BUILD_IMAGE} AS assets
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
ARG STATIC_BUILD_CMD
|
||||||
|
RUN sh -c "${STATIC_BUILD_CMD}"
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
ARG STATIC_DIR=dist
|
||||||
|
COPY --from=assets /app/${STATIC_DIR} /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
`
|
||||||
|
|
||||||
|
func (s *StaticBuilder) Build(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
|
||||||
|
buildCmd := req.BuildCommand
|
||||||
|
if buildCmd == "" {
|
||||||
|
buildCmd = "npm ci && npm run build"
|
||||||
|
}
|
||||||
|
staticDir := req.BuildArgs["STATIC_DIR"]
|
||||||
|
if staticDir == "" {
|
||||||
|
staticDir = "dist"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildArgs := map[string]*string{}
|
||||||
|
for k, v := range req.BuildArgs {
|
||||||
|
v := v
|
||||||
|
buildArgs[k] = &v
|
||||||
|
}
|
||||||
|
for k, v := range req.Environment {
|
||||||
|
v := v
|
||||||
|
buildArgs[k] = &v
|
||||||
|
}
|
||||||
|
buildArgs["STATIC_BUILD_CMD"] = &buildCmd
|
||||||
|
buildArgs["STATIC_DIR"] = &staticDir
|
||||||
|
|
||||||
|
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
||||||
|
buildCtx, err := createBuildContext(req.SourcePath, staticDockerfile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create build context: %w", err)
|
||||||
|
}
|
||||||
|
defer buildCtx.Close()
|
||||||
|
|
||||||
|
if _, err := s.dockerClient.BuildImage(ctx, buildCtx, docker.BuildOptions{
|
||||||
|
Dockerfile: "Dockerfile",
|
||||||
|
Tags: []string{imageName},
|
||||||
|
BuildArgs: buildArgs,
|
||||||
|
NoCache: req.NoCache,
|
||||||
|
Remove: true,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to build static image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.RegistryURL != "" {
|
||||||
|
full := fmt.Sprintf("%s/%s", req.RegistryURL, imageName)
|
||||||
|
if err := s.dockerClient.PushImage(ctx, imageName, req.RegistryURL); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to push image: %w", err)
|
||||||
|
}
|
||||||
|
imageName = full
|
||||||
|
}
|
||||||
|
|
||||||
|
imageInfo, err := s.dockerClient.GetImageInfo(ctx, imageName)
|
||||||
|
if err != nil {
|
||||||
|
imageInfo = &docker.ImageInfo{Size: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.BuildResponse{
|
||||||
|
ImageName: imageName,
|
||||||
|
ImageTag: req.ImageTag,
|
||||||
|
Size: imageInfo.Size,
|
||||||
|
Digest: imageInfo.Digest,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminCmd groups platform-admin operations.
|
||||||
|
var AdminCmd = &cobra.Command{
|
||||||
|
Use: "admin",
|
||||||
|
Short: "Platform administration (admin scope required)",
|
||||||
|
}
|
||||||
|
|
||||||
|
var adminOverviewCmd = &cobra.Command{
|
||||||
|
Use: "overview",
|
||||||
|
Short: "Platform overview",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/admin/overview", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var adminUsersCmd = &cobra.Command{
|
||||||
|
Use: "users",
|
||||||
|
Short: "List all users",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/admin/users", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "users")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, u := range items {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(u, "id"), str(u, "email"), str(u, "name"), str(u, "is_admin"), relTime(u, "created_at"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "EMAIL", "NAME", "ADMIN", "CREATED"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var adminSettingsCmd = &cobra.Command{
|
||||||
|
Use: "settings",
|
||||||
|
Short: "Show platform settings",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/settings", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var adminAuditCmd = &cobra.Command{
|
||||||
|
Use: "audit-logs",
|
||||||
|
Short: "List audit log entries",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/audit-logs", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// GatewayCmd manages the API gateway (admin).
|
||||||
|
var GatewayCmd = &cobra.Command{
|
||||||
|
Use: "gateway",
|
||||||
|
Short: "Manage the API gateway (admin)",
|
||||||
|
}
|
||||||
|
|
||||||
|
var gatewayServicesCmd = &cobra.Command{
|
||||||
|
Use: "services",
|
||||||
|
Short: "List gateway services",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/gateway/services", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var gatewayKeysCmd = &cobra.Command{
|
||||||
|
Use: "keys",
|
||||||
|
Short: "List gateway API keys",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/gateway/keys", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var gatewayAnalyticsCmd = &cobra.Command{
|
||||||
|
Use: "analytics",
|
||||||
|
Short: "Gateway traffic analytics",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kind, _ := cmd.Flags().GetString("type")
|
||||||
|
data, err := c.Do("GET", "/gateway/analytics/"+kind, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// HACmd manages high availability.
|
||||||
|
var HACmd = &cobra.Command{
|
||||||
|
Use: "ha",
|
||||||
|
Short: "High availability controls",
|
||||||
|
}
|
||||||
|
|
||||||
|
var haStatusCmd = &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show HA status",
|
||||||
|
RunE: simpleGet("/ha/status"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var haAlertsCmd = &cobra.Command{
|
||||||
|
Use: "alerts",
|
||||||
|
Short: "List active HA alerts",
|
||||||
|
RunE: simpleGet("/ha/alerts/active"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var haHealthCmd = &cobra.Command{
|
||||||
|
Use: "health",
|
||||||
|
Short: "List health check results",
|
||||||
|
RunE: simpleGet("/ha/health/results"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var haPoliciesCmd = &cobra.Command{
|
||||||
|
Use: "policies",
|
||||||
|
Short: "List failover policies",
|
||||||
|
RunE: simpleGet("/ha/failover/policies"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var haEnableCmd = &cobra.Command{
|
||||||
|
Use: "enable",
|
||||||
|
Short: "Enable the HA manager",
|
||||||
|
RunE: simplePost("/ha/enable"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var haDisableCmd = &cobra.Command{
|
||||||
|
Use: "disable",
|
||||||
|
Short: "Disable the HA manager",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if !Confirm("Disable HA? Services will no longer be failed over.") {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
return simplePost("/ha/disable")(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var haFailoverCmd = &cobra.Command{
|
||||||
|
Use: "failover",
|
||||||
|
Short: "Trigger a manual failover",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if !Confirm("Trigger a platform failover now? Services may be rescheduled.") {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
return simplePost("/ha/failover")(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScalingCmd manages autoscaling.
|
||||||
|
var ScalingCmd = &cobra.Command{
|
||||||
|
Use: "scaling",
|
||||||
|
Short: "Autoscaling controls",
|
||||||
|
}
|
||||||
|
|
||||||
|
var scalingStatusCmd = &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show autoscaler status",
|
||||||
|
RunE: simpleGet("/scaling/status"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var scalingPoliciesCmd = &cobra.Command{
|
||||||
|
Use: "policies",
|
||||||
|
Short: "List scaling policies",
|
||||||
|
RunE: simpleGet("/scaling/policies"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var scalingServicesCmd = &cobra.Command{
|
||||||
|
Use: "services",
|
||||||
|
Short: "Show per-service scaling state",
|
||||||
|
RunE: simpleGet("/scaling/services"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var scalingScaleCmd = &cobra.Command{
|
||||||
|
Use: "scale <service-id> <replicas>",
|
||||||
|
Short: "Manually scale a service",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var replicas int
|
||||||
|
fmt.Sscanf(args[1], "%d", &replicas)
|
||||||
|
data, err := c.Do("POST", "/scaling/services/"+args[0]+"/scale", map[string]int{"replicas": replicas})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityCmd manages security scans and findings.
|
||||||
|
var SecurityCmd = &cobra.Command{
|
||||||
|
Use: "security",
|
||||||
|
Short: "Security scans and vulnerabilities",
|
||||||
|
}
|
||||||
|
|
||||||
|
var securityScanCmd = &cobra.Command{
|
||||||
|
Use: "scan <project-id>",
|
||||||
|
Short: "Run a security scan",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
scanType, _ := cmd.Flags().GetString("type")
|
||||||
|
serviceID, _ := cmd.Flags().GetString("service")
|
||||||
|
body := map[string]interface{}{"project_id": args[0], "scan_type": scanType}
|
||||||
|
if serviceID != "" {
|
||||||
|
body["service_id"] = serviceID
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/security/scans", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var securityVulnsCmd = &cobra.Command{
|
||||||
|
Use: "vulnerabilities <project-id>",
|
||||||
|
Short: "List a project's vulnerabilities",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/projects/"+args[0]+"/vulnerabilities", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var securityMetricsCmd = &cobra.Command{
|
||||||
|
Use: "metrics <project-id>",
|
||||||
|
Short: "Show a project's security metrics",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/projects/"+args[0]+"/security/metrics", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// simpleGet/simplePost cover the many read/trigger endpoints whose full
|
||||||
|
// JSON response is the useful output.
|
||||||
|
func simpleGet(path string) func(*cobra.Command, []string) error {
|
||||||
|
return func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func simplePost(path string) func(*cobra.Command, []string) error {
|
||||||
|
return func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", path, map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
gatewayAnalyticsCmd.Flags().String("type", "traffic", "traffic|ops")
|
||||||
|
securityScanCmd.Flags().String("type", "configuration", "dependency|configuration|comprehensive")
|
||||||
|
securityScanCmd.Flags().String("service", "", "limit scan to one service")
|
||||||
|
|
||||||
|
AdminCmd.AddCommand(adminOverviewCmd, adminUsersCmd, adminSettingsCmd, adminAuditCmd)
|
||||||
|
GatewayCmd.AddCommand(gatewayServicesCmd, gatewayKeysCmd, gatewayAnalyticsCmd)
|
||||||
|
HACmd.AddCommand(haStatusCmd, haAlertsCmd, haHealthCmd, haPoliciesCmd, haEnableCmd, haDisableCmd, haFailoverCmd)
|
||||||
|
ScalingCmd.AddCommand(scalingStatusCmd, scalingPoliciesCmd, scalingServicesCmd, scalingScaleCmd)
|
||||||
|
SecurityCmd.AddCommand(securityScanCmd, securityVulnsCmd, securityMetricsCmd)
|
||||||
|
}
|
||||||
@@ -2,139 +2,150 @@ package commands
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthCmd represents the auth command
|
// AuthCmd groups authentication commands.
|
||||||
var AuthCmd = &cobra.Command{
|
var AuthCmd = &cobra.Command{
|
||||||
Use: "auth",
|
Use: "auth",
|
||||||
Short: "Authenticate with Containr API",
|
Short: "Authenticate with a Containr instance",
|
||||||
Long: `Manage authentication with the Containr API.
|
Long: `Login with a personal access token (cnp_...) created under
|
||||||
You can login, logout, and check your current authentication status.`,
|
Settings → Personal Access Tokens, check status, or switch profiles.`,
|
||||||
}
|
}
|
||||||
|
|
||||||
// loginCmd represents the login command
|
|
||||||
var loginCmd = &cobra.Command{
|
var loginCmd = &cobra.Command{
|
||||||
Use: "login [token]",
|
Use: "login [token]",
|
||||||
Short: "Login to Containr",
|
Short: "Store a personal access token",
|
||||||
Long: `Login to Containr using your API token.
|
Long: `Login with a cnp_ token. Reads interactively when no argument is
|
||||||
You can get your token from the Containr web interface.`,
|
given. The token is verified against the API before it is stored.`,
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: runLogin,
|
RunE: runLogin,
|
||||||
}
|
}
|
||||||
|
|
||||||
// logoutCmd represents the logout command
|
|
||||||
var logoutCmd = &cobra.Command{
|
var logoutCmd = &cobra.Command{
|
||||||
Use: "logout",
|
Use: "logout",
|
||||||
Short: "Logout from Containr",
|
Short: "Remove the stored token",
|
||||||
Long: `Remove stored authentication credentials.`,
|
|
||||||
RunE: runLogout,
|
RunE: runLogout,
|
||||||
}
|
}
|
||||||
|
|
||||||
// statusCmd represents the status command
|
|
||||||
var statusCmd = &cobra.Command{
|
var statusCmd = &cobra.Command{
|
||||||
Use: "status",
|
Use: "status",
|
||||||
Short: "Check authentication status",
|
Short: "Show the active profile and verify the token",
|
||||||
Long: `Check if you are currently authenticated with Containr.`,
|
|
||||||
RunE: runStatus,
|
RunE: runStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
AuthCmd.AddCommand(loginCmd)
|
loginCmd.Flags().String("url", "", "API URL to store in the profile (e.g. https://containr.example.com)")
|
||||||
AuthCmd.AddCommand(logoutCmd)
|
loginCmd.Flags().String("name", "", "profile name to save under (default: active profile)")
|
||||||
AuthCmd.AddCommand(statusCmd)
|
AuthCmd.AddCommand(loginCmd, logoutCmd, statusCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
type profileResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func runLogin(cmd *cobra.Command, args []string) error {
|
func runLogin(cmd *cobra.Command, args []string) error {
|
||||||
var token string
|
var token string
|
||||||
|
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
token = args[0]
|
token = args[0]
|
||||||
} else {
|
} else if isTerminal() {
|
||||||
// Prompt for token
|
fmt.Fprint(os.Stderr, "Token: ")
|
||||||
fmt.Print("Enter your Containr API token: ")
|
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
fmt.Scanln(&token)
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token = string(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return fmt.Errorf("token is required")
|
return &APIError{Message: "token is required — pass it as an argument or pipe it via stdin", ExitCode: ExitValidation}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store token in config
|
// Verify before saving so a mistyped token fails loudly.
|
||||||
viper.Set("token", token)
|
urlFlag, _ := cmd.Flags().GetString("url")
|
||||||
if err := viper.WriteConfig(); err != nil {
|
probe := &Client{BaseURL: ResolveAPIURL(), Token: token}
|
||||||
return fmt.Errorf("failed to save token: %w", err)
|
if urlFlag != "" {
|
||||||
|
probe.BaseURL = normalizeBase(urlFlag)
|
||||||
|
}
|
||||||
|
var profile profileResponse
|
||||||
|
if err := probe.DoJSON("GET", "/user/profile", nil, &profile); err != nil {
|
||||||
|
return fmt.Errorf("token verification failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("✓ Successfully logged in to Containr")
|
name, _ := cmd.Flags().GetString("name")
|
||||||
|
if name == "" {
|
||||||
|
name = ActiveProfile()
|
||||||
|
}
|
||||||
|
if err := SaveProfile(name, probe.BaseURL, token); err != nil {
|
||||||
|
return fmt.Errorf("failed to save profile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]interface{}{
|
||||||
|
"profile": name, "api_url": probe.BaseURL,
|
||||||
|
"user": profile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fmt.Printf("Logged in as %s (%s) — profile %q → %s\n", profile.Name, profile.Email, name, probe.BaseURL)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runLogout(cmd *cobra.Command, args []string) error {
|
func runLogout(cmd *cobra.Command, args []string) error {
|
||||||
// Remove token from config
|
name := ActiveProfile()
|
||||||
viper.Set("token", "")
|
if err := ClearProfileToken(name); err != nil {
|
||||||
if err := viper.WriteConfig(); err != nil {
|
return fmt.Errorf("failed to update config: %w", err)
|
||||||
return fmt.Errorf("failed to remove token: %w", err)
|
}
|
||||||
|
if !JSONMode() {
|
||||||
|
fmt.Printf("Removed token from profile %q\n", name)
|
||||||
|
} else {
|
||||||
|
return PrintJSON(map[string]string{"status": "ok", "profile": name})
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("✓ Successfully logged out from Containr")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runStatus(cmd *cobra.Command, args []string) error {
|
func runStatus(cmd *cobra.Command, args []string) error {
|
||||||
token := viper.GetString("token")
|
client := NewClient()
|
||||||
if token == "" {
|
profile := ActiveProfile()
|
||||||
fmt.Println("❌ Not authenticated")
|
|
||||||
fmt.Println("Run 'containr auth login <token>' to authenticate")
|
if JSONMode() {
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"profile": profile,
|
||||||
|
"api_url": client.BaseURL,
|
||||||
|
}
|
||||||
|
if client.Token != "" {
|
||||||
|
var p profileResponse
|
||||||
|
if err := client.DoJSON("GET", "/user/profile", nil, &p); err == nil {
|
||||||
|
out["user"] = p
|
||||||
|
out["authenticated"] = true
|
||||||
|
} else {
|
||||||
|
out["authenticated"] = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out["authenticated"] = false
|
||||||
|
}
|
||||||
|
return PrintJSON(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Profile: %s\n", profile)
|
||||||
|
fmt.Printf("API URL: %s\n", client.BaseURL)
|
||||||
|
if client.Token == "" {
|
||||||
|
fmt.Println("Token: not set — run `containr auth login <token>`")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
var p profileResponse
|
||||||
fmt.Println("✓ Authenticated with Containr")
|
if err := client.DoJSON("GET", "/user/profile", nil, &p); err != nil {
|
||||||
apiURL := buildAPIURL("/user/profile")
|
fmt.Printf("Token: present but rejected (%v)\n", err)
|
||||||
fmt.Printf("API URL: %s\n", strings.TrimSuffix(buildAPIURL(""), "/"))
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create verification request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("⚠ Token verification unavailable: %v\n", err)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
fmt.Printf("Identity: %s <%s>\n", p.Name, p.Email)
|
||||||
|
if p.IsAdmin {
|
||||||
switch resp.StatusCode {
|
fmt.Println("Role: platform admin")
|
||||||
case http.StatusOK:
|
|
||||||
fmt.Println("✓ Token verified against API")
|
|
||||||
case http.StatusUnauthorized, http.StatusForbidden:
|
|
||||||
fmt.Println("❌ Token rejected by API (expired/invalid)")
|
|
||||||
default:
|
|
||||||
fmt.Printf("⚠ Token verification returned unexpected status: %s\n", resp.Status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildAPIURL(endpoint string) string {
|
|
||||||
baseURL := viper.GetString("api-url")
|
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = "http://localhost:8080/api/v1"
|
|
||||||
}
|
|
||||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
|
||||||
if endpoint == "" {
|
|
||||||
return baseURL
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(endpoint, "/") {
|
|
||||||
endpoint = "/" + endpoint
|
|
||||||
}
|
|
||||||
return baseURL + endpoint
|
|
||||||
}
|
|
||||||
@@ -6,16 +6,52 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildAPIURLDefaultsAndTrimming(t *testing.T) {
|
func TestResolveAPIURLDefaultsAndTrimming(t *testing.T) {
|
||||||
viper.Set("api-url", "")
|
viper.Set("api-url", "")
|
||||||
got := buildAPIURL("/user/profile")
|
viper.Set("current_profile", "")
|
||||||
if got != "http://localhost:8080/api/v1/user/profile" {
|
viper.Set("profiles.default.api_url", "")
|
||||||
|
if got := ResolveAPIURL(); got != "http://localhost:8080/api/v1" {
|
||||||
t.Fatalf("unexpected default api url: %s", got)
|
t.Fatalf("unexpected default api url: %s", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
viper.Set("api-url", "https://api.example.com/api/v1/")
|
viper.Set("api-url", "https://api.example.com/api/v1/")
|
||||||
got = buildAPIURL("user/profile")
|
if got := ResolveAPIURL(); got != "https://api.example.com/api/v1" {
|
||||||
if got != "https://api.example.com/api/v1/user/profile" {
|
|
||||||
t.Fatalf("unexpected trimmed api url: %s", got)
|
t.Fatalf("unexpected trimmed api url: %s", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bare hosts gain the /api/v1 suffix.
|
||||||
|
viper.Set("api-url", "https://containr.example.com")
|
||||||
|
if got := ResolveAPIURL(); got != "https://containr.example.com/api/v1" {
|
||||||
|
t.Fatalf("unexpected suffixed api url: %s", got)
|
||||||
|
}
|
||||||
|
viper.Set("api-url", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTokenProfileAndEnv(t *testing.T) {
|
||||||
|
viper.Set("token", "")
|
||||||
|
viper.Set("current_profile", "staging")
|
||||||
|
viper.Set("profiles.staging.token", "cnp_staging")
|
||||||
|
if got := ResolveToken(); got != "cnp_staging" {
|
||||||
|
t.Fatalf("expected profile token, got %s", got)
|
||||||
|
}
|
||||||
|
t.Setenv("CONTAINR_TOKEN", "cnp_env")
|
||||||
|
if got := ResolveToken(); got != "cnp_env" {
|
||||||
|
t.Fatalf("env should win over profile, got %s", got)
|
||||||
|
}
|
||||||
|
viper.Set("token", "cnp_flag")
|
||||||
|
if got := ResolveToken(); got != "cnp_flag" {
|
||||||
|
t.Fatalf("flag should win over env, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExitCodeMapping(t *testing.T) {
|
||||||
|
if got := exitCodeFor(401); got != ExitAuth {
|
||||||
|
t.Fatalf("401 → %d, want %d", got, ExitAuth)
|
||||||
|
}
|
||||||
|
if got := exitCodeFor(404); got != ExitNotFound {
|
||||||
|
t.Fatalf("404 → %d, want %d", got, ExitNotFound)
|
||||||
|
}
|
||||||
|
if got := exitCodeFor(400); got != ExitValidation {
|
||||||
|
t.Fatalf("400 → %d, want %d", got, ExitValidation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exit codes — stable for scripting and agent use.
|
||||||
|
const (
|
||||||
|
ExitOK = 0
|
||||||
|
ExitError = 1
|
||||||
|
ExitAuth = 2
|
||||||
|
ExitNotFound = 3
|
||||||
|
ExitValidation = 4
|
||||||
|
ExitUnavailable = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIError is a non-2xx response from the Containr API. Code carries the
|
||||||
|
// stable exit code so `main` can map errors to exit statuses.
|
||||||
|
type APIError struct {
|
||||||
|
Status int
|
||||||
|
Message string
|
||||||
|
ExitCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
if e.Message != "" {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("request failed with status %d", e.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exitCodeFor(status int) int {
|
||||||
|
switch status {
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
return ExitAuth
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return ExitNotFound
|
||||||
|
case http.StatusBadRequest, http.StatusConflict, http.StatusUnprocessableEntity:
|
||||||
|
return ExitValidation
|
||||||
|
default:
|
||||||
|
return ExitError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is a thin authenticated HTTP client for the Containr API. All CLI
|
||||||
|
// commands go through it — no command builds its own request.
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
Token string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient resolves the active profile (flag > env > current_profile) and
|
||||||
|
// returns a client ready to call the API. Call RequireAuth() for commands
|
||||||
|
// that need a token.
|
||||||
|
func NewClient() *Client {
|
||||||
|
return &Client{
|
||||||
|
BaseURL: ResolveAPIURL(),
|
||||||
|
Token: ResolveToken(),
|
||||||
|
HTTPClient: &http.Client{Timeout: 60 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAuth returns an error suitable for the CLI when no token is set.
|
||||||
|
func (c *Client) RequireAuth() error {
|
||||||
|
if c.Token == "" {
|
||||||
|
return &APIError{
|
||||||
|
Status: 0,
|
||||||
|
Message: "not authenticated — run `containr auth login <token>` or set CONTAINR_TOKEN",
|
||||||
|
ExitCode: ExitAuth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do performs an API request. body may be nil, a []byte, or any value that
|
||||||
|
// marshals to JSON. Returns the raw response body.
|
||||||
|
func (c *Client) Do(method, path string, body interface{}) ([]byte, error) {
|
||||||
|
var reader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
var data []byte
|
||||||
|
switch v := body.(type) {
|
||||||
|
case []byte:
|
||||||
|
data = v
|
||||||
|
default:
|
||||||
|
var err error
|
||||||
|
data, err = json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode request: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := c.BaseURL + path
|
||||||
|
req, err := http.NewRequest(method, url, reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if k := viper.GetString("idempotency-key"); k != "" && method == http.MethodPost {
|
||||||
|
req.Header.Set("Idempotency-Key", k)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
hc := c.HTTPClient
|
||||||
|
if hc == nil {
|
||||||
|
hc = &http.Client{Timeout: 60 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &APIError{
|
||||||
|
Status: 0,
|
||||||
|
Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err),
|
||||||
|
ExitCode: ExitUnavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
msg := strings.TrimSpace(string(data))
|
||||||
|
var envelope struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &envelope) == nil && envelope.Error != "" {
|
||||||
|
msg = envelope.Error
|
||||||
|
if envelope.Code != "" {
|
||||||
|
msg = envelope.Code + ": " + msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, &APIError{
|
||||||
|
Status: resp.StatusCode,
|
||||||
|
Message: msg,
|
||||||
|
ExitCode: exitCodeFor(resp.StatusCode),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoJSON performs a request and unmarshals the response into out.
|
||||||
|
func (c *Client) DoJSON(method, path string, body interface{}, out interface{}) error {
|
||||||
|
data, err := c.Do(method, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if out == nil || len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActiveProfile resolves which profile applies: --profile flag >
|
||||||
|
// CONTAINR_PROFILE env > current_profile in config > "default".
|
||||||
|
func ActiveProfile() string {
|
||||||
|
if p := viper.GetString("profile"); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
if p := os.Getenv("CONTAINR_PROFILE"); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
if p := viper.GetString("current_profile"); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveAPIURL returns the effective API base URL:
|
||||||
|
// --api-url flag > CONTAINR_API_URL env > profile.api_url > localhost default.
|
||||||
|
func ResolveAPIURL() string {
|
||||||
|
if u := viper.GetString("api-url"); u != "" {
|
||||||
|
return normalizeBase(u)
|
||||||
|
}
|
||||||
|
if u := os.Getenv("CONTAINR_API_URL"); u != "" {
|
||||||
|
return normalizeBase(u)
|
||||||
|
}
|
||||||
|
if u := viper.GetString("profiles." + ActiveProfile() + ".api_url"); u != "" {
|
||||||
|
return normalizeBase(u)
|
||||||
|
}
|
||||||
|
return "http://localhost:8080/api/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveToken returns the effective auth token:
|
||||||
|
// --token flag > CONTAINR_TOKEN env > profile.token.
|
||||||
|
func ResolveToken() string {
|
||||||
|
if t := viper.GetString("token"); t != "" {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
if t := os.Getenv("CONTAINR_TOKEN"); t != "" {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return viper.GetString("profiles." + ActiveProfile() + ".token")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBase(u string) string {
|
||||||
|
u = strings.TrimSuffix(strings.TrimSpace(u), "/")
|
||||||
|
// Accept the bare host too — append the API prefix when missing.
|
||||||
|
if u != "" && !strings.HasSuffix(u, "/api/v1") {
|
||||||
|
u += "/api/v1"
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveProfile persists api_url/token under a named profile and makes it
|
||||||
|
// current. Creates the config file if none exists.
|
||||||
|
func SaveProfile(name, apiURL, token string) error {
|
||||||
|
base := "profiles." + name
|
||||||
|
if apiURL != "" {
|
||||||
|
viper.Set(base+".api_url", apiURL)
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
viper.Set(base+".token", token)
|
||||||
|
}
|
||||||
|
viper.Set("current_profile", name)
|
||||||
|
return writeConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearProfileToken removes only the token from the active profile.
|
||||||
|
func ClearProfileToken(name string) error {
|
||||||
|
viper.Set("profiles."+name+".token", "")
|
||||||
|
return writeConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProfiles returns profile name → api_url for `auth status --all`.
|
||||||
|
func ListProfiles() map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
profiles := viper.GetStringMap("profiles")
|
||||||
|
for name := range profiles {
|
||||||
|
if u := viper.GetString("profiles." + name + ".api_url"); u != "" {
|
||||||
|
out[name] = u
|
||||||
|
} else {
|
||||||
|
out[name] = "(no url)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeConfig() error {
|
||||||
|
if err := viper.WriteConfig(); err != nil {
|
||||||
|
// No config file yet — create one.
|
||||||
|
home, herr := os.UserHomeDir()
|
||||||
|
if herr != nil {
|
||||||
|
return fmt.Errorf("cannot locate home directory: %w", herr)
|
||||||
|
}
|
||||||
|
return viper.WriteConfigAs(home + "/.containr.yaml")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CronCmd manages scheduled jobs.
|
||||||
|
var CronCmd = &cobra.Command{
|
||||||
|
Use: "cron",
|
||||||
|
Aliases: []string{"cron-jobs"},
|
||||||
|
Short: "Manage cron jobs",
|
||||||
|
}
|
||||||
|
|
||||||
|
var cronListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List cron jobs",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/cron-jobs", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "cron_jobs", "jobs")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, j := range items {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(j, "id"), str(j, "name"), str(j, "schedule"), str(j, "service_id"),
|
||||||
|
str(j, "enabled", "is_active"), relTime(j, "last_run_at"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "NAME", "SCHEDULE", "SERVICE", "ENABLED", "LAST RUN"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var cronGetCmd = &cobra.Command{
|
||||||
|
Use: "get <id>",
|
||||||
|
Short: "Show a cron job",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/cron-jobs/"+args[0], nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var cronCreateCmd = &cobra.Command{
|
||||||
|
Use: "create",
|
||||||
|
Short: "Create a cron job (5-field expression)",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
for _, f := range []string{"name", "schedule", "command", "service-id", "timezone"} {
|
||||||
|
v, _ := cmd.Flags().GetString(f)
|
||||||
|
if v != "" {
|
||||||
|
key := f
|
||||||
|
if f == "service-id" {
|
||||||
|
key = "service_id"
|
||||||
|
}
|
||||||
|
body[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body["service_id"] == nil || body["schedule"] == nil || body["command"] == nil {
|
||||||
|
return &APIError{Message: "--service-id, --schedule, and --command are required", ExitCode: ExitValidation}
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/cron-jobs", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var cronDeleteCmd = &cobra.Command{
|
||||||
|
Use: "delete <id>",
|
||||||
|
Short: "Delete a cron job",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !Confirm(fmt.Sprintf("Delete cron job %s?", args[0])) {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if _, err := c.Do("DELETE", "/cron-jobs/"+args[0], nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||||
|
}
|
||||||
|
fmt.Printf("Deleted cron job %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var cronTriggerCmd = &cobra.Command{
|
||||||
|
Use: "trigger <id>",
|
||||||
|
Short: "Run a cron job now",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/cron-jobs/"+args[0]+"/trigger", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var cronExecutionsCmd = &cobra.Command{
|
||||||
|
Use: "executions <id>",
|
||||||
|
Short: "List a cron job's execution history",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/cron-jobs/"+args[0]+"/executions", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "executions")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, e := range items {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(e, "id"), str(e, "status", "exit_code"), str(e, "started_at"), str(e, "output", "stdout"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "STATUS", "STARTED", "OUTPUT"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
cronCreateCmd.Flags().String("name", "", "job name")
|
||||||
|
cronCreateCmd.Flags().String("service-id", "", "target service id (required)")
|
||||||
|
cronCreateCmd.Flags().String("schedule", "", "5-field cron expression (required)")
|
||||||
|
cronCreateCmd.Flags().String("command", "", "command run inside the container (required)")
|
||||||
|
cronCreateCmd.Flags().String("timezone", "", "IANA timezone (default UTC)")
|
||||||
|
CronCmd.AddCommand(cronListCmd, cronGetCmd, cronCreateCmd, cronDeleteCmd, cronTriggerCmd, cronExecutionsCmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbRegisterCmd = &cobra.Command{
|
||||||
|
Use: "register <name>",
|
||||||
|
Short: "Register an external database (probes the connection first)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{"name": args[0]}
|
||||||
|
for _, f := range []string{"type", "host", "database", "username", "password"} {
|
||||||
|
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||||
|
body[f] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, _ := cmd.Flags().GetInt("port"); v > 0 {
|
||||||
|
body["port"] = v
|
||||||
|
}
|
||||||
|
if v, _ := cmd.Flags().GetBool("ssl"); v {
|
||||||
|
body["ssl"] = true
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/databases/register-external", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, _ := unwrapObject(data)
|
||||||
|
fmt.Printf("Registered external database %s (%s)\n", args[0], str(m, "id"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbTestConnCmd = &cobra.Command{
|
||||||
|
Use: "test-connection [<id>]",
|
||||||
|
Short: "Probe a database connection — by id, or with --type/--host flags",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var data []byte
|
||||||
|
if len(args) == 1 {
|
||||||
|
data, err = c.Do("POST", "/databases/"+args[0]+"/test-connection", map[string]interface{}{})
|
||||||
|
} else {
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
for _, f := range []string{"type", "host", "database", "username", "password"} {
|
||||||
|
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||||
|
body[f] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, _ := cmd.Flags().GetInt("port"); v > 0 {
|
||||||
|
body["port"] = v
|
||||||
|
}
|
||||||
|
if v, _ := cmd.Flags().GetBool("ssl"); v {
|
||||||
|
body["ssl"] = true
|
||||||
|
}
|
||||||
|
data, err = c.Do("POST", "/databases/test-connection", body)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m, _ := unwrapObject(data)
|
||||||
|
if str(m, "ok") == "false" {
|
||||||
|
return &APIError{Message: "connection failed: " + str(m, "error"), ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Connection ok (%sms)\n", str(m, "latency_ms"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
dbCreateCmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
|
||||||
|
dbCreateCmd.Flags().String("version", "", "engine version tag")
|
||||||
|
dbCreateCmd.Flags().String("storage-size", "", "volume size (e.g. 10Gi)")
|
||||||
|
for _, cmd := range []*cobra.Command{dbRegisterCmd, dbTestConnCmd} {
|
||||||
|
cmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
|
||||||
|
cmd.Flags().String("host", "", "database host")
|
||||||
|
cmd.Flags().Int("port", 0, "database port (default per type)")
|
||||||
|
cmd.Flags().String("database", "", "database name")
|
||||||
|
cmd.Flags().String("username", "", "database username")
|
||||||
|
cmd.Flags().String("password", "", "database password (stored encrypted on register)")
|
||||||
|
cmd.Flags().Bool("ssl", false, "require TLS")
|
||||||
|
}
|
||||||
|
DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbRegisterCmd, dbTestConnCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbDownloadCmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeployCmd triggers a deployment for a service.
|
||||||
|
var DeployCmd = &cobra.Command{
|
||||||
|
Use: "deploy <service-id>",
|
||||||
|
Short: "Trigger a deployment",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{"trigger": "cli"}
|
||||||
|
if nc, _ := cmd.Flags().GetBool("no-cache"); nc {
|
||||||
|
body["no_cache"] = true
|
||||||
|
}
|
||||||
|
for _, f := range []string{"commit-hash", "branch"} {
|
||||||
|
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||||
|
key := "commit_hash"
|
||||||
|
if f == "branch" {
|
||||||
|
key = "branch"
|
||||||
|
}
|
||||||
|
body[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/services/"+args[0]+"/deployments", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d, _ := unwrapObject(data, "deployment")
|
||||||
|
fmt.Printf("Deployment %s started for service %s (status: %s)\n",
|
||||||
|
str(d, "id"), args[0], str(d, "status"))
|
||||||
|
fmt.Println("Follow with: containr deployments logs " + str(d, "id"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeploymentsCmd manages deployment history.
|
||||||
|
var DeploymentsCmd = &cobra.Command{
|
||||||
|
Use: "deployments",
|
||||||
|
Aliases: []string{"deployment"},
|
||||||
|
Short: "Inspect deployments",
|
||||||
|
}
|
||||||
|
|
||||||
|
var deploymentsListCmd = &cobra.Command{
|
||||||
|
Use: "list [service-id]",
|
||||||
|
Short: "List deployments (all recent, or for one service)",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := "/deployments"
|
||||||
|
if len(args) > 0 {
|
||||||
|
path = "/services/" + args[0] + "/deployments"
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "deployments")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, d := range items {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(d, "id"), str(d, "service_id"), str(d, "status"), str(d, "trigger"), relTime(d, "created_at", "started_at"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "SERVICE", "STATUS", "TRIGGER", "STARTED"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var deploymentsGetCmd = &cobra.Command{
|
||||||
|
Use: "get <id>",
|
||||||
|
Short: "Show a deployment",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/deployments/"+args[0], nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var deploymentsLogsCmd = &cobra.Command{
|
||||||
|
Use: "logs <id>",
|
||||||
|
Short: "Stream a deployment's build/deploy logs",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runDeploymentLogs,
|
||||||
|
}
|
||||||
|
|
||||||
|
var deploymentsCancelCmd = &cobra.Command{
|
||||||
|
Use: "cancel <id>",
|
||||||
|
Short: "Cancel a queued or running deployment",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/deployments/"+args[0]+"/cancel", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
obj, _ := unwrapObject(data)
|
||||||
|
fmt.Printf("Deployment %s: %s\n", args[0], str(obj, "status"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var deploymentsRollbackCmd = &cobra.Command{
|
||||||
|
Use: "rollback <id>",
|
||||||
|
Short: "Roll back to a deployment",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/deployments/"+args[0]+"/rollback", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Rolled back to deployment %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
DeployCmd.Flags().String("commit-hash", "", "commit to deploy")
|
||||||
|
DeployCmd.Flags().String("branch", "", "branch to deploy")
|
||||||
|
DeployCmd.Flags().Bool("no-cache", false, "build without Docker layer cache")
|
||||||
|
DeploymentsCmd.AddCommand(deploymentsListCmd, deploymentsGetCmd, deploymentsLogsCmd, deploymentsRollbackCmd, deploymentsCancelCmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// client returns an authenticated API client or an ExitAuth error.
|
||||||
|
func client() (*Client, error) {
|
||||||
|
c := NewClient()
|
||||||
|
if err := c.RequireAuth(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unwrapList accepts both top-level arrays and {"<key>": [...]} envelopes,
|
||||||
|
// which the Containr API uses inconsistently between endpoints.
|
||||||
|
func unwrapList(data []byte, keys ...string) ([]map[string]interface{}, error) {
|
||||||
|
var arr []map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &arr); err == nil {
|
||||||
|
return arr, nil
|
||||||
|
}
|
||||||
|
var obj map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &obj); err != nil {
|
||||||
|
return nil, fmt.Errorf("unexpected response shape: %w", err)
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
if raw, ok := obj[k]; ok {
|
||||||
|
if err := json.Unmarshal(raw, &arr); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot decode %q: %w", k, err)
|
||||||
|
}
|
||||||
|
return arr, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []map[string]interface{}{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unwrapObject(data []byte, keys ...string) (map[string]interface{}, error) {
|
||||||
|
var obj map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &obj); err != nil {
|
||||||
|
return nil, fmt.Errorf("unexpected response shape: %w", err)
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
if inner, ok := obj[k].(map[string]interface{}); ok {
|
||||||
|
return inner, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return obj, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// str extracts a display string from a response map.
|
||||||
|
func str(m map[string]interface{}, keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v, ok := m[k]; ok && v != nil {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
return t
|
||||||
|
case float64:
|
||||||
|
if t == float64(int64(t)) {
|
||||||
|
return fmt.Sprintf("%d", int64(t))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", t)
|
||||||
|
case bool:
|
||||||
|
return fmt.Sprintf("%t", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortID(m map[string]interface{}) string {
|
||||||
|
id := str(m, "id")
|
||||||
|
if len(id) > 8 {
|
||||||
|
return id[:8]
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func relTime(m map[string]interface{}, keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
s, ok := m[k].(string)
|
||||||
|
if !ok || s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, s)
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
d := time.Since(t)
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
return "just now"
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
// printRows renders a table, or raw JSON for the full response in --json mode.
|
||||||
|
func printRows(raw []byte, headers []string, rows [][]string) {
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
PrintTable(headers, rows)
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogsCmd prints service runtime logs.
|
||||||
|
var LogsCmd = &cobra.Command{
|
||||||
|
Use: "logs <service-id>",
|
||||||
|
Short: "Print or follow service logs",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
follow, _ := cmd.Flags().GetBool("follow")
|
||||||
|
tail, _ := cmd.Flags().GetInt("tail")
|
||||||
|
path := fmt.Sprintf("/services/%s/logs?tail=%d", args[0], tail)
|
||||||
|
if follow {
|
||||||
|
return streamSSE(c, path+"&follow=true")
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return printLogEntries(data)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecCmd runs a one-off command inside a service container.
|
||||||
|
var ExecCmd = &cobra.Command{
|
||||||
|
Use: "exec <service-id> -- <command>",
|
||||||
|
Short: "Run a one-off command in the service container (30s ceiling)",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
command := strings.Join(args[1:], " ")
|
||||||
|
if command == "" {
|
||||||
|
return &APIError{Message: "no command given — usage: containr exec <id> -- <command>", ExitCode: ExitValidation}
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/services/"+args[0]+"/exec", map[string]string{"command": command})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
resp, _ := unwrapObject(data)
|
||||||
|
fmt.Print(str(resp, "output", "stdout"))
|
||||||
|
if code := str(resp, "exit_code", "exitCode"); code != "-" && code != "0" {
|
||||||
|
return &APIError{Message: fmt.Sprintf("command exited with %s", code), ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type logEntry struct {
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Stream string `json:"stream"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// printLogEntries decodes the {"logs":[...]} envelope both log endpoints use.
|
||||||
|
func printLogEntries(data []byte) error {
|
||||||
|
var payload struct {
|
||||||
|
Logs []logEntry `json:"logs"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &payload); err != nil {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, e := range payload.Logs {
|
||||||
|
fmt.Println(e.Message)
|
||||||
|
}
|
||||||
|
if len(payload.Logs) == 0 && payload.Message != "" {
|
||||||
|
fmt.Fprintln(os.Stderr, payload.Message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDeploymentLogs prints persisted build+runtime logs for a deployment.
|
||||||
|
func runDeploymentLogs(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logType, _ := cmd.Flags().GetString("type")
|
||||||
|
data, err := c.Do("GET", "/deployments/"+args[0]+"/logs?type="+logType, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
BuildLog string `json:"build_log"`
|
||||||
|
RuntimeLog string `json:"runtime_log"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &payload) == nil && (payload.BuildLog != "" || payload.RuntimeLog != "") {
|
||||||
|
if payload.BuildLog != "" {
|
||||||
|
fmt.Print(payload.BuildLog)
|
||||||
|
}
|
||||||
|
if payload.RuntimeLog != "" {
|
||||||
|
fmt.Print(payload.RuntimeLog)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return printLogEntries(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamSSE consumes a text/event-stream response and prints each
|
||||||
|
// `data:` line's message field until the server closes the stream.
|
||||||
|
func streamSSE(c *Client, path string) error {
|
||||||
|
req, err := http.NewRequest("GET", c.BaseURL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
req.Header.Set("Accept", "text/event-stream")
|
||||||
|
hc := c.HTTPClient
|
||||||
|
if hc == nil {
|
||||||
|
hc = http.DefaultClient
|
||||||
|
}
|
||||||
|
resp, err := hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return &APIError{Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err), ExitCode: ExitUnavailable}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return &APIError{Status: resp.StatusCode, ExitCode: exitCodeFor(resp.StatusCode)}
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
scanner.Buffer(make([]byte, 64<<10), 4<<20)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if !strings.HasPrefix(line, "data:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var e logEntry
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &e); err == nil {
|
||||||
|
fmt.Println(e.Message)
|
||||||
|
} else {
|
||||||
|
fmt.Println(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
LogsCmd.Flags().BoolP("follow", "f", false, "stream new log lines (SSE)")
|
||||||
|
LogsCmd.Flags().Int("tail", 100, "number of lines to return")
|
||||||
|
deploymentsLogsCmd.Flags().String("type", "all", "log type: all|build|runtime")
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NodesCmd manages node agents (admin).
|
||||||
|
var NodesCmd = &cobra.Command{
|
||||||
|
Use: "nodes",
|
||||||
|
Aliases: []string{"node", "agents"},
|
||||||
|
Short: "Manage node agents (admin)",
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodesListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List registered node agents",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/agents", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "agents")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, a := range items {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(a, "id"), str(a, "name", "hostname"), str(a, "status"), str(a, "version"), relTime(a, "last_heartbeat_at"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "NAME", "STATUS", "VERSION", "HEARTBEAT"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodesGetCmd = &cobra.Command{
|
||||||
|
Use: "get <id>",
|
||||||
|
Short: "Show a node agent",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/agents/"+args[0], nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodesDeleteCmd = &cobra.Command{
|
||||||
|
Use: "delete <id>",
|
||||||
|
Short: "Remove a node agent",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !Confirm(fmt.Sprintf("Remove node agent %s?", args[0])) {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if _, err := c.Do("DELETE", "/agents/"+args[0], nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||||
|
}
|
||||||
|
fmt.Printf("Removed agent %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent tokens (cagt_) onboard new nodes.
|
||||||
|
var nodeTokensCmd = &cobra.Command{
|
||||||
|
Use: "tokens",
|
||||||
|
Short: "Manage agent onboarding tokens",
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodeTokensIssueCmd = &cobra.Command{
|
||||||
|
Use: "issue [label]",
|
||||||
|
Short: "Issue an onboarding token (shown once)",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
if len(args) > 0 {
|
||||||
|
body["label"] = args[0]
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/agent-tokens", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodeTokensListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List onboarding tokens",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/agent-tokens", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodeTokensRevokeCmd = &cobra.Command{
|
||||||
|
Use: "revoke <id>",
|
||||||
|
Short: "Revoke an onboarding token",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("DELETE", "/agent-tokens/"+args[0], nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
|
||||||
|
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesDeleteCmd, nodeTokensCmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotificationsCmd lists and manages notifications.
|
||||||
|
var NotificationsCmd = &cobra.Command{
|
||||||
|
Use: "notifications",
|
||||||
|
Aliases: []string{"notifs"},
|
||||||
|
Short: "Manage notifications",
|
||||||
|
}
|
||||||
|
|
||||||
|
var notifListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List notifications",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/notifications", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "notifications")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, n := range items {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(n, "id"), str(n, "type"), str(n, "title"), str(n, "read"), relTime(n, "created_at"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "TYPE", "TITLE", "READ", "AGE"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var notifReadCmd = &cobra.Command{
|
||||||
|
Use: "read <id>|all",
|
||||||
|
Short: "Mark a notification (or all) as read",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := "/notifications/" + args[0] + "/read"
|
||||||
|
if args[0] == "all" {
|
||||||
|
path = "/notifications/read-all"
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", path, map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
NotificationsCmd.AddCommand(notifListCmd, notifReadCmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSONMode reports whether --json was passed. In JSON mode every command
|
||||||
|
// prints the raw API response and nothing else — safe for agents and pipes.
|
||||||
|
func JSONMode() bool {
|
||||||
|
return viper.GetBool("json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintResult renders data: pretty JSON in --json mode, otherwise the
|
||||||
|
// default JSON rendering is used unless a command prints its own table
|
||||||
|
// first (commands that render a table should check JSONMode() themselves).
|
||||||
|
func PrintJSON(v interface{}) error {
|
||||||
|
data, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(string(data))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintRaw prints raw response bytes as pretty JSON when they decode as
|
||||||
|
// JSON, else verbatim.
|
||||||
|
func PrintRaw(data []byte) {
|
||||||
|
var v interface{}
|
||||||
|
if json.Unmarshal(data, &v) == nil {
|
||||||
|
out, _ := json.MarshalIndent(v, "", " ")
|
||||||
|
fmt.Println(string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintTable renders rows under headers with tabwriter alignment.
|
||||||
|
// Skipped entirely in JSON mode.
|
||||||
|
func PrintTable(headers []string, rows [][]string) {
|
||||||
|
if JSONMode() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, strings.Join(headers, "\t"))
|
||||||
|
for _, row := range rows {
|
||||||
|
fmt.Fprintln(w, strings.Join(row, "\t"))
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm asks the user to confirm a destructive action. Returns true
|
||||||
|
// immediately when --yes/--force was passed or stdin is not a TTY and
|
||||||
|
// --yes was given. In non-interactive contexts without --yes it refuses.
|
||||||
|
func Confirm(prompt string) bool {
|
||||||
|
if viper.GetBool("yes") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !isTerminal() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
answer, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
answer = strings.ToLower(strings.TrimSpace(answer))
|
||||||
|
return answer == "y" || answer == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTerminal() bool {
|
||||||
|
info, err := os.Stdin.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return info.Mode()&os.ModeCharDevice != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExitCode inspects an error and returns the process exit code.
|
||||||
|
func ExitCode(err error) int {
|
||||||
|
if err == nil {
|
||||||
|
return ExitOK
|
||||||
|
}
|
||||||
|
if apiErr, ok := err.(*APIError); ok {
|
||||||
|
return apiErr.ExitCode
|
||||||
|
}
|
||||||
|
return ExitError
|
||||||
|
}
|
||||||
@@ -1,219 +1,123 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"containr/internal/pkg/utils"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Project represents a Containr project
|
// ProjectsCmd manages projects.
|
||||||
type Project struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
OwnerID string `json:"owner_id"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProjectsCmd represents the projects command
|
|
||||||
var ProjectsCmd = &cobra.Command{
|
var ProjectsCmd = &cobra.Command{
|
||||||
Use: "projects",
|
Use: "projects",
|
||||||
|
Aliases: []string{"project"},
|
||||||
Short: "Manage projects",
|
Short: "Manage projects",
|
||||||
Long: `Manage your Containr projects.
|
|
||||||
You can list, create, update, and delete projects.`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// listProjectsCmd represents the list command
|
var projectsListCmd = &cobra.Command{
|
||||||
var listProjectsCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List all projects",
|
Short: "List projects",
|
||||||
Long: `List all your Containr projects.`,
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
RunE: runListProjects,
|
c, err := client()
|
||||||
}
|
if err != nil {
|
||||||
|
return err
|
||||||
// createProjectCmd represents the create command
|
|
||||||
var createProjectCmd = &cobra.Command{
|
|
||||||
Use: "create [name]",
|
|
||||||
Short: "Create a new project",
|
|
||||||
Long: `Create a new Containr project.
|
|
||||||
Provide a name and optional description.`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runCreateProject,
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteProjectCmd represents the delete command
|
|
||||||
var deleteProjectCmd = &cobra.Command{
|
|
||||||
Use: "delete [project-id]",
|
|
||||||
Short: "Delete a project",
|
|
||||||
Long: `Delete a Containr project by ID.`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runDeleteProject,
|
|
||||||
}
|
|
||||||
|
|
||||||
var projectDescription string
|
|
||||||
|
|
||||||
// getAPIURL constructs the full API URL for a given endpoint
|
|
||||||
func getAPIURL(endpoint string) string {
|
|
||||||
baseURL := viper.GetString("api-url")
|
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = "http://localhost:8080/api/v1" // Default for development
|
|
||||||
}
|
}
|
||||||
|
data, err := c.Do("GET", "/projects", nil)
|
||||||
// Ensure baseURL doesn't end with / and endpoint starts with /
|
if err != nil {
|
||||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
return err
|
||||||
if !strings.HasPrefix(endpoint, "/") {
|
|
||||||
endpoint = "/" + endpoint
|
|
||||||
}
|
}
|
||||||
|
items, err := unwrapList(data, "projects")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, p := range items {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(p, "id"), str(p, "name"), str(p, "description"), relTime(p, "created_at"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "NAME", "DESCRIPTION", "CREATED"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return baseURL + endpoint
|
var projectsGetCmd = &cobra.Command{
|
||||||
|
Use: "get <id>",
|
||||||
|
Short: "Show a project",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/projects/"+args[0], nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p, _ := unwrapObject(data, "project")
|
||||||
|
fmt.Printf("ID: %s\n", str(p, "id"))
|
||||||
|
fmt.Printf("Name: %s\n", str(p, "name"))
|
||||||
|
fmt.Printf("Description: %s\n", str(p, "description"))
|
||||||
|
fmt.Printf("Created: %s\n", str(p, "created_at"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectsCreateCmd = &cobra.Command{
|
||||||
|
Use: "create <name>",
|
||||||
|
Short: "Create a project",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
desc, _ := cmd.Flags().GetString("description")
|
||||||
|
body := map[string]interface{}{"name": args[0]}
|
||||||
|
if desc != "" {
|
||||||
|
body["description"] = desc
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/projects", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p, _ := unwrapObject(data, "project")
|
||||||
|
fmt.Printf("Created project %s (%s)\n", str(p, "name"), str(p, "id"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectsDeleteCmd = &cobra.Command{
|
||||||
|
Use: "delete <id>",
|
||||||
|
Short: "Delete a project and all its services",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !Confirm(fmt.Sprintf("Delete project %s and all its services?", args[0])) {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if _, err := c.Do("DELETE", "/projects/"+args[0], nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||||
|
}
|
||||||
|
fmt.Printf("Deleted project %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
ProjectsCmd.AddCommand(listProjectsCmd)
|
projectsCreateCmd.Flags().StringP("description", "d", "", "project description")
|
||||||
ProjectsCmd.AddCommand(createProjectCmd)
|
ProjectsCmd.AddCommand(projectsListCmd, projectsGetCmd, projectsCreateCmd, projectsDeleteCmd)
|
||||||
ProjectsCmd.AddCommand(deleteProjectCmd)
|
|
||||||
|
|
||||||
// Add flags
|
|
||||||
createProjectCmd.Flags().StringVarP(&projectDescription, "description", "d", "", "Project description")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runListProjects(cmd *cobra.Command, args []string) error {
|
|
||||||
apiURL := getAPIURL("/projects")
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
client := &http.Client{}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to make request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var projects []Project
|
|
||||||
if err := json.Unmarshal(body, &projects); err != nil {
|
|
||||||
return fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(projects) == 0 {
|
|
||||||
fmt.Println("No projects found")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Your Projects:")
|
|
||||||
fmt.Println()
|
|
||||||
for _, project := range projects {
|
|
||||||
fmt.Printf("📦 %s (%s)\n", project.Name, project.ID)
|
|
||||||
if project.Description != "" {
|
|
||||||
fmt.Printf(" %s\n", project.Description)
|
|
||||||
}
|
|
||||||
fmt.Printf(" Created: %s\n", utils.FormatTime(project.CreatedAt))
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runCreateProject(cmd *cobra.Command, args []string) error {
|
|
||||||
name := args[0]
|
|
||||||
|
|
||||||
projectData := map[string]interface{}{
|
|
||||||
"name": name,
|
|
||||||
}
|
|
||||||
|
|
||||||
if projectDescription != "" {
|
|
||||||
projectData["description"] = projectDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonData, err := json.Marshal(projectData)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to marshal project data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
apiURL := getAPIURL("/projects")
|
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
client := &http.Client{}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to make request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusCreated {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var project Project
|
|
||||||
if err := json.Unmarshal(body, &project); err != nil {
|
|
||||||
return fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("✓ Project '%s' created successfully!\n", project.Name)
|
|
||||||
fmt.Printf("ID: %s\n", project.ID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDeleteProject(cmd *cobra.Command, args []string) error {
|
|
||||||
projectID := args[0]
|
|
||||||
|
|
||||||
apiURL := getAPIURL("/projects/" + projectID)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("DELETE", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
|
|
||||||
|
|
||||||
client := &http.Client{}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to make request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusNoContent {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("✓ Project '%s' deleted successfully!\n", projectID)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegistriesCmd manages private-image registry credentials.
|
||||||
|
var RegistriesCmd = &cobra.Command{
|
||||||
|
Use: "registries",
|
||||||
|
Aliases: []string{"registry"},
|
||||||
|
Short: "Manage registry credentials",
|
||||||
|
}
|
||||||
|
|
||||||
|
var registriesListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List registry credentials",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/registries", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "registries")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, r := range items {
|
||||||
|
hasPw := ""
|
||||||
|
if b, ok := r["has_password"].(bool); ok && b {
|
||||||
|
hasPw = "yes"
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{str(r, "id"), str(r, "name"), str(r, "host"), str(r, "username"), hasPw})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "NAME", "HOST", "USERNAME", "PASSWORD"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var registriesAddCmd = &cobra.Command{
|
||||||
|
Use: "add <name> <host>",
|
||||||
|
Short: "Add a registry credential (e.g. ghcr.io, registry.example.com, docker.io)",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{"name": args[0], "host": args[1]}
|
||||||
|
if v, _ := cmd.Flags().GetString("username"); v != "" {
|
||||||
|
body["username"] = v
|
||||||
|
}
|
||||||
|
if v, _ := cmd.Flags().GetString("password"); v != "" {
|
||||||
|
body["password"] = v
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/registries", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r, _ := unwrapObject(data, "registry")
|
||||||
|
fmt.Printf("Added registry %s (%s)\n", str(r, "name"), str(r, "host"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var registriesUpdateCmd = &cobra.Command{
|
||||||
|
Use: "update <id>",
|
||||||
|
Short: "Update a registry credential (empty --password keeps the stored one)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
for _, f := range []string{"name", "host", "username", "password"} {
|
||||||
|
if cmd.Flags().Changed(f) {
|
||||||
|
v, _ := cmd.Flags().GetString(f)
|
||||||
|
body[f] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
// The API requires name+host — backfill them from the stored row
|
||||||
|
// when the caller didn't supply both.
|
||||||
|
_, hasName := body["name"]
|
||||||
|
_, hasHost := body["host"]
|
||||||
|
if !hasName || !hasHost {
|
||||||
|
data, err := c.Do("GET", "/registries", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, _ := unwrapList(data, "registries")
|
||||||
|
for _, r := range items {
|
||||||
|
if str(r, "id") == args[0] {
|
||||||
|
if !hasName {
|
||||||
|
body["name"] = str(r, "name")
|
||||||
|
}
|
||||||
|
if !hasHost {
|
||||||
|
body["host"] = str(r, "host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := c.Do("PUT", "/registries/"+args[0], body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Updated registry %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var registriesRemoveCmd = &cobra.Command{
|
||||||
|
Use: "remove <id>",
|
||||||
|
Short: "Remove a registry credential",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !Confirm(fmt.Sprintf("Remove registry %s?", args[0])) {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if _, err := c.Do("DELETE", "/registries/"+args[0], nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||||
|
}
|
||||||
|
fmt.Printf("Removed registry %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registriesAddCmd.Flags().String("username", "", "registry username")
|
||||||
|
registriesAddCmd.Flags().String("password", "", "registry password/token (stored encrypted)")
|
||||||
|
registriesUpdateCmd.Flags().String("name", "", "display name")
|
||||||
|
registriesUpdateCmd.Flags().String("host", "", "registry host")
|
||||||
|
registriesUpdateCmd.Flags().String("username", "", "registry username")
|
||||||
|
registriesUpdateCmd.Flags().String("password", "", "registry password/token")
|
||||||
|
RegistriesCmd.AddCommand(registriesListCmd, registriesAddCmd, registriesUpdateCmd, registriesRemoveCmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServicesCmd manages services.
|
||||||
|
var ServicesCmd = &cobra.Command{
|
||||||
|
Use: "services",
|
||||||
|
Aliases: []string{"service", "svc"},
|
||||||
|
Short: "Manage services",
|
||||||
|
}
|
||||||
|
|
||||||
|
var servicesListCmd = &cobra.Command{
|
||||||
|
Use: "list [project-id]",
|
||||||
|
Short: "List services in a project",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/projects/"+args[0]+"/services", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "services")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, s := range items {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(s, "id"), str(s, "name"), str(s, "type"), str(s, "status"), str(s, "environment"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "NAME", "TYPE", "STATUS", "ENV"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var servicesGetCmd = &cobra.Command{
|
||||||
|
Use: "get <id>",
|
||||||
|
Short: "Show a service",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/services/"+args[0], nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s, _ := unwrapObject(data, "service")
|
||||||
|
for _, k := range []string{"id", "name", "type", "status", "environment", "image", "git_repo", "git_branch", "domain", "port"} {
|
||||||
|
fmt.Printf("%-14s %s\n", k+":", str(s, k))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var servicesCreateCmd = &cobra.Command{
|
||||||
|
Use: "create <project-id> <name>",
|
||||||
|
Short: "Create a service (image- or git-based)",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"name": args[1],
|
||||||
|
}
|
||||||
|
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
|
||||||
|
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||||
|
key := f
|
||||||
|
// JSON uses snake_case keys matching the API schema.
|
||||||
|
switch f {
|
||||||
|
case "git-repo":
|
||||||
|
key = "git_repo"
|
||||||
|
case "git-branch":
|
||||||
|
key = "git_branch"
|
||||||
|
case "build-path":
|
||||||
|
key = "build_path"
|
||||||
|
case "restart-policy":
|
||||||
|
key = "restart_policy"
|
||||||
|
case "healthcheck-path":
|
||||||
|
key = "healthcheck_path"
|
||||||
|
case "cpu-reserve":
|
||||||
|
key = "cpu_reserve"
|
||||||
|
case "memory-reserve":
|
||||||
|
key = "memory_reserve"
|
||||||
|
case "static-cmd":
|
||||||
|
key = "static_build_cmd"
|
||||||
|
case "static-dir":
|
||||||
|
key = "static_dir"
|
||||||
|
}
|
||||||
|
body[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, _ := cmd.Flags().GetInt("port"); v > 0 {
|
||||||
|
body["port"] = v
|
||||||
|
}
|
||||||
|
if v, _ := cmd.Flags().GetInt("replicas"); v > 0 {
|
||||||
|
body["replicas"] = v
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/projects/"+args[0]+"/services", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s, _ := unwrapObject(data, "service")
|
||||||
|
fmt.Printf("Created service %s (%s)\n", str(s, "name"), str(s, "id"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var servicesDeleteCmd = &cobra.Command{
|
||||||
|
Use: "delete <id>",
|
||||||
|
Short: "Delete a service",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !Confirm(fmt.Sprintf("Delete service %s?", args[0])) {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if _, err := c.Do("DELETE", "/services/"+args[0], nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||||
|
}
|
||||||
|
fmt.Printf("Deleted service %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var servicesUpdateCmd = &cobra.Command{
|
||||||
|
Use: "update <id>",
|
||||||
|
Short: "Update a service (volumes, replicas, domain, ...)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
|
||||||
|
if cmd.Flags().Changed(f) {
|
||||||
|
key := f
|
||||||
|
switch f {
|
||||||
|
case "restart-policy":
|
||||||
|
key = "restart_policy"
|
||||||
|
case "healthcheck-path":
|
||||||
|
key = "healthcheck_path"
|
||||||
|
case "cpu-reserve":
|
||||||
|
key = "cpu_reserve"
|
||||||
|
case "memory-reserve":
|
||||||
|
key = "memory_reserve"
|
||||||
|
case "static-cmd":
|
||||||
|
key = "static_build_cmd"
|
||||||
|
case "static-dir":
|
||||||
|
key = "static_dir"
|
||||||
|
}
|
||||||
|
v, _ := cmd.Flags().GetString(f)
|
||||||
|
body[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("replicas") {
|
||||||
|
v, _ := cmd.Flags().GetInt("replicas")
|
||||||
|
body["replicas"] = v
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("port") {
|
||||||
|
v, _ := cmd.Flags().GetInt("port")
|
||||||
|
body["port"] = v
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("volume") || cmd.Flags().Changed("bind") || cmd.Flags().Changed("clear-volumes") {
|
||||||
|
volumes := []map[string]interface{}{}
|
||||||
|
for _, spec := range volumeFlagValues(cmd, "volume", "bind") {
|
||||||
|
volumes = append(volumes, spec)
|
||||||
|
}
|
||||||
|
body["volumes"] = volumes
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("maintenance") {
|
||||||
|
v, _ := cmd.Flags().GetString("maintenance")
|
||||||
|
body["maintenance_mode"] = v == "on" || v == "true"
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("basic-auth") {
|
||||||
|
creds := []map[string]interface{}{}
|
||||||
|
pairs, _ := cmd.Flags().GetStringArray("basic-auth")
|
||||||
|
for _, p := range pairs {
|
||||||
|
user, pass, ok := strings.Cut(p, ":")
|
||||||
|
if ok {
|
||||||
|
creds = append(creds, map[string]interface{}{"username": user, "password": pass})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body["basic_auth"] = creds
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
data, err := c.Do("PUT", "/services/"+args[0], body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Updated service %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var servicesCloneCmd = &cobra.Command{
|
||||||
|
Use: "clone <id>",
|
||||||
|
Short: "Clone a service (config, volumes, domains, variables)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
for _, f := range []string{"name", "project", "environment"} {
|
||||||
|
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||||
|
key := f
|
||||||
|
if f == "project" {
|
||||||
|
key = "project_id"
|
||||||
|
}
|
||||||
|
body[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/services/"+args[0]+"/clone", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, _ := unwrapObject(data)
|
||||||
|
fmt.Printf("Cloned to service %s (%s)\n", str(m, "service_id"), str(m, "name"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var servicesMoveCmd = &cobra.Command{
|
||||||
|
Use: "move <id> <project-id>",
|
||||||
|
Short: "Move a service to another project",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/services/"+args[0]+"/move", map[string]interface{}{"project_id": args[1]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Moved service %s to project %s\n", args[0], args[1])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// volumeFlagValues parses repeated "src:target[:ro]" flags into API mount
|
||||||
|
// objects. Bind mounts are type=bind, named volumes type=volume.
|
||||||
|
func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} {
|
||||||
|
var out []map[string]interface{}
|
||||||
|
for _, f := range flags {
|
||||||
|
vals, _ := cmd.Flags().GetStringArray(f)
|
||||||
|
for _, v := range vals {
|
||||||
|
parts := strings.SplitN(v, ":", 3)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := map[string]interface{}{
|
||||||
|
"type": f,
|
||||||
|
"source": parts[0],
|
||||||
|
"target": parts[1],
|
||||||
|
}
|
||||||
|
if len(parts) == 3 && parts[2] == "ro" {
|
||||||
|
m["read_only"] = true
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var servicesDomainsCmd = &cobra.Command{
|
||||||
|
Use: "domains",
|
||||||
|
Short: "Manage service domains",
|
||||||
|
}
|
||||||
|
|
||||||
|
var domainsListCmd = &cobra.Command{
|
||||||
|
Use: "list <service-id>",
|
||||||
|
Short: "List domains attached to a service",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/services/"+args[0]+"/domains", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "domains")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, d := range items {
|
||||||
|
def := ""
|
||||||
|
if b, ok := d["is_default"].(bool); ok && b {
|
||||||
|
def = "*"
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{str(d, "id"), str(d, "domain"), def, str(d, "cert_status")})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "DOMAIN", "DEFAULT", "CERT"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var domainsAddCmd = &cobra.Command{
|
||||||
|
Use: "add <service-id> <domain>",
|
||||||
|
Short: "Attach a domain to a service",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
def, _ := cmd.Flags().GetBool("default")
|
||||||
|
data, err := c.Do("POST", "/services/"+args[0]+"/domains", map[string]interface{}{
|
||||||
|
"domain": args[1], "is_default": def,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Added domain %s\n", args[1])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var domainsRemoveCmd = &cobra.Command{
|
||||||
|
Use: "remove <service-id> <domain-id>",
|
||||||
|
Short: "Detach a domain",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := c.Do("DELETE", fmt.Sprintf("/services/%s/domains/%s", args[0], args[1]), nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]string{"status": "deleted", "id": args[1]})
|
||||||
|
}
|
||||||
|
fmt.Printf("Removed domain %s\n", args[1])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var domainsDefaultCmd = &cobra.Command{
|
||||||
|
Use: "default <service-id> <domain-id>",
|
||||||
|
Short: "Set the default domain",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", fmt.Sprintf("/services/%s/domains/%s/default", args[0], args[1]), map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Default domain updated\n")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var domainsCheckCmd = &cobra.Command{
|
||||||
|
Use: "check <service-id>",
|
||||||
|
Short: "DNS preflight for all service domains",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/services/"+args[0]+"/domains/check", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "domains")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, d := range items {
|
||||||
|
resolved, _ := d["resolved"].([]interface{})
|
||||||
|
addrs := make([]string, 0, len(resolved))
|
||||||
|
for _, a := range resolved {
|
||||||
|
if s, ok := a.(string); ok {
|
||||||
|
addrs = append(addrs, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{str(d, "domain"), str(d, "status"), strings.Join(addrs, ", ")})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"DOMAIN", "STATUS", "RESOLVED"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceAction returns a RunE for the simple POST action endpoints.
|
||||||
|
func serviceAction(verb string) func(*cobra.Command, []string) error {
|
||||||
|
return func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", fmt.Sprintf("/services/%s/%s", args[0], verb), map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: %s\n", verb, args[0])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
f := servicesCreateCmd.Flags()
|
||||||
|
f.String("type", "web", "service type: web|worker|database|cron")
|
||||||
|
f.String("image", "", "container image (image-based service)")
|
||||||
|
f.String("git-repo", "", "git repository URL (build-from-source service)")
|
||||||
|
f.String("git-branch", "main", "git branch")
|
||||||
|
f.String("build-path", "", "build context subdirectory")
|
||||||
|
f.String("command", "", "container start command override")
|
||||||
|
f.String("environment", "production", "production|preview|development")
|
||||||
|
f.String("domain", "", "public domain")
|
||||||
|
f.String("restart-policy", "unless-stopped", "docker restart policy")
|
||||||
|
f.String("healthcheck-path", "", "HTTP health check path")
|
||||||
|
f.String("cpu", "", "CPU limit (e.g. 0.5)")
|
||||||
|
f.String("memory", "", "memory limit (e.g. 512m)")
|
||||||
|
f.Int("port", 0, "container port")
|
||||||
|
f.Int("replicas", 0, "replica count")
|
||||||
|
f.String("builder", "", "build strategy: auto|railpack|nixpacks|dockerfile|static")
|
||||||
|
f.String("cpu-reserve", "", "soft CPU reservation (e.g. 0.25)")
|
||||||
|
f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)")
|
||||||
|
f.String("static-cmd", "", "build command for the static builder")
|
||||||
|
f.String("static-dir", "", "output dir for the static builder (default dist)")
|
||||||
|
|
||||||
|
uf := servicesUpdateCmd.Flags()
|
||||||
|
uf.String("name", "", "service name")
|
||||||
|
uf.String("image", "", "container image")
|
||||||
|
uf.String("command", "", "container start command")
|
||||||
|
uf.String("domain", "", "public domain (empty string clears)")
|
||||||
|
uf.String("restart-policy", "", "docker restart policy")
|
||||||
|
uf.String("healthcheck-path", "", "HTTP health check path")
|
||||||
|
uf.String("cpu", "", "CPU limit (e.g. 0.5)")
|
||||||
|
uf.String("memory", "", "memory limit (e.g. 512m)")
|
||||||
|
uf.Int("replicas", 0, "replica count")
|
||||||
|
uf.Int("port", 0, "container port")
|
||||||
|
uf.StringArray("volume", nil, "named volume mount name:path[:ro] (repeatable, replaces all mounts)")
|
||||||
|
uf.StringArray("bind", nil, "bind mount src:path[:ro] (repeatable)")
|
||||||
|
uf.Bool("clear-volumes", false, "remove all volume mounts")
|
||||||
|
uf.String("maintenance", "", "maintenance mode: on|off")
|
||||||
|
uf.StringArray("basic-auth", nil, "basic-auth credential user:password (repeatable, replaces all)")
|
||||||
|
uf.String("builder", "", "build strategy: auto|railpack|nixpacks|dockerfile|static")
|
||||||
|
uf.String("cpu-reserve", "", "soft CPU reservation (empty clears)")
|
||||||
|
uf.String("memory-reserve", "", "soft memory reservation (empty clears)")
|
||||||
|
uf.String("static-cmd", "", "build command for the static builder (empty clears)")
|
||||||
|
uf.String("static-dir", "", "output dir for the static builder (empty clears)")
|
||||||
|
|
||||||
|
for _, a := range []string{"start", "stop", "restart", "redeploy"} {
|
||||||
|
verb := a
|
||||||
|
ServicesCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: verb + " <id>",
|
||||||
|
Short: verb + " a service",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: serviceAction(verb),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
servicesCloneCmd.Flags().String("name", "", "name for the clone (default <name>-copy)")
|
||||||
|
servicesCloneCmd.Flags().String("project", "", "target project id (default: same project)")
|
||||||
|
servicesCloneCmd.Flags().String("environment", "", "target environment (default: source's)")
|
||||||
|
|
||||||
|
domainsAddCmd.Flags().Bool("default", false, "set as the default domain")
|
||||||
|
servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd)
|
||||||
|
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TemplatesCmd manages the template catalog.
|
||||||
|
var TemplatesCmd = &cobra.Command{
|
||||||
|
Use: "templates",
|
||||||
|
Aliases: []string{"template"},
|
||||||
|
Short: "Browse and deploy templates",
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List templates",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/templates", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "templates")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, t := range items {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(t, "id"), str(t, "name"), str(t, "category"), str(t, "description"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "NAME", "CATEGORY", "DESCRIPTION"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesGetCmd = &cobra.Command{
|
||||||
|
Use: "get <id>",
|
||||||
|
Short: "Show a template definition",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/templates/"+args[0], nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesCreateCmd = &cobra.Command{
|
||||||
|
Use: "create <file>",
|
||||||
|
Short: "Create a user template from a JSON file",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
raw, err := os.ReadFile(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot read %s: %w", args[0], err)
|
||||||
|
}
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/templates", raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesDeleteCmd = &cobra.Command{
|
||||||
|
Use: "delete <id>",
|
||||||
|
Short: "Delete a user template",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !Confirm(fmt.Sprintf("Delete template %s?", args[0])) {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if _, err := c.Do("DELETE", "/templates/"+args[0], nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||||
|
}
|
||||||
|
fmt.Printf("Deleted template %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesDeployCmd = &cobra.Command{
|
||||||
|
Use: "deploy <template-id> <project-id>",
|
||||||
|
Short: "Deploy a template into a project",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", map[string]string{"project_id": args[1]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokensCmd manages personal access tokens (cnp_...).
|
||||||
|
var TokensCmd = &cobra.Command{
|
||||||
|
Use: "tokens",
|
||||||
|
Short: "Manage personal access tokens",
|
||||||
|
Long: `Create, list, and revoke the cnp_ tokens used by this CLI,
|
||||||
|
the MCP server, and external agents.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokensListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List your tokens (metadata only)",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/user/tokens", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "tokens")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, t := range items {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(t, "id"), str(t, "name"), str(t, "key_prefix"), str(t, "scope"),
|
||||||
|
relTime(t, "last_used_at"), str(t, "expires_at"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "NAME", "PREFIX", "SCOPE", "LAST USED", "EXPIRES"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokensCreateCmd = &cobra.Command{
|
||||||
|
Use: "create <name>",
|
||||||
|
Short: "Create a token (raw value printed once)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
scope, _ := cmd.Flags().GetString("scope")
|
||||||
|
days, _ := cmd.Flags().GetInt("expires-in-days")
|
||||||
|
data, err := c.Do("POST", "/user/tokens", map[string]interface{}{
|
||||||
|
"name": args[0], "scope": scope, "expires_in_days": days,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t, _ := unwrapObject(data)
|
||||||
|
fmt.Println(str(t, "token"))
|
||||||
|
fmt.Fprintln(os.Stderr, "Store it now — it is shown only once. Scope: "+str(t, "scope"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokensRevokeCmd = &cobra.Command{
|
||||||
|
Use: "revoke <id>",
|
||||||
|
Short: "Revoke a token",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !Confirm(fmt.Sprintf("Revoke token %s? Clients using it lose access immediately.", args[0])) {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if _, err := c.Do("DELETE", "/user/tokens/"+args[0], nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]string{"status": "revoked", "id": args[0]})
|
||||||
|
}
|
||||||
|
fmt.Printf("Revoked token %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
tokensCreateCmd.Flags().String("scope", "write", "read|write|admin (admin requires an admin account)")
|
||||||
|
tokensCreateCmd.Flags().Int("expires-in-days", 0, "0 = never expires, max 3650")
|
||||||
|
TokensCmd.AddCommand(tokensListCmd, tokensCreateCmd, tokensRevokeCmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpCmd deploys the current directory: resolves a project, creates (or
|
||||||
|
// reuses) a service named after the directory, and triggers a deployment.
|
||||||
|
// The git remote URL and current branch become the build source.
|
||||||
|
var UpCmd = &cobra.Command{
|
||||||
|
Use: "up",
|
||||||
|
Short: "Deploy the current directory (create service + deploy)",
|
||||||
|
Long: `Deploy the current directory to Containr.
|
||||||
|
|
||||||
|
The git remote and current branch are used as the build source. If no
|
||||||
|
service named after this directory exists in the project, one is
|
||||||
|
created; an existing service is redeployed.`,
|
||||||
|
RunE: runUp,
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUp(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID, _ := cmd.Flags().GetString("project")
|
||||||
|
if projectID == "" {
|
||||||
|
projectID = viper.GetString("profiles." + ActiveProfile() + ".project_id")
|
||||||
|
}
|
||||||
|
if projectID == "" {
|
||||||
|
return &APIError{
|
||||||
|
Message: "no project selected — pass --project <id> or set project_id on the active profile",
|
||||||
|
ExitCode: ExitValidation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name, _ := cmd.Flags().GetString("name")
|
||||||
|
if name == "" {
|
||||||
|
name = filepath.Base(cwd)
|
||||||
|
}
|
||||||
|
repo := gitOutput(cwd, "config", "--get", "remote.origin.url")
|
||||||
|
branch := gitOutput(cwd, "rev-parse", "--abbrev-ref", "HEAD")
|
||||||
|
commit := gitOutput(cwd, "rev-parse", "HEAD")
|
||||||
|
image, _ := cmd.Flags().GetString("image")
|
||||||
|
|
||||||
|
if repo == "" && image == "" {
|
||||||
|
return &APIError{
|
||||||
|
Message: "no git remote and no --image — either init git with an origin remote or pass --image",
|
||||||
|
ExitCode: ExitValidation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse an existing same-named service when present.
|
||||||
|
svcID, _ := cmd.Flags().GetString("service")
|
||||||
|
if svcID == "" {
|
||||||
|
if data, err := c.Do("GET", "/projects/"+projectID+"/services", nil); err == nil {
|
||||||
|
if items, err := unwrapList(data, "services"); err == nil {
|
||||||
|
for _, s := range items {
|
||||||
|
if str(s, "name") == name {
|
||||||
|
svcID = str(s, "id")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if svcID == "" {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"type": "web",
|
||||||
|
"environment": "production",
|
||||||
|
}
|
||||||
|
if repo != "" {
|
||||||
|
body["git_repo"] = repo
|
||||||
|
if branch != "" {
|
||||||
|
body["git_branch"] = branch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if image != "" {
|
||||||
|
body["image"] = image
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/projects/"+projectID+"/services", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s, _ := unwrapObject(data, "service")
|
||||||
|
svcID = str(s, "id")
|
||||||
|
if svcID == "" || svcID == "-" {
|
||||||
|
PrintRaw(data)
|
||||||
|
return &APIError{Message: "service created but id missing from response", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if !JSONMode() {
|
||||||
|
fmt.Fprintf(os.Stderr, "Created service %s (%s)\n", name, svcID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deployBody := map[string]interface{}{"trigger": "cli-up"}
|
||||||
|
if commit != "" {
|
||||||
|
deployBody["commit_hash"] = commit
|
||||||
|
}
|
||||||
|
if branch != "" {
|
||||||
|
deployBody["branch"] = branch
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/services/"+svcID+"/deployments", deployBody)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d, _ := unwrapObject(data, "deployment")
|
||||||
|
fmt.Printf("Deploying %s → service %s (deployment %s)\n", name, svcID, str(d, "id"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitOutput(dir string, args ...string) string {
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
UpCmd.Flags().String("project", "", "target project id (or set project_id on the profile)")
|
||||||
|
UpCmd.Flags().String("name", "", "service name (default: directory name)")
|
||||||
|
UpCmd.Flags().String("service", "", "existing service id to redeploy")
|
||||||
|
UpCmd.Flags().String("image", "", "deploy this image instead of building from git")
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VariablesCmd manages service environment variables.
|
||||||
|
var VariablesCmd = &cobra.Command{
|
||||||
|
Use: "variables",
|
||||||
|
Aliases: []string{"vars", "env"},
|
||||||
|
Short: "Manage service environment variables",
|
||||||
|
}
|
||||||
|
|
||||||
|
var varsListCmd = &cobra.Command{
|
||||||
|
Use: "list <service-id>",
|
||||||
|
Short: "List variables (secrets shown masked)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/services/"+args[0]+"/variables", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "variables")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, v := range items {
|
||||||
|
rows = append(rows, []string{str(v, "key"), str(v, "value"), str(v, "is_secret")})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"KEY", "VALUE", "SECRET"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variables are replaced wholesale server-side — set/unset fetch the
|
||||||
|
// current list, mutate it, and PUT it back. Masked secret placeholders are
|
||||||
|
// preserved by the server.
|
||||||
|
var varsSetCmd = &cobra.Command{
|
||||||
|
Use: "set <service-id> KEY=VALUE [KEY=VALUE...]",
|
||||||
|
Short: "Set one or more variables",
|
||||||
|
Args: cobra.MinimumNArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
secret, _ := cmd.Flags().GetBool("secret")
|
||||||
|
redeploy, _ := cmd.Flags().GetBool("redeploy")
|
||||||
|
return mutateVars(serviceVarsPath(args[0]), args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
|
||||||
|
for _, pair := range args[1:] {
|
||||||
|
k, v, found := strings.Cut(pair, "=")
|
||||||
|
if !found || k == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vars = upsertVar(vars, k, v, secret)
|
||||||
|
}
|
||||||
|
return vars
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var varsUnsetCmd = &cobra.Command{
|
||||||
|
Use: "unset <service-id> KEY [KEY...]",
|
||||||
|
Short: "Remove variables",
|
||||||
|
Args: cobra.MinimumNArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
drop := map[string]bool{}
|
||||||
|
for _, k := range args[1:] {
|
||||||
|
drop[k] = true
|
||||||
|
}
|
||||||
|
redeploy, _ := cmd.Flags().GetBool("redeploy")
|
||||||
|
return mutateVars(serviceVarsPath(args[0]), args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
|
||||||
|
out := vars[:0]
|
||||||
|
for _, v := range vars {
|
||||||
|
if !drop[str(v, "key")] {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared variables — project scope, referenced as ${{shared.KEY}}.
|
||||||
|
|
||||||
|
var varsSharedCmd = &cobra.Command{
|
||||||
|
Use: "shared",
|
||||||
|
Short: "Manage project-level shared variables (${{shared.KEY}})",
|
||||||
|
}
|
||||||
|
|
||||||
|
var sharedVarsListCmd = &cobra.Command{
|
||||||
|
Use: "list <project-id>",
|
||||||
|
Short: "List shared project variables (secrets shown masked)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", projectVarsPath(args[0]), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "variables")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, v := range items {
|
||||||
|
rows = append(rows, []string{str(v, "key"), str(v, "value"), str(v, "is_secret")})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"KEY", "VALUE", "SECRET"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var sharedVarsSetCmd = &cobra.Command{
|
||||||
|
Use: "set <project-id> KEY=VALUE [KEY=VALUE...]",
|
||||||
|
Short: "Set shared project variables",
|
||||||
|
Args: cobra.MinimumNArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
secret, _ := cmd.Flags().GetBool("secret")
|
||||||
|
return mutateVars(projectVarsPath(args[0]), args[0], false, func(vars []map[string]interface{}) []map[string]interface{} {
|
||||||
|
for _, pair := range args[1:] {
|
||||||
|
k, v, found := strings.Cut(pair, "=")
|
||||||
|
if !found || k == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vars = upsertVar(vars, k, v, secret)
|
||||||
|
}
|
||||||
|
return vars
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var sharedVarsUnsetCmd = &cobra.Command{
|
||||||
|
Use: "unset <project-id> KEY [KEY...]",
|
||||||
|
Short: "Remove shared project variables",
|
||||||
|
Args: cobra.MinimumNArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
drop := map[string]bool{}
|
||||||
|
for _, k := range args[1:] {
|
||||||
|
drop[k] = true
|
||||||
|
}
|
||||||
|
return mutateVars(projectVarsPath(args[0]), args[0], false, func(vars []map[string]interface{}) []map[string]interface{} {
|
||||||
|
out := vars[:0]
|
||||||
|
for _, v := range vars {
|
||||||
|
if !drop[str(v, "key")] {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceVarsPath(serviceID string) string { return "/services/" + serviceID + "/variables" }
|
||||||
|
func projectVarsPath(projectID string) string { return "/projects/" + projectID + "/variables" }
|
||||||
|
|
||||||
|
func mutateVars(path, label string, redeploy bool, mutate func([]map[string]interface{}) []map[string]interface{}) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vars, err := unwrapList(data, "variables")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vars = mutate(vars)
|
||||||
|
payload := make([]map[string]interface{}, 0, len(vars))
|
||||||
|
for _, v := range vars {
|
||||||
|
payload = append(payload, map[string]interface{}{
|
||||||
|
"key": str(v, "key"),
|
||||||
|
"value": str(v, "value"),
|
||||||
|
"is_secret": v["is_secret"] == true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{"variables": payload}
|
||||||
|
if redeploy {
|
||||||
|
body["redeploy"] = true
|
||||||
|
}
|
||||||
|
data, err = c.Do("PUT", path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
obj, _ := unwrapObject(data)
|
||||||
|
if obj["redeploy_queued"] == true {
|
||||||
|
fmt.Printf("Variables updated on %s — redeploy queued\n", label)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Variables updated on %s — redeploy to apply to running containers\n", label)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertVar(vars []map[string]interface{}, key, value string, secret bool) []map[string]interface{} {
|
||||||
|
for _, v := range vars {
|
||||||
|
if str(v, "key") == key {
|
||||||
|
v["value"] = value
|
||||||
|
v["is_secret"] = secret
|
||||||
|
return vars
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(vars, map[string]interface{}{"key": key, "value": value, "is_secret": secret})
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
varsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
|
||||||
|
varsSetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
|
||||||
|
varsUnsetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
|
||||||
|
sharedVarsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
|
||||||
|
varsSharedCmd.AddCommand(sharedVarsListCmd, sharedVarsSetCmd, sharedVarsUnsetCmd)
|
||||||
|
VariablesCmd.AddCommand(varsListCmd, varsSetCmd, varsUnsetCmd, varsSharedCmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VolumesCmd manages docker volumes on the node (admin).
|
||||||
|
var VolumesCmd = &cobra.Command{
|
||||||
|
Use: "volumes",
|
||||||
|
Aliases: []string{"volume"},
|
||||||
|
Short: "Manage docker volumes (admin)",
|
||||||
|
}
|
||||||
|
|
||||||
|
var volumesListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List docker volumes with in-use flags",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/admin/volumes", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "volumes")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, v := range items {
|
||||||
|
inUse := "no"
|
||||||
|
if b, ok := v["in_use"].(bool); ok && b {
|
||||||
|
inUse = "yes"
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{str(v, "name"), str(v, "driver"), inUse})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"NAME", "DRIVER", "IN USE"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var volumesDeleteCmd = &cobra.Command{
|
||||||
|
Use: "delete <name>",
|
||||||
|
Short: "Delete an unused docker volume",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !Confirm(fmt.Sprintf("Delete volume %s?", args[0])) {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if _, err := c.Do("DELETE", "/admin/volumes/"+args[0], nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]string{"status": "deleted", "name": args[0]})
|
||||||
|
}
|
||||||
|
fmt.Printf("Deleted volume %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
VolumesCmd.AddCommand(volumesListCmd, volumesDeleteCmd)
|
||||||
|
}
|
||||||
@@ -12,23 +12,35 @@ import (
|
|||||||
|
|
||||||
var cfgFile string
|
var cfgFile string
|
||||||
|
|
||||||
// rootCmd represents the base command when called without any subcommands
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "containr",
|
Use: "containr",
|
||||||
Short: "Containr CLI - Manage your self-hosted PaaS",
|
Short: "Containr CLI - manage your self-hosted PaaS",
|
||||||
Long: `Containr CLI is a command-line interface for managing your Containr platform.
|
Long: `Containr CLI controls every part of a Containr instance: projects,
|
||||||
You can manage projects, services, deployments, databases, and more from your terminal.`,
|
services, deployments, databases, cron jobs, templates, nodes, scaling,
|
||||||
Version: "1.0.0",
|
HA, security, and the API gateway.
|
||||||
|
|
||||||
|
Authenticate once with a personal access token (Settings → Personal
|
||||||
|
Access Tokens in the web UI):
|
||||||
|
|
||||||
|
containr auth login cnp_...
|
||||||
|
|
||||||
|
Every command supports --json for machine-readable output, and
|
||||||
|
destructive actions accept --yes to skip confirmation.`,
|
||||||
|
Version: "2.0.0",
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
// Execute runs the root command and returns the process exit code.
|
||||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
func Execute() int {
|
||||||
func Execute() {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
cobra.CheckErr(rootCmd.Execute())
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
return commands.ExitCode(err)
|
||||||
|
}
|
||||||
|
return commands.ExitOK
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetVersion overrides the version reported by `containr --version`.
|
// SetVersion overrides the version reported by `containr --version`.
|
||||||
// Called by main with the value injected via -ldflags at build time.
|
|
||||||
func SetVersion(v string) {
|
func SetVersion(v string) {
|
||||||
rootCmd.Version = v
|
rootCmd.Version = v
|
||||||
}
|
}
|
||||||
@@ -36,46 +48,58 @@ func SetVersion(v string) {
|
|||||||
func init() {
|
func init() {
|
||||||
cobra.OnInitialize(initConfig)
|
cobra.OnInitialize(initConfig)
|
||||||
|
|
||||||
// Global flags
|
pf := rootCmd.PersistentFlags()
|
||||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.containr.yaml)")
|
pf.StringVar(&cfgFile, "config", "", "config file (default $HOME/.containr.yaml)")
|
||||||
rootCmd.PersistentFlags().String("api-url", "", "Containr API URL (default is https://api.containr.dev)")
|
pf.String("api-url", "", "Containr API URL (overrides profile and CONTAINR_API_URL)")
|
||||||
rootCmd.PersistentFlags().String("token", "", "Authentication token")
|
pf.String("token", "", "auth token (overrides profile and CONTAINR_TOKEN)")
|
||||||
|
pf.StringP("profile", "p", "", "config profile to use (default current_profile)")
|
||||||
|
pf.Bool("json", false, "machine-readable JSON output")
|
||||||
|
pf.BoolP("yes", "y", false, "skip confirmation prompts (required for destructive ops in scripts)")
|
||||||
|
pf.String("idempotency-key", "", "Idempotency-Key header for POST mutations (safe retries)")
|
||||||
|
|
||||||
// Bind flags to viper
|
viper.BindPFlag("api-url", pf.Lookup("api-url"))
|
||||||
viper.BindPFlag("api-url", rootCmd.PersistentFlags().Lookup("api-url"))
|
viper.BindPFlag("token", pf.Lookup("token"))
|
||||||
viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token"))
|
viper.BindPFlag("profile", pf.Lookup("profile"))
|
||||||
|
viper.BindPFlag("json", pf.Lookup("json"))
|
||||||
|
viper.BindPFlag("yes", pf.Lookup("yes"))
|
||||||
|
viper.BindPFlag("idempotency-key", pf.Lookup("idempotency-key"))
|
||||||
|
|
||||||
// Add command groups
|
|
||||||
rootCmd.AddCommand(commands.AuthCmd)
|
rootCmd.AddCommand(commands.AuthCmd)
|
||||||
|
rootCmd.AddCommand(commands.TokensCmd)
|
||||||
rootCmd.AddCommand(commands.ProjectsCmd)
|
rootCmd.AddCommand(commands.ProjectsCmd)
|
||||||
|
rootCmd.AddCommand(commands.ServicesCmd)
|
||||||
|
rootCmd.AddCommand(commands.DeployCmd)
|
||||||
|
rootCmd.AddCommand(commands.DeploymentsCmd)
|
||||||
|
rootCmd.AddCommand(commands.LogsCmd)
|
||||||
|
rootCmd.AddCommand(commands.ExecCmd)
|
||||||
|
rootCmd.AddCommand(commands.VariablesCmd)
|
||||||
|
rootCmd.AddCommand(commands.VolumesCmd)
|
||||||
|
rootCmd.AddCommand(commands.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()
|
||||||
viper.AutomaticEnv() // read in environment variables that match
|
// CONTAINR_TOKEN / CONTAINR_API_URL are read explicitly in config.go;
|
||||||
|
// AutomaticEnv covers them only when bound, so this is belt-and-braces.
|
||||||
// If a config file is found, read it in.
|
viper.ReadInConfig()
|
||||||
if err := viper.ReadInConfig(); err == nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run executes the CLI
|
|
||||||
func Run() error {
|
|
||||||
rootCmd.Execute()
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
)
|
|
||||||
|
|
||||||
// getAPIURL constructs the full API URL for a given endpoint
|
|
||||||
func getAPIURL(endpoint string) string {
|
|
||||||
baseURL := viper.GetString("api-url")
|
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = "http://localhost:8080/api/v1" // Default for development
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure baseURL doesn't end with / and endpoint starts with /
|
|
||||||
if strings.HasSuffix(baseURL, "/") {
|
|
||||||
baseURL = baseURL[:len(baseURL)-1]
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(endpoint, "/") {
|
|
||||||
endpoint = "/" + endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseURL + endpoint
|
|
||||||
}
|
|
||||||
@@ -88,6 +88,52 @@ func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseS
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createExternalDatabaseService = `-- name: CreateExternalDatabaseService :exec
|
||||||
|
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region,
|
||||||
|
provider, external_host, external_port, external_name, external_username, external_password, external_ssl,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, 'running', $5, $6, $7, 'external', $8, $9, $10, $11, $12, $13, $14, $15)
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateExternalDatabaseServiceParams struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Plan string `json:"plan"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
ExternalHost sql.NullString `json:"external_host"`
|
||||||
|
ExternalPort sql.NullInt32 `json:"external_port"`
|
||||||
|
ExternalName sql.NullString `json:"external_name"`
|
||||||
|
ExternalUsername sql.NullString `json:"external_username"`
|
||||||
|
ExternalPassword string `json:"external_password"`
|
||||||
|
ExternalSsl bool `json:"external_ssl"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, createExternalDatabaseService,
|
||||||
|
arg.ID,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Type,
|
||||||
|
arg.Version,
|
||||||
|
arg.Plan,
|
||||||
|
arg.Region,
|
||||||
|
arg.ExternalHost,
|
||||||
|
arg.ExternalPort,
|
||||||
|
arg.ExternalName,
|
||||||
|
arg.ExternalUsername,
|
||||||
|
arg.ExternalPassword,
|
||||||
|
arg.ExternalSsl,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const databaseServiceExistsByIDAndUser = `-- name: DatabaseServiceExistsByIDAndUser :one
|
const databaseServiceExistsByIDAndUser = `-- name: DatabaseServiceExistsByIDAndUser :one
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
SELECT 1 FROM database_services WHERE id = $1 AND user_id = $2
|
SELECT 1 FROM database_services WHERE id = $1 AND user_id = $2
|
||||||
@@ -150,7 +196,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
|
const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
|
||||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.created_at, database_services.updated_at
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -170,6 +216,13 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
|
|||||||
&i.ConnectionUrl,
|
&i.ConnectionUrl,
|
||||||
&i.BackupSchedule,
|
&i.BackupSchedule,
|
||||||
&i.NextBackupAt,
|
&i.NextBackupAt,
|
||||||
|
&i.Provider,
|
||||||
|
&i.ExternalHost,
|
||||||
|
&i.ExternalPort,
|
||||||
|
&i.ExternalName,
|
||||||
|
&i.ExternalUsername,
|
||||||
|
&i.ExternalPassword,
|
||||||
|
&i.ExternalSsl,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -177,7 +230,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
|
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
|
||||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.created_at, database_services.updated_at
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
`
|
`
|
||||||
@@ -202,6 +255,13 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
|
|||||||
&i.ConnectionUrl,
|
&i.ConnectionUrl,
|
||||||
&i.BackupSchedule,
|
&i.BackupSchedule,
|
||||||
&i.NextBackupAt,
|
&i.NextBackupAt,
|
||||||
|
&i.Provider,
|
||||||
|
&i.ExternalHost,
|
||||||
|
&i.ExternalPort,
|
||||||
|
&i.ExternalName,
|
||||||
|
&i.ExternalUsername,
|
||||||
|
&i.ExternalPassword,
|
||||||
|
&i.ExternalSsl,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -222,7 +282,7 @@ func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (str
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
|
const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
|
||||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.created_at, database_services.updated_at
|
||||||
FROM database_services
|
FROM database_services
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
@@ -248,6 +308,13 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic
|
|||||||
&i.ConnectionUrl,
|
&i.ConnectionUrl,
|
||||||
&i.BackupSchedule,
|
&i.BackupSchedule,
|
||||||
&i.NextBackupAt,
|
&i.NextBackupAt,
|
||||||
|
&i.Provider,
|
||||||
|
&i.ExternalHost,
|
||||||
|
&i.ExternalPort,
|
||||||
|
&i.ExternalName,
|
||||||
|
&i.ExternalUsername,
|
||||||
|
&i.ExternalPassword,
|
||||||
|
&i.ExternalSsl,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -311,7 +378,7 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
|
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
|
||||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.created_at, database_services.updated_at
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -338,6 +405,13 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
|
|||||||
&i.ConnectionUrl,
|
&i.ConnectionUrl,
|
||||||
&i.BackupSchedule,
|
&i.BackupSchedule,
|
||||||
&i.NextBackupAt,
|
&i.NextBackupAt,
|
||||||
|
&i.Provider,
|
||||||
|
&i.ExternalHost,
|
||||||
|
&i.ExternalPort,
|
||||||
|
&i.ExternalName,
|
||||||
|
&i.ExternalUsername,
|
||||||
|
&i.ExternalPassword,
|
||||||
|
&i.ExternalSsl,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ type DatabaseService struct {
|
|||||||
ConnectionUrl sql.NullString `json:"connection_url"`
|
ConnectionUrl sql.NullString `json:"connection_url"`
|
||||||
BackupSchedule sql.NullString `json:"backup_schedule"`
|
BackupSchedule sql.NullString `json:"backup_schedule"`
|
||||||
NextBackupAt sql.NullTime `json:"next_backup_at"`
|
NextBackupAt sql.NullTime `json:"next_backup_at"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
ExternalHost sql.NullString `json:"external_host"`
|
||||||
|
ExternalPort sql.NullInt32 `json:"external_port"`
|
||||||
|
ExternalName sql.NullString `json:"external_name"`
|
||||||
|
ExternalUsername sql.NullString `json:"external_username"`
|
||||||
|
ExternalPassword string `json:"external_password"`
|
||||||
|
ExternalSsl bool `json:"external_ssl"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -167,6 +174,17 @@ type ProjectMember struct {
|
|||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
OwnerID uuid.UUID `json:"owner_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -195,6 +213,31 @@ type Service struct {
|
|||||||
BuildPath sql.NullString `json:"build_path"`
|
BuildPath sql.NullString `json:"build_path"`
|
||||||
Cpu sql.NullString `json:"cpu"`
|
Cpu sql.NullString `json:"cpu"`
|
||||||
Memory sql.NullString `json:"memory"`
|
Memory sql.NullString `json:"memory"`
|
||||||
|
Replicas int32 `json:"replicas"`
|
||||||
|
Port int32 `json:"port"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
HealthcheckPath string `json:"healthcheck_path"`
|
||||||
|
RestartPolicy string `json:"restart_policy"`
|
||||||
|
PublishedPort int32 `json:"published_port"`
|
||||||
|
Volumes json.RawMessage `json:"volumes"`
|
||||||
|
MaintenanceMode bool `json:"maintenance_mode"`
|
||||||
|
BasicAuthUsers string `json:"basic_auth_users"`
|
||||||
|
Builder string `json:"builder"`
|
||||||
|
CpuReserve string `json:"cpu_reserve"`
|
||||||
|
MemoryReserve string `json:"memory_reserve"`
|
||||||
|
StaticBuildCmd string `json:"static_build_cmd"`
|
||||||
|
StaticDir string `json:"static_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceDomain struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ServiceID uuid.UUID `json:"service_id"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
CertType string `json:"cert_type"`
|
||||||
|
CertStatus string `json:"cert_status"`
|
||||||
|
LastCheckedAt sql.NullTime `json:"last_checked_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServiceTemplate struct {
|
type ServiceTemplate struct {
|
||||||
@@ -211,3 +254,16 @@ type ServiceTemplate struct {
|
|||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserToken struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
KeyPrefix string `json:"key_prefix"`
|
||||||
|
TokenHash string `json:"token_hash"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
ExpiresAt sql.NullTime `json:"expires_at"`
|
||||||
|
LastUsedAt sql.NullTime `json:"last_used_at"`
|
||||||
|
RevokedAt sql.NullTime `json:"revoked_at"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -22,10 +22,12 @@ type Querier interface {
|
|||||||
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
|
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
|
||||||
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
|
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
|
||||||
CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error
|
CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error
|
||||||
|
CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error
|
||||||
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
|
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
|
||||||
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||||
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
||||||
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
|
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
|
||||||
|
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
|
||||||
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
||||||
DeleteAgent(ctx context.Context, id string) error
|
DeleteAgent(ctx context.Context, id string) error
|
||||||
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
||||||
@@ -46,6 +48,7 @@ type Querier interface {
|
|||||||
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||||
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
||||||
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
|
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
|
||||||
|
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
|
||||||
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
|
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
|
||||||
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
|
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
|
||||||
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
||||||
@@ -66,9 +69,11 @@ type Querier interface {
|
|||||||
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
||||||
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
||||||
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
|
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
|
||||||
|
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
|
||||||
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
||||||
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
||||||
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
||||||
|
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
|
||||||
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
||||||
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
||||||
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
|
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
|
||||||
@@ -77,6 +82,7 @@ type Querier interface {
|
|||||||
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
||||||
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
|
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
|
||||||
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
|
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
|
||||||
|
TouchUserToken(ctx context.Context, id uuid.UUID) error
|
||||||
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
|
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
|
||||||
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
||||||
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: user_tokens.sql
|
||||||
|
|
||||||
|
package sqlcdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createUserToken = `-- name: CreateUserToken :one
|
||||||
|
INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateUserTokenParams struct {
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
KeyPrefix string `json:"key_prefix"`
|
||||||
|
TokenHash string `json:"token_hash"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
ExpiresAt sql.NullTime `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createUserToken,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Name,
|
||||||
|
arg.KeyPrefix,
|
||||||
|
arg.TokenHash,
|
||||||
|
arg.Scope,
|
||||||
|
arg.ExpiresAt,
|
||||||
|
)
|
||||||
|
var i UserToken
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.KeyPrefix,
|
||||||
|
&i.TokenHash,
|
||||||
|
&i.Scope,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.LastUsedAt,
|
||||||
|
&i.RevokedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserTokenByHash = `-- name: GetUserTokenByHash :one
|
||||||
|
SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens
|
||||||
|
WHERE token_hash = $1
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getUserTokenByHash, tokenHash)
|
||||||
|
var i UserToken
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.KeyPrefix,
|
||||||
|
&i.TokenHash,
|
||||||
|
&i.Scope,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.LastUsedAt,
|
||||||
|
&i.RevokedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listUserTokens = `-- name: ListUserTokens :many
|
||||||
|
SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens
|
||||||
|
WHERE user_id = $1 AND revoked_at IS NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listUserTokens, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []UserToken{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i UserToken
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.KeyPrefix,
|
||||||
|
&i.TokenHash,
|
||||||
|
&i.Scope,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.LastUsedAt,
|
||||||
|
&i.RevokedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokeUserToken = `-- name: RevokeUserToken :execrows
|
||||||
|
UPDATE user_tokens SET revoked_at = NOW()
|
||||||
|
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
type RevokeUserTokenParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, revokeUserToken, arg.ID, arg.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
const touchUserToken = `-- name: TouchUserToken :exec
|
||||||
|
UPDATE user_tokens SET last_used_at = NOW()
|
||||||
|
WHERE id = $1 AND revoked_at IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) TouchUserToken(ctx context.Context, id uuid.UUID) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, touchUserToken, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -57,6 +57,11 @@ type ServiceConfig struct {
|
|||||||
PublicPort int32 `json:"public_port,omitempty"`
|
PublicPort int32 `json:"public_port,omitempty"`
|
||||||
// Domain is a public hostname routed to PublicPort via Traefik.
|
// Domain is a public hostname routed to PublicPort via Traefik.
|
||||||
Domain string `json:"domain,omitempty"`
|
Domain string `json:"domain,omitempty"`
|
||||||
|
// Domains is the full hostname set (includes Domain when set).
|
||||||
|
Domains []string `json:"domains,omitempty"`
|
||||||
|
Maintenance bool `json:"maintenance,omitempty"`
|
||||||
|
MaintenanceURL string `json:"maintenance_url,omitempty"`
|
||||||
|
BasicAuthUsers string `json:"basic_auth_users,omitempty"`
|
||||||
// HealthPath is an HTTP path probed on PublicPort for container health.
|
// HealthPath is an HTTP path probed on PublicPort for container health.
|
||||||
HealthPath string `json:"health_path,omitempty"`
|
HealthPath string `json:"health_path,omitempty"`
|
||||||
// PublishedPort is the host port bound by the previous deployment; replica 0
|
// PublishedPort is the host port bound by the previous deployment; replica 0
|
||||||
@@ -80,6 +85,7 @@ 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"`
|
||||||
@@ -147,9 +153,14 @@ 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.
|
||||||
|
PullUsername string `json:"pull_username,omitempty"`
|
||||||
|
PullPassword string `json:"pull_password,omitempty"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
Commit string `json:"commit"`
|
Commit string `json:"commit"`
|
||||||
|
NoCache bool `json:"no_cache"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TriggerConfig struct {
|
type TriggerConfig struct {
|
||||||
@@ -160,6 +171,12 @@ 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,
|
||||||
@@ -284,13 +301,17 @@ 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)
|
||||||
@@ -323,7 +344,14 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
|||||||
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 {
|
||||||
@@ -334,8 +362,6 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
|||||||
spec.RestartPolicy = "unless-stopped"
|
spec.RestartPolicy = "unless-stopped"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Volume mounts aren't reconciled replicas-aware yet; still applied on the
|
|
||||||
// container config of every replica via spec extension later if needed.
|
|
||||||
state, err := de.ReconcileService(ctx, spec)
|
state, err := de.ReconcileService(ctx, spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
)
|
)
|
||||||
@@ -46,7 +47,14 @@ type RuntimeSpec struct {
|
|||||||
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.
|
||||||
@@ -67,7 +75,9 @@ type RuntimeState struct {
|
|||||||
Status string `json:"status"` // running, degraded, stopped
|
Status string `json:"status"` // running, degraded, stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
func projectNetworkName(projectID string) string {
|
// ProjectNetworkName is the deterministic docker network every service in a
|
||||||
|
// project joins — containr-proj-<first 12 hex chars of the project uuid>.
|
||||||
|
func ProjectNetworkName(projectID string) string {
|
||||||
short := strings.ReplaceAll(projectID, "-", "")
|
short := strings.ReplaceAll(projectID, "-", "")
|
||||||
if len(short) > 12 {
|
if len(short) > 12 {
|
||||||
short = short[:12]
|
short = short[:12]
|
||||||
@@ -82,7 +92,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 {
|
||||||
@@ -223,12 +233,45 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
|
|||||||
projectNet: {Aliases: []string{spec.Name}},
|
projectNet: {Aliases: []string{spec.Name}},
|
||||||
}
|
}
|
||||||
if edgeNet != "" {
|
if edgeNet != "" {
|
||||||
|
router := "svc-" + spec.ServiceID[:8]
|
||||||
|
domains := spec.Domains
|
||||||
|
if len(domains) == 0 && spec.Domain != "" {
|
||||||
|
domains = []string{spec.Domain}
|
||||||
|
}
|
||||||
|
sort.Strings(domains)
|
||||||
|
rules := make([]string, 0, len(domains))
|
||||||
|
for _, d := range domains {
|
||||||
|
rules = append(rules, "Host(`"+d+"`)")
|
||||||
|
}
|
||||||
endpoints[edgeNet] = &network.EndpointSettings{}
|
endpoints[edgeNet] = &network.EndpointSettings{}
|
||||||
labels["traefik.enable"] = "true"
|
labels["traefik.enable"] = "true"
|
||||||
labels["traefik.docker.network"] = edgeNet
|
labels["traefik.docker.network"] = edgeNet
|
||||||
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".rule"] = "Host(`" + spec.Domain + "`)"
|
labels["traefik.http.routers."+router+".rule"] = strings.Join(rules, " || ")
|
||||||
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".entrypoints"] = "web"
|
labels["traefik.http.routers."+router+".entrypoints"] = "web"
|
||||||
labels["traefik.http.services.svc-"+spec.ServiceID[:8]+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
|
labels["traefik.http.services."+router+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
|
||||||
|
|
||||||
|
var middlewares []string
|
||||||
|
if spec.Maintenance {
|
||||||
|
if spec.MaintenanceURL != "" {
|
||||||
|
mw := router + "-maint"
|
||||||
|
labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
|
||||||
|
labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
|
||||||
|
labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
|
||||||
|
middlewares = append(middlewares, mw)
|
||||||
|
} else {
|
||||||
|
// No redirect target configured — take the site offline with
|
||||||
|
// empty responses instead of leaking traffic to the app.
|
||||||
|
labels["traefik.http.routers."+router+".service"] = "noop@internal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if spec.BasicAuthUsers != "" {
|
||||||
|
mw := router + "-auth"
|
||||||
|
labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
|
||||||
|
middlewares = append(middlewares, mw)
|
||||||
|
}
|
||||||
|
if len(middlewares) > 0 {
|
||||||
|
labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := docker.ContainerConfig{
|
cfg := docker.ContainerConfig{
|
||||||
@@ -239,10 +282,25 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
|
|||||||
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,
|
||||||
|
CPUShares: spec.CPUShares,
|
||||||
Networks: endpoints,
|
Networks: endpoints,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, v := range spec.Volumes {
|
||||||
|
mountType := mount.TypeVolume
|
||||||
|
if v.Type == "bind" {
|
||||||
|
mountType = mount.TypeBind
|
||||||
|
}
|
||||||
|
cfg.Mounts = append(cfg.Mounts, mount.Mount{
|
||||||
|
Type: mountType,
|
||||||
|
Source: v.Source,
|
||||||
|
Target: v.Destination,
|
||||||
|
ReadOnly: v.ReadOnly,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var wantPort nat.Port
|
var wantPort nat.Port
|
||||||
if spec.Port > 0 {
|
if spec.Port > 0 {
|
||||||
wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
|
wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
|
||||||
@@ -364,7 +422,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)
|
||||||
}
|
}
|
||||||
@@ -456,7 +514,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 {
|
||||||
@@ -472,8 +530,15 @@ 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])
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package deployqueue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Job is a unit of serialized deployment work. DeploymentID is the
|
||||||
|
// deployments row it mirrors (uuid.Nil for runtime ops with no row).
|
||||||
|
// Run is invoked with a cancellable context — cancelled by Cancel for
|
||||||
|
// active jobs, or the job is dropped before starting when queued.
|
||||||
|
type Job struct {
|
||||||
|
DeploymentID uuid.UUID
|
||||||
|
Run func(ctx context.Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue serializes deploy work per service: one active job, the rest FIFO.
|
||||||
|
// A build for service A never races a redeploy or second build of A, while
|
||||||
|
// services B, C proceed in parallel. Not a global worker pool — per-service
|
||||||
|
// serialization is what prevents container thrash.
|
||||||
|
type Queue struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
states map[uuid.UUID]*serviceState
|
||||||
|
cancels map[uuid.UUID]context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceState struct {
|
||||||
|
running bool
|
||||||
|
pending []Job
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Queue {
|
||||||
|
return &Queue{
|
||||||
|
states: make(map[uuid.UUID]*serviceState),
|
||||||
|
cancels: make(map[uuid.UUID]context.CancelFunc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue schedules a job. Returns the position: 0 means it starts
|
||||||
|
// immediately, >0 means that many jobs run first.
|
||||||
|
func (q *Queue) Enqueue(serviceID uuid.UUID, job Job) int {
|
||||||
|
q.mu.Lock()
|
||||||
|
st := q.states[serviceID]
|
||||||
|
if st == nil {
|
||||||
|
st = &serviceState{}
|
||||||
|
q.states[serviceID] = st
|
||||||
|
}
|
||||||
|
if st.running {
|
||||||
|
st.pending = append(st.pending, job)
|
||||||
|
pos := len(st.pending)
|
||||||
|
q.mu.Unlock()
|
||||||
|
return pos
|
||||||
|
}
|
||||||
|
st.running = true
|
||||||
|
q.mu.Unlock()
|
||||||
|
|
||||||
|
q.start(serviceID, job)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) start(serviceID uuid.UUID, job Job) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
if job.DeploymentID != uuid.Nil {
|
||||||
|
q.mu.Lock()
|
||||||
|
q.cancels[job.DeploymentID] = cancel
|
||||||
|
q.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if job.DeploymentID != uuid.Nil {
|
||||||
|
q.mu.Lock()
|
||||||
|
delete(q.cancels, job.DeploymentID)
|
||||||
|
q.mu.Unlock()
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
q.promote(serviceID)
|
||||||
|
}()
|
||||||
|
job.Run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// promote marks the service free and starts the next queued job, if any.
|
||||||
|
func (q *Queue) promote(serviceID uuid.UUID) {
|
||||||
|
q.mu.Lock()
|
||||||
|
st := q.states[serviceID]
|
||||||
|
if st == nil || len(st.pending) == 0 {
|
||||||
|
if st != nil {
|
||||||
|
st.running = false
|
||||||
|
}
|
||||||
|
q.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := st.pending[0]
|
||||||
|
st.pending = st.pending[1:]
|
||||||
|
q.mu.Unlock()
|
||||||
|
q.start(serviceID, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelState distinguishes what Cancel found.
|
||||||
|
type CancelState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
NotFound CancelState = iota
|
||||||
|
WasQueued // removed before it started — caller marks DB cancelled
|
||||||
|
WasActive // ctx cancelled — the running job marks DB itself
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cancel cancels a job by its deployment row id.
|
||||||
|
func (q *Queue) Cancel(serviceID, deploymentID uuid.UUID) CancelState {
|
||||||
|
q.mu.Lock()
|
||||||
|
if st := q.states[serviceID]; st != nil {
|
||||||
|
for i, j := range st.pending {
|
||||||
|
if j.DeploymentID == deploymentID {
|
||||||
|
st.pending = append(st.pending[:i], st.pending[i+1:]...)
|
||||||
|
q.mu.Unlock()
|
||||||
|
return WasQueued
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel, ok := q.cancels[deploymentID]
|
||||||
|
q.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
cancel()
|
||||||
|
return WasActive
|
||||||
|
}
|
||||||
|
return NotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do runs fn through the queue and waits for completion — for synchronous
|
||||||
|
// endpoints (redeploy) that still must not overlap an in-flight deployment.
|
||||||
|
// ctx bounds the wait, not the job; on timeout the job still runs to
|
||||||
|
// completion to avoid orphaned reconcile state.
|
||||||
|
func (q *Queue) Do(ctx context.Context, serviceID uuid.UUID, fn func(ctx context.Context)) {
|
||||||
|
done := make(chan struct{})
|
||||||
|
q.Enqueue(serviceID, Job{Run: func(jctx context.Context) {
|
||||||
|
defer close(done)
|
||||||
|
fn(jctx)
|
||||||
|
}})
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package deployqueue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSerializesPerService(t *testing.T) {
|
||||||
|
q := New()
|
||||||
|
svc := uuid.New()
|
||||||
|
|
||||||
|
var active, maxActive int32
|
||||||
|
finish := make(chan struct{})
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
q.Enqueue(svc, Job{Run: func(ctx context.Context) {
|
||||||
|
if n := atomic.AddInt32(&active, 1); n > atomic.LoadInt32(&maxActive) {
|
||||||
|
atomic.StoreInt32(&maxActive, n)
|
||||||
|
}
|
||||||
|
<-finish // hold until released
|
||||||
|
atomic.AddInt32(&active, -1)
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.After(2 * time.Second)
|
||||||
|
for atomic.LoadInt32(&active) < 1 {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("first job never started")
|
||||||
|
default:
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(finish)
|
||||||
|
deadline = time.After(2 * time.Second)
|
||||||
|
for atomic.LoadInt32(&active) > 0 {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("jobs did not drain")
|
||||||
|
default:
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&maxActive); got != 1 {
|
||||||
|
t.Fatalf("expected max concurrency 1, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDifferentServicesRunInParallel(t *testing.T) {
|
||||||
|
q := New()
|
||||||
|
started := make(chan struct{}, 2)
|
||||||
|
release := make(chan struct{})
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
q.Enqueue(uuid.New(), Job{Run: func(ctx context.Context) {
|
||||||
|
started <- struct{}{}
|
||||||
|
<-release
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("parallel services did not both start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelQueuedRemovesJob(t *testing.T) {
|
||||||
|
q := New()
|
||||||
|
svc := uuid.New()
|
||||||
|
release := make(chan struct{})
|
||||||
|
q.Enqueue(svc, Job{DeploymentID: uuid.New(), Run: func(ctx context.Context) { <-release }})
|
||||||
|
|
||||||
|
queuedID := uuid.New()
|
||||||
|
ran := make(chan struct{})
|
||||||
|
q.Enqueue(svc, Job{DeploymentID: queuedID, Run: func(ctx context.Context) { close(ran) }})
|
||||||
|
|
||||||
|
if got := q.Cancel(svc, queuedID); got != WasQueued {
|
||||||
|
t.Fatalf("expected WasQueued, got %v", got)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
select {
|
||||||
|
case <-ran:
|
||||||
|
t.Fatal("cancelled queued job still ran")
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelActiveCancelsContext(t *testing.T) {
|
||||||
|
q := New()
|
||||||
|
svc := uuid.New()
|
||||||
|
id := uuid.New()
|
||||||
|
ctxDone := make(chan struct{})
|
||||||
|
q.Enqueue(svc, Job{DeploymentID: id, Run: func(ctx context.Context) {
|
||||||
|
<-ctx.Done()
|
||||||
|
close(ctxDone)
|
||||||
|
}})
|
||||||
|
|
||||||
|
deadline := time.After(2 * time.Second)
|
||||||
|
for {
|
||||||
|
q.mu.Lock()
|
||||||
|
_, ok := q.cancels[id]
|
||||||
|
q.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("job never registered")
|
||||||
|
default:
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := q.Cancel(svc, id); got != WasActive {
|
||||||
|
t.Fatalf("expected WasActive, got %v", got)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctxDone:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("active job context not cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnqueuePosition(t *testing.T) {
|
||||||
|
q := New()
|
||||||
|
svc := uuid.New()
|
||||||
|
release := make(chan struct{})
|
||||||
|
if pos := q.Enqueue(svc, Job{Run: func(ctx context.Context) { <-release }}); pos != 0 {
|
||||||
|
t.Fatalf("first job position = %d, want 0", pos)
|
||||||
|
}
|
||||||
|
if pos := q.Enqueue(svc, Job{Run: func(ctx context.Context) {}}); pos != 1 {
|
||||||
|
t.Fatalf("second job position = %d, want 1", pos)
|
||||||
|
}
|
||||||
|
if pos := q.Enqueue(svc, Job{Run: func(ctx context.Context) {}}); pos != 2 {
|
||||||
|
t.Fatalf("third job position = %d, want 2", pos)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
}
|
||||||
@@ -157,7 +157,9 @@ func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (s
|
|||||||
Mounts: config.Mounts,
|
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),
|
||||||
}
|
}
|
||||||
@@ -291,13 +293,42 @@ 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) {
|
||||||
return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
|
resp, err := 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
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ type ContainerConfig struct {
|
|||||||
PortBindings nat.PortMap
|
PortBindings nat.PortMap
|
||||||
Mounts []mount.Mount
|
Mounts []mount.Mount
|
||||||
Memory int64
|
Memory int64
|
||||||
|
MemoryReservation int64
|
||||||
NanoCPUs int64
|
NanoCPUs int64
|
||||||
|
CPUShares int64
|
||||||
NetworkMode string
|
NetworkMode string
|
||||||
Networks map[string]*network.EndpointSettings
|
Networks map[string]*network.EndpointSettings
|
||||||
Healthcheck *container.HealthConfig
|
Healthcheck *container.HealthConfig
|
||||||
@@ -44,6 +46,7 @@ type BuildOptions struct {
|
|||||||
BuildArgs map[string]*string
|
BuildArgs map[string]*string
|
||||||
Labels map[string]string
|
Labels map[string]string
|
||||||
Remove bool
|
Remove bool
|
||||||
|
NoCache bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkConfig represents the configuration for creating a network
|
// NetworkConfig represents the configuration for creating a network
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ha
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -80,6 +81,8 @@ type HealthChecker struct {
|
|||||||
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
|
||||||
@@ -596,7 +599,11 @@ 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
|
||||||
@@ -617,12 +624,20 @@ 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()
|
||||||
|
|
||||||
var err error
|
host, port, err := hc.resolveTarget(ctx, check)
|
||||||
|
if err != nil {
|
||||||
|
result.Status = HealthStatusUnhealthy
|
||||||
|
result.Message = err.Error()
|
||||||
|
result.ErrorCode = "TARGET_UNRESOLVED"
|
||||||
|
result.Latency = time.Since(start)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
switch check.Type {
|
switch check.Type {
|
||||||
case HealthCheckTypeHTTP:
|
case HealthCheckTypeHTTP:
|
||||||
err = hc.performHTTPCheck(checkCtx, check)
|
err = hc.performHTTPCheck(checkCtx, check, host, port)
|
||||||
case HealthCheckTypeTCP:
|
case HealthCheckTypeTCP:
|
||||||
err = hc.performTCPCheck(checkCtx, check)
|
err = hc.performTCPCheck(checkCtx, check, host, port)
|
||||||
case HealthCheckTypeCommand:
|
case HealthCheckTypeCommand:
|
||||||
err = hc.performCommandCheck(checkCtx, check)
|
err = hc.performCommandCheck(checkCtx, check)
|
||||||
default:
|
default:
|
||||||
@@ -642,13 +657,11 @@ func (hc *HealthChecker) performHealthCheck(ctx context.Context, check *HealthCh
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthCheck) error {
|
func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthCheck, host string, port int) 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
|
||||||
@@ -684,9 +697,7 @@ func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthChec
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hc *HealthChecker) performTCPCheck(ctx context.Context, check *HealthCheck) error {
|
func (hc *HealthChecker) performTCPCheck(ctx context.Context, check *HealthCheck, host string, port int) error {
|
||||||
host := hc.resolveHealthCheckHost(check)
|
|
||||||
port := check.Config.Port
|
|
||||||
if port <= 0 {
|
if port <= 0 {
|
||||||
port = 80
|
port = 80
|
||||||
}
|
}
|
||||||
@@ -723,6 +734,27 @@ 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()
|
||||||
@@ -739,19 +771,109 @@ 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.
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
// 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_register_external", "Register a database hosted elsewhere — probed first, password stored encrypted. Args: name (required), type (required), host (required), port, database, username, password, ssl.", "POST", "/databases/register-external", nil, []string{"name", "type", "host"}, false})
|
||||||
|
register(spec{"containr_databases_test_connection", "Probe a DB connection ad-hoc. Args: type, host (required), port, database, username, password, ssl.", "POST", "/databases/test-connection", nil, []string{"type", "host"}, false})
|
||||||
|
register(spec{"containr_databases_test_connection_by_id", "Re-probe a registered external database with its stored credentials. Args: id.", "POST", "/databases/{id}/test-connection", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_databases_action", "Start/stop/restart a database. Args: id, action.", "POST", "/databases/{id}/action", []string{"id"}, []string{"action"}, false})
|
||||||
|
register(spec{"containr_databases_backup", "Take a manual backup snapshot", "POST", "/databases/{id}/backup", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_databases_restore", "Restore a database from a backup — overwrites current data. Requires confirm:true.", "POST", "/databases/{id}/restore", []string{"id"}, []string{"backup_id"}, true})
|
||||||
|
register(spec{"containr_databases_delete", "Delete a database and its data. Requires confirm:true.", "DELETE", "/databases/{id}", []string{"id"}, nil, true})
|
||||||
|
|
||||||
|
// Cron
|
||||||
|
register(spec{"containr_cron_list", "List cron jobs", "GET", "/cron-jobs", nil, nil, false})
|
||||||
|
register(spec{"containr_cron_create", "Create a cron job. Args: service_id, schedule (5-field), command, name, timezone.", "POST", "/cron-jobs", nil, nil, false})
|
||||||
|
register(spec{"containr_cron_trigger", "Run a cron job now", "POST", "/cron-jobs/{id}/trigger", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_cron_executions", "List a cron job's execution history", "GET", "/cron-jobs/{id}/executions", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_cron_delete", "Delete a cron job. Requires confirm:true.", "DELETE", "/cron-jobs/{id}", []string{"id"}, nil, true})
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
register(spec{"containr_templates_list", "List templates", "GET", "/templates", nil, nil, false})
|
||||||
|
register(spec{"containr_templates_get", "Get a template definition", "GET", "/templates/{id}", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
|
||||||
|
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
|
||||||
|
|
||||||
|
// Nodes / agent tokens (admin)
|
||||||
|
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false})
|
||||||
|
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
|
||||||
|
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
|
||||||
|
|
||||||
|
// Scaling / HA / security
|
||||||
|
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
|
||||||
|
register(spec{"containr_scaling_policies", "List scaling policies", "GET", "/scaling/policies", nil, nil, false})
|
||||||
|
register(spec{"containr_scaling_scale", "Manually scale a service. Args: service_id, replicas. (admin)", "POST", "/scaling/services/{service_id}/scale", []string{"service_id"}, []string{"replicas"}, false})
|
||||||
|
register(spec{"containr_ha_status", "HA manager status", "GET", "/ha/status", nil, nil, false})
|
||||||
|
register(spec{"containr_ha_alerts", "Active HA alerts", "GET", "/ha/alerts/active", nil, nil, false})
|
||||||
|
register(spec{"containr_ha_failover", "Trigger a manual failover — reschedules services. Requires confirm:true. (admin)", "POST", "/ha/failover", nil, nil, true})
|
||||||
|
register(spec{"containr_security_scan", "Run a security scan. Args: project_id (required), scan_type, service_id.", "POST", "/security/scans", nil, nil, false})
|
||||||
|
register(spec{"containr_security_vulnerabilities", "List a project's vulnerabilities", "GET", "/projects/{project_id}/vulnerabilities", []string{"project_id"}, nil, false})
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
register(spec{"containr_admin_overview", "Platform overview (admin)", "GET", "/admin/overview", nil, nil, false})
|
||||||
|
register(spec{"containr_admin_users", "List all users (admin)", "GET", "/admin/users", nil, nil, false})
|
||||||
|
register(spec{"containr_admin_audit_logs", "List audit log entries (admin)", "GET", "/audit-logs", nil, nil, false})
|
||||||
|
register(spec{"containr_gateway_services", "List gateway services (admin)", "GET", "/gateway/services", nil, nil, false})
|
||||||
|
register(spec{"containr_gateway_keys", "List gateway API keys (admin)", "GET", "/gateway/keys", nil, nil, false})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the stdio MCP server. apiURL/token come from env or config —
|
||||||
|
// the caller resolves them before calling.
|
||||||
|
func Run(ctx context.Context, apiURL, token, version string) error {
|
||||||
|
if token == "" {
|
||||||
|
return fmt.Errorf("CONTAINR_TOKEN is required (create a cnp_ token under Settings → Personal Access Tokens)")
|
||||||
|
}
|
||||||
|
client := &commands.Client{BaseURL: apiURL, Token: token}
|
||||||
|
|
||||||
|
server := mcp.NewServer(&mcp.Implementation{
|
||||||
|
Name: "containr",
|
||||||
|
Version: version,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
for _, s := range tools {
|
||||||
|
s := s
|
||||||
|
server.AddTool(&mcp.Tool{
|
||||||
|
Name: s.name,
|
||||||
|
Description: s.description,
|
||||||
|
InputSchema: schemaFor(s),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return call(ctx, client, s, req)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.Run(ctx, &mcp.StdioTransport{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemaFor builds a JSON Schema for the tool's arguments: path params +
|
||||||
|
// body keys + confirm for destructive tools. Properties accept any JSON
|
||||||
|
// type — the API validates values.
|
||||||
|
func schemaFor(s spec) map[string]interface{} {
|
||||||
|
props := map[string]interface{}{}
|
||||||
|
var required []string
|
||||||
|
for _, p := range s.params {
|
||||||
|
props[p] = map[string]interface{}{"type": "string", "description": "path parameter"}
|
||||||
|
required = append(required, p)
|
||||||
|
}
|
||||||
|
bodySrc := s.bodyKeys
|
||||||
|
if len(bodySrc) == 0 && s.method != "GET" && s.method != "DELETE" {
|
||||||
|
// Unrestricted body: advertise nothing, accept anything.
|
||||||
|
}
|
||||||
|
for _, k := range bodySrc {
|
||||||
|
props[k] = map[string]interface{}{"description": "request body field"}
|
||||||
|
}
|
||||||
|
if s.destructive {
|
||||||
|
props["confirm"] = map[string]interface{}{"type": "boolean", "description": "must be true — this operation is destructive"}
|
||||||
|
required = append(required, "confirm")
|
||||||
|
}
|
||||||
|
schema := map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": props,
|
||||||
|
"additionalProperties": true,
|
||||||
|
}
|
||||||
|
if len(required) > 0 {
|
||||||
|
schema["required"] = required
|
||||||
|
}
|
||||||
|
return schema
|
||||||
|
}
|
||||||
|
|
||||||
|
func call(ctx context.Context, c *commands.Client, s spec, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
var args map[string]interface{}
|
||||||
|
if len(req.Params.Arguments) > 0 {
|
||||||
|
if err := json.Unmarshal(req.Params.Arguments, &args); err != nil {
|
||||||
|
return errorResult("invalid arguments: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if args == nil {
|
||||||
|
args = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.destructive && args["confirm"] != true {
|
||||||
|
return errorResult("destructive operation — pass confirm:true to proceed"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
path := s.path
|
||||||
|
for _, p := range s.params {
|
||||||
|
v, ok := args[p]
|
||||||
|
if !ok || fmt.Sprint(v) == "" {
|
||||||
|
return errorResult(fmt.Sprintf("missing required argument %q", p)), nil
|
||||||
|
}
|
||||||
|
path = strings.ReplaceAll(path, "{"+p+"}", fmt.Sprint(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body interface{}
|
||||||
|
var query []string
|
||||||
|
for k, v := range args {
|
||||||
|
if k == "confirm" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inParams := false
|
||||||
|
for _, p := range s.params {
|
||||||
|
if k == p {
|
||||||
|
inParams = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inParams {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.method == "GET" || s.method == "DELETE" {
|
||||||
|
// Non-path args on reads become query parameters.
|
||||||
|
query = append(query, url.QueryEscape(k)+"="+url.QueryEscape(fmt.Sprint(v)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(s.bodyKeys) > 0 {
|
||||||
|
allowed := false
|
||||||
|
for _, b := range s.bodyKeys {
|
||||||
|
if k == b {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body == nil {
|
||||||
|
body = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
body.(map[string]interface{})[k] = v
|
||||||
|
}
|
||||||
|
if len(query) > 0 {
|
||||||
|
sep := "?"
|
||||||
|
if strings.Contains(path, "?") {
|
||||||
|
sep = "&"
|
||||||
|
}
|
||||||
|
path += sep + strings.Join(query, "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := c.Do(s.method, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func textResult(data []byte) *mcp.CallToolResult {
|
||||||
|
var v interface{}
|
||||||
|
if json.Unmarshal(data, &v) != nil {
|
||||||
|
v = string(data)
|
||||||
|
}
|
||||||
|
out, _ := json.MarshalIndent(v, "", " ")
|
||||||
|
return &mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{&mcp.TextContent{Text: string(out)}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorResult(msg string) *mcp.CallToolResult {
|
||||||
|
return &mcp.CallToolResult{
|
||||||
|
IsError: true,
|
||||||
|
Content: []mcp.Content{&mcp.TextContent{Text: msg}},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Idempotency replays POST responses for a repeated Idempotency-Key header
|
||||||
|
// so agents can safely retry mutations. The first request wins; concurrent
|
||||||
|
// duplicates get 409 IDEMPOTENCY_IN_PROGRESS; completed responses replay
|
||||||
|
// verbatim for 24h. Keys are scoped to the authenticated identity.
|
||||||
|
func Idempotency() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if c.Request.Method != http.MethodPost {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := c.GetHeader("Idempotency-Key")
|
||||||
|
if key == "" || len(key) > 128 {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbValue, exists := c.Get("redis")
|
||||||
|
if !exists {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rdb, ok := dbValue.(*database.Redis)
|
||||||
|
if !ok || rdb == nil || rdb.Client == nil {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope by caller identity so a stolen key can't replay across users.
|
||||||
|
actor := c.GetString("user_id")
|
||||||
|
if actor == "" {
|
||||||
|
actor = c.GetHeader("Authorization")
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(actor + ":" + c.Request.URL.Path + ":" + key))
|
||||||
|
redisKey := "idem:" + hex.EncodeToString(sum[:16])
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
stored, err := rdb.Client.Get(ctx, redisKey).Result()
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
if stored == "processing" {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{
|
||||||
|
"error": "a request with this idempotency key is still in progress",
|
||||||
|
"code": "IDEMPOTENCY_IN_PROGRESS",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rec idemRecord
|
||||||
|
if json.Unmarshal([]byte(stored), &rec) == nil {
|
||||||
|
c.Header("X-Idempotent-Replay", "true")
|
||||||
|
c.Data(rec.Status, rec.ContentType, rec.Body)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case err != redis.Nil:
|
||||||
|
// Redis hiccup — degrade to pass-through rather than failing the request.
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim the key for the duration of the request.
|
||||||
|
if set, serr := rdb.Client.SetNX(ctx, redisKey, "processing", 2*time.Minute).Result(); serr != nil || !set {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{
|
||||||
|
"error": "a request with this idempotency key is still in progress",
|
||||||
|
"code": "IDEMPOTENCY_IN_PROGRESS",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := &responseRecorder{ResponseWriter: c.Writer, status: http.StatusOK, body: &bytes.Buffer{}}
|
||||||
|
c.Writer = recorder
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
// Cache terminal responses only — 5xx is retryable and must not be replayed.
|
||||||
|
if recorder.status < 500 && recorder.body.Len() <= 1<<20 {
|
||||||
|
rec := idemRecord{
|
||||||
|
Status: recorder.status,
|
||||||
|
ContentType: recorder.Header().Get("Content-Type"),
|
||||||
|
Body: recorder.body.Bytes(),
|
||||||
|
}
|
||||||
|
if rec.ContentType == "" {
|
||||||
|
rec.ContentType = "application/json; charset=utf-8"
|
||||||
|
}
|
||||||
|
if raw, merr := json.Marshal(rec); merr == nil {
|
||||||
|
_ = rdb.Client.Set(context.Background(), redisKey, raw, 24*time.Hour).Err()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_ = rdb.Client.Del(context.Background(), redisKey).Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type idemRecord struct {
|
||||||
|
Status int `json:"s"`
|
||||||
|
ContentType string `json:"ct"`
|
||||||
|
Body []byte `json:"b"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type responseRecorder struct {
|
||||||
|
gin.ResponseWriter
|
||||||
|
status int
|
||||||
|
body *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *responseRecorder) WriteHeader(code int) {
|
||||||
|
r.status = code
|
||||||
|
r.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *responseRecorder) Write(b []byte) (int, error) {
|
||||||
|
r.body.Write(b)
|
||||||
|
return r.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIdempotencyPassThroughWithoutKey(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
called := 0
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(Idempotency())
|
||||||
|
router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/x", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if called != 1 || rec.Code != 200 {
|
||||||
|
t.Fatalf("expected pass-through, called=%d code=%d", called, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdempotencyPassThroughWithoutRedis(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
called := 0
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(Idempotency())
|
||||||
|
router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) })
|
||||||
|
|
||||||
|
// Key present but no redis in context — must not block the request.
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/x", nil)
|
||||||
|
req.Header.Set("Idempotency-Key", "abc-123")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if called != 1 || rec.Code != 200 {
|
||||||
|
t.Fatalf("expected pass-through without redis, called=%d code=%d", called, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdempotencySkipsNonPOST(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
called := 0
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(Idempotency())
|
||||||
|
router.GET("/x", func(c *gin.Context) { called++; c.String(200, "ok") })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||||
|
req.Header.Set("Idempotency-Key", "abc")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if called != 1 {
|
||||||
|
t.Fatalf("GET should bypass idempotency, called=%d", called)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
|
sqlcdb "containr/internal/database/sqlcdb"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -75,6 +78,14 @@ func RequestID() gin.HandlerFunc {
|
|||||||
func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) {
|
func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) {
|
||||||
tokenString, tokenErr, hasToken := extractJWTToken(c)
|
tokenString, tokenErr, hasToken := extractJWTToken(c)
|
||||||
if tokenString != "" {
|
if tokenString != "" {
|
||||||
|
// Personal access tokens (cnp_…) are not JWTs — they resolve
|
||||||
|
// against the user_tokens table by sha256 hash.
|
||||||
|
if strings.HasPrefix(tokenString, patPrefix) {
|
||||||
|
if id, mail, verified := resolvePersonalAccessToken(c, tokenString); verified {
|
||||||
|
return id, mail, "", hasToken, true
|
||||||
|
}
|
||||||
|
return "", "", "Invalid token", hasToken, false
|
||||||
|
}
|
||||||
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
|
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
|
||||||
userIDClaim, exists := claims["user_id"]
|
userIDClaim, exists := claims["user_id"]
|
||||||
if exists {
|
if exists {
|
||||||
@@ -101,12 +112,67 @@ func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *bette
|
|||||||
return "", "", tokenErr, hasToken, false
|
return "", "", tokenErr, hasToken, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const patPrefix = "cnp_"
|
||||||
|
|
||||||
|
// resolvePersonalAccessToken authenticates a `cnp_` bearer token against the
|
||||||
|
// user_tokens table (sha256 hash match, not revoked, not expired). On success
|
||||||
|
// it stamps last_used_at, records the token scope on the context, and
|
||||||
|
// returns the owner's id and email.
|
||||||
|
func resolvePersonalAccessToken(c *gin.Context, token string) (string, string, bool) {
|
||||||
|
dbValue, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
db, ok := dbValue.(*database.DB)
|
||||||
|
if !ok || db == nil || db.DB == nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
queries := sqlcdb.New(db.DB)
|
||||||
|
row, err := queries.GetUserTokenByHash(c.Request.Context(), hex.EncodeToString(sum[:]))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
var email string
|
||||||
|
if err := db.QueryRowContext(c.Request.Context(),
|
||||||
|
`SELECT email FROM users WHERE id = $1`, row.UserID).Scan(&email); err != nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = queries.TouchUserToken(c.Request.Context(), row.ID)
|
||||||
|
c.Set("pat_scope", row.Scope)
|
||||||
|
return row.UserID.String(), email, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// patScopeAllows reports whether a PAT-authenticated request may proceed.
|
||||||
|
// read scope permits only safe methods; write and admin are unrestricted at
|
||||||
|
// this layer (admin routes still require the admin scope AND a platform
|
||||||
|
// admin account — enforced via is_admin in setAuthenticatedContext).
|
||||||
|
func patScopeAllows(c *gin.Context) bool {
|
||||||
|
if c.GetString("pat_scope") != "read" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch c.Request.Method {
|
||||||
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// setAuthenticatedContext stores the resolved identity plus the admin flag on
|
// setAuthenticatedContext stores the resolved identity plus the admin flag on
|
||||||
// the gin context so handlers and RequireAdmin can rely on it.
|
// the gin context so handlers and RequireAdmin can rely on it.
|
||||||
func setAuthenticatedContext(c *gin.Context, userID, email string) {
|
func setAuthenticatedContext(c *gin.Context, userID, email string) {
|
||||||
c.Set("user_id", userID)
|
c.Set("user_id", userID)
|
||||||
c.Set("email", email)
|
c.Set("email", email)
|
||||||
c.Set("is_admin", loadIsAdmin(c, userID))
|
isAdmin := loadIsAdmin(c, userID)
|
||||||
|
// A PAT can never outrank its scope: non-admin scopes cap the token at
|
||||||
|
// non-admin even when the owning user is a platform admin.
|
||||||
|
if scope := c.GetString("pat_scope"); scope != "" && scope != "admin" {
|
||||||
|
isAdmin = false
|
||||||
|
}
|
||||||
|
c.Set("is_admin", isAdmin)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadIsAdmin(c *gin.Context, userID string) bool {
|
func loadIsAdmin(c *gin.Context, userID string) bool {
|
||||||
@@ -133,20 +199,28 @@ func Auth(jwtSecret string) gin.HandlerFunc {
|
|||||||
userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
|
userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
|
||||||
if ok {
|
if ok {
|
||||||
setAuthenticatedContext(c, userID, email)
|
setAuthenticatedContext(c, userID, email)
|
||||||
|
if !patScopeAllows(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"error": "Token scope is read-only",
|
||||||
|
"code": "SCOPE_INSUFFICIENT",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokenErr != "" {
|
if tokenErr != "" {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr, "code": "INVALID_TOKEN"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasToken {
|
if hasToken {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token", "code": "INVALID_TOKEN"})
|
||||||
} else {
|
} else {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"})
|
||||||
}
|
}
|
||||||
c.Abort()
|
c.Abort()
|
||||||
}
|
}
|
||||||
@@ -171,12 +245,12 @@ func OptionalAuth(jwtSecret string) gin.HandlerFunc {
|
|||||||
func RequireAdmin() gin.HandlerFunc {
|
func RequireAdmin() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if _, exists := c.Get("user_id"); !exists {
|
if _, exists := c.Get("user_id"); !exists {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !c.GetBool("is_admin") {
|
if !c.GetBool("is_admin") {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required", "code": "ADMIN_REQUIRED"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,61 @@ func TestAuthStoresStringUserIDForValidClaims(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthRejectsPATWithoutDatabase(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(Auth("secret"))
|
||||||
|
router.GET("/test", func(c *gin.Context) {
|
||||||
|
c.String(http.StatusOK, "ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
// A cnp_-prefixed token takes the PAT path, not JWT parsing — without a
|
||||||
|
// db handle it must fail closed with 401.
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer cnp_"+strings.Repeat("ab", 24))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPATScopeAllows(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
scope string
|
||||||
|
method string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"read allows GET", "read", http.MethodGet, true},
|
||||||
|
{"read allows HEAD", "read", http.MethodHead, true},
|
||||||
|
{"read allows OPTIONS", "read", http.MethodOptions, true},
|
||||||
|
{"read blocks POST", "read", http.MethodPost, false},
|
||||||
|
{"read blocks DELETE", "read", http.MethodDelete, false},
|
||||||
|
{"write allows POST", "write", http.MethodPost, true},
|
||||||
|
{"write allows DELETE", "write", http.MethodDelete, true},
|
||||||
|
{"admin allows PATCH", "admin", http.MethodPatch, true},
|
||||||
|
{"no scope (session/JWT) unrestricted", "", http.MethodPost, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(tc.method, "/x", nil)
|
||||||
|
if tc.scope != "" {
|
||||||
|
c.Set("pat_scope", tc.scope)
|
||||||
|
}
|
||||||
|
if got := patScopeAllows(c); got != tc.want {
|
||||||
|
t.Fatalf("patScopeAllows(scope=%q, method=%s) = %v, want %v", tc.scope, tc.method, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string {
|
func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package secrets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prefix marks AES-GCM-encrypted values in environment_variables.value.
|
||||||
|
// Plaintext rows keep working — Decrypt returns them unchanged, so the
|
||||||
|
// rollout is transparent encrypt-on-write / decrypt-on-read.
|
||||||
|
const Prefix = "enc:v1:"
|
||||||
|
|
||||||
|
var (
|
||||||
|
keyOnce sync.Once
|
||||||
|
key []byte
|
||||||
|
enabled bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// Configure derives the AES-256 key. SECRETS_KEY wins; JWT_SECRET is the
|
||||||
|
// fallback so existing installs need no new config. With neither, encrypt
|
||||||
|
// degrades to plaintext with a single warning.
|
||||||
|
func Configure(secretsKey, jwtSecret string) {
|
||||||
|
keyOnce.Do(func() {
|
||||||
|
seed := secretsKey
|
||||||
|
if seed == "" {
|
||||||
|
seed = jwtSecret
|
||||||
|
}
|
||||||
|
if seed == "" {
|
||||||
|
log.Printf("secrets: no SECRETS_KEY or JWT_SECRET — secret variables stay plaintext")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte("containr-env-secrets:" + seed))
|
||||||
|
key = sum[:]
|
||||||
|
enabled = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsEncrypted(value string) bool {
|
||||||
|
return strings.HasPrefix(value, Prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt returns value unchanged if disabled or already encrypted.
|
||||||
|
func Encrypt(value string) string {
|
||||||
|
if !enabled || IsEncrypted(value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
sealed := gcm.Seal(nonce, nonce, []byte(value), nil)
|
||||||
|
return Prefix + base64.StdEncoding.EncodeToString(sealed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt returns plaintext for enc:v1 values, the input otherwise.
|
||||||
|
// Corrupt ciphertext fails soft to the stored value rather than erroring
|
||||||
|
// the deploy path — an unreadable secret is no worse than a plaintext one.
|
||||||
|
func Decrypt(value string) string {
|
||||||
|
if !enabled || !IsEncrypted(value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, Prefix))
|
||||||
|
if err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if len(raw) < gcm.NonceSize() {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("secrets: decrypt failed (%v) — check SECRETS_KEY/JWT_SECRET rotation", fmt.Errorf("%w", err))
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return string(plain)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package secrets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resetForTest restores the package state so Configure can be re-run.
|
||||||
|
func resetForTest() {
|
||||||
|
keyOnce = sync.Once{}
|
||||||
|
key = nil
|
||||||
|
enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||||
|
resetForTest()
|
||||||
|
Configure("test-secrets-key", "")
|
||||||
|
|
||||||
|
enc := Encrypt("super-secret-value")
|
||||||
|
if !IsEncrypted(enc) {
|
||||||
|
t.Fatalf("expected encrypted prefix, got %q", enc)
|
||||||
|
}
|
||||||
|
if enc == "super-secret-value" {
|
||||||
|
t.Fatal("value stored as plaintext")
|
||||||
|
}
|
||||||
|
if got := Decrypt(enc); got != "super-secret-value" {
|
||||||
|
t.Fatalf("decrypt = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaintextPassThrough(t *testing.T) {
|
||||||
|
resetForTest()
|
||||||
|
Configure("test-secrets-key", "")
|
||||||
|
if got := Decrypt("legacy-plaintext"); got != "legacy-plaintext" {
|
||||||
|
t.Fatalf("legacy plaintext changed: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptIsIdempotent(t *testing.T) {
|
||||||
|
resetForTest()
|
||||||
|
Configure("test-secrets-key", "")
|
||||||
|
enc := Encrypt("v")
|
||||||
|
if got := Encrypt(enc); got != enc {
|
||||||
|
t.Fatal("double-encrypt changed the value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptFailsSoftOnCorruption(t *testing.T) {
|
||||||
|
resetForTest()
|
||||||
|
Configure("test-secrets-key", "")
|
||||||
|
corrupt := Prefix + "not-valid-base64!!!"
|
||||||
|
if got := Decrypt(corrupt); got != corrupt {
|
||||||
|
t.Fatal("corrupt ciphertext should pass through unchanged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisabledWithoutKey(t *testing.T) {
|
||||||
|
resetForTest()
|
||||||
|
Configure("", "")
|
||||||
|
if got := Encrypt("v"); got != "v" {
|
||||||
|
t.Fatal("encrypt should pass through when no key configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Persisted HA health checks. Previously checks lived only in memory, so a
|
||||||
|
-- backend restart silently deleted every configured check while services kept
|
||||||
|
-- running unmonitored.
|
||||||
|
CREATE TABLE IF NOT EXISTS ha_health_checks (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||||
|
node_id varchar(255) NOT NULL DEFAULT '',
|
||||||
|
type varchar(20) NOT NULL,
|
||||||
|
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
status varchar(20) NOT NULL DEFAULT 'unknown',
|
||||||
|
last_check timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS ha_health_checks;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Personal access tokens for CLI/MCP/agent authentication.
|
||||||
|
-- Only sha256 hashes are stored — raw tokens (cnp_…) are shown once at
|
||||||
|
-- creation and never persisted. Scope gates what the token may do:
|
||||||
|
-- read = GET/HEAD only, write = all non-admin routes, admin = everything
|
||||||
|
-- (admin still requires the owning user to be a platform admin).
|
||||||
|
CREATE TABLE IF NOT EXISTS public.user_tokens (
|
||||||
|
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
|
||||||
|
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
name character varying(255) NOT NULL,
|
||||||
|
key_prefix character varying(16) NOT NULL,
|
||||||
|
token_hash text NOT NULL UNIQUE,
|
||||||
|
scope character varying(20) NOT NULL DEFAULT 'write'
|
||||||
|
CHECK (scope IN ('read', 'write', 'admin')),
|
||||||
|
expires_at timestamp with time zone,
|
||||||
|
last_used_at timestamp with time zone,
|
||||||
|
revoked_at timestamp with time zone,
|
||||||
|
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_tokens_user_id ON public.user_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_tokens_token_hash ON public.user_tokens(token_hash);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS public.user_tokens;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Volume mounts on services: [{type: "volume"|"bind", source, target,
|
||||||
|
-- read_only}]. Applied to every replica's container config at deploy and
|
||||||
|
-- reconcile time. The baseline schema declared the column nullable with
|
||||||
|
-- no default — this normalizes it.
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS volumes JSONB DEFAULT '[]'::jsonb;
|
||||||
|
UPDATE services SET volumes = '[]'::jsonb WHERE volumes IS NULL;
|
||||||
|
ALTER TABLE services ALTER COLUMN volumes SET DEFAULT '[]'::jsonb;
|
||||||
|
ALTER TABLE services ALTER COLUMN volumes SET NOT NULL;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE services ALTER COLUMN volumes DROP NOT NULL;
|
||||||
|
ALTER TABLE services ALTER COLUMN volumes DROP DEFAULT;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Multi-domain support. services.domain stays as the derived default for
|
||||||
|
-- compatibility; service_domains is the source of truth for the full
|
||||||
|
-- hostname set. Backfills existing services.domain rows as defaults.
|
||||||
|
CREATE TABLE IF NOT EXISTS service_domains (
|
||||||
|
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
|
||||||
|
service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||||
|
domain varchar(255) NOT NULL,
|
||||||
|
is_default boolean NOT NULL DEFAULT false,
|
||||||
|
cert_type varchar(32) NOT NULL DEFAULT 'letsencrypt',
|
||||||
|
cert_status varchar(32) NOT NULL DEFAULT 'pending',
|
||||||
|
last_checked_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (service_id, domain)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_domains_service ON service_domains(service_id);
|
||||||
|
|
||||||
|
INSERT INTO service_domains (service_id, domain, is_default)
|
||||||
|
SELECT id, domain, true FROM services WHERE domain <> '' AND domain IS NOT NULL
|
||||||
|
ON CONFLICT (service_id, domain) DO NOTHING;
|
||||||
|
|
||||||
|
-- Access control: maintenance mode redirects to a platform page; basic auth
|
||||||
|
-- gates the route behind htpasswd-format user:hash pairs (Traefik middleware).
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS maintenance_mode boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS basic_auth_users text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS maintenance_mode;
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS basic_auth_users;
|
||||||
|
DROP TABLE IF EXISTS service_domains;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS builder varchar(32) NOT NULL DEFAULT 'auto';
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS cpu_reserve varchar(20) NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS memory_reserve varchar(20) NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS static_build_cmd varchar(255) NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS static_dir varchar(255) NOT NULL DEFAULT '';
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS static_dir;
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS static_build_cmd;
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS memory_reserve;
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS cpu_reserve;
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS builder;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE IF NOT EXISTS registries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
host VARCHAR(255) NOT NULL,
|
||||||
|
username VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
password TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (owner_id, host)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_registries_owner ON registries(owner_id);
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE IF EXISTS registries;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE database_services
|
||||||
|
ADD COLUMN IF NOT EXISTS provider VARCHAR(20) NOT NULL DEFAULT 'managed',
|
||||||
|
ADD COLUMN IF NOT EXISTS external_host VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS external_port INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS external_name VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS external_username VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS external_password TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS external_ssl BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE database_services
|
||||||
|
DROP CONSTRAINT IF EXISTS database_services_plan_check,
|
||||||
|
ADD CONSTRAINT database_services_plan_check
|
||||||
|
CHECK (plan IN ('hobby', 'starter', 'standard', 'business', 'external'));
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE database_services
|
||||||
|
DROP CONSTRAINT IF EXISTS database_services_plan_check,
|
||||||
|
ADD CONSTRAINT database_services_plan_check
|
||||||
|
CHECK (plan IN ('hobby', 'starter', 'standard', 'business'));
|
||||||
|
|
||||||
|
ALTER TABLE database_services
|
||||||
|
DROP COLUMN IF EXISTS provider,
|
||||||
|
DROP COLUMN IF EXISTS external_host,
|
||||||
|
DROP COLUMN IF EXISTS external_port,
|
||||||
|
DROP COLUMN IF EXISTS external_name,
|
||||||
|
DROP COLUMN IF EXISTS external_username,
|
||||||
|
DROP COLUMN IF EXISTS external_password,
|
||||||
|
DROP COLUMN IF EXISTS external_ssl;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Project-level shared variables (Railway-style). Services reference them via
|
||||||
|
-- ${{shared.KEY}} in environment values; secret entries are encrypted at rest
|
||||||
|
-- and masked in API responses exactly like service variables.
|
||||||
|
CREATE TABLE IF NOT EXISTS project_variables (
|
||||||
|
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
key varchar(255) NOT NULL,
|
||||||
|
value text NOT NULL DEFAULT '',
|
||||||
|
is_secret boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (project_id, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_project_variables_project ON project_variables(project_id);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS project_variables;
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
-- name: ListDatabaseServicesByUser :many
|
-- name: ListDatabaseServicesByUser :many
|
||||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
SELECT database_services.*
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY created_at DESC;
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: ListAllDatabaseServices :many
|
-- name: ListAllDatabaseServices :many
|
||||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
SELECT database_services.*
|
||||||
FROM database_services
|
FROM database_services
|
||||||
ORDER BY created_at DESC;
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: GetDatabaseServiceByID :one
|
-- name: GetDatabaseServiceByID :one
|
||||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
SELECT database_services.*
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ FROM database_services
|
|||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
-- name: GetDatabaseServiceByIDAndUser :one
|
-- name: GetDatabaseServiceByIDAndUser :one
|
||||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
SELECT database_services.*
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE id = $1 AND user_id = $2;
|
WHERE id = $1 AND user_id = $2;
|
||||||
|
|
||||||
@@ -110,3 +110,9 @@ WHERE backup_schedule IS NOT NULL AND backup_schedule <> ''
|
|||||||
UPDATE database_services
|
UPDATE database_services
|
||||||
SET next_backup_at = $1
|
SET next_backup_at = $1
|
||||||
WHERE id = $2;
|
WHERE id = $2;
|
||||||
|
|
||||||
|
-- name: CreateExternalDatabaseService :exec
|
||||||
|
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region,
|
||||||
|
provider, external_host, external_port, external_name, external_username, external_password, external_ssl,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, 'running', $5, $6, $7, 'external', $8, $9, $10, $11, $12, $13, $14, $15);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- name: CreateUserToken :one
|
||||||
|
INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListUserTokens :many
|
||||||
|
SELECT * FROM user_tokens
|
||||||
|
WHERE user_id = $1 AND revoked_at IS NULL
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: GetUserTokenByHash :one
|
||||||
|
SELECT * FROM user_tokens
|
||||||
|
WHERE token_hash = $1
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: TouchUserToken :exec
|
||||||
|
UPDATE user_tokens SET last_used_at = NOW()
|
||||||
|
WHERE id = $1 AND revoked_at IS NULL;
|
||||||
|
|
||||||
|
-- name: RevokeUserToken :execrows
|
||||||
|
UPDATE user_tokens SET revoked_at = NOW()
|
||||||
|
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL;
|
||||||
@@ -52,7 +52,45 @@ 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 (
|
||||||
@@ -101,6 +139,13 @@ CREATE TABLE database_services (
|
|||||||
connection_url TEXT,
|
connection_url TEXT,
|
||||||
backup_schedule VARCHAR(100),
|
backup_schedule VARCHAR(100),
|
||||||
next_backup_at TIMESTAMP WITH TIME ZONE,
|
next_backup_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
provider VARCHAR(20) NOT NULL DEFAULT 'managed',
|
||||||
|
external_host VARCHAR(255),
|
||||||
|
external_port INTEGER,
|
||||||
|
external_name VARCHAR(255),
|
||||||
|
external_username VARCHAR(255),
|
||||||
|
external_password TEXT NOT NULL DEFAULT '',
|
||||||
|
external_ssl BOOLEAN NOT NULL DEFAULT false,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
@@ -198,3 +243,16 @@ CREATE TABLE notifications (
|
|||||||
read_at TIMESTAMP WITH TIME ZONE,
|
read_at TIMESTAMP WITH TIME ZONE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
key_prefix VARCHAR(16) NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
scope VARCHAR(20) NOT NULL DEFAULT 'write',
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
last_used_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -17,12 +17,23 @@ import {
|
|||||||
createAgentToken,
|
createAgentToken,
|
||||||
listAgentTokens,
|
listAgentTokens,
|
||||||
revokeAgentToken,
|
revokeAgentToken,
|
||||||
|
createUserToken,
|
||||||
|
listUserTokens,
|
||||||
|
revokeUserToken,
|
||||||
|
listRegistries,
|
||||||
|
createRegistry,
|
||||||
|
deleteRegistry,
|
||||||
|
type Registry,
|
||||||
listDatabases,
|
listDatabases,
|
||||||
databaseAction,
|
databaseAction,
|
||||||
updateDatabaseBackupSchedule,
|
updateDatabaseBackupSchedule,
|
||||||
createDatabaseBackup,
|
createDatabaseBackup,
|
||||||
restoreDatabaseBackup,
|
restoreDatabaseBackup,
|
||||||
createManagedDatabase,
|
createManagedDatabase,
|
||||||
|
registerExternalDatabase,
|
||||||
|
testDatabaseConnection,
|
||||||
|
testDatabaseConnectionByID,
|
||||||
|
type ExternalDatabaseInput,
|
||||||
listServicesByProject,
|
listServicesByProject,
|
||||||
listServiceVariables,
|
listServiceVariables,
|
||||||
updateServiceVariables,
|
updateServiceVariables,
|
||||||
@@ -46,6 +57,8 @@ import {
|
|||||||
updateVulnerability,
|
updateVulnerability,
|
||||||
getSecurityMetrics,
|
getSecurityMetrics,
|
||||||
type AgentAuthTokenCreated,
|
type AgentAuthTokenCreated,
|
||||||
|
type UserTokenCreated,
|
||||||
|
type UserTokenScope,
|
||||||
type CreateDatabaseInput,
|
type CreateDatabaseInput,
|
||||||
type DatabaseEntity,
|
type DatabaseEntity,
|
||||||
type FailoverPolicy,
|
type FailoverPolicy,
|
||||||
@@ -1267,6 +1280,293 @@ function PlatformSettingsSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAT_EXPIRY_OPTIONS: { label: string; days: number }[] = [
|
||||||
|
{ label: 'Never', days: 0 },
|
||||||
|
{ label: '30 days', days: 30 },
|
||||||
|
{ label: '90 days', days: 90 },
|
||||||
|
{ label: '1 year', days: 365 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const tokensQuery = useQuery({
|
||||||
|
queryKey: ['user-tokens'],
|
||||||
|
queryFn: listUserTokens,
|
||||||
|
});
|
||||||
|
const [tokenName, setTokenName] = useState('');
|
||||||
|
const [tokenScope, setTokenScope] = useState<UserTokenScope>('write');
|
||||||
|
const [expiryDays, setExpiryDays] = useState(0);
|
||||||
|
const [issuedToken, setIssuedToken] = useState<UserTokenCreated | null>(null);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createUserToken({
|
||||||
|
name: tokenName.trim(),
|
||||||
|
scope: tokenScope,
|
||||||
|
expiresInDays: expiryDays,
|
||||||
|
}),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setIssuedToken(data);
|
||||||
|
setTokenName('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['user-tokens'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => revokeUserToken(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['user-tokens'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens = tokensQuery.data ?? [];
|
||||||
|
const scopeOptions: { value: UserTokenScope; label: string }[] = isAdmin
|
||||||
|
? [
|
||||||
|
{ value: 'read', label: 'Read - GET requests only' },
|
||||||
|
{ value: 'write', label: 'Write - read and manage resources' },
|
||||||
|
{ value: 'admin', label: 'Admin - everything, incl. platform settings' },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ value: 'read', label: 'Read - GET requests only' },
|
||||||
|
{ value: 'write', label: 'Write - read and manage resources' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||||
|
<Key size={18} className="text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Personal Access Tokens</h2>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input
|
||||||
|
value={tokenName}
|
||||||
|
onChange={(e) => setTokenName(e.target.value)}
|
||||||
|
placeholder="Token name (e.g. laptop cli)"
|
||||||
|
className="h-9 flex-1 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={tokenScope}
|
||||||
|
onChange={(e) => setTokenScope(e.target.value as UserTokenScope)}
|
||||||
|
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
>
|
||||||
|
{scopeOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={expiryDays}
|
||||||
|
onChange={(e) => setExpiryDays(Number(e.target.value))}
|
||||||
|
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
>
|
||||||
|
{PAT_EXPIRY_OPTIONS.map((option) => (
|
||||||
|
<option key={option.days} value={option.days}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
disabled={createMutation.isPending || !tokenName.trim()}
|
||||||
|
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Creating…' : 'Create token'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issuedToken ? (
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
|
||||||
|
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
|
||||||
|
>
|
||||||
|
<Copy size={11} />
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-tertiary)]">
|
||||||
|
Shown once - store it now. Use with <span className="mono">containr auth login --token</span> or as a Bearer token.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{createMutation.isError ? (
|
||||||
|
<p className="text-xs text-[var(--error)]">
|
||||||
|
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create token'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tokensQuery.isLoading ? (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">No tokens yet. Create one to connect the CLI or an agent.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
|
||||||
|
{tokens.map((token) => (
|
||||||
|
<div key={token.id} className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-[var(--text-primary)]">{token.name}</span>
|
||||||
|
<span className="ml-2 mono text-[var(--text-muted)]">{token.key_prefix}…</span>
|
||||||
|
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">{token.scope}</span>
|
||||||
|
<span className="ml-2 text-[var(--text-muted)]">
|
||||||
|
{token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'}
|
||||||
|
{token.expires_at ? ` · expires ${formatRelative(token.expires_at)}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Revoke token "${token.name}"? Clients using it lose access immediately.`)) {
|
||||||
|
revokeMutation.mutate(token.id ?? '');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={revokeMutation.isPending}
|
||||||
|
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegistriesSection() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const registriesQuery = useQuery({
|
||||||
|
queryKey: ['registries'],
|
||||||
|
queryFn: listRegistries,
|
||||||
|
});
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [host, setHost] = useState('');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createRegistry({
|
||||||
|
name: name.trim(),
|
||||||
|
host: host.trim(),
|
||||||
|
username: username.trim() || undefined,
|
||||||
|
password: password || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setName('');
|
||||||
|
setHost('');
|
||||||
|
setUsername('');
|
||||||
|
setPassword('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['registries'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteRegistry(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['registries'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const registries = registriesQuery.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||||
|
<Archive size={18} className="text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Registry credentials</h2>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">Pull private images on deploy - matched by image host, stored encrypted</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Name (e.g. ghcr)"
|
||||||
|
className="h-9 flex-1 min-w-[120px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => setHost(e.target.value)}
|
||||||
|
placeholder="Host (ghcr.io, docker.io)"
|
||||||
|
className="h-9 flex-1 min-w-[160px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Username"
|
||||||
|
className="h-9 w-40 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Password / token"
|
||||||
|
className="h-9 w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
disabled={createMutation.isPending || !name.trim() || !host.trim()}
|
||||||
|
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Adding…' : 'Add registry'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createMutation.isError ? (
|
||||||
|
<p className="text-xs text-[var(--error)]">
|
||||||
|
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add registry'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{registriesQuery.isLoading ? (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
) : registries.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
No credentials. Image pulls are anonymous unless a registry matching the image host exists here.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
|
||||||
|
{registries.map((r: Registry) => (
|
||||||
|
<div key={r.id} className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-[var(--text-primary)]">{r.name}</span>
|
||||||
|
<span className="ml-2 mono text-[var(--text-muted)]">{r.host}</span>
|
||||||
|
{r.username ? <span className="ml-2 text-[var(--text-secondary)]">{r.username}</span> : null}
|
||||||
|
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
|
||||||
|
{r.has_password ? 'password set' : 'anonymous'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Remove credentials for ${r.host}?`)) {
|
||||||
|
deleteMutation.mutate(r.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -1438,6 +1738,10 @@ 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}
|
||||||
|
|
||||||
@@ -1601,7 +1905,11 @@ export function DatabasesPage() {
|
|||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
const [bindDb, setBindDb] = useState<DatabaseEntity | null>(null);
|
const [bindDb, setBindDb] = useState<DatabaseEntity | null>(null);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createMode, setCreateMode] = useState<'managed' | 'external'>('managed');
|
||||||
const [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' });
|
const [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' });
|
||||||
|
const [externalForm, setExternalForm] = useState({ name: '', type: 'postgresql', host: '', port: '', database: '', username: '', password: '', ssl: false });
|
||||||
|
const [externalProbe, setExternalProbe] = useState<{ ok: boolean; latency_ms?: number; error?: string } | null>(null);
|
||||||
|
const [externalTestResult, setExternalTestResult] = useState<Record<string, { ok: boolean; latency_ms?: number; error?: string }>>({});
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
const databasesQuery = useQuery({
|
const databasesQuery = useQuery({
|
||||||
queryKey: ['databases'],
|
queryKey: ['databases'],
|
||||||
@@ -1629,6 +1937,40 @@ export function DatabasesPage() {
|
|||||||
setCreateError(error instanceof Error ? error.message : 'Failed to create database');
|
setCreateError(error instanceof Error ? error.message : 'Failed to create database');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const externalPayload = (): ExternalDatabaseInput => ({
|
||||||
|
name: externalForm.name.trim(),
|
||||||
|
type: externalForm.type,
|
||||||
|
host: externalForm.host.trim(),
|
||||||
|
port: Number(externalForm.port) || undefined,
|
||||||
|
database: externalForm.database.trim() || undefined,
|
||||||
|
username: externalForm.username.trim() || undefined,
|
||||||
|
password: externalForm.password || undefined,
|
||||||
|
ssl: externalForm.ssl,
|
||||||
|
});
|
||||||
|
const registerExternalMutation = useMutation({
|
||||||
|
mutationFn: () => registerExternalDatabase(externalPayload()),
|
||||||
|
onSuccess: () => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
setExternalForm({ name: '', type: 'postgresql', host: '', port: '', database: '', username: '', password: '', ssl: false });
|
||||||
|
setExternalProbe(null);
|
||||||
|
setCreateError(null);
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setCreateError(error instanceof Error ? error.message : 'Failed to register database');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const probeMutation = useMutation({
|
||||||
|
mutationFn: () => testDatabaseConnection(externalPayload()),
|
||||||
|
onSuccess: (result) => setExternalProbe(result),
|
||||||
|
onError: (error) => setExternalProbe({ ok: false, error: error instanceof Error ? error.message : 'probe failed' }),
|
||||||
|
});
|
||||||
|
const testByIdMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => testDatabaseConnectionByID(id),
|
||||||
|
onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })),
|
||||||
|
onError: (error, id) =>
|
||||||
|
setExternalTestResult((prev) => ({ ...prev, [id]: { ok: false, error: error instanceof Error ? error.message : 'probe failed' } })),
|
||||||
|
});
|
||||||
const actionMutation = useMutation({
|
const actionMutation = useMutation({
|
||||||
mutationFn: ({ id, action }: { id: string; action: 'start' | 'stop' | 'restart' }) =>
|
mutationFn: ({ id, action }: { id: string; action: 'start' | 'stop' | 'restart' }) =>
|
||||||
databaseAction(id, action),
|
databaseAction(id, action),
|
||||||
@@ -1714,9 +2056,16 @@ export function DatabasesPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-[var(--text-primary)]">{db.name}</span>
|
<span className="text-sm font-medium text-[var(--text-primary)]">{db.name}</span>
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] mono">{db.type}{db.version ? ` ${db.version}` : ''}</span>
|
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] mono">{db.type}{db.version ? ` ${db.version}` : ''}</span>
|
||||||
|
{db.provider === 'external' && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]">external</span>
|
||||||
|
)}
|
||||||
<span className={`text-xs px-1.5 py-0.5 rounded ${dbStatusClass[db.status ?? ''] ?? 'bg-[var(--surface-muted)] text-[var(--text-muted)]'}`}>{db.status}</span>
|
<span className={`text-xs px-1.5 py-0.5 rounded ${dbStatusClass[db.status ?? ''] ?? 'bg-[var(--surface-muted)] text-[var(--text-muted)]'}`}>{db.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 text-xs text-[var(--text-muted)]">{db.plan} · {db.region}</p>
|
<p className="mt-0.5 text-xs text-[var(--text-muted)]">
|
||||||
|
{db.provider === 'external' && db.external
|
||||||
|
? `${db.external.host}:${db.external.port}${db.external.database ? ` · ${db.external.database}` : ''}`
|
||||||
|
: `${db.plan} · ${db.region}`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown size={15} className={`shrink-0 text-[var(--text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
<ChevronDown size={15} className={`shrink-0 text-[var(--text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
@@ -1744,6 +2093,25 @@ export function DatabasesPage() {
|
|||||||
{signedIn ? (
|
{signedIn ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mr-1">Actions</span>
|
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mr-1">Actions</span>
|
||||||
|
{db.provider === 'external' ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => testByIdMutation.mutate(db.id ?? '')}
|
||||||
|
disabled={testByIdMutation.isPending}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={11} /> Test connection
|
||||||
|
</button>
|
||||||
|
{externalTestResult[db.id ?? ''] && (
|
||||||
|
<span className={`text-xs ${externalTestResult[db.id ?? '']?.ok ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
|
||||||
|
{externalTestResult[db.id ?? '']?.ok
|
||||||
|
? `reachable (${externalTestResult[db.id ?? '']?.latency_ms ?? 0}ms)`
|
||||||
|
: `unreachable — ${externalTestResult[db.id ?? '']?.error ?? 'probe failed'}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
|
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
|
||||||
disabled={running || actionMutation.isPending}
|
disabled={running || actionMutation.isPending}
|
||||||
@@ -1765,6 +2133,8 @@ export function DatabasesPage() {
|
|||||||
>
|
>
|
||||||
<RefreshCw size={11} /> Restart
|
<RefreshCw size={11} /> Restart
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setBindDb(bindDb?.id === db.id ? null : db)}
|
onClick={() => setBindDb(bindDb?.id === db.id ? null : db)}
|
||||||
disabled={!db.connection_url}
|
disabled={!db.connection_url}
|
||||||
@@ -1807,6 +2177,7 @@ export function DatabasesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{db.provider !== 'external' && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p>
|
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p>
|
||||||
@@ -1863,6 +2234,7 @@ export function DatabasesPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1877,18 +2249,125 @@ export function DatabasesPage() {
|
|||||||
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
||||||
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
|
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
|
||||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">New database</h2>
|
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">New database</h2>
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">Provision a managed database service.</p>
|
<div className="mt-3 flex gap-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-1">
|
||||||
|
{(['managed', 'external'] as const).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setCreateMode(mode); setCreateError(null); }}
|
||||||
|
className={`flex-1 rounded-[calc(var(--radius-md)-2px)] px-3 py-1.5 text-xs font-medium capitalize transition-colors ${createMode === mode ? 'bg-[var(--bg-base)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
||||||
|
>
|
||||||
|
{mode}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-[var(--text-tertiary)]">
|
||||||
|
{createMode === 'managed' ? 'Provision a managed database service.' : 'Register a database hosted outside Containr.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 px-5 py-4">
|
<div className="space-y-4 px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Name</label>
|
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Name</label>
|
||||||
<input
|
<input
|
||||||
value={createForm.name}
|
value={createMode === 'managed' ? createForm.name : externalForm.name}
|
||||||
onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))}
|
onChange={(e) => (createMode === 'managed' ? setCreateForm((f) => ({ ...f, name: e.target.value })) : setExternalForm((f) => ({ ...f, name: e.target.value })))}
|
||||||
placeholder="my-postgres"
|
placeholder="my-postgres"
|
||||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{createMode === 'external' ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</label>
|
||||||
|
<select
|
||||||
|
value={externalForm.type}
|
||||||
|
onChange={(e) => setExternalForm((f) => ({ ...f, type: e.target.value }))}
|
||||||
|
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-sm focus:border-[var(--accent-primary)]"
|
||||||
|
>
|
||||||
|
{DATABASE_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Host</label>
|
||||||
|
<input
|
||||||
|
value={externalForm.host}
|
||||||
|
onChange={(e) => setExternalForm((f) => ({ ...f, host: e.target.value }))}
|
||||||
|
placeholder="db.example.com"
|
||||||
|
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Port</label>
|
||||||
|
<input
|
||||||
|
value={externalForm.port}
|
||||||
|
onChange={(e) => setExternalForm((f) => ({ ...f, port: e.target.value }))}
|
||||||
|
placeholder="default"
|
||||||
|
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Database</label>
|
||||||
|
<input
|
||||||
|
value={externalForm.database}
|
||||||
|
onChange={(e) => setExternalForm((f) => ({ ...f, database: e.target.value }))}
|
||||||
|
placeholder="optional"
|
||||||
|
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Username</label>
|
||||||
|
<input
|
||||||
|
value={externalForm.username}
|
||||||
|
onChange={(e) => setExternalForm((f) => ({ ...f, username: e.target.value }))}
|
||||||
|
placeholder="optional"
|
||||||
|
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={externalForm.password}
|
||||||
|
onChange={(e) => setExternalForm((f) => ({ ...f, password: e.target.value }))}
|
||||||
|
placeholder="optional"
|
||||||
|
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-[var(--text-secondary)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={externalForm.ssl}
|
||||||
|
onChange={(e) => setExternalForm((f) => ({ ...f, ssl: e.target.checked }))}
|
||||||
|
className="accent-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
Require TLS/SSL
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => probeMutation.mutate()}
|
||||||
|
disabled={probeMutation.isPending || externalForm.host.trim().length === 0}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{probeMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : null}
|
||||||
|
Test connection
|
||||||
|
</button>
|
||||||
|
{externalProbe && (
|
||||||
|
<span className={`text-xs ${externalProbe.ok ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
|
||||||
|
{externalProbe.ok ? `reachable (${externalProbe.latency_ms ?? 0}ms)` : `unreachable — ${externalProbe.error ?? 'probe failed'}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</label>
|
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</label>
|
||||||
@@ -1924,6 +2403,7 @@ export function DatabasesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{createError ? (
|
{createError ? (
|
||||||
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-3.5 py-2.5 text-xs text-[var(--error)]">{createError}</div>
|
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-3.5 py-2.5 text-xs text-[var(--error)]">{createError}</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1938,12 +2418,15 @@ export function DatabasesPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => createMutation.mutate()}
|
onClick={() => (createMode === 'managed' ? createMutation.mutate() : registerExternalMutation.mutate())}
|
||||||
disabled={createMutation.isPending || createForm.name.trim().length === 0}
|
disabled={
|
||||||
|
(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ||
|
||||||
|
(createMode === 'managed' ? createForm.name.trim().length === 0 : externalForm.name.trim().length === 0 || externalForm.host.trim().length === 0)
|
||||||
|
}
|
||||||
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{createMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
|
{(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||||
Create database
|
{createMode === 'managed' ? 'Create database' : 'Register database'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,12 +28,24 @@ 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';
|
||||||
@@ -66,6 +78,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Box,
|
Box,
|
||||||
|
Ban,
|
||||||
Play,
|
Play,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
@@ -104,7 +117,9 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false },
|
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false },
|
||||||
building: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
|
building: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
|
||||||
pending: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
|
pending: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
|
||||||
|
queued: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Clock, animate: false },
|
||||||
rolling_back: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: RefreshCw, animate: true },
|
rolling_back: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: RefreshCw, animate: true },
|
||||||
|
cancelled: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Ban, animate: false },
|
||||||
stopped: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false },
|
stopped: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false },
|
||||||
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false };
|
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false };
|
||||||
|
|
||||||
@@ -374,6 +389,14 @@ export function ServiceDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cancelDeploymentMutation = useMutation({
|
||||||
|
mutationFn: (deploymentId: string) => cancelDeployment(deploymentId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['service-deployments', serviceId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const rollbackMutation = useMutation({
|
const rollbackMutation = useMutation({
|
||||||
mutationFn: (deploymentId: string) => rollbackDeployment(deploymentId),
|
mutationFn: (deploymentId: string) => rollbackDeployment(deploymentId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -392,6 +415,36 @@ export function ServiceDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const projectsQuery = useQuery({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: () => listProjects({ limit: 100 }),
|
||||||
|
enabled: !isDemoMode && activeSection === 'settings',
|
||||||
|
});
|
||||||
|
|
||||||
|
const cloneMutation = useMutation({
|
||||||
|
mutationFn: (input: { name?: string; project_id?: string }) => cloneService(serviceId, input),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||||
|
const targetProject = lifecycleForm?.cloneProject || projectId;
|
||||||
|
navigate(`/projects/${targetProject}/services/${data.service_id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveMutation = useMutation({
|
||||||
|
mutationFn: (targetProjectId: string) => moveService(serviceId, targetProjectId),
|
||||||
|
onSuccess: (_data, targetProjectId) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
navigate(`/projects/${targetProjectId}/services/${serviceId}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [lifecycleForm, setLifecycleForm] = useState<{
|
||||||
|
cloneName: string;
|
||||||
|
cloneProject: string;
|
||||||
|
moveTarget: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const invalidateService = () => {
|
const invalidateService = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
||||||
@@ -424,6 +477,27 @@ 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;
|
||||||
@@ -514,7 +588,7 @@ export function ServiceDetailPage() {
|
|||||||
const deployments = isDemoMode ? [] : deploymentsQuery.data ?? [];
|
const deployments = isDemoMode ? [] : deploymentsQuery.data ?? [];
|
||||||
const total = deployments.length;
|
const total = deployments.length;
|
||||||
const active = deployments.filter((deployment) =>
|
const active = deployments.filter((deployment) =>
|
||||||
['pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status),
|
['queued', 'pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status),
|
||||||
).length;
|
).length;
|
||||||
const failed = deployments.filter((deployment) => deployment.status === 'failed').length;
|
const failed = deployments.filter((deployment) => deployment.status === 'failed').length;
|
||||||
|
|
||||||
@@ -1181,8 +1255,9 @@ 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> — e.g.{' '}
|
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> or a project
|
||||||
<code className="mono text-[var(--accent-primary)]">{'${{postgres.POSTGRES_PASSWORD}}'}</code>
|
shared variable with{' '}
|
||||||
|
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2054,6 +2129,442 @@ 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">
|
||||||
@@ -2121,6 +2632,19 @@ export function ServiceDetailPage() {
|
|||||||
>
|
>
|
||||||
Logs
|
Logs
|
||||||
</button>
|
</button>
|
||||||
|
{['queued', 'pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status) && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Cancel deployment ${deployment.id.slice(0, 8)}?`)) {
|
||||||
|
cancelDeploymentMutation.mutate(deployment.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={cancelDeploymentMutation.isPending}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--error)] hover:border-[var(--error)]/50 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{cancelDeploymentMutation.isPending && cancelDeploymentMutation.variables === deployment.id ? '...' : 'Cancel'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm(`Rollback to deployment ${deployment.id.slice(0, 8)}?`)) {
|
if (window.confirm(`Rollback to deployment ${deployment.id.slice(0, 8)}?`)) {
|
||||||
@@ -2142,6 +2666,135 @@ 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>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { listProjectVariables, updateProjectVariables } from '@/lib/api-client';
|
||||||
|
import { Loader2, Plus, Save, Trash2, KeyRound } from 'lucide-react';
|
||||||
|
|
||||||
|
type Row = { key: string; value: string; isSecret: boolean };
|
||||||
|
|
||||||
|
// SharedVariablesSection edits project-level variables referenced from
|
||||||
|
// service env values as ${{shared.KEY}}.
|
||||||
|
export function SharedVariablesSection({ projectId }: { projectId: string }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [drafts, setDrafts] = useState<Row[] | null>(null);
|
||||||
|
|
||||||
|
const varsQuery = useQuery({
|
||||||
|
queryKey: ['project-variables', projectId],
|
||||||
|
queryFn: () => listProjectVariables(projectId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (rows: Row[]) =>
|
||||||
|
updateProjectVariables(
|
||||||
|
projectId,
|
||||||
|
rows
|
||||||
|
.filter((r) => r.key.trim())
|
||||||
|
.map((r) => ({ key: r.key.trim(), value: r.value, is_secret: r.isSecret })),
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
setDrafts(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-variables', projectId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows: Row[] = drafts ?? (varsQuery.data ?? []).map((v) => ({ key: v.key, value: v.value, isSecret: v.isSecret }));
|
||||||
|
const dirty = drafts !== null;
|
||||||
|
|
||||||
|
const updateRow = (i: number, patch: Partial<Row>) => {
|
||||||
|
const next = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
|
||||||
|
setDrafts(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Shared Variables</h3>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||||
|
Reference from any service as{' '}
|
||||||
|
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next
|
||||||
|
deploy/redeploy — not live-updated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => saveMutation.mutate(rows)}
|
||||||
|
disabled={!dirty || saveMutation.isPending}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-50 transition-all"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{varsQuery.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-6 text-[var(--text-muted)]">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">No shared variables.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<div key={i} className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
|
||||||
|
<input
|
||||||
|
value={row.key}
|
||||||
|
onChange={(e) => updateRow(i, { key: e.target.value })}
|
||||||
|
placeholder="KEY"
|
||||||
|
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={row.value}
|
||||||
|
onChange={(e) => updateRow(i, { value: e.target.value })}
|
||||||
|
type={row.isSecret ? 'password' : 'text'}
|
||||||
|
placeholder="value"
|
||||||
|
autoComplete="off"
|
||||||
|
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={row.isSecret ? 'Secret — masked on save' : 'Mark as secret'}
|
||||||
|
onClick={() => updateRow(i, { isSecret: !row.isSecret })}
|
||||||
|
className={`w-9 h-9 rounded-[var(--radius-md)] border flex items-center justify-center transition-colors ${
|
||||||
|
row.isSecret
|
||||||
|
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||||
|
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-default)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<KeyRound size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDrafts(rows.filter((_, idx) => idx !== i))}
|
||||||
|
className="w-9 h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{saveMutation.isError && (
|
||||||
|
<p className="mt-2 text-xs text-[var(--error)]">
|
||||||
|
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save shared variables'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import type { ServiceVariable } from '../auto-connections';
|
|||||||
import { ProjectCanvas } from '../canvas/ProjectCanvas';
|
import { 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,
|
||||||
@@ -935,6 +936,8 @@ 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,6 +36,12 @@ export const bundledDocs: BundledDoc[] = [
|
|||||||
"title": "🚀 Zero-Interaction Docker Deployment System",
|
"title": "🚀 Zero-Interaction Docker Deployment System",
|
||||||
"markdown": "# 🚀 Zero-Interaction Docker Deployment System\n\n## 🎯 **Fully Automatic Deployment**\n\nI've created a **completely automated deployment system** that requires **ZERO user interaction**. Just run one command and it does everything!\n\n## ⚡ **One-Click Deployment**\n\n### **Option 1: Quick Start (Recommended)**\n```bash\n# Run the quick deploy script\n./scripts/quick-deploy.sh\n```\n\n**What happens:**\n1. 🎯 **Shows you 6 deployment options**\n2. 🚀 **Deploys your choice automatically**\n3. 🔧 **Handles everything** (secrets, directories, ports)\n4. 🌐 **Shows access URLs** when done\n\n### **Option 2: Direct Command**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh umami\n./scripts/auto-deploy.sh plex\n./scripts/auto-deploy.sh nextcloud\n```\n\n### **Option 3: Deploy Everything**\n```bash\n# Deploy all 20 templates at once\n./scripts/auto-deploy.sh --all\n```\n\n## 🔄 **What Happens Automatically**\n\n### **🔍 Dependency Check**\n```bash\n✅ Checks if Docker is installed\n✅ Installs Docker if missing\n✅ Checks if Docker Compose is installed \n✅ Installs Docker Compose if missing\n```\n\n### **📁 Directory Creation**\n```bash\n✅ Creates deployment directory\n✅ Creates required subdirectories\n✅ Sets proper permissions\n✅ Handles existing deployments\n```\n\n### **🔧 Configuration Setup**\n```bash\n✅ Extracts docker-compose.yml from template\n✅ Creates .env file with all variables\n✅ Auto-generates secrets and passwords\n✅ Fixes port conflicts automatically\n✅ Optimizes volume paths\n```\n\n### **🐳 Docker Deployment**\n```bash\n✅ Pulls all required images\n✅ Starts all services\n✅ Waits for services to be ready\n✅ Checks service health\n✅ Shows deployment status\n```\n\n### **🌐 Access Information**\n```bash\n✅ Shows all access URLs\n✅ Provides management commands\n✅ Shows deployment directory\n✅ Opens browser automatically (optional)\n```\n\n## 🎮 **Try It Now**\n\n### **Easiest Way:**\n```bash\n./scripts/quick-deploy.sh\n```\n\nYou'll see:\n```\n🚀 One-Click Docker Deployment\n============================\n\n🎯 Choose your deployment:\n\n1. 🌐 Deploy Glance Dashboard (Recommended - Simple & Fast)\n2. 📊 Deploy Umami Analytics (Web Analytics)\n3. 📝 Deploy Memos (Note-taking)\n4. 🔍 Deploy MeiliSearch (Search Engine)\n5. 📈 Deploy Uptime Kuma (Monitoring)\n6. 🚀 Deploy ALL Templates (Advanced)\n\nEnter your choice (1-6): 1\n\n🌐 Deploying Glance Dashboard...\n```\n\n### **Direct Way:**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh glance\n```\n\n## 📊 **Example Output**\n\nHere's what you'll see during deployment:\n\n```\n🚀 Auto-Deploying glance\n========================================\n🔄 Checking dependencies...\n✅ Docker is installed\n✅ Docker Compose is installed\n🔄 Auto-deploying glance...\n✅ Created deployment directory: /path/to/deployments/glance\n🔄 Extracting docker-compose.yml...\n✅ docker-compose.yml extracted and optimized\n🔄 Creating .env file...\n✅ .env file created (no variables required)\n🔄 Auto-deploying glance...\n🔄 Pulling Docker images...\n✅ Images pulled\n🔄 Starting services...\n✅ Services started successfully\n\n🎉 Deployment Complete!\n========================================\n✅ Access URLs:\n 🌐 http://localhost:8080 (port: 8080)\n\n✅ Management Commands:\n 📋 View logs: docker-compose logs -f\n 🛑 Stop services: docker-compose down\n 🔄 Restart: docker-compose restart\n 📊 Status: docker-compose ps\n 🗑️ Cleanup: docker-compose down -v\n\n✅ Deployment directory: /path/to/deployments/glance\n🎉 glance deployed successfully!\n```\n\n## 🔧 **Advanced Options**\n\n### **Custom Configuration**\n```bash\n# Deploy without auto-generating secrets\n./scripts/auto-deploy.sh --no-secrets umami\n\n# Deploy without creating directories\n./scripts/auto-deploy.sh --no-dirs plex\n\n# Deploy without auto-starting services\n./scripts/auto-deploy.sh --no-start nextcloud\n\n# Deploy and auto-open browser\n./scripts/auto-deploy.sh --open-browser glance\n```\n\n### **Batch Deployment**\n```bash\n# Deploy multiple templates\nfor template in umami glance memos; do\n ./scripts/auto-deploy.sh \"$template\"\ndone\n\n# Deploy all templates\n./scripts/auto-deploy.sh --all\n```\n\n## 🛡️ **Safety Features**\n\n### **Automatic Conflict Resolution**\n```bash\n✅ Detects port conflicts\n✅ Auto-assigns available ports\n✅ Handles existing deployments\n✅ Cleans up previous installations\n```\n\n### **Security**\n```bash\n✅ Generates strong random secrets\n✅ Uses secure default passwords\n✅ Isolates deployments in separate directories\n✅ Logs all actions for audit trail\n```\n\n### **Error Handling**\n```bash\n✅ Validates template existence\n✅ Checks service health\n✅ Provides detailed error messages\n✅ Offers rollback commands\n```\n\n## 📁 **Di
Line truncated
|
"markdown": "# 🚀 Zero-Interaction Docker Deployment System\n\n## 🎯 **Fully Automatic Deployment**\n\nI've created a **completely automated deployment system** that requires **ZERO user interaction**. Just run one command and it does everything!\n\n## ⚡ **One-Click Deployment**\n\n### **Option 1: Quick Start (Recommended)**\n```bash\n# Run the quick deploy script\n./scripts/quick-deploy.sh\n```\n\n**What happens:**\n1. 🎯 **Shows you 6 deployment options**\n2. 🚀 **Deploys your choice automatically**\n3. 🔧 **Handles everything** (secrets, directories, ports)\n4. 🌐 **Shows access URLs** when done\n\n### **Option 2: Direct Command**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh umami\n./scripts/auto-deploy.sh plex\n./scripts/auto-deploy.sh nextcloud\n```\n\n### **Option 3: Deploy Everything**\n```bash\n# Deploy all 20 templates at once\n./scripts/auto-deploy.sh --all\n```\n\n## 🔄 **What Happens Automatically**\n\n### **🔍 Dependency Check**\n```bash\n✅ Checks if Docker is installed\n✅ Installs Docker if missing\n✅ Checks if Docker Compose is installed \n✅ Installs Docker Compose if missing\n```\n\n### **📁 Directory Creation**\n```bash\n✅ Creates deployment directory\n✅ Creates required subdirectories\n✅ Sets proper permissions\n✅ Handles existing deployments\n```\n\n### **🔧 Configuration Setup**\n```bash\n✅ Extracts docker-compose.yml from template\n✅ Creates .env file with all variables\n✅ Auto-generates secrets and passwords\n✅ Fixes port conflicts automatically\n✅ Optimizes volume paths\n```\n\n### **🐳 Docker Deployment**\n```bash\n✅ Pulls all required images\n✅ Starts all services\n✅ Waits for services to be ready\n✅ Checks service health\n✅ Shows deployment status\n```\n\n### **🌐 Access Information**\n```bash\n✅ Shows all access URLs\n✅ Provides management commands\n✅ Shows deployment directory\n✅ Opens browser automatically (optional)\n```\n\n## 🎮 **Try It Now**\n\n### **Easiest Way:**\n```bash\n./scripts/quick-deploy.sh\n```\n\nYou'll see:\n```\n🚀 One-Click Docker Deployment\n============================\n\n🎯 Choose your deployment:\n\n1. 🌐 Deploy Glance Dashboard (Recommended - Simple & Fast)\n2. 📊 Deploy Umami Analytics (Web Analytics)\n3. 📝 Deploy Memos (Note-taking)\n4. 🔍 Deploy MeiliSearch (Search Engine)\n5. 📈 Deploy Uptime Kuma (Monitoring)\n6. 🚀 Deploy ALL Templates (Advanced)\n\nEnter your choice (1-6): 1\n\n🌐 Deploying Glance Dashboard...\n```\n\n### **Direct Way:**\n```bash\n# Deploy any template instantly\n./scripts/auto-deploy.sh glance\n```\n\n## 📊 **Example Output**\n\nHere's what you'll see during deployment:\n\n```\n🚀 Auto-Deploying glance\n========================================\n🔄 Checking dependencies...\n✅ Docker is installed\n✅ Docker Compose is installed\n🔄 Auto-deploying glance...\n✅ Created deployment directory: /path/to/deployments/glance\n🔄 Extracting docker-compose.yml...\n✅ docker-compose.yml extracted and optimized\n🔄 Creating .env file...\n✅ .env file created (no variables required)\n🔄 Auto-deploying glance...\n🔄 Pulling Docker images...\n✅ Images pulled\n🔄 Starting services...\n✅ Services started successfully\n\n🎉 Deployment Complete!\n========================================\n✅ Access URLs:\n 🌐 http://localhost:8080 (port: 8080)\n\n✅ Management Commands:\n 📋 View logs: docker-compose logs -f\n 🛑 Stop services: docker-compose down\n 🔄 Restart: docker-compose restart\n 📊 Status: docker-compose ps\n 🗑️ Cleanup: docker-compose down -v\n\n✅ Deployment directory: /path/to/deployments/glance\n🎉 glance deployed successfully!\n```\n\n## 🔧 **Advanced Options**\n\n### **Custom Configuration**\n```bash\n# Deploy without auto-generating secrets\n./scripts/auto-deploy.sh --no-secrets umami\n\n# Deploy without creating directories\n./scripts/auto-deploy.sh --no-dirs plex\n\n# Deploy without auto-starting services\n./scripts/auto-deploy.sh --no-start nextcloud\n\n# Deploy and auto-open browser\n./scripts/auto-deploy.sh --open-browser glance\n```\n\n### **Batch Deployment**\n```bash\n# Deploy multiple templates\nfor template in umami glance memos; do\n ./scripts/auto-deploy.sh \"$template\"\ndone\n\n# Deploy all templates\n./scripts/auto-deploy.sh --all\n```\n\n## 🛡️ **Safety Features**\n\n### **Automatic Conflict Resolution**\n```bash\n✅ Detects port conflicts\n✅ Auto-assigns available ports\n✅ Handles existing deployments\n✅ Cleans up previous installations\n```\n\n### **Security**\n```bash\n✅ Generates strong random secrets\n✅ Uses secure default passwords\n✅ Isolates deployments in separate directories\n✅ Logs all actions for audit trail\n```\n\n### **Error Handling**\n```bash\n✅ Validates template existence\n✅ Checks service health\n✅ Provides detailed error messages\n✅ Offers rollback commands\n```\n\n## 📁 **Di
Line truncated
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "guides/CLI.md",
|
||||||
|
"section": "guides",
|
||||||
|
"title": "Containr CLI",
|
||||||
|
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add <id> app.example.com --default\ncontainr services domains check <id> # DNS preflight per domain\ncontainr services domains remove <id> <domain-id>\n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update <id> --maintenance on\ncontainr services update <id> --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate eLine truncated
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||||
"section": "guides",
|
"section": "guides",
|
||||||
@@ -52,7 +58,7 @@ export const bundledDocs: BundledDoc[] = [
|
|||||||
"path": "guides/OPERATIONS.md",
|
"path": "guides/OPERATIONS.md",
|
||||||
"section": "guides",
|
"section": "guides",
|
||||||
"title": "Operations",
|
"title": "Operations",
|
||||||
"markdown": "# Operations\n\nDay-2 features for running services: scheduled jobs, ad-hoc exec, managed\ndatabases, preview environments, security scans, and high availability.\n\n## Cron jobs\n\nService detail → **Cron**. Standard 5-field cron expressions (`robfig/cron`\nsyntax), per-job timezone, enable/disable, manual trigger, and execution\nhistory with captured stdout/stderr.\n\n- The scheduler ticks every minute and runs due jobs via `docker exec`\n (`sh -c <command>`) inside the service's container.\n- Executions are retained per-job (`retention` count) and visible in the UI.\n- Requires a running container — a stopped service's due job records a\n failure, it does not queue.\n\n```\nGET/POST /api/v1/cron-jobs PUT/DELETE /api/v1/cron-jobs/:id\nPOST /api/v1/cron-jobs/:id/trigger\nGET /api/v1/cron-jobs/:id/executions\n```\n\n## Exec console\n\nService detail → **Console**. One-off `sh -c` commands against the running\ncontainer: 30s ceiling, 64KB output cap, exit code + captured output shown\nper run. Debugging aid — not an interactive shell.\n\n```\nPOST /api/v1/services/:id/exec {command}\n```\n\n## Managed databases\n\n**/databases** page — provision PostgreSQL/Redis/MySQL/MariaDB/MongoDB/\nClickHouse/Dragonfly as managed containers on the `containr` network.\n\n- Start/stop/restart actions; connection URL with copy.\n- **Backups**: manual snapshot now, or set a cron expression\n (`backup_schedule`) — due backups run inside the same scheduler tick as\n cron jobs. Archives live in the `containr-db-backups` volume and are\n downloadable (`GET /databases/:id/backups/:bid/download`) and restorable.\n- **Bind to service**: injects `connection_url` into a service's variables\n as a secret (`DATABASE_URL` by default). Redeploy the service afterward —\n variables are read at deploy time.\n\n## Preview environments\n\nService detail → **Previews**. Create a preview per branch/PR with a TTL,\npromote it to production, or delete it.\n\nCurrent limitation: previews are bookkeeping records — they do not build,\ndeploy, or route a real container. The generated `*.preview.containr.local`\nURL is a placeholder. Real preview runtime is tracked on the roadmap.\n\n## Security scans\n\n**/security** page — pick a project, run a scan (dependency / configuration\n/ comprehensive, optionally scoped to one service).\n\nThe scanner is heuristic config analysis, not Trivy/Grype: it flags\nunpinned image tags, `http://` source/public URLs, `npm install` without\n`npm ci`, `pip install` without hashes, `curl|sh` build steps, missing\nhealth checks, and similar smells. Findings get severity badges and can be\nresolved or ignored; the page also shows a security score, open/resolved\ncounts, scan history, and compliance status.\n\n```\nPOST /api/v1/security/scans {project_id, service_id?, scan_type}\nGET /api/v1/projects/:id/vulnerabilities\nPUT /api/v1/vulnerabilities/:id {status: open|resolved|ignored}\nGET /api/v1/projects/:id/security/metrics\nGET /api/v1/projects/:id/security/history\n```\n\n## High availability\n\n**/ha** page — manager enable/disable, node/health-check/alert status\ncards, manual failover (with confirmation), per-service failover policies\n(project → service picker, strategy, min healthy nodes, max failures),\nactive alerts with resolve, and health check results.\n\nHA state is in-memory like autoscaling — policies and alerts reset on\nbackend restart.\n\n## Node agents\n\n**/usage** page → *Connect node*: issue a per-node token (`cagt_…`, shown\nonce), copy the prefilled install command, revoke tokens when done. Agents\nregister over `POST /api/agents/register` and heartbeat via\n`POST /api/agents/heartbeat`.\n"
|
"markdown": "# Operations\n\nDay-2 features for running services: scheduled jobs, ad-hoc exec, managed\ndatabases, preview environments, security scans, and high availability.\n\n## Cron jobs\n\nService detail → **Cron**. Standard 5-field cron expressions (`robfig/cron`\nsyntax), per-job timezone, enable/disable, manual trigger, and execution\nhistory with captured stdout/stderr.\n\n- The scheduler ticks every minute and runs due jobs via `docker exec`\n (`sh -c <command>`) inside the service's container.\n- Executions are retained per-job (`retention` count) and visible in the UI.\n- Requires a running container — a stopped service's due job records a\n failure, it does not queue.\n\n```\nGET/POST /api/v1/cron-jobs PUT/DELETE /api/v1/cron-jobs/:id\nPOST /api/v1/cron-jobs/:id/trigger\nGET /api/v1/cron-jobs/:id/executions\n```\n\n## Exec console\n\nService detail → **Console**. One-off `sh -c` commands against the running\ncontainer: 30s ceiling, 64KB output cap, exit code + captured output shown\nper run. Debugging aid — not an interactive shell.\n\n```\nPOST /api/v1/services/:id/exec {command}\n```\n\n## Managed databases\n\n**/databases** page — provision PostgreSQL/Redis/MySQL/MariaDB/MongoDB/\nClickHouse/Dragonfly as managed containers on the `containr` network.\n\n- Start/stop/restart actions; connection URL with copy.\n- **Backups**: manual snapshot now, or set a cron expression\n (`backup_schedule`) — due backups run inside the same scheduler tick as\n cron jobs. Archives live in the `containr-db-backups` volume and are\n downloadable (`GET /databases/:id/backups/:bid/download`) and restorable.\n- **Bind to service**: injects `connection_url` into a service's variables\n as a secret (`DATABASE_URL` by default). Redeploy the service afterward —\n variables are read at deploy time.\n\n## Preview environments\n\nService detail → **Previews**. Create a preview per branch/PR with a TTL,\npromote it to production, or delete it.\n\nCurrent limitation: previews are bookkeeping records — they do not build,\ndeploy, or route a real container. The generated `*.preview.containr.local`\nURL is a placeholder. Real preview runtime is tracked on the roadmap.\n\n## Security scans\n\n**/security** page — pick a project, run a scan (dependency / configuration\n/ comprehensive, optionally scoped to one service).\n\nThe scanner is heuristic config analysis, not Trivy/Grype: it flags\nunpinned image tags, `http://` source/public URLs, `npm install` without\n`npm ci`, `pip install` without hashes, `curl|sh` build steps, missing\nhealth checks, and similar smells. Findings get severity badges and can be\nresolved or ignored; the page also shows a security score, open/resolved\ncounts, scan history, and compliance status.\n\n```\nPOST /api/v1/security/scans {project_id, service_id?, scan_type}\nGET /api/v1/projects/:id/vulnerabilities\nPUT /api/v1/vulnerabilities/:id {status: open|resolved|ignored}\nGET /api/v1/projects/:id/security/metrics\nGET /api/v1/projects/:id/security/history\n```\n\n## High availability\n\n**/ha** page — manager enable/disable, node/health-check/alert status\ncards, manual failover (with confirmation), per-service failover policies\n(project → service picker, strategy, min healthy nodes, max failures),\nactive alerts with resolve, and health check results.\n\nHA state is in-memory like autoscaling — policies and alerts reset on\nbackend restart.\n\n## Node agents\n\n**/usage** page → *Connect node*: issue a per-node token (`cagt_…`, shown\nonce), copy the prefilled install command, revoke tokens when done. Agents\nregister over `POST /api/agents/register` and heartbeat via\n`POST /api/agents/heartbeat`.\n\n## Personal access tokens\n\n**Settings → Personal Access Tokens**: issue `cnp_…` bearer tokens for the\nCLI, MCP server, and scripts — the same privileges as your account, bounded\nby scope. The raw token is shown once at creation; only its SHA-256 hash is\nstored.\n\nScopes:\n\n- `read` — `GET`/`HEAD`/`OPTIONS` only; mutations return `403 SCOPE_INSUFFICIENT`.\n- `write` — everything except admin routes.\n- `admin` — full access; requires a platform admin account, and the token is\n still capped at the owner's privileges.\n\nA token can never outrank its owner: non-admin scopes report\n`is_admin = false` even on admin accounts.\n\n```\nGET /api/v1/user/tokens list (metadata only, never hashes)\nPOST /api/v1/user/tokens {name, scope?, expires_in_days?} → raw token once\nDELETE /api/v1/user/tokens/:id revoke (owner-scoped)\n```\n\nUse as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`.\nRevocation takes effect on the next request; `last_used_at` updates on each\nauthenticated call.\n\n## Deployment queue\n\nDeployments are serialized per service: a deploy while another is active\nlands in `queued` state and promotes when it finishes — deploys, rollbacksLine truncated
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "guides/README_BACKEND.md",
|
"path": "guides/README_BACKEND.md",
|
||||||
|
|||||||
@@ -1523,6 +1523,7 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -1534,6 +1535,7 @@ 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,9 +54,42 @@ 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;
|
||||||
@@ -552,6 +585,15 @@ 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -993,6 +1035,34 @@ export async function revokeAgentToken(id: string): Promise<void> {
|
|||||||
await requestJson(`/agent-tokens/${id}`, { method: 'DELETE' });
|
await requestJson(`/agent-tokens/${id}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserToken = components['schemas']['UserToken'];
|
||||||
|
export type UserTokenCreated = components['schemas']['UserTokenCreated'];
|
||||||
|
export type UserTokenScope = 'read' | 'write' | 'admin';
|
||||||
|
|
||||||
|
export async function createUserToken(input: {
|
||||||
|
name: string;
|
||||||
|
scope?: UserTokenScope;
|
||||||
|
expiresInDays?: number;
|
||||||
|
}): Promise<UserTokenCreated> {
|
||||||
|
return requestJson<UserTokenCreated>(`/user/tokens`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: input.name,
|
||||||
|
scope: input.scope ?? 'write',
|
||||||
|
expires_in_days: input.expiresInDays ?? 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUserTokens(): Promise<UserToken[]> {
|
||||||
|
const payload = await requestJson<{ tokens?: UserToken[] }>(`/user/tokens`);
|
||||||
|
return payload.tokens ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeUserToken(id: string): Promise<void> {
|
||||||
|
await requestJson(`/user/tokens/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> {
|
export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> {
|
||||||
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, {
|
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1063,6 +1133,36 @@ export async function createService(projectId: string, input: CreateServiceInput
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listServiceDomains(serviceId: string): Promise<ServiceDomain[]> {
|
||||||
|
const payload = await requestJson<{ domains?: ServiceDomain[] }>(`/services/${serviceId}/domains`);
|
||||||
|
return payload.domains ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addServiceDomain(serviceId: string, domain: string, isDefault = false): Promise<ServiceDomain> {
|
||||||
|
const payload = await requestJson<{ domain: ServiceDomain }>(`/services/${serviceId}/domains`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ domain, is_default: isDefault }),
|
||||||
|
});
|
||||||
|
return payload.domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeServiceDomain(serviceId: string, domainId: string): Promise<void> {
|
||||||
|
await requestJson(`/services/${serviceId}/domains/${domainId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDefaultServiceDomain(serviceId: string, domainId: string): Promise<ServiceDomain[]> {
|
||||||
|
const payload = await requestJson<{ domains?: ServiceDomain[] }>(
|
||||||
|
`/services/${serviceId}/domains/${domainId}/default`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
return payload.domains ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkServiceDomains(serviceId: string): Promise<DomainCheckResult[]> {
|
||||||
|
const payload = await requestJson<{ domains?: DomainCheckResult[] }>(`/services/${serviceId}/domains/check`);
|
||||||
|
return payload.domains ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateService(serviceId: string, input: UpdateServiceInput): Promise<ServiceEntity> {
|
export async function updateService(serviceId: string, input: UpdateServiceInput): Promise<ServiceEntity> {
|
||||||
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`, {
|
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -1085,6 +1185,68 @@ export async function deleteService(serviceId: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CloneServiceInput = {
|
||||||
|
name?: string;
|
||||||
|
project_id?: string;
|
||||||
|
environment?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function cloneService(serviceId: string, input: CloneServiceInput): Promise<{ service_id: string; name: string }> {
|
||||||
|
return requestJson<{ service_id: string; name: string }>(`/services/${serviceId}/clone`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveService(serviceId: string, projectId: string): Promise<void> {
|
||||||
|
await requestJson(`/services/${serviceId}/move`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ project_id: projectId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Registry = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
username?: string;
|
||||||
|
has_password: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistryInput = {
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listRegistries(): Promise<Registry[]> {
|
||||||
|
const payload = await requestJson<{ registries?: Registry[] }>('/registries');
|
||||||
|
return payload.registries ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRegistry(input: RegistryInput): Promise<Registry> {
|
||||||
|
const payload = await requestJson<{ registry: Registry }>('/registries', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
return payload.registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRegistry(id: string, input: RegistryInput): Promise<Registry> {
|
||||||
|
const payload = await requestJson<{ registry: Registry }>(`/registries/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
return payload.registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRegistry(id: string): Promise<void> {
|
||||||
|
await requestJson(`/registries/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRuntime(runtime?: ServiceRuntime): ServiceRuntime {
|
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.
|
||||||
@@ -1148,6 +1310,22 @@ 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) {
|
||||||
@@ -1292,6 +1470,12 @@ export async function rollbackDeployment(deploymentId: string): Promise<Rollback
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cancelDeployment(deploymentId: string): Promise<{ status: string }> {
|
||||||
|
return requestJson<{ status: string }>(`/deployments/${deploymentId}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function listBuilds(input: ListBuildsInput = {}): Promise<ListBuildsResult> {
|
export async function listBuilds(input: ListBuildsInput = {}): Promise<ListBuildsResult> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (input.projectId) {
|
if (input.projectId) {
|
||||||
@@ -1565,6 +1749,39 @@ export async function createManagedDatabase(
|
|||||||
return { id: payload.id, status: payload.status ?? 'building' };
|
return { id: payload.id, status: payload.status ?? 'building' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExternalDatabaseInput = {
|
||||||
|
name?: string;
|
||||||
|
type: string;
|
||||||
|
host: string;
|
||||||
|
port?: number;
|
||||||
|
database?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
ssl?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function registerExternalDatabase(input: ExternalDatabaseInput): Promise<{ id: string }> {
|
||||||
|
const payload = await requestJson<{ id?: string }>('/databases/register-external', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!payload.id) {
|
||||||
|
throw new ApiError('Database response is invalid', 500);
|
||||||
|
}
|
||||||
|
return { id: payload.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testDatabaseConnection(input: ExternalDatabaseInput): Promise<{ ok: boolean; latency_ms?: number; error?: string }> {
|
||||||
|
return requestJson('/databases/test-connection', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testDatabaseConnectionByID(id: string): Promise<{ ok: boolean; latency_ms?: number; error?: string }> {
|
||||||
|
return requestJson(`/databases/${encodeURIComponent(id)}/test-connection`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoRequest'];
|
export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoRequest'];
|
||||||
export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest'];
|
export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest'];
|
||||||
export type GitWebhookEntity = components['schemas']['GitWebhook'];
|
export type GitWebhookEntity = components['schemas']['GitWebhook'];
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
This is an Expo/React Native mobile application — the Containr companion client. Prioritize mobile-first patterns, performance, and cross-platform compatibility.
|
||||||
|
|
||||||
|
## Expo has changed — do not trust your training data
|
||||||
|
|
||||||
|
Expo ships breaking changes every SDK release. APIs you remember are likely renamed, moved, or removed. Before writing any code that touches an Expo, EAS, or React Native API:
|
||||||
|
|
||||||
|
1. Read the major version of the `expo` package in `package.json`.
|
||||||
|
2. Fetch the matching versioned docs: `https://docs.expo.dev/versions/v<major>.0.0/`
|
||||||
|
3. For anything else, fetch https://docs.expo.dev/llms.txt — an index of all Expo docs with corrections to common LLM misconceptions. Follow its links to the specific page you need; never answer from memory.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install <package> # ALWAYS use instead of npm/yarn/pnpm/bun add — resolves SDK-compatible versions
|
||||||
|
npx expo start # start the dev server
|
||||||
|
npx expo lint # lint
|
||||||
|
npx tsc --noEmit # typecheck
|
||||||
|
npx expo-doctor # diagnose dependency and config issues
|
||||||
|
npx expo install --fix # fix incompatible package versions
|
||||||
|
```
|
||||||
|
|
||||||
|
Run lint and typecheck before declaring any task done.
|
||||||
|
|
||||||
|
## Navigation & Routing
|
||||||
|
|
||||||
|
- Use **Expo Router** for all navigation. Routes live in `src/app/` — every file there is a screen, `_layout.tsx` files define navigators. Keep non-route code (components, hooks, utils) outside `src/app/`.
|
||||||
|
- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`.
|
||||||
|
- Docs: https://docs.expo.dev/router/introduction.md
|
||||||
|
|
||||||
|
## Building with EAS
|
||||||
|
|
||||||
|
Use EAS to build, sign, and submit the app in the cloud (`eas build`, `eas submit`) and to ship over-the-air updates (`eas update`) — no local Xcode or Android Studio required. Run EAS CLI as `npx eas-cli@latest <command>`.
|
||||||
|
Docs: https://docs.expo.dev/eas/index.md
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- If `ios/` and `android/` directories do not exist, they are generated (Continuous Native Generation). Never create or edit them by hand — configure native behavior in `app.json` and config plugins. They are gitignored.
|
||||||
|
- Expo Go only includes its bundled native modules. After adding a library with native code, the app needs a development build: `npx expo run:ios|android` locally, or `eas build --profile development`.
|
||||||
|
- Prefer recommended Expo modules over third-party libraries. Docs: https://docs.expo.dev/versions/latest/index.md
|
||||||
|
- The app speaks only to the Containr REST API (`/api/v1`) with a `cnp_` personal access token — same contract as the CLI and MCP server. Add endpoints there, never to the mobile client alone.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Containr",
|
||||||
|
"slug": "containr-mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"scheme": "containr",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "dark",
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "ai.containr.mobile"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "ai.containr.mobile",
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#0B0F14",
|
||||||
|
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"predictiveBackGestureEnabled": false
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-secure-store",
|
||||||
|
"expo-font"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user