mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b47ced0135 | ||
|
|
9408464f12 | ||
|
|
954a1d1f80 | ||
|
|
cf7afd2ba7 | ||
|
|
f524d95894 | ||
|
|
f499678964 | ||
|
|
64cbf82dd8 | ||
|
|
5c2f2861bd | ||
|
|
dca8aaa7b3 | ||
|
|
6946357a7b | ||
|
|
a0952faf25 | ||
|
|
999d719377 | ||
|
|
8d682583ff | ||
|
|
c80fbc766d | ||
|
|
99ee4e79cf | ||
|
|
f729ef2c82 | ||
|
|
f90fc5f5ad | ||
|
|
f68225a97d | ||
|
|
7536443610 | ||
|
|
68da4236fa | ||
|
|
b9441f3e11 | ||
|
|
605a43455d | ||
|
|
c7256954c2 | ||
|
|
a50260e857 | ||
|
|
3441acbc37 | ||
|
|
c5e0561add | ||
|
|
1ef5ea5f7f | ||
|
|
d26452d97e | ||
|
|
164a39c9c6 | ||
|
|
63f65c78cc | ||
|
|
d5960fdfff | ||
|
|
31c04ad51d | ||
|
|
03f6dcc80d | ||
|
|
1a4ee99f5b | ||
|
|
264f619be5 | ||
|
|
11525194b0 | ||
|
|
9e3994e937 | ||
|
|
355eb2c9ab | ||
|
|
b5a1a8cc21 | ||
|
|
91f40a652d | ||
|
|
2be1715015 | ||
|
|
b32e63368a | ||
|
|
f1a0f2189d | ||
|
|
e3676bd89a | ||
|
|
71c7d2b9f0 | ||
|
|
e67a39436c | ||
|
|
2c3d9fc123 | ||
|
|
4734be0d9d | ||
|
|
4e8d364911 | ||
|
|
f53695be4f | ||
|
|
925199633b | ||
|
|
1054679ca2 | ||
|
|
57f8b4f56e | ||
|
|
ace68da188 | ||
|
|
2d626f8ee8 | ||
|
|
6d3aff6d58 | ||
|
|
c60c31693b | ||
|
|
9fd34482e3 | ||
|
|
541a4719d4 | ||
|
|
dd1bbaf5a2 | ||
|
|
f22a464ad0 | ||
|
|
58424ed0ab | ||
|
|
25fa8c47e6 | ||
|
|
f5f1ff34a0 | ||
|
|
bac317e0e6 | ||
|
|
26d585ffb7 | ||
|
|
f0df61b52e | ||
|
|
ffe66d7b3d | ||
|
|
121b33f36a | ||
|
|
8a8b903cdf | ||
|
|
f83ad81f73 | ||
|
|
2049087ccd | ||
|
|
be83467cc9 | ||
|
|
c99b2df419 | ||
|
|
a26c103eb4 | ||
|
|
9f8eaed386 | ||
|
|
3360eedc29 | ||
|
|
79fb73afc8 | ||
|
|
99a25cb10e | ||
|
|
d135e5fd4b | ||
|
|
dbc87bf34c | ||
|
|
b4baa853b2 | ||
|
|
15045b8dd3 | ||
|
|
109f79268f | ||
|
|
6f43bbf8f6 | ||
|
|
b7131965b1 | ||
|
|
5dc9b5f115 | ||
|
|
3664eaed1a |
No files matched your search
@@ -39,6 +39,10 @@ REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0
|
|||||||
# Application Configuration
|
# Application Configuration
|
||||||
# In production this must be a strong value with at least 32 characters.
|
# In production this must be a strong value with at least 32 characters.
|
||||||
JWT_SECRET=SuperSecretJWTKey123456789!@#$%^&*()
|
JWT_SECRET=SuperSecretJWTKey123456789!@#$%^&*()
|
||||||
|
# Encrypts secret env vars at rest. Defaults to JWT_SECRET when unset;
|
||||||
|
# setting it explicitly survives a JWT_SECRET rotation.
|
||||||
|
SECRETS_KEY=
|
||||||
|
|
||||||
# Shared secret for node-agent registration/heartbeat auth.
|
# Shared secret for node-agent registration/heartbeat auth.
|
||||||
CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
|
CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
|
||||||
# Optional rotation list (comma-separated). When set, this takes precedence.
|
# Optional rotation list (comma-separated). When set, this takes precedence.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+124
-77
@@ -86,7 +86,7 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
|
|||||||
| Multi-tenant organisations + custom RBAC (per-resource CRUD, create-limits, read-scope) | 🟡 `is_admin` + `project_members` | Defer full RBAC; design PAT scopes role-compatible |
|
| 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 |
|
| 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) |
|
| 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 |
|
| Outbound webhooks (collection events, HMAC, custom headers) | ✅ `outbound_webhooks` + `webhook_deliveries`, HMAC `X-Containr-Signature`, wildcard events, 3-retry, UI page | — |
|
||||||
| Banners (global + per-tenant, dismissible) | ❌ | `banners` table + top bar |
|
| 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** |
|
| 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 |
|
| Activity feed (icon/severity/category/metadata, 90-day TTL job) | 🟡 `audit_logs` coarser | Enrich + project feed UI |
|
||||||
@@ -121,16 +121,16 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
|
|||||||
- `openapi.yaml` stays the single source of truth; UI, CLI, MCP are thin
|
- `openapi.yaml` stays the single source of truth; UI, CLI, MCP are thin
|
||||||
clients.
|
clients.
|
||||||
|
|
||||||
### Phase A — Agent surface (do this FIRST; everything after inherits it)
|
### Phase A — Agent surface (do this FIRST; everything after inherits it) ✅ SHIPPED (PRs #14–#18)
|
||||||
|
|
||||||
- [ ] **Personal access tokens.** `user_tokens` table — `id`, `user_id`,
|
- [x] **Personal access tokens.** `user_tokens` table — `id`, `user_id`,
|
||||||
`name`, `key_prefix` (`cnp_…`), `key_hash` (sha256), `scopes`
|
`name`, `key_prefix` (`cnp_…`), `key_hash` (sha256), `scopes`
|
||||||
(`read`/`write`/`admin`, later resource-scoped), `expires_at`,
|
(`read`/`write`/`admin`, later resource-scoped), `expires_at`,
|
||||||
`last_used_at`. Model on `agent_auth_tokens` (raw shown once, hash stored).
|
`last_used_at`. Model on `agent_auth_tokens` (raw shown once, hash stored).
|
||||||
`POST/GET/DELETE /api/v1/user/tokens`; `middleware.Auth` accepts
|
`POST/GET/DELETE /api/v1/user/tokens`; `middleware.Auth` accepts
|
||||||
`Bearer cnp_…`; Settings → Access tokens UI with one-time display +
|
`Bearer cnp_…`; Settings → Access tokens UI with one-time display +
|
||||||
copyable CLI/MCP config snippets.
|
copyable CLI/MCP config snippets.
|
||||||
- [ ] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client:
|
- [x] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client:
|
||||||
- `auth login [--token] [--url] [--profile]` (multi-instance profiles).
|
- `auth login [--token] [--url] [--profile]` (multi-instance profiles).
|
||||||
- Resources: `projects services deploy logs exec variables databases
|
- Resources: `projects services deploy logs exec variables databases
|
||||||
cron templates nodes scaling ha security gateway notifications
|
cron templates nodes scaling ha security gateway notifications
|
||||||
@@ -143,16 +143,16 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
|
|||||||
0/1/2/3/4 (ok/err/not-found/unauthorized/conflict).
|
0/1/2/3/4 (ok/err/not-found/unauthorized/conflict).
|
||||||
- GoReleaser → GH releases + `install.sh`; `version` command.
|
- GoReleaser → GH releases + `install.sh`; `version` command.
|
||||||
- Conventions from docsync/envdiff/cix/repolint.
|
- Conventions from docsync/envdiff/cix/repolint.
|
||||||
- [ ] **MCP server** (`cmd/mcp`, same module, stdio). Tools mirror the
|
- [x] **MCP server** (`cmd/mcp`, same module, stdio). Tools mirror the
|
||||||
CLI: `list_projects create_service deploy get_logs set_variable
|
CLI: `list_projects create_service deploy get_logs set_variable
|
||||||
restart rollback create_database trigger_cron deploy_template exec
|
restart rollback create_database trigger_cron deploy_template exec
|
||||||
get_metrics security_scan …`. Auth: `CONTAINR_URL` + `CONTAINR_TOKEN`
|
get_metrics security_scan …`. Auth: `CONTAINR_URL` + `CONTAINR_TOKEN`
|
||||||
or shared `~/.containr/config`. `containr mcp serve` + install snippets
|
or shared `~/.containr/config`. `containr mcp serve` + install snippets
|
||||||
for Devin/Claude/Cursor.
|
for Devin/Claude/Cursor.
|
||||||
- [ ] **API agent ergonomics.** `Idempotency-Key` on mutating endpoints;
|
- [x] **API agent ergonomics.** `Idempotency-Key` on mutating endpoints;
|
||||||
uniform `{error, code, details}` envelope (audit bare-string returns);
|
uniform `{error, code, details}` envelope (audit bare-string returns);
|
||||||
cursor pagination on growing lists; name-or-id lookup everywhere.
|
cursor pagination on growing lists; name-or-id lookup everywhere.
|
||||||
- [ ] **Agent docs.** Root `AGENTS.md`, `/docs` serves `llms.txt` +
|
- [x] **Agent docs.** Root `AGENTS.md`, `/docs` serves `llms.txt` +
|
||||||
`llms-full.txt`, MCP tool list generated from OpenAPI where possible.
|
`llms-full.txt`, MCP tool list generated from OpenAPI where possible.
|
||||||
|
|
||||||
**Gate**: an agent holding only a PAT registers a project, deploys from a
|
**Gate**: an agent holding only a PAT registers a project, deploys from a
|
||||||
@@ -161,78 +161,68 @@ for a human.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Phase B — Deployment pipeline maturity
|
## 3. Phase B — Deployment pipeline maturity ✅ SHIPPED (PR #19)
|
||||||
|
|
||||||
dflow's one real architectural edge. Containr deploys are fire-and-forget
|
dflow's one real architectural edge. Containr deploys are fire-and-forget
|
||||||
goroutines; this fixes correctness *and* unlocks the ops surface.
|
goroutines; this fixes correctness *and* unlocks the ops surface.
|
||||||
|
|
||||||
- [ ] **Deploy job queue.** Lightweight internal queue (Postgres
|
- [x] **Deploy job queue.** Lightweight internal queue — per-service
|
||||||
`deployment_queue` table + single worker loop — no new infra; Redis
|
FIFO in `internal/deployqueue`; status gains `queued`.
|
||||||
exists but don't add BullMQ-style sprawl). Serialize per service;
|
- [x] **Cancel deployments.** `POST /deployments/:id/cancel` — queued
|
||||||
optional per-node serialization. Status gains `queued`.
|
jobs cancel immediately, active jobs cancel context; `cancelled` status.
|
||||||
- [ ] **Cancel deployments.** Route `POST /deployments/:id/cancel` →
|
- [x] **Cache modes.** `no_cache` on deploys; webhook-triggered deploys
|
||||||
engine `CancelDeployment` (exists, unrouted). Queued jobs cancel
|
default to no-cache (dflow convention — prevents stale-layer surprises).
|
||||||
cleanly; building jobs cancel context where safe.
|
- [x] **Queued→building→running status + persisted step logs.** `queued`
|
||||||
- [ ] **Cache modes.** `?cache=full` on redeploy → rebuild from existing
|
state lands; streaming over existing log surface.
|
||||||
image layers vs clean pull/checkout. Webhook-triggered deploys default
|
- [x] **Env var apply → redeploy.** `--redeploy` flag on variable writes;
|
||||||
to no-cache (dflow convention — prevents stale-layer surprises).
|
backend requeues honoring running state.
|
||||||
- [ ] **Queued→building→running status + persisted step logs.** Already
|
- [x] **Encrypt variables at rest.** `is_secret` values encrypted
|
||||||
mostly there; add `queued`, stream over existing `/ws`.
|
AES-GCM `enc:v1:` (key from `SECRETS_KEY`); masked-echo preserves
|
||||||
- [ ] **Env var apply → redeploy.** Saving variables asks "restart to
|
ciphertext.
|
||||||
apply?" (or `--apply` flag in CLI); backend triggers redeploy honoring
|
|
||||||
running state. Currently rows update but containers keep stale env —
|
|
||||||
silent-drift bug class, not just UX.
|
|
||||||
- [ ] **Encrypt variables at rest.** `environment_variables.value` is
|
|
||||||
plaintext today (verified). Envelope-encrypt `is_secret` values
|
|
||||||
(AES-GCM, key from `JWT_SECRET` or dedicated `SECRETS_KEY`); dflow
|
|
||||||
encrypts every var, we encrypt secrets — migration = transparent
|
|
||||||
encrypt-on-write + decrypt-on-read.
|
|
||||||
|
|
||||||
## 4. Phase C — Service configuration parity
|
## 4. Phase C — Service configuration parity ✅ SHIPPED (PRs #20–#23)
|
||||||
|
|
||||||
- [ ] **Volumes on services** — `services.volumes` jsonb
|
- [x] **Volumes on services** — `services.volumes` jsonb
|
||||||
`[{volume|host_path, container_path, read_only}]`; engine accepts
|
`[{volume|host_path, container_path, read_only}]`; Volumes tab +
|
||||||
`VolumeMounts` already. Volumes tab + orphan-volume listing
|
admin volume inventory.
|
||||||
(`docker volume ls -f dangling`) with delete/attach.
|
- [x] **Multi-domain** — `service_domains(service_id, domain, is_default,
|
||||||
- [ ] **Multi-domain** — `service_domains(service_id, domain, is_default,
|
cert_type, status, last_checked_at)`. Domains UI: add/remove/
|
||||||
cert_type, status, last_checked_at)`. Domains tab: add/remove/
|
set-default/DNS-status badge. `services.domain` stays derived default.
|
||||||
set-default/DNS-status badge. Keep `services.domain` as derived
|
- [x] **DNS preflight** — `GET /services/:id/domains/check` resolves and
|
||||||
default for compat.
|
|
||||||
- [ ] **DNS preflight** — `GET /services/:id/domains/check` resolves and
|
|
||||||
compares vs edge IP → `ok|wrong-target|pending`.
|
compares vs edge IP → `ok|wrong-target|pending`.
|
||||||
- [ ] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
|
- [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
|
||||||
no proxy code needed.
|
no proxy code needed.
|
||||||
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with
|
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with
|
||||||
allowlist validation (middlewares, headers, redirects).
|
allowlist validation (middlewares, headers, redirects).
|
||||||
- [ ] **Builder override** — `services.builder` enum
|
- [x] **Builder override** — `services.builder` enum
|
||||||
`auto|railpack|nixpacks|dockerfile|static`; fix hardcoded `nixpacks`
|
`auto|railpack|nixpacks|dockerfile|static`; static builder = build +
|
||||||
at `deployments.go:406`. Static builder = build + nginx sidecar.
|
nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug.
|
||||||
- [ ] **Resource limits + reserves** — adopt dflow-style explicit units
|
- [x] **Resource limits + reserves** — `cpu`/`memory` caps +
|
||||||
(`"0.5"`/`"512m"`); add `cpu_reserve`/`memory_reserve` →
|
`cpu_reserve`/`memory_reserve` → NanoCPUs/Memory/MemoryReservation;
|
||||||
`NanoCPUs`/`Memory`/`MemoryReservation`; instance defaults in
|
instance defaults in `app_settings`.
|
||||||
`app_settings`.
|
- [x] **Pre-deploy capacity check** — reservations vs node capacity;
|
||||||
- [ ] **Pre-deploy capacity check** — sum reservations vs node capacity
|
`app_settings.capacity_policy=block` rejects, default warns.
|
||||||
(agent telemetry); warn or block per policy flag.
|
- [x] **Service clone** + **move between projects** — copies volumes,
|
||||||
- [ ] **Service clone** + **move between projects**.
|
domains, access gates, vars (ciphertext verbatim); audit-logged.
|
||||||
- [ ] **Private registries** — `registry_credentials`
|
- [x] **Private registries** — `registry_credentials`
|
||||||
(server/username/secret, encrypted); `docker login` at pull time;
|
(server/username/secret, encrypted); pull auth resolved at deploy;
|
||||||
per-service registry link.
|
per-service registry link.
|
||||||
- [ ] **Service detail tabs to match dflow's surface**: add Domains,
|
- [x] **Service detail tabs to match dflow's surface**: Domains,
|
||||||
Volumes, Proxy/access-controls, Backups sections to the service page
|
Volumes, access-controls sections added to the service page.
|
||||||
(Cron/Previews/Scaling already exist).
|
|
||||||
|
|
||||||
## 5. Phase D — Data layer maturity
|
## 5. Phase D — Data layer maturity
|
||||||
|
|
||||||
- [ ] **External DB registration** — `provider='managed'|'external'`,
|
- [x] **External DB registration** — `provider='managed'|'external'`,
|
||||||
conn fields, `POST /databases/test-connection`, same bind flow.
|
conn fields, `POST /databases/test-connection`, same bind flow. (PR #24)
|
||||||
- [ ] **Public DB port expose** — opt-in Traefik TCP route or host port;
|
- [x] **Public DB port expose** — `public_port` opt-in `0.0.0.0` host
|
||||||
default stays loopback.
|
bind; default stays loopback. (PR #25)
|
||||||
- [ ] **Offsite backups** — `backup_targets` (S3-compatible:
|
- [x] **Offsite backups** — `backup_targets` (S3-compatible:
|
||||||
endpoint/bucket/keys encrypted); per-DB destination; ship archives via
|
endpoint/bucket/keys encrypted); per-DB destination; archives shipped
|
||||||
utility container + s3 SDK; restore-from-S3.
|
via holder container + minio-go; restore-from-S3 fallback. (PR #26)
|
||||||
- [ ] **Cross-node DB migration** — `POST /databases/:id/migrate
|
- [ ] **Cross-node DB migration** — `POST /databases/:id/migrate
|
||||||
{target_node_id}`: dump → stream transfer → provision → restore →
|
{target_node_id}`: dump → stream transfer → provision → restore →
|
||||||
re-point bound services. Needs ≥2 nodes or local↔node.
|
re-point bound services. Deferred until Phase F lands real second
|
||||||
|
nodes — nothing to migrate between today.
|
||||||
- [ ] **Backup import** — upload archive → new restore point (pairs with
|
- [ ] **Backup import** — upload archive → new restore point (pairs with
|
||||||
migrators).
|
migrators).
|
||||||
|
|
||||||
@@ -288,20 +278,29 @@ and wired refs, one click or one CLI call.
|
|||||||
|
|
||||||
## 8. Phase G — Platform polish
|
## 8. Phase G — Platform polish
|
||||||
|
|
||||||
- [ ] **Outbound webhooks** — `webhooks(url, secret, events[], headers,
|
- [x] **Outbound webhooks** — `outbound_webhooks(url, secret, events[],
|
||||||
enabled)` + `webhook_deliveries` log; HMAC `X-Containr-Signature`;
|
headers, enabled)` + `webhook_deliveries` log; HMAC
|
||||||
events: `deployment.* service.* database.* security.* agent.*`.
|
`X-Containr-Signature`; wildcard events on every audited action;
|
||||||
|
CLI + MCP + `/settings/webhooks` page with delivery log + test ping.
|
||||||
- [ ] **Activity feed** — enrich `audit_logs` with `severity category
|
- [ ] **Activity feed** — enrich `audit_logs` with `severity category
|
||||||
icon label metadata`; per-project timeline + global feed; TTL job.
|
icon label metadata`; per-project timeline + global feed; TTL job.
|
||||||
- [ ] **Banners** — `banners(title, body, level, scope, active)`; top
|
- [x] **Banners** — `banners` table with title/body/level/active/
|
||||||
bar in UI + surfaced to CLI on `auth status`.
|
dismissible/schedule window. `GET /banners/active` for signed-in
|
||||||
- [ ] **White-label** — `app_settings.branding`: product name, logos,
|
users; admin CRUD at `/admin/banners`. `BannerBar` renders above the
|
||||||
favicon, accent override, docs/support links. `GET /api/v1/branding`
|
header (per-banner dismiss via localStorage); admin console manages
|
||||||
public + cached. **Containr/Vertice remains default;** this is for
|
them; `containr banners` CLI + `auth status` surfaces active ones.
|
||||||
self-hosters who rebrand their instance.
|
- [x] **White-label** — `app_settings` `branding.*` keys: product name,
|
||||||
- [ ] **Operations page** — unified view of running/queued/failed jobs
|
logo/favicon URL, accent color, docs/support links. Public
|
||||||
(deploys, builds, backups, cron, scans, agent commands) — dflow's
|
`GET /api/v1/branding` (defaults = Containr); admin writes via
|
||||||
bubble panel as a real page; flush/stuck-job tools once queue exists.
|
`PUT /settings`. `useBranding()` applies title/accent/favicon;
|
||||||
|
`BrandWordmark` replaces hardcoded marks; admin Platform section
|
||||||
|
edits all fields. MCP `containr_branding_get` + `settings_update`.
|
||||||
|
- [x] **Operations page** — `GET /operations` aggregates active
|
||||||
|
deployments, in-memory deploy-queue depth (`Queue.Snapshot()`),
|
||||||
|
24h failures, cron runs and backups scoped to the caller's projects.
|
||||||
|
UI at `/operations` (Operate nav) with live refetch; `containr
|
||||||
|
operations` CLI + `containr_operations_get` MCP. Remaining: agent
|
||||||
|
commands once node fleet lands; flush/stuck-job tools.
|
||||||
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`);
|
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`);
|
||||||
`nodes/:id/terminal` via agent (dflow's wetty without the extra
|
`nodes/:id/terminal` via agent (dflow's wetty without the extra
|
||||||
container).
|
container).
|
||||||
@@ -318,6 +317,32 @@ and wired refs, one click or one CLI call.
|
|||||||
audit logs, deployments, notifications tables.
|
audit logs, deployments, notifications tables.
|
||||||
- [ ] **Notification producers round-out** — backups, agent offline,
|
- [ ] **Notification producers round-out** — backups, agent offline,
|
||||||
upgrade available, security findings.
|
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
|
- [ ] **Custom roles/RBAC** — defer until demanded; PAT scopes designed
|
||||||
forward-compatible now.
|
forward-compatible now.
|
||||||
|
|
||||||
@@ -329,12 +354,34 @@ and wired refs, one click or one CLI call.
|
|||||||
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST
|
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST
|
||||||
export or Mongo dump: servers→nodes, projects, services(+vars/
|
export or Mongo dump: servers→nodes, projects, services(+vars/
|
||||||
domains/volumes), databases, templates. Onboards dflow refugees.
|
domains/volumes), databases, templates. Onboards dflow refugees.
|
||||||
- [ ] **`containr import compose`** — Phase-E parser straight to a
|
- [x] **`containr import compose`** — `POST /projects/:id/import-compose`
|
||||||
project (no template intermediate).
|
deploys a compose file straight into a project; CLI `import compose`,
|
||||||
|
MCP tool, workspace import modal; project delete cascades to
|
||||||
|
graph-provisioned managed databases (`database_services.project_id`).
|
||||||
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
|
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
|
||||||
fetch/publish; bundled catalog works offline (dflow's self-contained
|
fetch/publish; bundled catalog works offline (dflow's self-contained
|
||||||
principle — nothing phones home).
|
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)
|
## 10. Carried-over debt (ROADMAP.md items still open)
|
||||||
|
|
||||||
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit →
|
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit →
|
||||||
|
|||||||
+12
-3
@@ -13,6 +13,7 @@ require (
|
|||||||
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/minio/minio-go/v7 v7.0.95
|
||||||
github.com/modelcontextprotocol/go-sdk v1.7.0
|
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
|
||||||
@@ -23,6 +24,7 @@ require (
|
|||||||
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
|
golang.org/x/term v0.46.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -37,23 +39,28 @@ require (
|
|||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
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.5 // indirect
|
||||||
github.com/google/jsonschema-go v0.4.3 // 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/compress v1.18.0 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||||
|
github.com/minio/crc64nvme v1.0.2 // indirect
|
||||||
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
@@ -63,8 +70,10 @@ require (
|
|||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/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/rs/xid v1.6.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/asm v1.2.0 // indirect
|
||||||
github.com/segmentio/encoding v0.5.4 // indirect
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
@@ -74,6 +83,7 @@ require (
|
|||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/tinylib/msgp v1.3.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/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
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
@@ -97,7 +107,6 @@ require (
|
|||||||
golang.org/x/time v0.15.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
|
|
||||||
gotest.tools/v3 v3.5.2 // indirect
|
gotest.tools/v3 v3.5.2 // indirect
|
||||||
modernc.org/sqlite v1.39.0 // indirect
|
modernc.org/sqlite v1.39.0 // indirect
|
||||||
)
|
)
|
||||||
+21
-5
@@ -46,6 +46,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
|||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||||
|
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||||
|
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -63,8 +65,8 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC
|
|||||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
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.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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=
|
||||||
@@ -82,9 +84,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
|||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -99,6 +104,12 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B
|
|||||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||||
|
github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
|
||||||
|
github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||||
|
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
|
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
|
github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
|
||||||
|
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||||
@@ -130,6 +141,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
|
|||||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
@@ -144,6 +157,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
|
|||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
|
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
|
||||||
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||||
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/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=
|
||||||
@@ -182,6 +197,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
||||||
|
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
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=
|
||||||
@@ -227,7 +244,6 @@ golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
|||||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
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.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=
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitWebhookEvent(userID, resource, resourceID, action, details)
|
||||||
}
|
}
|
||||||
|
|
||||||
func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) {
|
func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) {
|
||||||
@@ -79,6 +81,8 @@ func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, de
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitWebhookEvent(userIDStr, resource, resourceID, action, details)
|
||||||
}
|
}
|
||||||
|
|
||||||
var auditDB *database.DB
|
var auditDB *database.DB
|
||||||
|
|||||||
@@ -0,0 +1,646 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database/sqlcdb"
|
||||||
|
"containr/internal/docker"
|
||||||
|
"containr/internal/secrets"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/mount"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BackupTarget is an S3-compatible archive destination. Secret keys are
|
||||||
|
// stored encrypted and never serialized — has_credentials signals presence.
|
||||||
|
type BackupTarget struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Bucket string `json:"bucket"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
Prefix string `json:"prefix,omitempty"`
|
||||||
|
HasCredentials bool `json:"has_credentials"`
|
||||||
|
UseTLS bool `json:"use_tls"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
accessKey string `json:"-"`
|
||||||
|
secretKey string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type backupTargetRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Endpoint string `json:"endpoint" binding:"required"`
|
||||||
|
Bucket string `json:"bucket" binding:"required"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
AccessKey string `json:"access_key"`
|
||||||
|
SecretKey string `json:"secret_key"`
|
||||||
|
UseTLS *bool `json:"use_tls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapBackupTargetRow(row sqlcdb.BackupTarget) BackupTarget {
|
||||||
|
return BackupTarget{
|
||||||
|
ID: row.ID,
|
||||||
|
Name: row.Name,
|
||||||
|
Endpoint: row.Endpoint,
|
||||||
|
Bucket: row.Bucket,
|
||||||
|
Region: row.Region,
|
||||||
|
Prefix: row.Prefix,
|
||||||
|
HasCredentials: row.SecretKey != "",
|
||||||
|
UseTLS: row.UseTls,
|
||||||
|
CreatedAt: databaseNullTime(row.CreatedAt),
|
||||||
|
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
||||||
|
accessKey: secrets.Decrypt(row.AccessKey),
|
||||||
|
secretKey: secrets.Decrypt(row.SecretKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// s3Client builds a client for the target. Endpoint is a bare host[:port] —
|
||||||
|
// scheme comes from use_tls, matching MinIO/Garage/AWS conventions.
|
||||||
|
func (t BackupTarget) s3Client() (*minio.Client, error) {
|
||||||
|
opts := &minio.Options{Secure: t.UseTLS, Region: t.Region}
|
||||||
|
if t.accessKey != "" || t.secretKey != "" {
|
||||||
|
opts.Creds = credentials.NewStaticV4(t.accessKey, t.secretKey, "")
|
||||||
|
} else {
|
||||||
|
opts.Creds = credentials.NewIAM("")
|
||||||
|
}
|
||||||
|
return minio.New(t.Endpoint, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBackupTargetRequest(req *backupTargetRequest) error {
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
req.Endpoint = strings.TrimSpace(req.Endpoint)
|
||||||
|
req.Endpoint = strings.TrimPrefix(strings.TrimPrefix(req.Endpoint, "https://"), "http://")
|
||||||
|
req.Endpoint = strings.TrimSuffix(req.Endpoint, "/")
|
||||||
|
req.Bucket = strings.TrimSpace(req.Bucket)
|
||||||
|
req.Prefix = strings.Trim(strings.TrimSpace(req.Prefix), "/")
|
||||||
|
if req.Name == "" {
|
||||||
|
return errors.New("name is required")
|
||||||
|
}
|
||||||
|
if req.Endpoint == "" || req.Bucket == "" {
|
||||||
|
return errors.New("endpoint and bucket are required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeBackupTarget verifies credentials and bucket access.
|
||||||
|
func probeBackupTarget(ctx context.Context, t BackupTarget) (time.Duration, error) {
|
||||||
|
client, err := t.s3Client()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
exists, err := client.BucketExists(ctx, t.Bucket)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return 0, fmt.Errorf("bucket %q not found or not accessible", t.Bucket)
|
||||||
|
}
|
||||||
|
return time.Since(start), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DatabaseHandler) ListBackupTargets(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := h.queries.ListBackupTargetsByUser(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list backup targets"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targets := make([]BackupTarget, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
t := mapBackupTargetRow(row)
|
||||||
|
t.accessKey, t.secretKey = "", ""
|
||||||
|
targets = append(targets, t)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"backup_targets": targets})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DatabaseHandler) CreateBackupTarget(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req backupTargetRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := normalizeBackupTargetRequest(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
useTLS := true
|
||||||
|
if req.UseTLS != nil {
|
||||||
|
useTLS = *req.UseTLS
|
||||||
|
}
|
||||||
|
target := BackupTarget{
|
||||||
|
ID: "bt_" + strings.ReplaceAll(uuid.NewString(), "-", "")[:12],
|
||||||
|
Name: req.Name,
|
||||||
|
Endpoint: req.Endpoint,
|
||||||
|
Bucket: req.Bucket,
|
||||||
|
Region: req.Region,
|
||||||
|
Prefix: req.Prefix,
|
||||||
|
UseTLS: useTLS,
|
||||||
|
accessKey: req.AccessKey,
|
||||||
|
secretKey: req.SecretKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := probeBackupTarget(probeCtx, target); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Connection check failed: %v", err), "code": "DEPENDENCY_UNAVAILABLE"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enc := func(v string) string {
|
||||||
|
if v == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return secrets.Encrypt(v)
|
||||||
|
}
|
||||||
|
now := sql.NullTime{Time: time.Now(), Valid: true}
|
||||||
|
if err := h.queries.CreateBackupTarget(c.Request.Context(), sqlcdb.CreateBackupTargetParams{
|
||||||
|
ID: target.ID,
|
||||||
|
UserID: userID,
|
||||||
|
Name: target.Name,
|
||||||
|
Endpoint: target.Endpoint,
|
||||||
|
Bucket: target.Bucket,
|
||||||
|
Region: target.Region,
|
||||||
|
Prefix: target.Prefix,
|
||||||
|
AccessKey: enc(req.AccessKey),
|
||||||
|
SecretKey: enc(req.SecretKey),
|
||||||
|
UseTls: useTLS,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create backup target"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LogAudit(userID, "backup_target", target.ID, "create", map[string]interface{}{"name": req.Name, "endpoint": req.Endpoint, "bucket": req.Bucket})
|
||||||
|
target.accessKey, target.secretKey = "", ""
|
||||||
|
target.HasCredentials = req.SecretKey != ""
|
||||||
|
target.CreatedAt, target.UpdatedAt = now.Time, now.Time
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"backup_target": target})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DatabaseHandler) UpdateBackupTarget(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetID := c.Param("id")
|
||||||
|
row, err := h.queries.GetBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.GetBackupTargetByIDAndUserParams{ID: targetID, UserID: userID})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Backup target not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req backupTargetRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := normalizeBackupTargetRequest(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
useTLS := row.UseTls
|
||||||
|
if req.UseTLS != nil {
|
||||||
|
useTLS = *req.UseTLS
|
||||||
|
}
|
||||||
|
// Empty credentials keep the stored pair.
|
||||||
|
accessKey := row.AccessKey
|
||||||
|
secretKey := row.SecretKey
|
||||||
|
if req.AccessKey != "" || req.SecretKey != "" {
|
||||||
|
accessKey = secrets.Encrypt(req.AccessKey)
|
||||||
|
secretKey = secrets.Encrypt(req.SecretKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
target := BackupTarget{
|
||||||
|
Endpoint: req.Endpoint,
|
||||||
|
Bucket: req.Bucket,
|
||||||
|
Region: req.Region,
|
||||||
|
Prefix: req.Prefix,
|
||||||
|
UseTLS: useTLS,
|
||||||
|
accessKey: secrets.Decrypt(accessKey),
|
||||||
|
secretKey: secrets.Decrypt(secretKey),
|
||||||
|
}
|
||||||
|
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := probeBackupTarget(probeCtx, target); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Connection check failed: %v", err), "code": "DEPENDENCY_UNAVAILABLE"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.queries.UpdateBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.UpdateBackupTargetByIDAndUserParams{
|
||||||
|
Name: req.Name,
|
||||||
|
Endpoint: req.Endpoint,
|
||||||
|
Bucket: req.Bucket,
|
||||||
|
Region: req.Region,
|
||||||
|
Prefix: req.Prefix,
|
||||||
|
AccessKey: accessKey,
|
||||||
|
SecretKey: secretKey,
|
||||||
|
UseTls: useTLS,
|
||||||
|
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
|
ID: targetID,
|
||||||
|
UserID: userID,
|
||||||
|
}); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update backup target"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Backup target updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DatabaseHandler) DeleteBackupTarget(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetID := c.Param("id")
|
||||||
|
count, err := h.queries.CountDatabasesUsingBackupTarget(c.Request.Context(), sql.NullString{String: targetID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check target usage"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("%d database(s) still back up to this target", count), "code": "CONFLICT"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.queries.DeleteBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.DeleteBackupTargetByIDAndUserParams{ID: targetID, UserID: userID}); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete backup target"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LogAudit(userID, "backup_target", targetID, "delete", nil)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Backup target deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportBackup accepts a `.tar.gz` archive (multipart `file` field or raw
|
||||||
|
// body) and stores it as a restore point — the counterpart to offsite
|
||||||
|
// export, and the entry path for migrators.
|
||||||
|
func (h *DatabaseHandler) ImportBackup(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
databaseID := c.Param("id")
|
||||||
|
userID = h.effectiveUserID(c, userID, databaseID)
|
||||||
|
|
||||||
|
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||||
|
ID: databaseID, UserID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.Provider == "external" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "backup import applies to managed databases — external restores are not supported yet", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer to a temp file — tar headers need the size up front, and a
|
||||||
|
// streamed upload can't be rewound for the archive write.
|
||||||
|
src, _, err := openUploadStream(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp("", "containr-backup-import-*")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to stage upload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { tmp.Close(); os.Remove(tmp.Name()) }()
|
||||||
|
|
||||||
|
const maxImport = 2 << 30 // 2 GiB ceiling for staged archives
|
||||||
|
size, err := io.Copy(tmp, http.MaxBytesReader(c.Writer, src, maxImport))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "archive too large or unreadable", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read staged upload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
magic := make([]byte, 2)
|
||||||
|
if _, err := tmp.Read(magic); err != nil || magic[0] != 0x1f || magic[1] != 0x8b {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "archive must be gzip-compressed (.tar.gz)", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read staged upload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
backupID := generateDatabaseBackupID(databaseID)
|
||||||
|
archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID))
|
||||||
|
|
||||||
|
if _, err := h.dockerClient.CreateVolume(c.Request.Context(), docker.VolumeConfig{Name: managedDatabaseBackupVolume}); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already exists") {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to prepare backup volume"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
holderID, err := h.createBackupVolumeHolder(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to open backup volume: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) }()
|
||||||
|
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
tw := tar.NewWriter(pw)
|
||||||
|
go func() {
|
||||||
|
err := tw.WriteHeader(&tar.Header{Name: archivePath, Mode: 0o644, Size: size})
|
||||||
|
if err == nil {
|
||||||
|
_, err = io.Copy(tw, tmp)
|
||||||
|
}
|
||||||
|
if cerr := tw.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
_ = pw.CloseWithError(err)
|
||||||
|
}()
|
||||||
|
if err := h.dockerClient.CopyToContainer(c.Request.Context(), holderID, "/backup", pr); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to store archive: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := sql.NullTime{Time: time.Now(), Valid: true}
|
||||||
|
if err := h.queries.CreateDatabaseBackup(c.Request.Context(), sqlcdb.CreateDatabaseBackupParams{
|
||||||
|
ID: backupID,
|
||||||
|
DatabaseID: databaseID,
|
||||||
|
Size: humanReadableBytes(size),
|
||||||
|
Status: "completed",
|
||||||
|
BackupPath: sql.NullString{String: archivePath, Valid: true},
|
||||||
|
CreatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register backup"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.queries.SetDatabaseBackupStatusByID(c.Request.Context(), sqlcdb.SetDatabaseBackupStatusByIDParams{
|
||||||
|
Status: "completed",
|
||||||
|
Size: humanReadableBytes(size),
|
||||||
|
CompletedAt: now,
|
||||||
|
ID: backupID,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Imported archives ship offsite too when a target is configured —
|
||||||
|
// the import should be a first-class restore point.
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if key, err := h.uploadBackupToTarget(ctx, row, archivePath, backupID); err != nil {
|
||||||
|
log.Printf("containr: imported backup %s remote upload failed: %v (local archive kept)", backupID, err)
|
||||||
|
} else if key != "" {
|
||||||
|
log.Printf("containr: imported backup %s shipped to %s", backupID, key)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
LogAudit(userID, "database", databaseID, "backup_import", map[string]interface{}{"backup_id": backupID, "size": size})
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"backup_id": backupID, "size": humanReadableBytes(size), "status": "completed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// openUploadStream resolves the request body: multipart `file` field when
|
||||||
|
// present, otherwise the raw body (e.g. CLI pipe of a .tar.gz).
|
||||||
|
func openUploadStream(c *gin.Context) (io.ReadCloser, string, error) {
|
||||||
|
if fh, err := c.FormFile("file"); err == nil {
|
||||||
|
src, err := fh.Open()
|
||||||
|
return src, fh.Filename, err
|
||||||
|
}
|
||||||
|
if c.Request.Body == nil || c.Request.ContentLength == 0 {
|
||||||
|
return nil, "", errors.New("no archive uploaded — send multipart field 'file' or a raw .tar.gz body")
|
||||||
|
}
|
||||||
|
return c.Request.Body, "archive.tar.gz", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DatabaseHandler) TestBackupTarget(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetID := c.Param("id")
|
||||||
|
row, err := h.queries.GetBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.GetBackupTargetByIDAndUserParams{ID: targetID, UserID: userID})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Backup target not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := mapBackupTargetRow(row)
|
||||||
|
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
latency, probeErr := probeBackupTarget(probeCtx, target)
|
||||||
|
if probeErr != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": false, "error": probeErr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadBackupToTarget copies the archive out of the backup volume and puts
|
||||||
|
// it in the configured S3 target. Returns the object key, or "" when no
|
||||||
|
// target is configured.
|
||||||
|
func (h *DatabaseHandler) uploadBackupToTarget(ctx context.Context, db sqlcdb.DatabaseService, archivePath, backupID string) (string, error) {
|
||||||
|
if !db.BackupTargetID.Valid || db.BackupTargetID.String == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
targetRow, err := h.queries.GetBackupTargetByID(ctx, db.BackupTargetID.String)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("backup target lookup failed: %w", err)
|
||||||
|
}
|
||||||
|
target := mapBackupTargetRow(targetRow)
|
||||||
|
|
||||||
|
archive, err := h.readBackupArchive(ctx, archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read archive: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_, _ = io.Copy(io.Discard, archive)
|
||||||
|
_ = archive.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := target.s3Client()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key := target.Prefix
|
||||||
|
if key != "" {
|
||||||
|
key += "/"
|
||||||
|
}
|
||||||
|
key += "databases/" + db.ID + "/" + sanitizeBackupArchivePath(archivePath)
|
||||||
|
_, err = client.PutObject(ctx, target.Bucket, key, archive, -1, minio.PutObjectOptions{ContentType: "application/gzip"})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("s3 upload: %w", err)
|
||||||
|
}
|
||||||
|
_ = h.queries.SetDatabaseBackupRemoteKeyByID(ctx, sqlcdb.SetDatabaseBackupRemoteKeyByIDParams{
|
||||||
|
RemoteKey: sql.NullString{String: key, Valid: true},
|
||||||
|
ID: backupID,
|
||||||
|
})
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBackupArchive streams an archive out of the shared backup volume via a
|
||||||
|
// stopped holder container — the volume has no direct host path.
|
||||||
|
func (h *DatabaseHandler) readBackupArchive(ctx context.Context, archivePath string) (io.ReadCloser, error) {
|
||||||
|
holderID, err := h.createBackupVolumeHolder(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reader, err := h.dockerClient.CopyFromContainer(ctx, holderID, "/backup/"+sanitizeBackupArchivePath(archivePath))
|
||||||
|
if err != nil {
|
||||||
|
_ = h.dockerClient.RemoveContainer(context.Background(), holderID, true)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// The holder must outlive the stream — deleting the source container while
|
||||||
|
// dockerd serves the archive stalls the read without an error.
|
||||||
|
return &archiveReader{tar: reader, cleanup: func() {
|
||||||
|
_ = h.dockerClient.RemoveContainer(context.Background(), holderID, true)
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadBackupToVolume fetches a remote archive back into the backup volume
|
||||||
|
// so the normal restore path can run unchanged.
|
||||||
|
func (h *DatabaseHandler) downloadBackupToVolume(ctx context.Context, db sqlcdb.DatabaseService, remoteKey, archivePath string) error {
|
||||||
|
targetRow, err := h.queries.GetBackupTargetByID(ctx, db.BackupTargetID.String)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("backup target lookup failed: %w", err)
|
||||||
|
}
|
||||||
|
target := mapBackupTargetRow(targetRow)
|
||||||
|
client, err := target.s3Client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
obj, err := client.GetObject(ctx, target.Bucket, remoteKey, minio.GetObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer obj.Close()
|
||||||
|
|
||||||
|
archive := sanitizeBackupArchivePath(archivePath)
|
||||||
|
if archive == "" {
|
||||||
|
return errors.New("backup path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
tw := tar.NewWriter(&buf)
|
||||||
|
data, err := io.ReadAll(obj)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("s3 download: %w", err)
|
||||||
|
}
|
||||||
|
if err := tw.WriteHeader(&tar.Header{Name: archive, Mode: 0o644, Size: int64(len(data))}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tw.Write(data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
holderID, err := h.createBackupVolumeHolder(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) }()
|
||||||
|
return h.dockerClient.CopyToContainer(ctx, holderID, "/backup", &buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// backupArchiveExists reports whether the archive is still in the local
|
||||||
|
// backup volume — a copy attempt on a holder container is the cheapest check.
|
||||||
|
func (h *DatabaseHandler) backupArchiveExists(ctx context.Context, archivePath string) (bool, error) {
|
||||||
|
holderID, err := h.createBackupVolumeHolder(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) }()
|
||||||
|
reader, err := h.dockerClient.CopyFromContainer(ctx, holderID, "/backup/"+sanitizeBackupArchivePath(archivePath))
|
||||||
|
if err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
_ = reader.Close()
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createBackupVolumeHolder creates a container mounting the backup volume so
|
||||||
|
// archives can be copied in and out. It must be started — docker cp/archive
|
||||||
|
// only resolves volume mounts on containers that have run at least once.
|
||||||
|
func (h *DatabaseHandler) createBackupVolumeHolder(ctx context.Context) (string, error) {
|
||||||
|
if err := h.ensureImage(ctx, managedDatabaseBackupImage); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
holderID, err := h.dockerClient.CreateContainer(ctx, docker.ContainerConfig{
|
||||||
|
Image: managedDatabaseBackupImage,
|
||||||
|
Cmd: []string{"true"},
|
||||||
|
RestartPolicy: "no",
|
||||||
|
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: managedDatabaseBackupVolume, Target: "/backup"}},
|
||||||
|
Labels: map[string]string{"containr.managed": "true", "containr.utility": "backup-io"},
|
||||||
|
NetworkMode: "none",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := h.dockerClient.StartContainer(ctx, holderID); err != nil {
|
||||||
|
_ = h.dockerClient.RemoveContainer(context.Background(), holderID, true)
|
||||||
|
return "", fmt.Errorf("failed to start backup holder: %w", err)
|
||||||
|
}
|
||||||
|
return holderID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// archiveReader unwraps the tar stream CopyFromContainer returns.
|
||||||
|
type archiveReader struct {
|
||||||
|
tar io.ReadCloser
|
||||||
|
tr *tar.Reader
|
||||||
|
err error
|
||||||
|
cleanup func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *archiveReader) Read(p []byte) (int, error) {
|
||||||
|
if r.err != nil {
|
||||||
|
return 0, r.err
|
||||||
|
}
|
||||||
|
if r.tr == nil {
|
||||||
|
r.tr = tar.NewReader(r.tar)
|
||||||
|
// Skip to the first regular file entry.
|
||||||
|
for {
|
||||||
|
hdr, err := r.tr.Next()
|
||||||
|
if err != nil {
|
||||||
|
r.err = err
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if hdr.Typeflag == tar.TypeReg {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n, err := r.tr.Read(p)
|
||||||
|
if err != nil {
|
||||||
|
r.err = err
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *archiveReader) Close() error {
|
||||||
|
err := r.tar.Close()
|
||||||
|
if r.cleanup != nil {
|
||||||
|
r.cleanup()
|
||||||
|
r.cleanup = nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeBackupTargetRequest(t *testing.T) {
|
||||||
|
req := backupTargetRequest{
|
||||||
|
Name: " garage ",
|
||||||
|
Endpoint: "https://garage.local:3900/",
|
||||||
|
Bucket: " backups ",
|
||||||
|
Prefix: " /prod/dbs/ ",
|
||||||
|
}
|
||||||
|
if err := normalizeBackupTargetRequest(&req); err != nil {
|
||||||
|
t.Fatalf("normalize failed: %v", err)
|
||||||
|
}
|
||||||
|
if req.Endpoint != "garage.local:3900" {
|
||||||
|
t.Fatalf("endpoint not normalized: %q", req.Endpoint)
|
||||||
|
}
|
||||||
|
if req.Bucket != "backups" || req.Prefix != "prod/dbs" {
|
||||||
|
t.Fatalf("bucket/prefix not trimmed: %q %q", req.Bucket, req.Prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, bad := range []backupTargetRequest{
|
||||||
|
{Endpoint: "e", Bucket: "b"}, // no name
|
||||||
|
{Name: "n", Bucket: "b"}, // no endpoint
|
||||||
|
{Name: "n", Endpoint: "e"}, // no bucket
|
||||||
|
} {
|
||||||
|
if err := normalizeBackupTargetRequest(&bad); err == nil {
|
||||||
|
t.Fatalf("case %d: expected error", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArchiveReaderUnwrapsTar(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
tw := tar.NewWriter(&buf)
|
||||||
|
payload := []byte("backup-bytes")
|
||||||
|
if err := tw.WriteHeader(&tar.Header{Name: "b.tar.gz", Mode: 0o644, Size: int64(len(payload))}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := tw.Write(payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &archiveReader{tar: io.NopCloser(&buf)}
|
||||||
|
got, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, payload) {
|
||||||
|
t.Fatalf("payload mismatch: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/database/sqlcdb"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Instance-wide announcement banners. Admins manage them under
|
||||||
|
// /admin/banners; every signed-in user reads the active set at
|
||||||
|
// /banners/active so the UI (and CLI) can surface announcements.
|
||||||
|
|
||||||
|
var bannerLevels = map[string]bool{"info": true, "success": true, "warning": true, "error": true}
|
||||||
|
|
||||||
|
// bannerJSON flattens sqlc nullable wrappers into plain API values.
|
||||||
|
type bannerJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Dismissible bool `json:"dismissible"`
|
||||||
|
StartsAt *time.Time `json:"starts_at,omitempty"`
|
||||||
|
EndsAt *time.Time `json:"ends_at,omitempty"`
|
||||||
|
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBannerJSON(b sqlcdb.Banner) bannerJSON {
|
||||||
|
out := bannerJSON{
|
||||||
|
ID: b.ID.String(),
|
||||||
|
Title: b.Title,
|
||||||
|
Body: b.Body,
|
||||||
|
Level: b.Level,
|
||||||
|
Active: b.Active,
|
||||||
|
Dismissible: b.Dismissible,
|
||||||
|
}
|
||||||
|
if b.StartsAt.Valid {
|
||||||
|
out.StartsAt = &b.StartsAt.Time
|
||||||
|
}
|
||||||
|
if b.EndsAt.Valid {
|
||||||
|
out.EndsAt = &b.EndsAt.Time
|
||||||
|
}
|
||||||
|
if b.CreatedAt.Valid {
|
||||||
|
out.CreatedAt = &b.CreatedAt.Time
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBannerList(banners []sqlcdb.Banner) []bannerJSON {
|
||||||
|
out := make([]bannerJSON, 0, len(banners))
|
||||||
|
for _, b := range banners {
|
||||||
|
out = append(out, toBannerJSON(b))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type bannerRequest struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Body *string `json:"body"`
|
||||||
|
Level *string `json:"level"`
|
||||||
|
Active *bool `json:"active"`
|
||||||
|
Dismissible *bool `json:"dismissible"`
|
||||||
|
StartsAt *time.Time `json:"starts_at"`
|
||||||
|
EndsAt *time.Time `json:"ends_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func bannerQueries(c *gin.Context) *sqlcdb.Queries {
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
return sqlcdb.New(db.DB)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBannerID(c *gin.Context) (uuid.UUID, bool) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid banner id")
|
||||||
|
return uuid.Nil, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validBannerLevel(level string) bool { return bannerLevels[level] }
|
||||||
|
|
||||||
|
func handleListActiveBanners(c *gin.Context) {
|
||||||
|
banners, err := bannerQueries(c).ListActiveBanners(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list banners")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"banners": toBannerList(banners)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAdminListBanners(c *gin.Context) {
|
||||||
|
banners, err := bannerQueries(c).ListBanners(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list banners")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"banners": toBannerList(banners)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAdminCreateBanner(c *gin.Context) {
|
||||||
|
var req bannerRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "INVALID_BODY", "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Title == nil || *req.Title == "" {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "title is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
level := "info"
|
||||||
|
if req.Level != nil {
|
||||||
|
if !validBannerLevel(*req.Level) {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "level must be info, success, warning or error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
level = *req.Level
|
||||||
|
}
|
||||||
|
userID, _ := requireAuthenticatedUserUUID(c)
|
||||||
|
params := sqlcdb.CreateBannerParams{
|
||||||
|
Title: *req.Title,
|
||||||
|
Level: level,
|
||||||
|
Active: true,
|
||||||
|
Dismissible: true,
|
||||||
|
CreatedBy: uuid.NullUUID{UUID: userID, Valid: true},
|
||||||
|
}
|
||||||
|
if req.Body != nil {
|
||||||
|
params.Body = *req.Body
|
||||||
|
}
|
||||||
|
if req.Active != nil {
|
||||||
|
params.Active = *req.Active
|
||||||
|
}
|
||||||
|
if req.Dismissible != nil {
|
||||||
|
params.Dismissible = *req.Dismissible
|
||||||
|
}
|
||||||
|
if req.StartsAt != nil {
|
||||||
|
params.StartsAt = sql.NullTime{Time: *req.StartsAt, Valid: true}
|
||||||
|
}
|
||||||
|
if req.EndsAt != nil {
|
||||||
|
params.EndsAt = sql.NullTime{Time: *req.EndsAt, Valid: true}
|
||||||
|
}
|
||||||
|
banner, err := bannerQueries(c).CreateBanner(c.Request.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to create banner")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, toBannerJSON(banner))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAdminUpdateBanner(c *gin.Context) {
|
||||||
|
id, ok := parseBannerID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := bannerQueries(c)
|
||||||
|
banner, err := q.GetBannerByID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusNotFound, "NOT_FOUND", "banner not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req bannerRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "INVALID_BODY", "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params := sqlcdb.UpdateBannerParams{
|
||||||
|
ID: id,
|
||||||
|
Title: banner.Title,
|
||||||
|
Body: banner.Body,
|
||||||
|
Level: banner.Level,
|
||||||
|
Active: banner.Active,
|
||||||
|
Dismissible: banner.Dismissible,
|
||||||
|
StartsAt: banner.StartsAt,
|
||||||
|
EndsAt: banner.EndsAt,
|
||||||
|
}
|
||||||
|
if req.Title != nil {
|
||||||
|
params.Title = *req.Title
|
||||||
|
}
|
||||||
|
if req.Body != nil {
|
||||||
|
params.Body = *req.Body
|
||||||
|
}
|
||||||
|
if req.Level != nil {
|
||||||
|
if !validBannerLevel(*req.Level) {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "level must be info, success, warning or error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.Level = *req.Level
|
||||||
|
}
|
||||||
|
if req.Active != nil {
|
||||||
|
params.Active = *req.Active
|
||||||
|
}
|
||||||
|
if req.Dismissible != nil {
|
||||||
|
params.Dismissible = *req.Dismissible
|
||||||
|
}
|
||||||
|
if req.StartsAt != nil {
|
||||||
|
params.StartsAt = sql.NullTime{Time: *req.StartsAt, Valid: true}
|
||||||
|
}
|
||||||
|
if req.EndsAt != nil {
|
||||||
|
params.EndsAt = sql.NullTime{Time: *req.EndsAt, Valid: true}
|
||||||
|
}
|
||||||
|
updated, err := q.UpdateBanner(c.Request.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to update banner")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, toBannerJSON(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAdminDeleteBanner(c *gin.Context) {
|
||||||
|
id, ok := parseBannerID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := bannerQueries(c).DeleteBanner(c.Request.Context(), id); err != nil {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to delete banner")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ package api
|
|||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
"containr/internal/database/sqlcdb"
|
"containr/internal/database/sqlcdb"
|
||||||
|
"containr/internal/deployment"
|
||||||
"containr/internal/docker"
|
"containr/internal/docker"
|
||||||
|
"containr/internal/secrets"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
@@ -21,6 +23,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
dockercontainer "github.com/docker/docker/api/types/container"
|
dockercontainer "github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
@@ -51,12 +55,32 @@ 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
|
||||||
|
PublicPort bool `json:"public_port" db:"public_port"`
|
||||||
|
BackupTargetID string `json:"backup_target_id,omitempty"` // S3-compatible archive destination
|
||||||
|
External *DatabaseExternalRef `json:"external,omitempty"` // connection target for external DBs
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
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
|
||||||
@@ -86,6 +110,7 @@ type DatabaseBackup struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Size string `json:"size"`
|
Size string `json:"size"`
|
||||||
Status string `json:"status"` // completed, failed, in_progress
|
Status string `json:"status"` // completed, failed, in_progress
|
||||||
|
Remote bool `json:"remote"` // archive also exists on the configured backup target
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseSettings represents database configuration
|
// DatabaseSettings represents database configuration
|
||||||
@@ -98,10 +123,11 @@ type DatabaseSettings struct {
|
|||||||
|
|
||||||
// DatabaseCreateRequest represents a request to create a new database
|
// DatabaseCreateRequest represents a request to create a new database
|
||||||
type DatabaseCreateRequest struct {
|
type DatabaseCreateRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Type string `json:"type" binding:"required"`
|
Type string `json:"type" binding:"required"`
|
||||||
Plan string `json:"plan" binding:"required,oneof=hobby starter standard business"`
|
Plan string `json:"plan" binding:"required,oneof=hobby starter standard business"`
|
||||||
Region string `json:"region" binding:"required"`
|
Region string `json:"region" binding:"required"`
|
||||||
|
PublicPort bool `json:"public_port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseUpdateRequest represents a request to update a database
|
// DatabaseUpdateRequest represents a request to update a database
|
||||||
@@ -109,6 +135,8 @@ type DatabaseUpdateRequest struct {
|
|||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Plan string `json:"plan,omitempty"`
|
Plan string `json:"plan,omitempty"`
|
||||||
BackupSchedule *string `json:"backup_schedule,omitempty"`
|
BackupSchedule *string `json:"backup_schedule,omitempty"`
|
||||||
|
PublicPort *bool `json:"public_port,omitempty"`
|
||||||
|
BackupTargetID *string `json:"backup_target_id,omitempty"` // empty string clears
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseActionRequest represents a request to perform database actions
|
// DatabaseActionRequest represents a request to perform database actions
|
||||||
@@ -272,10 +300,11 @@ func (h *DatabaseHandler) CreateDatabase(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
databaseID, dbName, dbType, err := h.createManagedDatabase(c.Request.Context(), userID, managedDatabaseCreateRequest{
|
databaseID, dbName, dbType, err := h.createManagedDatabase(c.Request.Context(), userID, managedDatabaseCreateRequest{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
Plan: req.Plan,
|
Plan: req.Plan,
|
||||||
Region: req.Region,
|
Region: req.Region,
|
||||||
|
PublicPort: req.PublicPort,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
@@ -303,7 +332,13 @@ type managedDatabaseCreateRequest struct {
|
|||||||
Type string
|
Type string
|
||||||
Plan string
|
Plan string
|
||||||
Region string
|
Region string
|
||||||
|
PublicPort bool
|
||||||
RuntimeVariables map[string]string
|
RuntimeVariables map[string]string
|
||||||
|
// ProjectID + NetworkAlias attach the container to a project network
|
||||||
|
// under Alias — set by graph-template deploys so sibling services can
|
||||||
|
// resolve the database by name.
|
||||||
|
ProjectID string
|
||||||
|
NetworkAlias string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID string, req managedDatabaseCreateRequest) (databaseID string, name string, dbType string, err error) {
|
func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID string, req managedDatabaseCreateRequest) (databaseID string, name string, dbType string, err error) {
|
||||||
@@ -345,17 +380,26 @@ func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID stri
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
version := h.getDefaultVersion(dbType)
|
version := h.getDefaultVersion(dbType)
|
||||||
|
|
||||||
|
var projectID uuid.NullUUID
|
||||||
|
if req.ProjectID != "" {
|
||||||
|
if parsed, parseErr := uuid.Parse(req.ProjectID); parseErr == nil {
|
||||||
|
projectID = uuid.NullUUID{UUID: parsed, Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err = h.queries.CreateDatabaseService(ctx, sqlcdb.CreateDatabaseServiceParams{
|
err = h.queries.CreateDatabaseService(ctx, sqlcdb.CreateDatabaseServiceParams{
|
||||||
ID: databaseID,
|
ID: databaseID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: dbType,
|
Type: dbType,
|
||||||
Status: "building",
|
Status: "building",
|
||||||
Version: version,
|
Version: version,
|
||||||
Plan: plan,
|
Plan: plan,
|
||||||
Region: region,
|
Region: region,
|
||||||
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
PublicPort: req.PublicPort,
|
||||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
ProjectID: projectID,
|
||||||
|
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||||
|
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", "", fmt.Errorf("failed to create database service: %w", err)
|
return "", "", "", fmt.Errorf("failed to create database service: %w", err)
|
||||||
@@ -370,10 +414,17 @@ func (h *DatabaseHandler) createManagedDatabaseAndProvision(ctx context.Context,
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
go h.provisionDatabaseWithVariables(databaseID, name, dbType, req.RuntimeVariables)
|
go h.provisionDatabaseWithVariables(databaseID, name, dbType, req.RuntimeVariables, dbNetworkAttach{ProjectID: req.ProjectID, Alias: req.NetworkAlias})
|
||||||
return databaseID, nil
|
return databaseID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dbNetworkAttach carries the optional project-network membership for a
|
||||||
|
// managed database provisioned by a graph template.
|
||||||
|
type dbNetworkAttach struct {
|
||||||
|
ProjectID string
|
||||||
|
Alias string
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateDatabase updates a database service
|
// UpdateDatabase updates a database service
|
||||||
func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
|
func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
|
||||||
userID, ok := requireAuthenticatedUserID(c)
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
@@ -392,11 +443,64 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
|
|||||||
req.Name = strings.TrimSpace(req.Name)
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
req.Plan = strings.TrimSpace(req.Plan)
|
req.Plan = strings.TrimSpace(req.Plan)
|
||||||
|
|
||||||
if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil {
|
if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil && req.PublicPort == nil && req.BackupTargetID == nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.PublicPort != nil || req.BackupTargetID != nil {
|
||||||
|
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||||
|
ID: databaseID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.PublicPort != nil {
|
||||||
|
if row.Provider == "external" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "public_port applies to managed databases — external databases are already reachable", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.queries.SetDatabaseServicePublicPortByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseServicePublicPortByIDAndUserParams{
|
||||||
|
PublicPort: *req.PublicPort,
|
||||||
|
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
|
ID: databaseID,
|
||||||
|
UserID: userID,
|
||||||
|
}); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update public port"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Port bindings are baked into the container — recreate it so the
|
||||||
|
// new bind scope takes effect. The data volume survives.
|
||||||
|
if *req.PublicPort != row.PublicPort {
|
||||||
|
go h.reprovisionManagedDatabase(databaseID, row.Name, row.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.BackupTargetID != nil {
|
||||||
|
if row.Provider == "external" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "backup targets apply to managed databases — external backups are not supported yet", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetID := strings.TrimSpace(*req.BackupTargetID)
|
||||||
|
if targetID != "" {
|
||||||
|
if _, err := h.queries.GetBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.GetBackupTargetByIDAndUserParams{ID: targetID, UserID: userID}); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Backup target not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := h.queries.SetDatabaseBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseBackupTargetByIDAndUserParams{
|
||||||
|
BackupTargetID: sql.NullString{String: targetID, Valid: targetID != ""},
|
||||||
|
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
|
ID: databaseID,
|
||||||
|
UserID: userID,
|
||||||
|
}); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update backup target"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if req.BackupSchedule != nil {
|
if req.BackupSchedule != nil {
|
||||||
var schedule sql.NullString
|
var schedule sql.NullString
|
||||||
var nextBackup sql.NullTime
|
var nextBackup sql.NullTime
|
||||||
@@ -467,21 +571,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 +618,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 +729,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 +776,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 +910,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,6 +1041,7 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string
|
|||||||
CreatedAt: createdAt,
|
CreatedAt: createdAt,
|
||||||
Size: row.Size,
|
Size: row.Size,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
|
Remote: row.RemoteKey.Valid && row.RemoteKey.String != "",
|
||||||
})
|
})
|
||||||
if lastBackup == nil && row.Status == "completed" {
|
if lastBackup == nil && row.Status == "completed" {
|
||||||
t := createdAt
|
t := createdAt
|
||||||
@@ -945,7 +1069,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 +1115,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 +1182,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 +1193,25 @@ 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,
|
||||||
|
PublicPort: row.PublicPort,
|
||||||
|
BackupTargetID: databaseNullString(row.BackupTargetID),
|
||||||
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 {
|
||||||
@@ -1230,10 +1373,21 @@ type databaseRuntimePlan struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) provisionDatabase(databaseID, databaseName, dbType string) {
|
func (h *DatabaseHandler) provisionDatabase(databaseID, databaseName, dbType string) {
|
||||||
h.provisionDatabaseWithVariables(databaseID, databaseName, dbType, nil)
|
h.provisionDatabaseWithVariables(databaseID, databaseName, dbType, nil, dbNetworkAttach{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string) {
|
// reprovisionManagedDatabase recreates a managed container so changed
|
||||||
|
// runtime settings (public_port) take effect. The data volume persists.
|
||||||
|
func (h *DatabaseHandler) reprovisionManagedDatabase(databaseID, databaseName, dbType string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if h.dockerClient != nil {
|
||||||
|
_ = h.dockerClient.RemoveContainer(ctx, managedDatabaseContainerName(databaseID), true)
|
||||||
|
}
|
||||||
|
h.provisionDatabase(databaseID, databaseName, dbType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string, attach dbNetworkAttach) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -1242,7 +1396,7 @@ func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseNam
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables)
|
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables, attach)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err)
|
log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err)
|
||||||
_ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{})
|
_ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{})
|
||||||
@@ -1264,7 +1418,7 @@ func (h *DatabaseHandler) setDatabaseStatusAndConnection(databaseID, status stri
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, databaseID, databaseName, dbType string, runtimeVariables map[string]string) (string, error) {
|
func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, databaseID, databaseName, dbType string, runtimeVariables map[string]string, attach dbNetworkAttach) (string, error) {
|
||||||
plan, err := buildDatabaseRuntimePlan(dbType, databaseName, runtimeVariables)
|
plan, err := buildDatabaseRuntimePlan(dbType, databaseName, runtimeVariables)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -1289,8 +1443,12 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hostIP := "127.0.0.1"
|
||||||
|
if row, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil && row.PublicPort {
|
||||||
|
hostIP = "0.0.0.0"
|
||||||
|
}
|
||||||
portBindings := nat.PortMap{
|
portBindings := nat.PortMap{
|
||||||
plan.Port: []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: ""}},
|
plan.Port: []nat.PortBinding{{HostIP: hostIP, HostPort: ""}},
|
||||||
}
|
}
|
||||||
|
|
||||||
exposedPorts := nat.PortSet{plan.Port: struct{}{}}
|
exposedPorts := nat.PortSet{plan.Port: struct{}{}}
|
||||||
@@ -1348,6 +1506,17 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
|
|||||||
return "", fmt.Errorf("failed to start container: %w", err)
|
return "", fmt.Errorf("failed to start container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Graph-template deploys attach the database to the project network so
|
||||||
|
// sibling services resolve it by alias ({{service.<key>.host}}).
|
||||||
|
if attach.ProjectID != "" && attach.Alias != "" {
|
||||||
|
projectNet := deployment.ProjectNetworkName(attach.ProjectID)
|
||||||
|
if err := h.dockerClient.ConnectNetwork(ctx, projectNet, containerID, network.EndpointSettings{
|
||||||
|
Aliases: []string{attach.Alias},
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("containr: database %s attach to %s failed: %v", databaseID, projectNet, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hostPort, err := h.resolvePublishedHostPort(ctx, containerID, plan.Port)
|
hostPort, err := h.resolvePublishedHostPort(ctx, containerID, plan.Port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -1709,10 +1878,17 @@ func (h *DatabaseHandler) createBackupProcess(databaseID, backupID, backupPath s
|
|||||||
_ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false)
|
_ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil {
|
||||||
|
if key, err := h.uploadBackupToTarget(ctx, dbRow, backupPath, backupID); err != nil {
|
||||||
|
log.Printf("containr: backup %s remote upload failed: %v (local archive kept)", backupID, err)
|
||||||
|
} else if key != "" {
|
||||||
|
log.Printf("containr: backup %s shipped to %s", backupID, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = h.setDatabaseBackupStatus(backupID, "completed", sizeLabel, true)
|
_ = h.setDatabaseBackupStatus(backupID, "completed", sizeLabel, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) restoreBackupProcess(databaseID, _ string, backupPath string) {
|
func (h *DatabaseHandler) restoreBackupProcess(databaseID, backupID string, backupPath string) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -1721,6 +1897,22 @@ func (h *DatabaseHandler) restoreBackupProcess(databaseID, _ string, backupPath
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore the archive from the configured S3 target when the local copy
|
||||||
|
// is gone (fresh node, pruned volume) — local takes precedence.
|
||||||
|
if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil && dbRow.BackupTargetID.Valid && dbRow.BackupTargetID.String != "" {
|
||||||
|
if ok, _ := h.backupArchiveExists(ctx, backupPath); !ok {
|
||||||
|
if b, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(ctx, sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{
|
||||||
|
ID: backupID, DatabaseID: databaseID, UserID: dbRow.UserID,
|
||||||
|
}); err == nil && b.RemoteKey.Valid && b.RemoteKey.String != "" {
|
||||||
|
if err := h.downloadBackupToVolume(ctx, dbRow, b.RemoteKey.String, backupPath); err != nil {
|
||||||
|
log.Printf("containr: remote restore fetch failed for backup %s: %v", backupID, err)
|
||||||
|
_ = h.setDatabaseStatus(databaseID, "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_ = h.setDatabaseStatus(databaseID, "building")
|
_ = h.setDatabaseStatus(databaseID, "building")
|
||||||
_ = h.stopManagedDatabase(databaseID)
|
_ = h.stopManagedDatabase(databaseID)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database/sqlcdb"
|
||||||
|
"containr/internal/secrets"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// externalDatabaseRequest is the shared payload for registering an external
|
||||||
|
// database and for ad-hoc connection tests.
|
||||||
|
type externalDatabaseRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type" binding:"required"`
|
||||||
|
Host string `json:"host" binding:"required"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
SSL bool `json:"ssl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var externalDefaultPorts = map[string]int{
|
||||||
|
"postgresql": 5432,
|
||||||
|
"mysql": 3306,
|
||||||
|
"mariadb": 3306,
|
||||||
|
"mongodb": 27017,
|
||||||
|
"redis": 6379,
|
||||||
|
"dragonfly": 6379,
|
||||||
|
"clickhouse": 9000,
|
||||||
|
}
|
||||||
|
|
||||||
|
// externalConnectionURL composes a DSN for display/bind use. The password is
|
||||||
|
// embedded only on privileged reads — the value is decrypted at that point.
|
||||||
|
func externalConnectionURL(dbType string, ext *DatabaseExternalRef, password string) string {
|
||||||
|
host := net.JoinHostPort(ext.Host, fmt.Sprintf("%d", ext.Port))
|
||||||
|
creds := ""
|
||||||
|
if ext.Username != "" {
|
||||||
|
creds = url.QueryEscape(ext.Username)
|
||||||
|
if password != "" {
|
||||||
|
creds += ":" + url.QueryEscape(password)
|
||||||
|
}
|
||||||
|
creds += "@"
|
||||||
|
}
|
||||||
|
switch dbType {
|
||||||
|
case "postgresql":
|
||||||
|
u := fmt.Sprintf("postgres://%s%s/%s", creds, host, ext.Database)
|
||||||
|
if ext.SSL {
|
||||||
|
u += "?sslmode=require"
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
case "mysql", "mariadb":
|
||||||
|
return fmt.Sprintf("mysql://%s%s/%s", creds, host, ext.Database)
|
||||||
|
case "mongodb":
|
||||||
|
u := fmt.Sprintf("mongodb://%s%s/%s", creds, host, ext.Database)
|
||||||
|
if ext.SSL {
|
||||||
|
u += "?tls=true"
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
case "redis", "dragonfly":
|
||||||
|
scheme := "redis"
|
||||||
|
if ext.SSL {
|
||||||
|
scheme = "rediss"
|
||||||
|
}
|
||||||
|
// Redis auth is password-only; username support is ACL-era.
|
||||||
|
if password != "" {
|
||||||
|
creds = ":" + url.QueryEscape(password) + "@"
|
||||||
|
if ext.Username != "" {
|
||||||
|
creds = url.QueryEscape(ext.Username) + creds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s%s", scheme, creds, host)
|
||||||
|
case "clickhouse":
|
||||||
|
return fmt.Sprintf("clickhouse://%s%s/%s", creds, host, ext.Database)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s%s", dbType, creds, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeExternalDatabase verifies reachability. postgres and redis-family get
|
||||||
|
// real protocol checks via the vendored drivers; everything else gets a TCP
|
||||||
|
// dial — enough to catch typos and firewall issues without new dependencies.
|
||||||
|
func probeExternalDatabase(ctx context.Context, req externalDatabaseRequest) (time.Duration, error) {
|
||||||
|
addr := net.JoinHostPort(req.Host, fmt.Sprintf("%d", req.Port))
|
||||||
|
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
switch req.Type {
|
||||||
|
case "postgresql":
|
||||||
|
sslmode := "disable"
|
||||||
|
if req.SSL {
|
||||||
|
sslmode = "require"
|
||||||
|
}
|
||||||
|
dsn := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s&connect_timeout=5",
|
||||||
|
url.QueryEscape(req.Username), url.QueryEscape(req.Password), addr,
|
||||||
|
url.QueryEscape(req.Database), sslmode)
|
||||||
|
conn, err := sql.Open("postgres", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if err := conn.PingContext(ctx); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
case "redis", "dragonfly":
|
||||||
|
client := redis.NewClient(&redis.Options{
|
||||||
|
Addr: addr,
|
||||||
|
Username: req.Username,
|
||||||
|
Password: req.Password,
|
||||||
|
DialTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 5 * time.Second,
|
||||||
|
TLSConfig: redisTLSConfig(req.SSL, req.Host),
|
||||||
|
})
|
||||||
|
defer client.Close()
|
||||||
|
if err := client.Ping(ctx).Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
return time.Since(start), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func redisTLSConfig(enabled bool, host string) *tls.Config {
|
||||||
|
if !enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTestDatabaseConnection probes arbitrary connection parameters — used
|
||||||
|
// by the register form's "test" button and by agents before registering.
|
||||||
|
func (h *DatabaseHandler) TestDatabaseConnection(c *gin.Context) {
|
||||||
|
if _, ok := requireAuthenticatedUserID(c); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req externalDatabaseRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := normalizeExternalRequest(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
latency, err := probeExternalDatabase(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDatabaseConnectionByID re-probes a registered external database using
|
||||||
|
// its stored (decrypted) credentials.
|
||||||
|
func (h *DatabaseHandler) TestDatabaseConnectionByID(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
databaseID := c.Param("id")
|
||||||
|
userID = h.effectiveUserID(c, userID, databaseID)
|
||||||
|
|
||||||
|
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||||
|
ID: databaseID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.Provider != "external" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "test-connection applies to external databases", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := externalDatabaseRequest{
|
||||||
|
Type: row.Type,
|
||||||
|
Host: databaseNullString(row.ExternalHost),
|
||||||
|
Port: int(row.ExternalPort.Int32),
|
||||||
|
Database: databaseNullString(row.ExternalName),
|
||||||
|
Username: databaseNullString(row.ExternalUsername),
|
||||||
|
Password: secrets.Decrypt(row.ExternalPassword),
|
||||||
|
SSL: row.ExternalSsl,
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
latency, probeErr := probeExternalDatabase(ctx, req)
|
||||||
|
if probeErr != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": false, "error": probeErr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeExternalRequest(req *externalDatabaseRequest) error {
|
||||||
|
req.Type = normalizeDatabaseType(req.Type)
|
||||||
|
if _, ok := supportedDatabaseTypes[req.Type]; !ok {
|
||||||
|
return fmt.Errorf("%w: %s", errUnsupportedDatabaseType, req.Type)
|
||||||
|
}
|
||||||
|
req.Host = strings.TrimSpace(req.Host)
|
||||||
|
if req.Host == "" {
|
||||||
|
return errors.New("host is required")
|
||||||
|
}
|
||||||
|
if req.Port <= 0 {
|
||||||
|
req.Port = externalDefaultPorts[req.Type]
|
||||||
|
}
|
||||||
|
if req.Port <= 0 || req.Port > 65535 {
|
||||||
|
return errors.New("port must be 1-65535")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterExternalDatabase records a database hosted elsewhere — same list,
|
||||||
|
// same bind surface, no container. The connection is probed first so typos
|
||||||
|
// fail at registration, not at the first service bind.
|
||||||
|
func (h *DatabaseHandler) RegisterExternalDatabase(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req externalDatabaseRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := normalizeExternalRequest(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := probeExternalDatabase(probeCtx, req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": fmt.Sprintf("Connection check failed: %v", err),
|
||||||
|
"code": "DEPENDENCY_UNAVAILABLE",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := h.queries.CountDatabaseServicesByUserAndName(c.Request.Context(), sqlcdb.CountDatabaseServicesByUserAndNameParams{
|
||||||
|
UserID: userID,
|
||||||
|
Name: req.Name,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate database name"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "database name already exists", "code": "CONFLICT"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseID := generateDatabaseID(req.Name)
|
||||||
|
now := sql.NullTime{Time: time.Now(), Valid: true}
|
||||||
|
encPassword := ""
|
||||||
|
if req.Password != "" {
|
||||||
|
encPassword = secrets.Encrypt(req.Password)
|
||||||
|
}
|
||||||
|
if err := h.queries.CreateExternalDatabaseService(c.Request.Context(), sqlcdb.CreateExternalDatabaseServiceParams{
|
||||||
|
ID: databaseID,
|
||||||
|
UserID: userID,
|
||||||
|
Name: req.Name,
|
||||||
|
Type: req.Type,
|
||||||
|
Version: "",
|
||||||
|
Plan: "external",
|
||||||
|
Region: "external",
|
||||||
|
ExternalHost: sql.NullString{String: req.Host, Valid: true},
|
||||||
|
ExternalPort: sql.NullInt32{Int32: int32(req.Port), Valid: true},
|
||||||
|
ExternalName: sql.NullString{String: req.Database, Valid: req.Database != ""},
|
||||||
|
ExternalUsername: sql.NullString{String: req.Username, Valid: req.Username != ""},
|
||||||
|
ExternalPassword: encPassword,
|
||||||
|
ExternalSsl: req.SSL,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register database"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogAudit(userID, "database", databaseID, "register-external", map[string]interface{}{
|
||||||
|
"name": req.Name,
|
||||||
|
"type": req.Type,
|
||||||
|
"host": req.Host,
|
||||||
|
})
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"id": databaseID,
|
||||||
|
"message": "External database registered",
|
||||||
|
"status": "running",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeExternalRequestDefaults(t *testing.T) {
|
||||||
|
req := externalDatabaseRequest{Type: "PostgreSQL", Host: " db.example.com ", Port: 0}
|
||||||
|
if err := normalizeExternalRequest(&req); err != nil {
|
||||||
|
t.Fatalf("normalize failed: %v", err)
|
||||||
|
}
|
||||||
|
if req.Type != "postgresql" {
|
||||||
|
t.Fatalf("type not normalized: %q", req.Type)
|
||||||
|
}
|
||||||
|
if req.Host != "db.example.com" {
|
||||||
|
t.Fatalf("host not trimmed: %q", req.Host)
|
||||||
|
}
|
||||||
|
if req.Port != 5432 {
|
||||||
|
t.Fatalf("default port = %d, want 5432", req.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeExternalRequestRejects(t *testing.T) {
|
||||||
|
cases := []externalDatabaseRequest{
|
||||||
|
{Type: "sqlite", Host: "x"}, // unsupported type
|
||||||
|
{Type: "postgresql", Host: " "}, // empty host
|
||||||
|
{Type: "postgresql", Host: "h", Port: 70000}, // out of range
|
||||||
|
}
|
||||||
|
for i, req := range cases {
|
||||||
|
if err := normalizeExternalRequest(&req); err == nil {
|
||||||
|
t.Fatalf("case %d: expected error", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalConnectionURLMasking(t *testing.T) {
|
||||||
|
ext := &DatabaseExternalRef{
|
||||||
|
Host: "db.example.com",
|
||||||
|
Port: 5432,
|
||||||
|
Database: "appdb",
|
||||||
|
Username: "svc",
|
||||||
|
SSL: true,
|
||||||
|
}
|
||||||
|
u := externalConnectionURL("postgresql", ext, "s3cret")
|
||||||
|
if u != "postgres://svc:[email protected]:5432/appdb?sslmode=require" {
|
||||||
|
t.Fatalf("unexpected pg url: %s", u)
|
||||||
|
}
|
||||||
|
u = externalConnectionURL("postgresql", ext, "")
|
||||||
|
if u != "postgres://[email protected]:5432/appdb?sslmode=require" {
|
||||||
|
t.Fatalf("unexpected passwordless url: %s", u)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &DatabaseExternalRef{Host: "cache.internal", Port: 6379, SSL: true}
|
||||||
|
u = externalConnectionURL("redis", r, "pw")
|
||||||
|
if u != "rediss://:[email protected]:6379" {
|
||||||
|
t.Fatalf("unexpected redis url: %s", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeExternalDatabaseTCP(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
// TCP fallback path — mysql uses a plain dial.
|
||||||
|
latency, err := probeExternalDatabase(context.Background(), externalDatabaseRequest{
|
||||||
|
Type: "mysql", Host: "127.0.0.1", Port: port,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("probe should succeed against open listener: %v", err)
|
||||||
|
}
|
||||||
|
if latency <= 0 {
|
||||||
|
t.Fatalf("latency not recorded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closed port must fail fast.
|
||||||
|
if _, err := probeExternalDatabase(context.Background(), externalDatabaseRequest{
|
||||||
|
Type: "mysql", Host: "127.0.0.1", Port: 1,
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatalf("probe should fail against closed port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedisTLSConfig(t *testing.T) {
|
||||||
|
if redisTLSConfig(false, "h") != nil {
|
||||||
|
t.Fatalf("disabled ssl must produce nil tls config")
|
||||||
|
}
|
||||||
|
cfg := redisTLSConfig(true, "db.example.com")
|
||||||
|
if cfg == nil || cfg.ServerName != "db.example.com" {
|
||||||
|
t.Fatalf("expected ServerName passthrough, got %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -456,16 +456,21 @@ 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
|
// "auto" leaves BuildType empty so the manager detects it; the static
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/deployqueue"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /operations — a single cross-cutting view of platform work: active
|
||||||
|
// and recent deployments, the per-service deploy queue, recent cron runs,
|
||||||
|
// and recent backups. Scoped to the caller's projects (admins see all).
|
||||||
|
|
||||||
|
type operationDeployment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ServiceID string `json:"service_id"`
|
||||||
|
ServiceName string `json:"service_name"`
|
||||||
|
ProjectName string `json:"project_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Error *string `json:"error,omitempty"`
|
||||||
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type operationCronRun struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
JobName string `json:"job_name"`
|
||||||
|
Schedule string `json:"schedule"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||||
|
Error *string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type operationBackup struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DatabaseID string `json:"database_id"`
|
||||||
|
DBName string `json:"database_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Size string `json:"size"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Completed *time.Time `json:"completed_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetOperations(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
isAdmin := contextIsAdmin(c)
|
||||||
|
|
||||||
|
// Same visibility rule as the deployment list endpoint.
|
||||||
|
visibility := `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)`
|
||||||
|
|
||||||
|
active := []operationDeployment{}
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT d.id, d.service_id, s.name, p.name, d.status,
|
||||||
|
COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at
|
||||||
|
FROM deployments d
|
||||||
|
JOIN services s ON s.id = d.service_id
|
||||||
|
JOIN projects p ON p.id = s.project_id
|
||||||
|
WHERE d.status IN ('queued','pending','building','deploying','rolling_back')
|
||||||
|
AND (`+visibility+`)
|
||||||
|
ORDER BY d.created_at ASC`, userID, isAdmin)
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var d operationDeployment
|
||||||
|
var sid string
|
||||||
|
_ = rows.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt)
|
||||||
|
d.ServiceID = sid
|
||||||
|
active = append(active, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recentFailed := []operationDeployment{}
|
||||||
|
rows2, err2 := db.Query(
|
||||||
|
`SELECT d.id, d.service_id, s.name, p.name, d.status,
|
||||||
|
COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at
|
||||||
|
FROM deployments d
|
||||||
|
JOIN services s ON s.id = d.service_id
|
||||||
|
JOIN projects p ON p.id = s.project_id
|
||||||
|
WHERE d.status IN ('failed','cancelled','rolled_back')
|
||||||
|
AND d.updated_at > NOW() - INTERVAL '24 hours'
|
||||||
|
AND (`+visibility+`)
|
||||||
|
ORDER BY d.updated_at DESC
|
||||||
|
LIMIT 20`, userID, isAdmin)
|
||||||
|
if err2 == nil {
|
||||||
|
defer rows2.Close()
|
||||||
|
for rows2.Next() {
|
||||||
|
var d operationDeployment
|
||||||
|
var sid string
|
||||||
|
_ = rows2.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt)
|
||||||
|
d.ServiceID = sid
|
||||||
|
recentFailed = append(recentFailed, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cronRuns := []operationCronRun{}
|
||||||
|
rows3, err3 := db.Query(
|
||||||
|
`SELECT e.id, j.name, j.schedule, e.status, e.started_at, e.finished_at, e.error
|
||||||
|
FROM cron_executions e
|
||||||
|
JOIN cron_jobs j ON j.id = e.cron_job_id
|
||||||
|
JOIN projects p ON p.id = j.project_id
|
||||||
|
WHERE e.started_at > NOW() - INTERVAL '24 hours'
|
||||||
|
AND (`+visibility+`)
|
||||||
|
ORDER BY e.started_at DESC
|
||||||
|
LIMIT 30`, userID, isAdmin)
|
||||||
|
if err3 == nil {
|
||||||
|
defer rows3.Close()
|
||||||
|
for rows3.Next() {
|
||||||
|
var r operationCronRun
|
||||||
|
_ = rows3.Scan(&r.ID, &r.JobName, &r.Schedule, &r.Status, &r.StartedAt, &r.FinishedAt, &r.Error)
|
||||||
|
cronRuns = append(cronRuns, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
backups := []operationBackup{}
|
||||||
|
rows4, err4 := db.Query(
|
||||||
|
`SELECT b.id, b.database_id, ds.name, b.status, b.size, b.created_at, b.completed_at
|
||||||
|
FROM database_backups b
|
||||||
|
JOIN database_services ds ON ds.id = b.database_id
|
||||||
|
WHERE b.created_at > NOW() - INTERVAL '24 hours'
|
||||||
|
AND (ds.user_id = $1 OR $2::bool)
|
||||||
|
ORDER BY b.created_at DESC
|
||||||
|
LIMIT 20`, userID, isAdmin)
|
||||||
|
if err4 == nil {
|
||||||
|
defer rows4.Close()
|
||||||
|
for rows4.Next() {
|
||||||
|
var b operationBackup
|
||||||
|
_ = rows4.Scan(&b.ID, &b.DatabaseID, &b.DBName, &b.Status, &b.Size, &b.CreatedAt, &b.Completed)
|
||||||
|
backups = append(backups, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var queue []deployqueue.ServiceQueueState
|
||||||
|
if queueValue, exists := c.Get("deploy_queue"); exists && queueValue != nil {
|
||||||
|
queue = queueValue.(*deployqueue.Queue).Snapshot()
|
||||||
|
}
|
||||||
|
if queue == nil {
|
||||||
|
queue = []deployqueue.ServiceQueueState{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"active_deployments": active,
|
||||||
|
"recent_failures": recentFailed,
|
||||||
|
"cron_runs": cronRuns,
|
||||||
|
"backups": backups,
|
||||||
|
"deploy_queue": queue,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -297,6 +298,28 @@ func handleDeleteProject(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Managed databases provisioned into this project (graph templates,
|
||||||
|
// compose imports) carry project_id and must be torn down first — their
|
||||||
|
// containers are not labelled containr.project and hold references to the
|
||||||
|
// project network.
|
||||||
|
if dbHandlerValue, exists := c.Get("database_handler"); exists && dbHandlerValue != nil {
|
||||||
|
if dbHandler, ok := dbHandlerValue.(*DatabaseHandler); ok {
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||||
|
projectDBs, listErr := dbHandler.queries.ListDatabaseServicesByProject(ctx, uuid.NullUUID{UUID: projectID, Valid: true})
|
||||||
|
if listErr == nil {
|
||||||
|
for _, projectDB := range projectDBs {
|
||||||
|
if projectDB.Provider != "external" {
|
||||||
|
if err := dbHandler.deleteManagedDatabaseRuntime(projectDB.ID); err != nil {
|
||||||
|
log.Printf("delete project %s: database %s runtime removal failed: %v", projectID, projectDB.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = dbHandler.queries.DeleteDatabaseServiceByID(ctx, projectDB.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Remove live containers and the project network before dropping rows.
|
// Remove live containers and the project network before dropping rows.
|
||||||
// Best-effort: Docker may be unavailable; DB cleanup proceeds regardless.
|
// Best-effort: Docker may be unavailable; DB cleanup proceeds regardless.
|
||||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
||||||
|
// Audit + outbound webhook delivery need a package-level handle for
|
||||||
|
// call sites that don't carry a gin context.
|
||||||
|
SetAuditDB(db)
|
||||||
|
StartWebhookDispatcher()
|
||||||
|
|
||||||
// Expose Better Auth through backend so frontend can use a single backend origin.
|
// Expose Better Auth through backend so frontend can use a single backend origin.
|
||||||
setupAuthProxyRoutes(router, cfg, db)
|
setupAuthProxyRoutes(router, cfg, db)
|
||||||
|
|
||||||
@@ -98,6 +103,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// Start the cron scheduler — executes due cron_jobs via docker exec.
|
// Start the cron scheduler — executes due cron_jobs via docker exec.
|
||||||
StartCronScheduler(context.Background(), db, dockerClient)
|
StartCronScheduler(context.Background(), db, dockerClient)
|
||||||
|
|
||||||
|
// Sleep sweeper — scales idle services to zero and prunes wake containers.
|
||||||
|
StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port)
|
||||||
|
|
||||||
// Initialize database handler
|
// Initialize database handler
|
||||||
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
||||||
|
|
||||||
@@ -158,6 +166,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
c.Set("deployment_engine", deploymentEngine)
|
c.Set("deployment_engine", deploymentEngine)
|
||||||
}
|
}
|
||||||
c.Set("deploy_queue", deployQueue)
|
c.Set("deploy_queue", deployQueue)
|
||||||
|
c.Set("api_port", cfg.Port)
|
||||||
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)
|
||||||
@@ -249,6 +258,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
public.POST("/auth/login", handleLogin)
|
public.POST("/auth/login", handleLogin)
|
||||||
public.POST("/auth/register", handleRegister)
|
public.POST("/auth/register", handleRegister)
|
||||||
public.GET("/maintenance", handleMaintenancePage)
|
public.GET("/maintenance", handleMaintenancePage)
|
||||||
|
public.GET("/branding", handleGetBranding)
|
||||||
|
|
||||||
|
// Wake callback for sleep-mode placeholders. Unauthenticated but
|
||||||
|
// non-destructive: it only reconciles the service back to running.
|
||||||
|
public.Any("/internal/wake/:id", handleServiceWake)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read routes — public browsing. OptionalAuth resolves a session when
|
// Read routes — public browsing. OptionalAuth resolves a session when
|
||||||
@@ -271,6 +285,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
read.GET("/services/:id/deployments", handleGetDeployments)
|
read.GET("/services/:id/deployments", handleGetDeployments)
|
||||||
read.GET("/services/:id/domains", handleListServiceDomains)
|
read.GET("/services/:id/domains", handleListServiceDomains)
|
||||||
read.GET("/services/:id/domains/check", handleCheckServiceDomains)
|
read.GET("/services/:id/domains/check", handleCheckServiceDomains)
|
||||||
|
read.GET("/services/:id/env-check", handleServiceEnvCheck)
|
||||||
read.GET("/deployments", handleGetRecentDeployments)
|
read.GET("/deployments", handleGetRecentDeployments)
|
||||||
read.GET("/deployments/:id", handleGetDeployment)
|
read.GET("/deployments/:id", handleGetDeployment)
|
||||||
|
|
||||||
@@ -319,11 +334,26 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// WebSocket endpoint
|
// WebSocket endpoint
|
||||||
authed.GET("/ws", handleWebSocket)
|
authed.GET("/ws", handleWebSocket)
|
||||||
|
|
||||||
|
// Cross-cutting ops view — active/queued/failed work
|
||||||
|
authed.GET("/operations", handleGetOperations)
|
||||||
|
|
||||||
|
// Instance announcements shown to all signed-in users
|
||||||
|
authed.GET("/banners/active", handleListActiveBanners)
|
||||||
|
|
||||||
|
// Outbound webhooks — signed event delivery to user HTTP endpoints
|
||||||
|
authed.GET("/webhooks", handleListOutboundWebhooks)
|
||||||
|
authed.POST("/webhooks", handleCreateOutboundWebhook)
|
||||||
|
authed.PATCH("/webhooks/:id", handleUpdateOutboundWebhook)
|
||||||
|
authed.DELETE("/webhooks/:id", handleDeleteOutboundWebhook)
|
||||||
|
authed.GET("/webhooks/:id/deliveries", handleListOutboundWebhookDeliveries)
|
||||||
|
authed.POST("/webhooks/:id/test", handleTestOutboundWebhook)
|
||||||
|
|
||||||
// Owned-resource mutations — handlers scope to the caller and
|
// Owned-resource mutations — handlers scope to the caller and
|
||||||
// admit admins. New projects land unapproved until an admin
|
// admit admins. New projects land unapproved until an admin
|
||||||
// publishes them.
|
// publishes them.
|
||||||
authed.POST("/projects", handleCreateProject)
|
authed.POST("/projects", handleCreateProject)
|
||||||
authed.POST("/projects/:id/services", handleCreateService)
|
authed.POST("/projects/:id/services", handleCreateService)
|
||||||
|
authed.POST("/projects/:id/import-compose", handleImportProjectCompose)
|
||||||
authed.PUT("/projects/:id", handleUpdateProject)
|
authed.PUT("/projects/:id", handleUpdateProject)
|
||||||
authed.DELETE("/projects/:id", handleDeleteProject)
|
authed.DELETE("/projects/:id", handleDeleteProject)
|
||||||
|
|
||||||
@@ -335,7 +365,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
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/sleep", handleServiceSleep)
|
||||||
|
authed.POST("/services/:id/wake", handleServiceWakeNow)
|
||||||
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)
|
authed.POST("/deployments/:id/cancel", handleCancelDeployment)
|
||||||
@@ -344,6 +378,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
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)
|
||||||
@@ -352,6 +390,19 @@ 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)
|
||||||
|
|
||||||
|
// S3-compatible backup targets — owner-scoped, keys encrypted at rest.
|
||||||
|
authed.GET("/backup-targets", databaseHandler.ListBackupTargets)
|
||||||
|
authed.POST("/backup-targets", databaseHandler.CreateBackupTarget)
|
||||||
|
authed.PUT("/backup-targets/:id", databaseHandler.UpdateBackupTarget)
|
||||||
|
authed.DELETE("/backup-targets/:id", databaseHandler.DeleteBackupTarget)
|
||||||
|
authed.POST("/backup-targets/:id/test", databaseHandler.TestBackupTarget)
|
||||||
|
|
||||||
|
// Private-image registries — owner-scoped, credentials encrypted at rest.
|
||||||
|
authed.GET("/registries", handleListRegistries)
|
||||||
|
authed.POST("/registries", handleCreateRegistry)
|
||||||
|
authed.PUT("/registries/:id", handleUpdateRegistry)
|
||||||
|
authed.DELETE("/registries/:id", handleDeleteRegistry)
|
||||||
|
|
||||||
// Git integration — providers carry per-user credentials.
|
// 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)
|
||||||
@@ -375,11 +426,15 @@ 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)
|
||||||
authed.POST("/databases/:id/backup", databaseHandler.CreateBackup)
|
authed.POST("/databases/:id/backup", databaseHandler.CreateBackup)
|
||||||
authed.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
|
authed.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
|
||||||
|
authed.POST("/databases/:id/backups/import", databaseHandler.ImportBackup)
|
||||||
authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
|
authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
|
||||||
|
|
||||||
authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
|
authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
|
||||||
@@ -398,6 +453,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
authed.PUT("/templates/:id", handleUpdateTemplate)
|
authed.PUT("/templates/:id", handleUpdateTemplate)
|
||||||
authed.DELETE("/templates/:id", handleDeleteTemplate)
|
authed.DELETE("/templates/:id", handleDeleteTemplate)
|
||||||
authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
|
authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
|
||||||
|
authed.POST("/templates/:id/plan", handlePlanTemplate)
|
||||||
|
authed.POST("/templates/import/compose", handleImportCompose)
|
||||||
|
authed.POST("/templates/deploy", handleDeployInlineGraph)
|
||||||
|
|
||||||
authed.POST("/cron-jobs", handleCreateCronJob)
|
authed.POST("/cron-jobs", handleCreateCronJob)
|
||||||
authed.PUT("/cron-jobs/:id", handleUpdateCronJob)
|
authed.PUT("/cron-jobs/:id", handleUpdateCronJob)
|
||||||
@@ -431,6 +489,12 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
|
|
||||||
admin.GET("/admin/volumes", handleAdminListVolumes)
|
admin.GET("/admin/volumes", handleAdminListVolumes)
|
||||||
admin.DELETE("/admin/volumes/:name", handleAdminDeleteVolume)
|
admin.DELETE("/admin/volumes/:name", handleAdminDeleteVolume)
|
||||||
|
|
||||||
|
// Announcement banners
|
||||||
|
admin.GET("/admin/banners", handleAdminListBanners)
|
||||||
|
admin.POST("/admin/banners", handleAdminCreateBanner)
|
||||||
|
admin.PATCH("/admin/banners/:id", handleAdminUpdateBanner)
|
||||||
|
admin.DELETE("/admin/banners/:id", handleAdminDeleteBanner)
|
||||||
admin.GET("/audit-logs", handleGetAuditLogs)
|
admin.GET("/audit-logs", handleGetAuditLogs)
|
||||||
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
||||||
|
|
||||||
|
|||||||
@@ -119,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
|
||||||
@@ -638,3 +641,112 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
|
|||||||
_, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID)
|
_, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleServiceEnvCheck reports environment problems without exposing
|
||||||
|
// values: empty vars, ${{service.KEY}} refs that fail to resolve, and
|
||||||
|
// secrets whose ciphertext can't be decrypted (key rotation).
|
||||||
|
func handleServiceEnvCheck(c *gin.Context) {
|
||||||
|
dbVal, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := dbVal.(*database.DB)
|
||||||
|
service, ok := loadReadableService(c, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT key, value, COALESCE(is_secret, false) FROM environment_variables WHERE service_id = $1`,
|
||||||
|
service.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var unresolved, empty, unreadable []string
|
||||||
|
var raw []struct{ key, val string }
|
||||||
|
for rows.Next() {
|
||||||
|
var k, v string
|
||||||
|
var secret bool
|
||||||
|
if err := rows.Scan(&k, &v, &secret); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
decrypted := secrets.Decrypt(v)
|
||||||
|
if secret && secrets.IsEncrypted(v) && decrypted == v {
|
||||||
|
// Ciphertext failed to decrypt — Decrypt returns the input.
|
||||||
|
unreadable = append(unreadable, k)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(decrypted) == "" {
|
||||||
|
empty = append(empty, k)
|
||||||
|
}
|
||||||
|
raw = append(raw, struct{ key, val string }{k, decrypted})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project siblings for ${{name.KEY}} resolution.
|
||||||
|
siblingIDs := map[string]string{}
|
||||||
|
svcRows, err := db.Query(`SELECT id, name FROM services WHERE project_id = $1`, service.ProjectID)
|
||||||
|
if err == nil {
|
||||||
|
for svcRows.Next() {
|
||||||
|
var id, name string
|
||||||
|
if err := svcRows.Scan(&id, &name); err == nil {
|
||||||
|
siblingIDs[strings.ToLower(name)] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svcRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvable := func(serviceName, key string) bool {
|
||||||
|
id, ok := siblingIDs[strings.ToLower(serviceName)]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.EqualFold(key, "HOST") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.EqualFold(key, "PORT") {
|
||||||
|
var port int
|
||||||
|
if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil {
|
||||||
|
return port > 0
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM environment_variables WHERE service_id = $1 AND key = $2`,
|
||||||
|
id, key,
|
||||||
|
).Scan(&n); err == nil {
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, kv := range raw {
|
||||||
|
for _, match := range refVarPattern.FindAllStringSubmatch(kv.val, -1) {
|
||||||
|
if len(match) == 3 && !resolvable(match[1], match[2]) {
|
||||||
|
unresolved = append(unresolved, fmt.Sprintf("%s → %s", kv.key, match[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if unresolved == nil {
|
||||||
|
unresolved = []string{}
|
||||||
|
}
|
||||||
|
if empty == nil {
|
||||||
|
empty = []string{}
|
||||||
|
}
|
||||||
|
if unreadable == nil {
|
||||||
|
unreadable = []string{}
|
||||||
|
}
|
||||||
|
okResult := len(unresolved) == 0 && len(empty) == 0 && len(unreadable) == 0
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": okResult,
|
||||||
|
"unresolved": unresolved,
|
||||||
|
"empty": empty,
|
||||||
|
"unreadable": unreadable,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CloneServiceRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"` // empty = clone within the same project
|
||||||
|
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCloneService duplicates a service's full configuration — image/git
|
||||||
|
// source, volumes, domains, access gates, builder settings, and variables —
|
||||||
|
// into the same or another project. Containers are not cloned; the copy
|
||||||
|
// starts stopped until its first deploy.
|
||||||
|
func handleCloneService(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
|
||||||
|
var req CloneServiceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
source, ok := loadOwnedService(c, db.(*database.DB))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetProjectID := source.ProjectID
|
||||||
|
if req.ProjectID != uuid.Nil {
|
||||||
|
targetProjectID = req.ProjectID
|
||||||
|
}
|
||||||
|
if !ownsProject(db.(*database.DB), targetProjectID, userID.(string), contextIsAdmin(c)) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = source.Name + "-copy"
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
_ = db.(*database.DB).QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
|
||||||
|
targetProjectID, name,
|
||||||
|
).Scan(&count)
|
||||||
|
if count > 0 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in target project", "code": "CONFLICT"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
environment := source.Environment
|
||||||
|
if req.Environment != "" {
|
||||||
|
environment = req.Environment
|
||||||
|
}
|
||||||
|
if environment == "" {
|
||||||
|
environment = "production"
|
||||||
|
}
|
||||||
|
environmentID, err := getProjectEnvironmentID(db.(*database.DB), targetProjectID, environment)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maintenance, basicAuth := serviceAccess(db.(*database.DB), source.ID)
|
||||||
|
newID := uuid.New()
|
||||||
|
now := time.Now()
|
||||||
|
// string() so pq sends text — []byte would go as bytea and fail ::jsonb.
|
||||||
|
volumesJSON := string(loadServiceVolumesJSON(db.(*database.DB), source.ID))
|
||||||
|
|
||||||
|
_, err = db.(*database.DB).Exec(
|
||||||
|
`INSERT INTO services
|
||||||
|
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||||
|
build_command, start_command, type, status, image, command, environment,
|
||||||
|
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||||
|
healthcheck_path, restart_policy, volumes, maintenance_mode, basic_auth_users,
|
||||||
|
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
||||||
|
created_at, updated_at)
|
||||||
|
SELECT $1, $2, $3, $4, service_type, source_type, source_url, image_name,
|
||||||
|
build_command, start_command, type, 'stopped', image, command, $5,
|
||||||
|
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||||
|
healthcheck_path, restart_policy, $6::jsonb, $7, $8,
|
||||||
|
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
||||||
|
$9, $9
|
||||||
|
FROM services WHERE id = $10`,
|
||||||
|
newID, targetProjectID, name, environmentID, environment, volumesJSON,
|
||||||
|
maintenance, basicAuth, now, source.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.(*database.DB).Exec(
|
||||||
|
`INSERT INTO service_domains (service_id, domain, is_default, cert_type)
|
||||||
|
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
|
||||||
|
newID, source.ID,
|
||||||
|
); err != nil {
|
||||||
|
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service domains"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Variable values are copied as stored — ciphertext stays ciphertext.
|
||||||
|
if _, err := db.(*database.DB).Exec(
|
||||||
|
`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
|
||||||
|
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
|
||||||
|
newID, source.ID,
|
||||||
|
); err != nil {
|
||||||
|
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service variables"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type MoveServiceRequest struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMoveService re-parents a service to another project owned by the
|
||||||
|
// caller. Variables, domains, and volumes follow via the service id;
|
||||||
|
// running containers keep running (they join the new project's network on
|
||||||
|
// the next deploy).
|
||||||
|
func handleMoveService(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
serviceID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req MoveServiceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
service, ok := loadOwnedService(c, db.(*database.DB))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ProjectID == service.ProjectID {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Service is already in that project", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ownsProject(db.(*database.DB), req.ProjectID, userID.(string), contextIsAdmin(c)) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
_ = db.(*database.DB).QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
|
||||||
|
req.ProjectID, service.Name,
|
||||||
|
).Scan(&count)
|
||||||
|
if count > 0 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "A service with this name already exists in the target project", "code": "CONFLICT"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
envName := service.Environment
|
||||||
|
if envName == "" {
|
||||||
|
envName = "production"
|
||||||
|
}
|
||||||
|
environmentID, err := getProjectEnvironmentID(db.(*database.DB), req.ProjectID, envName)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.(*database.DB).Exec(
|
||||||
|
`UPDATE services SET project_id = $1, environment_id = $2, updated_at = $3 WHERE id = $4`,
|
||||||
|
req.ProjectID, environmentID, time.Now(), serviceID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to move service"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogAuditWithRequest(c, "service", serviceID.String(), "service.move", map[string]interface{}{"to_project": req.ProjectID.String()})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Service moved", "project_id": req.ProjectID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownsProject is true when the user owns the project or is an admin.
|
||||||
|
func ownsProject(db *database.DB, projectID uuid.UUID, userID string, isAdmin bool) bool {
|
||||||
|
if isAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var owner string
|
||||||
|
err := db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, projectID).Scan(&owner)
|
||||||
|
return err == nil && owner == userID
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadServiceVolumesJSON returns the raw stored mounts for a verbatim copy.
|
||||||
|
func loadServiceVolumesJSON(db *database.DB, serviceID uuid.UUID) []byte {
|
||||||
|
var raw []byte
|
||||||
|
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil || len(raw) == 0 {
|
||||||
|
return []byte("[]")
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
@@ -31,23 +31,37 @@ type Service struct {
|
|||||||
CPU string `json:"cpu" db:"cpu"`
|
CPU string `json:"cpu" db:"cpu"`
|
||||||
Memory string `json:"memory" db:"memory"`
|
Memory string `json:"memory" db:"memory"`
|
||||||
// Runtime spec
|
// Runtime spec
|
||||||
Replicas int `json:"replicas" db:"replicas"`
|
Replicas int `json:"replicas" db:"replicas"`
|
||||||
Port int `json:"port" db:"port"` // container port to expose
|
Port int `json:"port" db:"port"` // container port to expose
|
||||||
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
|
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
|
||||||
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
|
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
|
||||||
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
|
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
|
||||||
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
|
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
|
||||||
Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB
|
Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB
|
||||||
Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
|
Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
|
||||||
MaintenanceMode bool `json:"maintenance_mode" db:"-"`
|
MaintenanceMode bool `json:"maintenance_mode" db:"-"`
|
||||||
BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes
|
BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes
|
||||||
Builder string `json:"builder" db:"builder"` // auto|railpack|nixpacks|dockerfile|static
|
Builder string `json:"builder" db:"builder"` // auto|railpack|nixpacks|dockerfile|static
|
||||||
CPUReserve string `json:"cpu_reserve,omitempty" db:"cpu_reserve"`
|
CPUReserve string `json:"cpu_reserve,omitempty" db:"cpu_reserve"`
|
||||||
MemoryReserve string `json:"memory_reserve,omitempty" db:"memory_reserve"`
|
MemoryReserve string `json:"memory_reserve,omitempty" db:"memory_reserve"`
|
||||||
StaticBuildCmd string `json:"static_build_cmd,omitempty" db:"static_build_cmd"`
|
StaticBuildCmd string `json:"static_build_cmd,omitempty" db:"static_build_cmd"`
|
||||||
StaticDir string `json:"static_dir,omitempty" db:"static_dir"`
|
StaticDir string `json:"static_dir,omitempty" db:"static_dir"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
SleepEnabled bool `json:"sleep_enabled" db:"-"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
SleepIdleMinutes int `json:"sleep_idle_minutes" db:"-"`
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadServiceSleep fills the sleep-mode columns, which live outside the
|
||||||
|
// wide service SELECTs. Missing columns (pre-migration) degrade to off.
|
||||||
|
func loadServiceSleep(db *database.DB, s *Service) {
|
||||||
|
var idle int
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15) FROM services WHERE id = $1`,
|
||||||
|
s.ID,
|
||||||
|
).Scan(&s.SleepEnabled, &idle); err == nil {
|
||||||
|
s.SleepIdleMinutes = idle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceVolume is a volume/bind mount applied to every replica.
|
// ServiceVolume is a volume/bind mount applied to every replica.
|
||||||
@@ -131,29 +145,31 @@ type CreateServiceRequest struct {
|
|||||||
|
|
||||||
// UpdateServiceRequest represents a request to update a service
|
// UpdateServiceRequest represents a request to update a service
|
||||||
type UpdateServiceRequest struct {
|
type UpdateServiceRequest struct {
|
||||||
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
||||||
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
||||||
GitRepo string `json:"git_repo"`
|
GitRepo string `json:"git_repo"`
|
||||||
GitBranch string `json:"git_branch"`
|
GitBranch string `json:"git_branch"`
|
||||||
BuildPath string `json:"build_path"`
|
BuildPath string `json:"build_path"`
|
||||||
CPU string `json:"cpu"`
|
CPU string `json:"cpu"`
|
||||||
Memory string `json:"memory"`
|
Memory string `json:"memory"`
|
||||||
Replicas *int `json:"replicas"`
|
Replicas *int `json:"replicas"`
|
||||||
Port *int `json:"port"`
|
Port *int `json:"port"`
|
||||||
Domain *string `json:"domain"`
|
Domain *string `json:"domain"`
|
||||||
HealthCheckPath *string `json:"healthcheck_path"`
|
HealthCheckPath *string `json:"healthcheck_path"`
|
||||||
RestartPolicy string `json:"restart_policy"`
|
RestartPolicy string `json:"restart_policy"`
|
||||||
Volumes *[]ServiceVolume `json:"volumes"`
|
Volumes *[]ServiceVolume `json:"volumes"`
|
||||||
MaintenanceMode *bool `json:"maintenance_mode"`
|
MaintenanceMode *bool `json:"maintenance_mode"`
|
||||||
BasicAuth *[]BasicAuthCred `json:"basic_auth"`
|
BasicAuth *[]BasicAuthCred `json:"basic_auth"`
|
||||||
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
|
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
|
||||||
CPUReserve *string `json:"cpu_reserve"`
|
CPUReserve *string `json:"cpu_reserve"`
|
||||||
MemoryReserve *string `json:"memory_reserve"`
|
MemoryReserve *string `json:"memory_reserve"`
|
||||||
StaticBuildCmd *string `json:"static_build_cmd"`
|
StaticBuildCmd *string `json:"static_build_cmd"`
|
||||||
StaticDir *string `json:"static_dir"`
|
StaticDir *string `json:"static_dir"`
|
||||||
|
SleepEnabled *bool `json:"sleep_enabled"`
|
||||||
|
SleepIdleMinutes *int `json:"sleep_idle_minutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetServices retrieves all services for a project
|
// handleGetServices retrieves all services for a project
|
||||||
@@ -201,9 +217,10 @@ func handleGetServices(c *gin.Context) {
|
|||||||
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''),
|
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''),
|
||||||
COALESCE(memory_reserve, ''), COALESCE(static_build_cmd, ''),
|
COALESCE(memory_reserve, ''), COALESCE(static_build_cmd, ''),
|
||||||
COALESCE(static_dir, ''),
|
COALESCE(static_dir, ''),
|
||||||
created_at, updated_at
|
COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15),
|
||||||
FROM services
|
created_at, updated_at
|
||||||
WHERE project_id = $1
|
FROM services
|
||||||
|
WHERE project_id = $1
|
||||||
ORDER BY created_at DESC`,
|
ORDER BY created_at DESC`,
|
||||||
projectID,
|
projectID,
|
||||||
)
|
)
|
||||||
@@ -223,6 +240,7 @@ func handleGetServices(c *gin.Context) {
|
|||||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||||
|
&service.SleepEnabled, &service.SleepIdleMinutes,
|
||||||
&service.CreatedAt, &service.UpdatedAt,
|
&service.CreatedAt, &service.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -436,6 +454,10 @@ func handleCreateService(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
|
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
|
||||||
|
|
||||||
|
LogAuditWithRequest(c, "service", service.ID.String(), "create", map[string]interface{}{
|
||||||
|
"name": service.Name, "type": service.Type, "project_id": service.ProjectID,
|
||||||
|
})
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"service": service})
|
c.JSON(http.StatusCreated, gin.H{"service": service})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,6 +546,7 @@ func handleGetService(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
|
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
|
||||||
|
loadServiceSleep(db.(*database.DB), &service)
|
||||||
maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
|
maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
|
||||||
service.MaintenanceMode = maintenance
|
service.MaintenanceMode = maintenance
|
||||||
service.BasicAuth = basicAuthUsernames(basicAuth)
|
service.BasicAuth = basicAuthUsernames(basicAuth)
|
||||||
@@ -665,6 +688,17 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
if existingService.Builder == "static" && existingService.StaticDir == "" {
|
if existingService.Builder == "static" && existingService.StaticDir == "" {
|
||||||
existingService.StaticDir = "dist"
|
existingService.StaticDir = "dist"
|
||||||
}
|
}
|
||||||
|
loadServiceSleep(db.(*database.DB), &existingService)
|
||||||
|
if req.SleepEnabled != nil {
|
||||||
|
existingService.SleepEnabled = *req.SleepEnabled
|
||||||
|
}
|
||||||
|
if req.SleepIdleMinutes != nil {
|
||||||
|
if *req.SleepIdleMinutes < 1 || *req.SleepIdleMinutes > 1440 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "sleep_idle_minutes must be 1-1440", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existingService.SleepIdleMinutes = *req.SleepIdleMinutes
|
||||||
|
}
|
||||||
for label, check := range map[string]func(string) error{
|
for label, check := range map[string]func(string) error{
|
||||||
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
||||||
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
||||||
@@ -701,7 +735,8 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
|
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
|
||||||
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
|
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
|
||||||
builder = $19, cpu_reserve = $20, memory_reserve = $21,
|
builder = $19, cpu_reserve = $20, memory_reserve = $21,
|
||||||
static_build_cmd = $22, static_dir = $23
|
static_build_cmd = $22, static_dir = $23,
|
||||||
|
sleep_enabled = $24, sleep_idle_minutes = $25
|
||||||
WHERE id = $18`,
|
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,
|
||||||
@@ -711,6 +746,7 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
existingService.UpdatedAt, volumesArg, existingService.ID,
|
existingService.UpdatedAt, volumesArg, existingService.ID,
|
||||||
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
|
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
|
||||||
existingService.StaticBuildCmd, existingService.StaticDir,
|
existingService.StaticBuildCmd, existingService.StaticDir,
|
||||||
|
existingService.SleepEnabled, existingService.SleepIdleMinutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -844,5 +880,7 @@ func handleDeleteService(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LogAuditWithRequest(c, "service", serviceID.String(), "delete", map[string]interface{}{})
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Service deleted successfully"})
|
c.JSON(http.StatusOK, gin.H{"message": "Service deleted successfully"})
|
||||||
}
|
}
|
||||||
@@ -23,10 +23,49 @@ const (
|
|||||||
settingSignupEnabled = "signup_enabled"
|
settingSignupEnabled = "signup_enabled"
|
||||||
settingCloudflareTunnelToken = "cloudflare_tunnel_token"
|
settingCloudflareTunnelToken = "cloudflare_tunnel_token"
|
||||||
|
|
||||||
|
// White-label branding keys — unset fields fall back to Containr
|
||||||
|
// defaults. Self-hosters rebrand their instance via these.
|
||||||
|
settingBrandProductName = "branding.product_name"
|
||||||
|
settingBrandLogoURL = "branding.logo_url"
|
||||||
|
settingBrandFaviconURL = "branding.favicon_url"
|
||||||
|
settingBrandAccent = "branding.accent_color"
|
||||||
|
settingBrandDocsURL = "branding.docs_url"
|
||||||
|
settingBrandSupportURL = "branding.support_url"
|
||||||
|
|
||||||
cloudflaredContainerName = "containr-cloudflared"
|
cloudflaredContainerName = "containr-cloudflared"
|
||||||
cloudflaredImage = "cloudflare/cloudflared:latest"
|
cloudflaredImage = "cloudflare/cloudflared:latest"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// brandingConfig is the public white-label payload. Fields are always
|
||||||
|
// populated — defaults keep the instance Containr-branded.
|
||||||
|
type brandingConfig struct {
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
LogoURL string `json:"logo_url"`
|
||||||
|
FaviconURL string `json:"favicon_url"`
|
||||||
|
AccentColor string `json:"accent_color"`
|
||||||
|
DocsURL string `json:"docs_url"`
|
||||||
|
SupportURL string `json:"support_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveBranding(db *database.DB) brandingConfig {
|
||||||
|
return brandingConfig{
|
||||||
|
ProductName: settingValue(db, settingBrandProductName, "BRAND_NAME", "Containr"),
|
||||||
|
LogoURL: settingValue(db, settingBrandLogoURL, "BRAND_LOGO_URL", ""),
|
||||||
|
FaviconURL: settingValue(db, settingBrandFaviconURL, "BRAND_FAVICON_URL", ""),
|
||||||
|
AccentColor: settingValue(db, settingBrandAccent, "BRAND_ACCENT_COLOR", ""),
|
||||||
|
DocsURL: settingValue(db, settingBrandDocsURL, "BRAND_DOCS_URL", ""),
|
||||||
|
SupportURL: settingValue(db, settingBrandSupportURL, "BRAND_SUPPORT_URL", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /branding — public so the login page and shell can brand themselves
|
||||||
|
// before a session exists.
|
||||||
|
func handleGetBranding(c *gin.Context) {
|
||||||
|
db, _ := c.Get("db")
|
||||||
|
database, _ := db.(*database.DB)
|
||||||
|
c.JSON(http.StatusOK, resolveBranding(database))
|
||||||
|
}
|
||||||
|
|
||||||
// settingValue resolves a setting: app_settings row first, env fallback,
|
// settingValue resolves a setting: app_settings row first, env fallback,
|
||||||
// then the caller's default.
|
// then the caller's default.
|
||||||
func settingValue(db *database.DB, key, envKey, fallback string) string {
|
func settingValue(db *database.DB, key, envKey, fallback string) string {
|
||||||
@@ -110,6 +149,7 @@ func handleGetSettings(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"signup_enabled": signupEnabled(db),
|
"signup_enabled": signupEnabled(db),
|
||||||
"cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client),
|
"cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client),
|
||||||
|
"branding": resolveBranding(db),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +162,14 @@ func handleUpdateSettings(c *gin.Context) {
|
|||||||
var req struct {
|
var req struct {
|
||||||
SignupEnabled *bool `json:"signup_enabled"`
|
SignupEnabled *bool `json:"signup_enabled"`
|
||||||
CloudflareTunnelToken *string `json:"cloudflare_tunnel_token"`
|
CloudflareTunnelToken *string `json:"cloudflare_tunnel_token"`
|
||||||
|
Branding *struct {
|
||||||
|
ProductName *string `json:"product_name"`
|
||||||
|
LogoURL *string `json:"logo_url"`
|
||||||
|
FaviconURL *string `json:"favicon_url"`
|
||||||
|
AccentColor *string `json:"accent_color"`
|
||||||
|
DocsURL *string `json:"docs_url"`
|
||||||
|
SupportURL *string `json:"support_url"`
|
||||||
|
} `json:"branding"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -158,6 +206,36 @@ func handleUpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Branding != nil {
|
||||||
|
fields := []struct {
|
||||||
|
key string
|
||||||
|
value *string
|
||||||
|
}{
|
||||||
|
{settingBrandProductName, req.Branding.ProductName},
|
||||||
|
{settingBrandLogoURL, req.Branding.LogoURL},
|
||||||
|
{settingBrandFaviconURL, req.Branding.FaviconURL},
|
||||||
|
{settingBrandAccent, req.Branding.AccentColor},
|
||||||
|
{settingBrandDocsURL, req.Branding.DocsURL},
|
||||||
|
{settingBrandSupportURL, req.Branding.SupportURL},
|
||||||
|
}
|
||||||
|
for _, f := range fields {
|
||||||
|
if f.value == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := strings.TrimSpace(*f.value)
|
||||||
|
var err error
|
||||||
|
if v == "" {
|
||||||
|
err = deleteSetting(db, f.key)
|
||||||
|
} else {
|
||||||
|
err = setSetting(db, f.key, v, false)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save branding"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleGetSettings(c)
|
handleGetSettings(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,461 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/docker"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/api/types/network"
|
||||||
|
"github.com/docker/docker/api/types/registry"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sleep mode: services with sleep_enabled are scaled to zero after
|
||||||
|
// sleep_idle_minutes without inbound traffic. Idleness is measured as
|
||||||
|
// NetworkIO byte deltas across the service's containers — we have no
|
||||||
|
// per-request layer in front of Traefik, so counters are the signal.
|
||||||
|
//
|
||||||
|
// When a service sleeps its containers are removed and a tiny busybox
|
||||||
|
// "wake" container takes over the same Traefik host rule at a higher
|
||||||
|
// priority: it serves a reloading "waking" page and pings the unauthenticated
|
||||||
|
// internal wake endpoint, which reconciles the service back to running.
|
||||||
|
// Wake containers carry a dedicated label (not the service label) so
|
||||||
|
// RuntimeState and reconcile never count them as replicas; the sweeper
|
||||||
|
// prunes them once the workload is running again.
|
||||||
|
|
||||||
|
const (
|
||||||
|
wakeImage = "busybox:1.36"
|
||||||
|
wakeLabel = "containr.wake"
|
||||||
|
wakeForLabel = "containr.wake-for"
|
||||||
|
wakeSweepInterval = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type sleepTracker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
activity map[uuid.UUID]sleepActivity
|
||||||
|
}
|
||||||
|
|
||||||
|
type sleepActivity struct {
|
||||||
|
bytes uint64
|
||||||
|
idleSince time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var tracker = &sleepTracker{activity: make(map[uuid.UUID]sleepActivity)}
|
||||||
|
|
||||||
|
// StartSleepSweeper polls enabled services and scales idle ones to zero.
|
||||||
|
func StartSleepSweeper(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, apiPort int) {
|
||||||
|
if db == nil || dc == nil || engine == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(wakeSweepInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
sweepSleeping(ctx, db, dc, engine, apiPort)
|
||||||
|
pruneWakeContainers(ctx, db, dc, engine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Printf("sleep sweeper: running (interval %s)", wakeSweepInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
type sleepCandidate struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
ProjectID uuid.UUID
|
||||||
|
Name string
|
||||||
|
Status string
|
||||||
|
IdleMinutes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func sweepSleeping(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, apiPort int) {
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
`SELECT id, project_id, name, COALESCE(status,''), COALESCE(sleep_idle_minutes,15)
|
||||||
|
FROM services WHERE sleep_enabled AND COALESCE(status,'') IN ('running','deployed','degraded','starting')`)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var candidates []sleepCandidate
|
||||||
|
for rows.Next() {
|
||||||
|
var s sleepCandidate
|
||||||
|
if err := rows.Scan(&s.ID, &s.ProjectID, &s.Name, &s.Status, &s.IdleMinutes); err == nil {
|
||||||
|
candidates = append(candidates, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
for _, svc := range candidates {
|
||||||
|
containers, err := engine.ListServiceContainers(ctx, svc.ID.String())
|
||||||
|
if err != nil || len(containers) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Ignore wake containers — they're the placeholder, not the workload.
|
||||||
|
workload := containers[:0]
|
||||||
|
for _, c := range containers {
|
||||||
|
if c.Labels[wakeLabel] != "true" {
|
||||||
|
workload = append(workload, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(workload) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var total uint64
|
||||||
|
for _, c := range workload {
|
||||||
|
total += containerNetBytes(ctx, dc, c.ID)
|
||||||
|
}
|
||||||
|
if markIdle(svc.ID, total, time.Duration(svc.IdleMinutes)*time.Minute) {
|
||||||
|
putToSleep(ctx, db, dc, engine, svc, apiPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// markIdle returns true when the network counter stopped growing past the
|
||||||
|
// timeout. First sighting only sets the baseline — no idle without history.
|
||||||
|
func markIdle(id uuid.UUID, bytes uint64, timeout time.Duration) bool {
|
||||||
|
tracker.mu.Lock()
|
||||||
|
defer tracker.mu.Unlock()
|
||||||
|
a := tracker.activity[id]
|
||||||
|
if bytes != a.bytes || a.idleSince.IsZero() {
|
||||||
|
tracker.activity[id] = sleepActivity{bytes: bytes, idleSince: time.Now()}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Since(a.idleSince) >= timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerNetBytes(ctx context.Context, dc *docker.Client, id string) uint64 {
|
||||||
|
stats, err := dc.GetContainerStats(ctx, id, false)
|
||||||
|
if err != nil || stats == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
defer stats.Body.Close()
|
||||||
|
var snap struct {
|
||||||
|
Networks map[string]struct {
|
||||||
|
RxBytes uint64 `json:"rx_bytes"`
|
||||||
|
TxBytes uint64 `json:"tx_bytes"`
|
||||||
|
} `json:"networks"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(stats.Body).Decode(&snap); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var total uint64
|
||||||
|
for _, n := range snap.Networks {
|
||||||
|
total += n.RxBytes + n.TxBytes
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// putToSleep removes the workload containers, marks the service sleeping,
|
||||||
|
// and installs the wake placeholder when the service has a public domain.
|
||||||
|
func putToSleep(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, svc sleepCandidate, apiPort int) {
|
||||||
|
containers, err := engine.ListServiceContainers(ctx, svc.ID.String())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range containers {
|
||||||
|
if c.Labels[wakeLabel] == "true" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = dc.StopContainer(ctx, c.ID, nil)
|
||||||
|
_ = dc.RemoveContainer(ctx, c.ID, true)
|
||||||
|
}
|
||||||
|
tracker.mu.Lock()
|
||||||
|
delete(tracker.activity, svc.ID)
|
||||||
|
tracker.mu.Unlock()
|
||||||
|
_, _ = db.Exec(`UPDATE services SET status = 'sleeping', updated_at = $1 WHERE id = $2`, time.Now(), svc.ID)
|
||||||
|
log.Printf("sleep: %s (%s) scaled to zero after idle timeout", svc.Name, svc.ID)
|
||||||
|
|
||||||
|
var domain string
|
||||||
|
_ = db.QueryRow(`SELECT COALESCE(domain,'') FROM services WHERE id = $1`, svc.ID).Scan(&domain)
|
||||||
|
domains := serviceDomainNames(db, svc.ID, domain)
|
||||||
|
if len(domains) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := spawnWakeContainer(ctx, db, dc, engine, svc, domains, apiPort); err != nil {
|
||||||
|
log.Printf("sleep: wake placeholder for %s failed: %v", svc.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawnWakeContainer runs a busybox httpd holding the service's Traefik
|
||||||
|
// host rule at higher priority. On boot it calls the wake endpoint, then
|
||||||
|
// keeps serving a reloading page until the next reconcile removes it.
|
||||||
|
func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, svc sleepCandidate, domains []string, apiPort int) error {
|
||||||
|
edgeNet := ""
|
||||||
|
for _, n := range networkList(ctx, dc) {
|
||||||
|
if n == "containr-network" {
|
||||||
|
edgeNet = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if edgeNet == "" {
|
||||||
|
return fmt.Errorf("no edge network")
|
||||||
|
}
|
||||||
|
router := "svc-" + svc.ID.String()[:8]
|
||||||
|
sort.Strings(domains)
|
||||||
|
rules := make([]string, 0, len(domains))
|
||||||
|
for _, d := range domains {
|
||||||
|
rules = append(rules, "Host(`"+d+"`)")
|
||||||
|
}
|
||||||
|
labels := map[string]string{
|
||||||
|
deployment.LabelManaged: "true",
|
||||||
|
deployment.LabelProject: svc.ProjectID.String(),
|
||||||
|
wakeForLabel: svc.ID.String(),
|
||||||
|
wakeLabel: "true",
|
||||||
|
"traefik.enable": "true",
|
||||||
|
"traefik.docker.network": edgeNet,
|
||||||
|
"traefik.http.routers." + router + ".rule": strings.Join(rules, " || "),
|
||||||
|
"traefik.http.routers." + router + ".entrypoints": "web",
|
||||||
|
"traefik.http.routers." + router + ".priority": "100",
|
||||||
|
"traefik.http.services." + router + ".loadbalancer.server.port": "80",
|
||||||
|
}
|
||||||
|
|
||||||
|
wakeURL := wakeEndpointURL(ctx, dc, apiPort, svc.ID, edgeNet)
|
||||||
|
page := `<!doctype html><html><head><meta http-equiv="refresh" content="4"><title>Waking up</title>` +
|
||||||
|
`<style>body{font-family:system-ui,sans-serif;background:#0b0d12;color:#dde2ea;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}` +
|
||||||
|
`.card{text-align:center;max-width:34rem;padding:2rem}h1{font-size:1.25rem;font-weight:600}p{color:#98a2b3}</style></head>` +
|
||||||
|
`<body><div class="card"><h1>Service is waking up</h1><p>This Containr service was sleeping and is resuming now. The page reloads automatically.</p></div></body></html>`
|
||||||
|
// httpd without -f daemonizes, so the sequence is: write page, start
|
||||||
|
// httpd, fire the wake callback, then hold PID1 on sleep infinity.
|
||||||
|
cmd := fmt.Sprintf(
|
||||||
|
`mkdir -p /www && printf '%%s' '%s' > /www/index.html && `+
|
||||||
|
`httpd -p 80 -h /www && `+
|
||||||
|
`(sleep 2; wget -q -O /dev/null --timeout=20 '%s' 2>/dev/null || true) && `+
|
||||||
|
`sleep infinity`,
|
||||||
|
strings.ReplaceAll(page, "'", "'\\''"), wakeURL)
|
||||||
|
|
||||||
|
endpoints := map[string]*network.EndpointSettings{edgeNet: {}}
|
||||||
|
if ownNet := dc.OwnNetworkName(ctx); ownNet != "" && ownNet != edgeNet {
|
||||||
|
endpoints[ownNet] = &network.EndpointSettings{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull is best-effort — the image almost always sits in cache.
|
||||||
|
_ = dc.PullImageWait(ctx, wakeImage, registry.AuthConfig{})
|
||||||
|
|
||||||
|
// A previous placeholder may linger as Exited — clear the name first.
|
||||||
|
name := fmt.Sprintf("containr-wake-%s", svc.ID.String()[:8])
|
||||||
|
if stale, err := dc.ListContainersFiltered(ctx, filters.NewArgs(
|
||||||
|
filters.Arg("name", "^/"+name+"$"),
|
||||||
|
), true); err == nil {
|
||||||
|
for _, s := range stale {
|
||||||
|
_ = dc.RemoveContainer(ctx, s.ID, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := dc.CreateContainer(ctx, docker.ContainerConfig{
|
||||||
|
Name: name,
|
||||||
|
Image: wakeImage,
|
||||||
|
Cmd: []string{"sh", "-c", cmd},
|
||||||
|
Labels: labels,
|
||||||
|
Networks: endpoints,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return dc.StartContainer(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func networkList(ctx context.Context, dc *docker.Client) []string {
|
||||||
|
networks, err := dc.ListNetworks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(networks))
|
||||||
|
for _, n := range networks {
|
||||||
|
names = append(names, n.Name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// wakeEndpointURL builds the internal URL the wake container calls. The
|
||||||
|
// edge network's gateway reaches the host's published API port; when the
|
||||||
|
// API itself is containerized its own name resolves on the shared network.
|
||||||
|
func wakeEndpointURL(ctx context.Context, dc *docker.Client, apiPort int, serviceID uuid.UUID, edgeNet string) string {
|
||||||
|
if dc.OwnNetworkName(ctx) != "" {
|
||||||
|
if self, err := dc.GetContainer(ctx, selfID()); err == nil {
|
||||||
|
name := strings.TrimPrefix(self.Name, "/")
|
||||||
|
if name != "" {
|
||||||
|
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", name, apiPort, serviceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gw := networkGateway(ctx, dc, edgeNet); gw != "" {
|
||||||
|
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", gw, apiPort, serviceID)
|
||||||
|
}
|
||||||
|
if probe := dc.HostProbeHost(ctx); probe != "" {
|
||||||
|
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", probe, apiPort, serviceID)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("http://127.0.0.1:%d/api/v1/internal/wake/%s", apiPort, serviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func networkGateway(ctx context.Context, dc *docker.Client, name string) string {
|
||||||
|
// ListNetworks does not populate IPAM config — inspect is required.
|
||||||
|
return dc.NetworkGateway(ctx, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selfID() string {
|
||||||
|
// Inside a container the hostname is the short container ID.
|
||||||
|
if hn, err := os.Hostname(); err == nil {
|
||||||
|
return hn
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneWakeContainers removes wake placeholders whose service is healthy
|
||||||
|
// again — belt & braces over the reconcile drift sweep.
|
||||||
|
func pruneWakeContainers(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine) {
|
||||||
|
containers, err := dc.ListContainersFiltered(ctx, filters.NewArgs(filters.Arg("label", wakeLabel+"=true")), true)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range containers {
|
||||||
|
svcID := c.Labels[wakeForLabel]
|
||||||
|
if svcID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
running, _ := engine.ListServiceContainers(ctx, svcID)
|
||||||
|
live := 0
|
||||||
|
for _, r := range running {
|
||||||
|
if r.State == "running" {
|
||||||
|
live++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
_ = db.QueryRow(`SELECT COALESCE(status,'') FROM services WHERE id = $1`, svcID).Scan(&status)
|
||||||
|
if live > 0 || status == "stopped" || status == "" {
|
||||||
|
_ = dc.RemoveContainer(ctx, c.ID, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServiceWake is the unauthenticated internal endpoint the wake
|
||||||
|
// container hits. It reconciles the service back to running. Waking is
|
||||||
|
// idempotent and non-destructive, so no token is required; the endpoint
|
||||||
|
// never mutates configuration.
|
||||||
|
func handleServiceWake(c *gin.Context) {
|
||||||
|
dbVal, _ := c.Get("db")
|
||||||
|
db := dbVal.(*database.DB)
|
||||||
|
engineVal, _ := c.Get("deployment_engine")
|
||||||
|
engine, _ := engineVal.(*deployment.DeploymentEngine)
|
||||||
|
if engine == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "deployment engine unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid service id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var service Service
|
||||||
|
err = db.QueryRow(
|
||||||
|
`SELECT s.id, s.project_id, s.name,
|
||||||
|
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
|
||||||
|
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
|
||||||
|
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
|
||||||
|
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
|
||||||
|
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
|
||||||
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
|
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||||
|
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||||
|
COALESCE(s.static_dir, ''),
|
||||||
|
s.created_at, s.updated_at
|
||||||
|
FROM services s WHERE s.id = $1`, id,
|
||||||
|
).Scan(
|
||||||
|
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||||
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
|
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||||
|
&service.CreatedAt, &service.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "service not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if service.Status == "running" || service.Status == "deployed" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": service.Status})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
spec, err := serviceRuntimeSpec(db, service)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state, err := engine.ReconcileService(ctx, spec)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status := "running"
|
||||||
|
if state != nil {
|
||||||
|
status = state.Status
|
||||||
|
persistPublishedPort(db, service.ID, state.Ports)
|
||||||
|
}
|
||||||
|
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
|
||||||
|
}()
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"status": "waking"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServiceSleep puts a service to sleep immediately (owner+ only).
|
||||||
|
func handleServiceSleep(c *gin.Context) {
|
||||||
|
dbVal, _ := c.Get("db")
|
||||||
|
db := dbVal.(*database.DB)
|
||||||
|
engineVal, _ := c.Get("deployment_engine")
|
||||||
|
engine, _ := engineVal.(*deployment.DeploymentEngine)
|
||||||
|
dcVal, _ := c.Get("docker_client")
|
||||||
|
dc, _ := dcVal.(*docker.Client)
|
||||||
|
if engine == nil || dc == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "deployment engine unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service, ok := loadOwnedService(c, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var port int
|
||||||
|
_ = db.QueryRow(`SELECT COALESCE(port,0) FROM services WHERE id = $1`, service.ID).Scan(&port)
|
||||||
|
svc := sleepCandidate{
|
||||||
|
ID: service.ID,
|
||||||
|
ProjectID: service.ProjectID,
|
||||||
|
Name: service.Name,
|
||||||
|
Status: service.Status,
|
||||||
|
}
|
||||||
|
go putToSleep(context.Background(), db, dc, engine, svc, apiPortFromContext(c))
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"status": "sleeping"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServiceWakeNow wakes a sleeping service on demand (owner+ only).
|
||||||
|
func handleServiceWakeNow(c *gin.Context) {
|
||||||
|
if _, ok := loadOwnedService(c, c.MustGet("db").(*database.DB)); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleServiceWake(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiPortFromContext pulls the configured HTTP port for the wake callback URL.
|
||||||
|
func apiPortFromContext(c *gin.Context) int {
|
||||||
|
if v, exists := c.Get("api_port"); exists {
|
||||||
|
if p, ok := v.(int); ok {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 8080
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMarkIdleTracksCounter(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
timeout := 50 * time.Millisecond
|
||||||
|
|
||||||
|
// First sighting sets the baseline only.
|
||||||
|
if markIdle(id, 100, timeout) {
|
||||||
|
t.Fatal("slept on first sighting")
|
||||||
|
}
|
||||||
|
// Counter growth resets the idle clock.
|
||||||
|
if markIdle(id, 200, timeout) {
|
||||||
|
t.Fatal("slept while counter grew")
|
||||||
|
}
|
||||||
|
// Flat counter within the window doesn't sleep.
|
||||||
|
if markIdle(id, 200, timeout) {
|
||||||
|
t.Fatal("slept before timeout")
|
||||||
|
}
|
||||||
|
time.Sleep(60 * time.Millisecond)
|
||||||
|
if !markIdle(id, 200, timeout) {
|
||||||
|
t.Fatal("did not sleep after timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkIdleZeroCounterSleeps(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
timeout := 20 * time.Millisecond
|
||||||
|
|
||||||
|
markIdle(id, 0, timeout)
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
if !markIdle(id, 0, timeout) {
|
||||||
|
t.Fatal("zero-traffic service never slept")
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,126 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func composeToSpecsForTest(t *testing.T, y string) ([]TemplateServiceSpec, []string, error) {
|
||||||
|
t.Helper()
|
||||||
|
var file composeFileDef
|
||||||
|
if err := yaml.Unmarshal([]byte(y), &file); err != nil {
|
||||||
|
t.Fatalf("yaml: %v", err)
|
||||||
|
}
|
||||||
|
return composeToSpecs(&file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeToSpecsConvertsStack(t *testing.T) {
|
||||||
|
specs, warnings, err := composeToSpecsForTest(t, `services:
|
||||||
|
web:
|
||||||
|
image: traefik/whoami:latest
|
||||||
|
ports:
|
||||||
|
- "80"
|
||||||
|
environment:
|
||||||
|
GREETING: hello-compose
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: pgsecret123
|
||||||
|
POSTGRES_DB: appdb
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(specs) != 2 {
|
||||||
|
t.Fatalf("want 2 specs, got %d", len(specs))
|
||||||
|
}
|
||||||
|
db, web := specs[0], specs[1]
|
||||||
|
if db.Key != "db" || db.Type != "database" || db.Runtime != "postgresql" {
|
||||||
|
t.Errorf("db spec wrong: %+v", db)
|
||||||
|
}
|
||||||
|
if db.Environment["POSTGRES_PASSWORD"] != "pgsecret123" {
|
||||||
|
t.Errorf("db env lost: %v", db.Environment)
|
||||||
|
}
|
||||||
|
if web.Key != "web" || web.Type != "web" || web.Runtime != "traefik/whoami:latest" {
|
||||||
|
t.Errorf("web spec wrong: %+v", web)
|
||||||
|
}
|
||||||
|
if web.Port != 80 {
|
||||||
|
t.Errorf("web port = %d, want 80", web.Port)
|
||||||
|
}
|
||||||
|
if len(web.DependsOn) != 1 || web.DependsOn[0] != "db" {
|
||||||
|
t.Errorf("web depends_on = %v, want [db]", web.DependsOn)
|
||||||
|
}
|
||||||
|
if len(warnings) != 0 {
|
||||||
|
t.Errorf("unexpected warnings: %v", warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeToSpecsDeterministicOrder(t *testing.T) {
|
||||||
|
y := `services:
|
||||||
|
zebra:
|
||||||
|
image: alpine
|
||||||
|
alpha:
|
||||||
|
image: alpine
|
||||||
|
`
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
specs, _, err := composeToSpecsForTest(t, y)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if specs[0].Key != "alpha" || specs[1].Key != "zebra" {
|
||||||
|
t.Fatalf("unstable order: %v", specs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeToSpecsDatabaseImages(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"postgres:16": "postgresql",
|
||||||
|
"redis:7": "redis",
|
||||||
|
"mysql:8": "mysql",
|
||||||
|
"mariadb:11": "mariadb",
|
||||||
|
"mongo:7": "mongodb",
|
||||||
|
"clickhouse:24": "clickhouse",
|
||||||
|
}
|
||||||
|
for image, want := range cases {
|
||||||
|
specs, _, err := composeToSpecsForTest(t, "services:\n db:\n image: "+image+"\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", image, err)
|
||||||
|
}
|
||||||
|
if specs[0].Type != "database" || specs[0].Runtime != want {
|
||||||
|
t.Errorf("%s → %+v, want database/%s", image, specs[0], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeToSpecsNoServices(t *testing.T) {
|
||||||
|
_, _, err := composeToSpecsForTest(t, "version: '3'\n")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "no services") {
|
||||||
|
t.Errorf("want no-services error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeToSpecsWarnings(t *testing.T) {
|
||||||
|
_, warnings, err := composeToSpecsForTest(t, `services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
orphan:
|
||||||
|
image: alpine
|
||||||
|
environment:
|
||||||
|
- UNSET_VAR
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
joined := strings.Join(warnings, "\n")
|
||||||
|
if !strings.Contains(joined, "build contexts are not supported") {
|
||||||
|
t.Errorf("missing build warning: %v", warnings)
|
||||||
|
}
|
||||||
|
if !strings.Contains(joined, "UNSET_VAR") {
|
||||||
|
t.Errorf("missing env warning: %v", warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTopoSortServicesOrdersByDependsOn(t *testing.T) {
|
||||||
|
sorted, err := topoSortServices([]TemplateServiceSpec{
|
||||||
|
{Key: "web", DependsOn: []string{"db"}},
|
||||||
|
{Key: "db", Type: "database"},
|
||||||
|
{Key: "worker", DependsOn: []string{"web", "db"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("topoSortServices: %v", err)
|
||||||
|
}
|
||||||
|
if sorted[0].Key != "db" || sorted[1].Key != "web" || sorted[2].Key != "worker" {
|
||||||
|
t.Fatalf("unexpected order: %v", []string{sorted[0].Key, sorted[1].Key, sorted[2].Key})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopoSortServicesRejectsCycle(t *testing.T) {
|
||||||
|
_, err := topoSortServices([]TemplateServiceSpec{
|
||||||
|
{Key: "a", DependsOn: []string{"b"}},
|
||||||
|
{Key: "b", DependsOn: []string{"a"}},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "cycle") {
|
||||||
|
t.Fatalf("expected cycle error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopoSortServicesRejectsUnknownDep(t *testing.T) {
|
||||||
|
_, err := topoSortServices([]TemplateServiceSpec{
|
||||||
|
{Key: "a", DependsOn: []string{"ghost"}},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "unknown key") {
|
||||||
|
t.Fatalf("expected unknown-key error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveStaticExpr(t *testing.T) {
|
||||||
|
unresolved := []string{}
|
||||||
|
var generated bool
|
||||||
|
|
||||||
|
v := resolveStaticExpr("{{secret}}", nil, &unresolved, &generated)
|
||||||
|
if len(v) != 32 || !generated {
|
||||||
|
t.Fatalf("secret: got %q generated=%v", v, generated)
|
||||||
|
}
|
||||||
|
v = resolveStaticExpr("{{secret(8)}}", nil, &unresolved, &generated)
|
||||||
|
if len(v) != 16 {
|
||||||
|
t.Fatalf("secret(8): got %q", v)
|
||||||
|
}
|
||||||
|
v = resolveStaticExpr("port-{{random:10}}", nil, &unresolved, &generated)
|
||||||
|
if !strings.HasPrefix(v, "port-") {
|
||||||
|
t.Fatalf("random: got %q", v)
|
||||||
|
}
|
||||||
|
v = resolveStaticExpr("{{DOMAIN:-example.com}}", nil, &unresolved, &generated)
|
||||||
|
if v != "example.com" {
|
||||||
|
t.Fatalf("var default: got %q", v)
|
||||||
|
}
|
||||||
|
v = resolveStaticExpr("{{DOMAIN}}", map[string]string{"DOMAIN": "x.io"}, &unresolved, &generated)
|
||||||
|
if v != "x.io" {
|
||||||
|
t.Fatalf("var override: got %q", v)
|
||||||
|
}
|
||||||
|
v = resolveStaticExpr("{{MISSING}}", nil, &unresolved, &generated)
|
||||||
|
if v != "" || len(unresolved) == 0 || unresolved[0] != "MISSING" {
|
||||||
|
t.Fatalf("missing var: got %q unresolved=%v", v, unresolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTemplateGraphDatabaseRefs(t *testing.T) {
|
||||||
|
sorted, err := topoSortServices([]TemplateServiceSpec{
|
||||||
|
{Key: "db", Type: "database", Runtime: "postgresql", Environment: map[string]string{
|
||||||
|
"POSTGRES_DB": "shop",
|
||||||
|
}},
|
||||||
|
{Key: "web", Type: "web", Runtime: "nginx", DependsOn: []string{"db"}, Environment: map[string]string{
|
||||||
|
"DATABASE_URL": "{{service.db.url}}",
|
||||||
|
"DB_HOST": "{{service.db.host}}",
|
||||||
|
"DB_PORT": "{{service.db.port}}",
|
||||||
|
"SESSION_TOKEN": "{{secret}}",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("topo: %v", err)
|
||||||
|
}
|
||||||
|
graph := resolveTemplateGraph(sorted, nil)
|
||||||
|
if len(graph.unresolved) > 0 {
|
||||||
|
t.Fatalf("unresolved: %v", graph.unresolved)
|
||||||
|
}
|
||||||
|
web := graph.members[1]
|
||||||
|
if web.env["DB_HOST"] != "db" {
|
||||||
|
t.Fatalf("DB_HOST=%q", web.env["DB_HOST"])
|
||||||
|
}
|
||||||
|
if web.env["DB_PORT"] != "5432" {
|
||||||
|
t.Fatalf("DB_PORT=%q", web.env["DB_PORT"])
|
||||||
|
}
|
||||||
|
dbURL := web.env["DATABASE_URL"]
|
||||||
|
if !strings.HasPrefix(dbURL, "postgresql://") || !strings.Contains(dbURL, "@db:5432/shop") {
|
||||||
|
t.Fatalf("DATABASE_URL=%q", dbURL)
|
||||||
|
}
|
||||||
|
if len(web.env["SESSION_TOKEN"]) != 32 || !web.secretKeys["SESSION_TOKEN"] {
|
||||||
|
t.Fatalf("SESSION_TOKEN=%q secret=%v", web.env["SESSION_TOKEN"], web.secretKeys["SESSION_TOKEN"])
|
||||||
|
}
|
||||||
|
// Database member must carry the generated password so refs and the
|
||||||
|
// container agree.
|
||||||
|
db := graph.members[0]
|
||||||
|
if pw := db.env["POSTGRES_PASSWORD"]; len(pw) != 32 || !db.secretKeys["POSTGRES_PASSWORD"] {
|
||||||
|
t.Fatalf("POSTGRES_PASSWORD=%q secret=%v", pw, db.secretKeys["POSTGRES_PASSWORD"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(dbURL, db.env["POSTGRES_PASSWORD"]) {
|
||||||
|
t.Fatalf("url %q does not carry the resolved password", dbURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTemplateGraphServiceRefs(t *testing.T) {
|
||||||
|
sorted, err := topoSortServices([]TemplateServiceSpec{
|
||||||
|
{Key: "api", Type: "web", Runtime: "ghcr.io/x/api:1", Port: 8080},
|
||||||
|
{Key: "front", Type: "web", Runtime: "ghcr.io/x/front:1", Port: 3000, DependsOn: []string{"api"}, Environment: map[string]string{
|
||||||
|
"API_URL": "{{service.api.url}}/v2",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("topo: %v", err)
|
||||||
|
}
|
||||||
|
graph := resolveTemplateGraph(sorted, nil)
|
||||||
|
if got := graph.members[1].env["API_URL"]; got != "http://api:8080/v2" {
|
||||||
|
t.Fatalf("API_URL=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTemplateGraphUnresolvedServiceProp(t *testing.T) {
|
||||||
|
sorted, _ := topoSortServices([]TemplateServiceSpec{
|
||||||
|
{Key: "web", Type: "web", Runtime: "nginx", Environment: map[string]string{
|
||||||
|
"X": "{{service.nope.host}}",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
graph := resolveTemplateGraph(sorted, nil)
|
||||||
|
if len(graph.unresolved) == 0 {
|
||||||
|
t.Fatal("expected unresolved ref")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTemplateGraph(t *testing.T) {
|
||||||
|
if err := validateTemplateGraph([]TemplateServiceSpec{
|
||||||
|
{Key: "db", Type: "database", Runtime: "postgresql"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("valid db spec rejected: %v", err)
|
||||||
|
}
|
||||||
|
if err := validateTemplateGraph([]TemplateServiceSpec{
|
||||||
|
{Key: "db", Type: "database", Runtime: "nonsense"},
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("expected unsupported db engine error")
|
||||||
|
}
|
||||||
|
if err := validateTemplateGraph([]TemplateServiceSpec{
|
||||||
|
{Key: "web", Type: "web"},
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("expected missing runtime error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOfficialTemplatesAllParse walks every seeded template and asserts the
|
||||||
|
// config parses, graph templates topo-sort + validate, and a dry-run resolve
|
||||||
|
// produces no unresolved refs (defaults cover all expressions).
|
||||||
|
func TestOfficialTemplatesAllParse(t *testing.T) {
|
||||||
|
for _, tpl := range SeedTemplates() {
|
||||||
|
var cfg TemplateConfig
|
||||||
|
if err := json.Unmarshal([]byte(tpl.Config), &cfg); err != nil {
|
||||||
|
t.Errorf("%s: config parse: %v", tpl.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(cfg.Services) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sorted, err := topoSortServices(cfg.Services)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: topo sort: %v", tpl.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validateTemplateGraph(sorted); err != nil {
|
||||||
|
t.Errorf("%s: validate: %v", tpl.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var vars []TemplateVariable
|
||||||
|
if tpl.Variables != "" {
|
||||||
|
if err := json.Unmarshal([]byte(tpl.Variables), &vars); err != nil {
|
||||||
|
t.Errorf("%s: variables parse: %v", tpl.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userVars := map[string]string{}
|
||||||
|
for _, v := range vars {
|
||||||
|
if v.Default != "" {
|
||||||
|
userVars[v.Key] = v.Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
graph := resolveTemplateGraph(sorted, userVars)
|
||||||
|
if len(graph.unresolved) > 0 {
|
||||||
|
t.Errorf("%s: unresolved refs: %v", tpl.ID, graph.unresolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package api
|
|||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/database/sqlcdb"
|
"containr/internal/database/sqlcdb"
|
||||||
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/secrets"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -34,6 +36,7 @@ type ServiceTemplate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TemplateConfig struct {
|
type TemplateConfig struct {
|
||||||
|
Version int `json:"version,omitempty"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Runtime string `json:"runtime"`
|
Runtime string `json:"runtime"`
|
||||||
BuildCommand string `json:"build_command"`
|
BuildCommand string `json:"build_command"`
|
||||||
@@ -43,6 +46,9 @@ type TemplateConfig struct {
|
|||||||
Environment map[string]string `json:"environment"`
|
Environment map[string]string `json:"environment"`
|
||||||
Dockerfile string `json:"dockerfile,omitempty"`
|
Dockerfile string `json:"dockerfile,omitempty"`
|
||||||
NixpacksConfig map[string]string `json:"nixpacks_config,omitempty"`
|
NixpacksConfig map[string]string `json:"nixpacks_config,omitempty"`
|
||||||
|
// Services makes this a v2 graph template. When non-empty the flat
|
||||||
|
// fields above are ignored and deployTemplateGraph handles the deploy.
|
||||||
|
Services []TemplateServiceSpec `json:"services,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TemplateVariable struct {
|
type TemplateVariable struct {
|
||||||
@@ -147,7 +153,7 @@ func handleCreateFromTemplate(c *gin.Context) {
|
|||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
ProjectID string `json:"project_id" binding:"required"`
|
ProjectID string `json:"project_id" binding:"required"`
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name"`
|
||||||
Plan string `json:"plan,omitempty"`
|
Plan string `json:"plan,omitempty"`
|
||||||
Region string `json:"region,omitempty"`
|
Region string `json:"region,omitempty"`
|
||||||
Variables map[string]string `json:"variables"`
|
Variables map[string]string `json:"variables"`
|
||||||
@@ -160,10 +166,6 @@ func handleCreateFromTemplate(c *gin.Context) {
|
|||||||
|
|
||||||
req.ProjectID = strings.TrimSpace(req.ProjectID)
|
req.ProjectID = strings.TrimSpace(req.ProjectID)
|
||||||
req.Name = strings.TrimSpace(req.Name)
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
if req.Name == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
projectID, err := uuid.Parse(req.ProjectID)
|
projectID, err := uuid.Parse(req.ProjectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -213,6 +215,51 @@ func handleCreateFromTemplate(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(config.Services) > 0 {
|
||||||
|
var engine *deployment.DeploymentEngine
|
||||||
|
if v, exists := c.Get("deployment_engine"); exists && v != nil {
|
||||||
|
engine, _ = v.(*deployment.DeploymentEngine)
|
||||||
|
}
|
||||||
|
var dbHandler *DatabaseHandler
|
||||||
|
if v, exists := c.Get("database_handler"); exists {
|
||||||
|
dbHandler, _ = v.(*DatabaseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
userVars := map[string]string{}
|
||||||
|
for _, v := range templateVars {
|
||||||
|
if override, ok := req.Variables[v.Key]; ok {
|
||||||
|
userVars[v.Key] = override
|
||||||
|
} else if v.Default != "" {
|
||||||
|
userVars[v.Key] = v.Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range req.Variables {
|
||||||
|
userVars[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
created, fail := deployTemplateGraph(c, db, engine, dbHandler, projectID, userID,
|
||||||
|
config.Services, templateVars, userVars, req.Plan, req.Region, "template:"+templateID)
|
||||||
|
if fail != nil {
|
||||||
|
status := http.StatusBadRequest
|
||||||
|
if (*fail)["code"] == "CONFLICT" {
|
||||||
|
status = http.StatusConflict
|
||||||
|
}
|
||||||
|
c.JSON(status, *fail)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"resource": "stack",
|
||||||
|
"created": created,
|
||||||
|
"message": fmt.Sprintf("Deployed %d resources from template %s", len(created), template.Name),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
serviceType, err := normalizeTemplateServiceType(config.Type)
|
serviceType, err := normalizeTemplateServiceType(config.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -351,11 +398,15 @@ func handleCreateFromTemplate(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stored := value
|
||||||
|
if secretKeys[key] {
|
||||||
|
stored = secrets.Encrypt(value)
|
||||||
|
}
|
||||||
err = txQueries.UpsertEnvironmentVariable(ctx, sqlcdb.UpsertEnvironmentVariableParams{
|
err = txQueries.UpsertEnvironmentVariable(ctx, sqlcdb.UpsertEnvironmentVariableParams{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
ServiceID: serviceID,
|
ServiceID: serviceID,
|
||||||
Key: key,
|
Key: key,
|
||||||
Value: value,
|
Value: stored,
|
||||||
IsSecret: sql.NullBool{Bool: secretKeys[key], Valid: true},
|
IsSecret: sql.NullBool{Bool: secretKeys[key], Valid: true},
|
||||||
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||||
@@ -927,6 +978,102 @@ func SeedTemplates() []ServiceTemplate {
|
|||||||
Variables: `[]`,
|
Variables: `[]`,
|
||||||
IsOfficial: true,
|
IsOfficial: true,
|
||||||
},
|
},
|
||||||
|
// Multi-service stacks — v2 graph configs. Database members become
|
||||||
|
// managed databases attached to the project network under their key.
|
||||||
|
{
|
||||||
|
ID: "tpl-wordpress-stack",
|
||||||
|
Name: "WordPress + MySQL",
|
||||||
|
Description: "WordPress with a managed MySQL database on a private project network",
|
||||||
|
Category: "stack",
|
||||||
|
Logo: "https://cdn.simpleicons.org/wordpress",
|
||||||
|
Config: `{"version":2,"services":[
|
||||||
|
{"key":"db","type":"database","runtime":"mysql","environment":{"MYSQL_DATABASE":"wordpress"}},
|
||||||
|
{"key":"web","name":"wordpress","type":"web","runtime":"wordpress:6-apache","port":80,"health_check":"/wp-login.php","depends_on":["db"],
|
||||||
|
"environment":{"WORDPRESS_DB_HOST":"{{service.db.host}}","WORDPRESS_DB_USER":"{{service.db.user}}","WORDPRESS_DB_PASSWORD":"{{service.db.password}}","WORDPRESS_DB_NAME":"{{service.db.database}}"}}]}`,
|
||||||
|
Variables: `[]`,
|
||||||
|
IsOfficial: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "tpl-n8n-stack",
|
||||||
|
Name: "n8n + PostgreSQL",
|
||||||
|
Description: "n8n workflow automation backed by a managed PostgreSQL database",
|
||||||
|
Category: "stack",
|
||||||
|
Logo: "https://cdn.simpleicons.org/n8n",
|
||||||
|
Config: `{"version":2,"services":[
|
||||||
|
{"key":"db","type":"database","runtime":"postgresql"},
|
||||||
|
{"key":"n8n","type":"web","runtime":"n8nio/n8n:latest","port":5678,"health_check":"/healthz","depends_on":["db"],
|
||||||
|
"environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"{{service.db.port}}","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","N8N_ENCRYPTION_KEY":"{{secret}}"}}]}`,
|
||||||
|
Variables: `[]`,
|
||||||
|
IsOfficial: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "tpl-ghost-stack",
|
||||||
|
Name: "Ghost + MySQL",
|
||||||
|
Description: "Ghost publishing platform backed by a managed MySQL database",
|
||||||
|
Category: "stack",
|
||||||
|
Logo: "https://cdn.simpleicons.org/ghost",
|
||||||
|
Config: `{"version":2,"services":[
|
||||||
|
{"key":"db","type":"database","runtime":"mysql","environment":{"MYSQL_DATABASE":"ghost"}},
|
||||||
|
{"key":"web","name":"ghost","type":"web","runtime":"ghost:5-alpine","port":2368,"depends_on":["db"],
|
||||||
|
"environment":{"url":"{{APP_URL:-http://localhost:2368}}","database__client":"mysql","database__connection__host":"{{service.db.host}}","database__connection__user":"{{service.db.user}}","database__connection__password":"{{service.db.password}}","database__connection__database":"{{service.db.database}}"}}]}`,
|
||||||
|
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
|
||||||
|
IsOfficial: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "tpl-gitea-stack",
|
||||||
|
Name: "Gitea + PostgreSQL",
|
||||||
|
Description: "Self-hosted Git service backed by a managed PostgreSQL database",
|
||||||
|
Category: "stack",
|
||||||
|
Logo: "https://cdn.simpleicons.org/gitea",
|
||||||
|
Config: `{"version":2,"services":[
|
||||||
|
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"gitea"}},
|
||||||
|
{"key":"web","name":"gitea","type":"web","runtime":"gitea/gitea:1","port":3000,"health_check":"/api/healthz","depends_on":["db"],
|
||||||
|
"environment":{"GITEA__database__DB_TYPE":"postgres","GITEA__database__HOST":"{{service.db.host}}:{{service.db.port}}","GITEA__database__NAME":"{{service.db.database}}","GITEA__database__USER":"{{service.db.user}}","GITEA__database__PASSWD":"{{service.db.password}}","GITEA__security__SECRET_KEY":"{{secret}}","GITEA__security__INTERNAL_TOKEN":"{{secret(64)}}"}}]}`,
|
||||||
|
Variables: `[]`,
|
||||||
|
IsOfficial: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "tpl-nextcloud-stack",
|
||||||
|
Name: "Nextcloud + PostgreSQL + Redis",
|
||||||
|
Description: "Nextcloud with a managed PostgreSQL database and Redis cache",
|
||||||
|
Category: "stack",
|
||||||
|
Logo: "https://cdn.simpleicons.org/nextcloud",
|
||||||
|
Config: `{"version":2,"services":[
|
||||||
|
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"nextcloud"}},
|
||||||
|
{"key":"cache","type":"database","runtime":"redis"},
|
||||||
|
{"key":"web","name":"nextcloud","type":"web","runtime":"nextcloud:30-apache","port":80,"health_check":"/status.php","depends_on":["db","cache"],
|
||||||
|
"environment":{"POSTGRES_HOST":"{{service.db.host}}","POSTGRES_DB":"{{service.db.database}}","POSTGRES_USER":"{{service.db.user}}","POSTGRES_PASSWORD":"{{service.db.password}}","REDIS_HOST":"{{service.cache.host}}","REDIS_HOST_PASSWORD":"{{service.cache.password}}","NEXTCLOUD_ADMIN_USER":"admin","NEXTCLOUD_ADMIN_PASSWORD":"{{secret}}","NEXTCLOUD_TRUSTED_DOMAINS":"{{APP_DOMAIN:-localhost}}"}}]}`,
|
||||||
|
Variables: `[{"key":"APP_DOMAIN","label":"Trusted domain","default":"","required":false,"secret":false}]`,
|
||||||
|
IsOfficial: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "tpl-paperless-stack",
|
||||||
|
Name: "Paperless-ngx + PostgreSQL + Redis",
|
||||||
|
Description: "Document management with managed PostgreSQL and Redis",
|
||||||
|
Category: "stack",
|
||||||
|
Logo: "https://cdn.simpleicons.org/paperlessngx",
|
||||||
|
Config: `{"version":2,"services":[
|
||||||
|
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"paperless"}},
|
||||||
|
{"key":"cache","type":"database","runtime":"redis"},
|
||||||
|
{"key":"web","name":"paperless","type":"web","runtime":"ghcr.io/paperless-ngx/paperless-ngx:latest","port":8000,"depends_on":["db","cache"],
|
||||||
|
"environment":{"PAPERLESS_DBHOST":"{{service.db.host}}","PAPERLESS_DBPORT":"{{service.db.port}}","PAPERLESS_DBNAME":"{{service.db.database}}","PAPERLESS_DBUSER":"{{service.db.user}}","PAPERLESS_DBPASS":"{{service.db.password}}","PAPERLESS_REDIS":"{{service.cache.url}}","PAPERLESS_ADMIN_USER":"admin","PAPERLESS_ADMIN_PASSWORD":"{{secret}}","PAPERLESS_SECRET_KEY":"{{secret(48)}}","PAPERLESS_URL":"{{APP_URL:-http://localhost:8000}}"}}]}`,
|
||||||
|
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
|
||||||
|
IsOfficial: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "tpl-plausible-stack",
|
||||||
|
Name: "Plausible + PostgreSQL + ClickHouse",
|
||||||
|
Description: "Privacy-friendly analytics with managed PostgreSQL and ClickHouse",
|
||||||
|
Category: "stack",
|
||||||
|
Logo: "https://cdn.simpleicons.org/plausibleanalytics",
|
||||||
|
Config: `{"version":2,"services":[
|
||||||
|
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"plausible"}},
|
||||||
|
{"key":"events","type":"database","runtime":"clickhouse","environment":{"CLICKHOUSE_DB":"plausible"}},
|
||||||
|
{"key":"web","name":"plausible","type":"web","runtime":"ghcr.io/plausible/community-edition:v2","port":8000,"health_check":"/api/health","depends_on":["db","events"],
|
||||||
|
"environment":{"BASE_URL":"{{APP_URL:-http://localhost:8000}}","SECRET_KEY_BASE":"{{secret(64)}}","DATABASE_URL":"{{service.db.url}}","CLICKHOUSE_DATABASE_URL":"http://{{service.events.host}}:{{service.events.port}}/{{service.events.database}}","TOTP_VAULT_KEY":"{{secret(32)}}"}}]}`,
|
||||||
|
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
|
||||||
|
IsOfficial: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return templates
|
return templates
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,543 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/database/sqlcdb"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/sqlc-dev/pqtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Outbound webhooks: every audited action (LogAudit) emits an event named
|
||||||
|
// "resource.action" (e.g. "service.deploy"). Webhooks subscribe with an
|
||||||
|
// event list supporting "service.*" and "*" wildcards. Deliveries are
|
||||||
|
// HMAC-signed and retried up to 3 times with backoff; each attempt lands
|
||||||
|
// in webhook_deliveries.
|
||||||
|
|
||||||
|
var (
|
||||||
|
webhookQueue = make(chan webhookJob, 512)
|
||||||
|
webhookWorkers sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
type webhookJob struct {
|
||||||
|
Webhook sqlcdb.OutboundWebhook
|
||||||
|
Event string
|
||||||
|
Payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type webhookEventPayload struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
Resource string `json:"resource"`
|
||||||
|
ResourceID string `json:"resource_id"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
Details map[string]interface{} `json:"details,omitempty"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitWebhookEvent fans an audited action out to all enabled webhooks whose
|
||||||
|
// event list matches. Called from LogAudit — fire-and-forget.
|
||||||
|
func emitWebhookEvent(userID, resource, resourceID, action string, details map[string]interface{}) {
|
||||||
|
db := GetAuditDB()
|
||||||
|
if db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Actions that already carry a resource prefix ("service.clone") pass
|
||||||
|
// through unchanged; bare actions ("create") get resource-qualified.
|
||||||
|
event := action
|
||||||
|
if !strings.Contains(action, ".") {
|
||||||
|
event = resource + "." + action
|
||||||
|
}
|
||||||
|
|
||||||
|
queries := sqlcdb.New(db.DB)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
hooks, err := queries.ListEnabledOutboundWebhooks(ctx)
|
||||||
|
if err != nil || len(hooks) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(webhookEventPayload{
|
||||||
|
Event: event, Resource: resource, ResourceID: resourceID,
|
||||||
|
Action: action, UserID: userID, Details: details, Timestamp: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uid, uidErr := uuid.Parse(strings.TrimSpace(userID))
|
||||||
|
for _, hook := range hooks {
|
||||||
|
if !webhookEventMatches(hook.Events, event) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Webhooks only receive events for their owner — unless subscribed to "*"
|
||||||
|
// is deliberate admin reach; owner scoping keeps per-user isolation.
|
||||||
|
if uidErr != nil || hook.UserID != uid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case webhookQueue <- webhookJob{Webhook: hook, Event: event, Payload: payload}:
|
||||||
|
default:
|
||||||
|
// Queue full: drop rather than block the request path.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func webhookEventMatches(events json.RawMessage, event string) bool {
|
||||||
|
var subs []string
|
||||||
|
if err := json.Unmarshal(events, &subs); err != nil || len(subs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, sub := range subs {
|
||||||
|
sub = strings.TrimSpace(sub)
|
||||||
|
if sub == "*" || sub == event {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(sub, ".*") && strings.HasPrefix(event, strings.TrimSuffix(sub, "*")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartWebhookDispatcher launches delivery workers; call once at boot.
|
||||||
|
func StartWebhookDispatcher() {
|
||||||
|
webhookWorkers.Do(func() {
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
go webhookDeliveryWorker()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func webhookDeliveryWorker() {
|
||||||
|
for job := range webhookQueue {
|
||||||
|
deliverWebhook(job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var webhookHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
func deliverWebhook(job webhookJob) {
|
||||||
|
db := GetAuditDB()
|
||||||
|
if db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queries := sqlcdb.New(db.DB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
deliveryID := uuid.New()
|
||||||
|
_ = queries.CreateWebhookDelivery(ctx, sqlcdb.CreateWebhookDeliveryParams{
|
||||||
|
ID: deliveryID,
|
||||||
|
WebhookID: job.Webhook.ID,
|
||||||
|
Event: job.Event,
|
||||||
|
Payload: pqtype.NullRawMessage{RawMessage: job.Payload, Valid: true},
|
||||||
|
Status: "pending",
|
||||||
|
})
|
||||||
|
|
||||||
|
var lastStatus string = "failed"
|
||||||
|
var respStatus sql.NullInt32
|
||||||
|
var respBody sql.NullString
|
||||||
|
var durationMs sql.NullInt32
|
||||||
|
var deliveredAt sql.NullTime
|
||||||
|
|
||||||
|
for attempt := int32(1); attempt <= 3; attempt++ {
|
||||||
|
start := time.Now()
|
||||||
|
status, code, body := postWebhook(job, deliveryID, attempt)
|
||||||
|
durationMs = sql.NullInt32{Int32: int32(time.Since(start).Milliseconds()), Valid: true}
|
||||||
|
respStatus = code
|
||||||
|
respBody = body
|
||||||
|
if status == "success" {
|
||||||
|
lastStatus = "success"
|
||||||
|
deliveredAt = sql.NullTime{Time: time.Now().UTC(), Valid: true}
|
||||||
|
_ = queries.UpdateWebhookDeliveryResult(ctx, sqlcdb.UpdateWebhookDeliveryResultParams{
|
||||||
|
ID: deliveryID,
|
||||||
|
Status: lastStatus,
|
||||||
|
ResponseStatus: respStatus,
|
||||||
|
ResponseBody: respBody,
|
||||||
|
Attempts: attempt,
|
||||||
|
DurationMs: durationMs,
|
||||||
|
DeliveredAt: deliveredAt,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if attempt < 3 {
|
||||||
|
time.Sleep(time.Duration(attempt*attempt) * 2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = queries.UpdateWebhookDeliveryResult(ctx, sqlcdb.UpdateWebhookDeliveryResultParams{
|
||||||
|
ID: deliveryID,
|
||||||
|
Status: lastStatus,
|
||||||
|
ResponseStatus: respStatus,
|
||||||
|
ResponseBody: respBody,
|
||||||
|
Attempts: 3,
|
||||||
|
DurationMs: durationMs,
|
||||||
|
DeliveredAt: deliveredAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func postWebhook(job webhookJob, deliveryID uuid.UUID, attempt int32) (string, sql.NullInt32, sql.NullString) {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, job.Webhook.Url, bytes.NewReader(job.Payload))
|
||||||
|
if err != nil {
|
||||||
|
return "failed", sql.NullInt32{}, sql.NullString{String: err.Error(), Valid: true}
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "Containr-Webhook/1.0")
|
||||||
|
req.Header.Set("X-Containr-Event", job.Event)
|
||||||
|
req.Header.Set("X-Containr-Delivery", deliveryID.String())
|
||||||
|
if job.Webhook.Secret != "" {
|
||||||
|
mac := hmac.New(sha256.New, []byte(job.Webhook.Secret))
|
||||||
|
mac.Write(job.Payload)
|
||||||
|
req.Header.Set("X-Containr-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||||
|
}
|
||||||
|
if job.Webhook.Headers.Valid {
|
||||||
|
var custom map[string]string
|
||||||
|
if err := json.Unmarshal(job.Webhook.Headers.RawMessage, &custom); err == nil {
|
||||||
|
for k, v := range custom {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := webhookHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "failed", sql.NullInt32{}, sql.NullString{String: err.Error(), Valid: true}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
code := sql.NullInt32{Int32: int32(resp.StatusCode), Valid: true}
|
||||||
|
bodyStr := sql.NullString{String: string(body), Valid: len(body) > 0}
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
return "success", code, bodyStr
|
||||||
|
}
|
||||||
|
return "failed", code, bodyStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- HTTP API ----------
|
||||||
|
|
||||||
|
type outboundWebhookResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Secret string `json:"secret,omitempty"`
|
||||||
|
Events []string `json:"events"`
|
||||||
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func outboundWebhookJSON(row sqlcdb.OutboundWebhook, revealSecret bool) outboundWebhookResponse {
|
||||||
|
var events []string
|
||||||
|
_ = json.Unmarshal(row.Events, &events)
|
||||||
|
if events == nil {
|
||||||
|
events = []string{}
|
||||||
|
}
|
||||||
|
var headers map[string]string
|
||||||
|
if row.Headers.Valid {
|
||||||
|
_ = json.Unmarshal(row.Headers.RawMessage, &headers)
|
||||||
|
}
|
||||||
|
resp := outboundWebhookResponse{
|
||||||
|
ID: row.ID.String(),
|
||||||
|
Name: row.Name,
|
||||||
|
URL: row.Url,
|
||||||
|
Events: events,
|
||||||
|
Headers: headers,
|
||||||
|
Enabled: row.Enabled,
|
||||||
|
}
|
||||||
|
if revealSecret {
|
||||||
|
resp.Secret = row.Secret
|
||||||
|
}
|
||||||
|
if row.CreatedAt.Valid {
|
||||||
|
resp.CreatedAt = row.CreatedAt.Time
|
||||||
|
}
|
||||||
|
if row.UpdatedAt.Valid {
|
||||||
|
resp.UpdatedAt = row.UpdatedAt.Time
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func validWebhookURL(raw string) error {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
return errors.New("url must be a valid http(s) URL")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type webhookMutationRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Secret *string `json:"secret"`
|
||||||
|
Events []string `json:"events"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListOutboundWebhooks(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserUUID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
rows, err := sqlcdb.New(db.DB).ListOutboundWebhooksByUser(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list webhooks"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]outboundWebhookResponse, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, outboundWebhookJSON(row, false))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"webhooks": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateOutboundWebhook(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserUUID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req webhookMutationRequest
|
||||||
|
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 == "" {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validWebhookURL(req.URL); err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Events) == 0 {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "at least one event subscription is required (e.g. \"service.*\" or \"*\")")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := ""
|
||||||
|
if req.Secret != nil {
|
||||||
|
secret = strings.TrimSpace(*req.Secret)
|
||||||
|
}
|
||||||
|
if secret == "" {
|
||||||
|
buf := make([]byte, 24)
|
||||||
|
_, _ = rand.Read(buf)
|
||||||
|
secret = hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
eventsJSON, _ := json.Marshal(req.Events)
|
||||||
|
var headersJSON pqtype.NullRawMessage
|
||||||
|
if len(req.Headers) > 0 {
|
||||||
|
if raw, err := json.Marshal(req.Headers); err == nil {
|
||||||
|
headersJSON = pqtype.NullRawMessage{RawMessage: raw, Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enabled := true
|
||||||
|
if req.Enabled != nil {
|
||||||
|
enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
id := uuid.New()
|
||||||
|
err := sqlcdb.New(db.DB).CreateOutboundWebhook(c.Request.Context(), sqlcdb.CreateOutboundWebhookParams{
|
||||||
|
ID: id,
|
||||||
|
UserID: userID,
|
||||||
|
Name: req.Name,
|
||||||
|
Url: strings.TrimSpace(req.URL),
|
||||||
|
Secret: secret,
|
||||||
|
Events: eventsJSON,
|
||||||
|
Headers: headersJSON,
|
||||||
|
Enabled: enabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create webhook"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LogAuditWithRequest(c, "webhook", id.String(), "create", map[string]interface{}{"name": req.Name})
|
||||||
|
c.JSON(http.StatusCreated, outboundWebhookJSON(sqlcdb.OutboundWebhook{
|
||||||
|
ID: id, Name: req.Name, Url: strings.TrimSpace(req.URL), Secret: secret,
|
||||||
|
Events: eventsJSON, Headers: headersJSON, Enabled: enabled,
|
||||||
|
CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
|
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
|
}, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateOutboundWebhook(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserUUID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webhookID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
queries := sqlcdb.New(db.DB)
|
||||||
|
row, err := queries.GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{
|
||||||
|
ID: webhookID, UserID: userID,
|
||||||
|
})
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load webhook"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req webhookMutationRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := row.Name
|
||||||
|
if req.Name != "" {
|
||||||
|
name = strings.TrimSpace(req.Name)
|
||||||
|
}
|
||||||
|
targetURL := row.Url
|
||||||
|
if req.URL != "" {
|
||||||
|
if err := validWebhookURL(req.URL); err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetURL = strings.TrimSpace(req.URL)
|
||||||
|
}
|
||||||
|
secret := row.Secret
|
||||||
|
if req.Secret != nil {
|
||||||
|
secret = strings.TrimSpace(*req.Secret)
|
||||||
|
}
|
||||||
|
eventsJSON := row.Events
|
||||||
|
if req.Events != nil {
|
||||||
|
if len(req.Events) == 0 {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "at least one event subscription is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
eventsJSON, _ = json.Marshal(req.Events)
|
||||||
|
}
|
||||||
|
headersJSON := row.Headers
|
||||||
|
if req.Headers != nil {
|
||||||
|
if raw, err := json.Marshal(req.Headers); err == nil {
|
||||||
|
headersJSON = pqtype.NullRawMessage{RawMessage: raw, Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enabled := row.Enabled
|
||||||
|
if req.Enabled != nil {
|
||||||
|
enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
err = queries.UpdateOutboundWebhook(c.Request.Context(), sqlcdb.UpdateOutboundWebhookParams{
|
||||||
|
ID: webhookID, UserID: userID,
|
||||||
|
Name: name, Url: targetURL, Secret: secret,
|
||||||
|
Events: eventsJSON, Headers: headersJSON, Enabled: enabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update webhook"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LogAuditWithRequest(c, "webhook", webhookID.String(), "update", map[string]interface{}{"name": name})
|
||||||
|
row.Name, row.Url, row.Secret, row.Events, row.Headers, row.Enabled = name, targetURL, secret, eventsJSON, headersJSON, enabled
|
||||||
|
c.JSON(http.StatusOK, outboundWebhookJSON(row, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteOutboundWebhook(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserUUID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webhookID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
err = sqlcdb.New(db.DB).DeleteOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.DeleteOutboundWebhookByIDAndUserParams{
|
||||||
|
ID: webhookID, UserID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete webhook"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LogAuditWithRequest(c, "webhook", webhookID.String(), "delete", map[string]interface{}{})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Webhook deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListOutboundWebhookDeliveries(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserUUID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webhookID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
queries := sqlcdb.New(db.DB)
|
||||||
|
if _, err := queries.GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{
|
||||||
|
ID: webhookID, UserID: userID,
|
||||||
|
}); errors.Is(err, sql.ErrNoRows) {
|
||||||
|
respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := queries.ListWebhookDeliveriesByWebhook(c.Request.Context(), webhookID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list deliveries"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deliveries": rows})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleTestOutboundWebhook(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserUUID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webhookID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
row, err := sqlcdb.New(db.DB).GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{
|
||||||
|
ID: webhookID, UserID: userID,
|
||||||
|
})
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load webhook"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !row.Enabled {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", "Webhook is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(webhookEventPayload{
|
||||||
|
Event: "ping", Resource: "webhook", ResourceID: webhookID.String(),
|
||||||
|
Action: "ping", UserID: userID.String(), Timestamp: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
select {
|
||||||
|
case webhookQueue <- webhookJob{Webhook: row, Event: "ping", Payload: payload}:
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"message": "Test delivery queued"})
|
||||||
|
default:
|
||||||
|
respondError(c, http.StatusServiceUnavailable, "UNAVAILABLE", "Delivery queue is full")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWebhookEventMatches(t *testing.T) {
|
||||||
|
subs := func(list ...string) json.RawMessage {
|
||||||
|
raw, _ := json.Marshal(list)
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
events json.RawMessage
|
||||||
|
event string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{subs("service.*"), "service.deploy", true},
|
||||||
|
{subs("service.*"), "database.create", false},
|
||||||
|
{subs("*"), "service.deploy", true},
|
||||||
|
{subs("deployment.fail", "service.*"), "deployment.fail", true},
|
||||||
|
{subs("deployment.fail"), "deployment.success", false},
|
||||||
|
{subs(" ping "), "ping", true},
|
||||||
|
{subs(), "service.deploy", false},
|
||||||
|
{json.RawMessage(`"not-a-list"`), "service.deploy", false},
|
||||||
|
{json.RawMessage(`invalid`), "service.deploy", false},
|
||||||
|
}
|
||||||
|
for i, tc := range cases {
|
||||||
|
if got := webhookEventMatches(tc.events, tc.event); got != tc.want {
|
||||||
|
t.Errorf("case %d: matches(%s, %q) = %v, want %v", i, tc.events, tc.event, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,5 +147,21 @@ func runStatus(cmd *cobra.Command, args []string) error {
|
|||||||
if p.IsAdmin {
|
if p.IsAdmin {
|
||||||
fmt.Println("Role: platform admin")
|
fmt.Println("Role: platform admin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var bannersResp struct {
|
||||||
|
Banners []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
} `json:"banners"`
|
||||||
|
}
|
||||||
|
if err := client.DoJSON("GET", "/banners/active", nil, &bannersResp); err == nil {
|
||||||
|
for _, b := range bannersResp.Banners {
|
||||||
|
fmt.Printf("[%s] %s\n", b.Level, b.Title)
|
||||||
|
if b.Body != "" {
|
||||||
|
fmt.Printf(" %s\n", b.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BackupTargetsCmd manages S3-compatible backup destinations.
|
||||||
|
var BackupTargetsCmd = &cobra.Command{
|
||||||
|
Use: "backup-targets",
|
||||||
|
Aliases: []string{"backup-target", "targets"},
|
||||||
|
Short: "Manage S3-compatible backup targets",
|
||||||
|
}
|
||||||
|
|
||||||
|
var btListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List backup targets",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/backup-targets", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "backup_targets")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, t := range items {
|
||||||
|
creds := "no"
|
||||||
|
if str(t, "has_credentials") == "true" {
|
||||||
|
creds = "yes"
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{str(t, "id"), str(t, "name"), str(t, "endpoint"), str(t, "bucket"), creds})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "NAME", "ENDPOINT", "BUCKET", "CREDS"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var btAddCmd = &cobra.Command{
|
||||||
|
Use: "add <name>",
|
||||||
|
Short: "Register an S3-compatible backup target (probed before storing)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{"name": args[0]}
|
||||||
|
for _, f := range []string{"endpoint", "bucket", "region", "prefix", "access-key", "secret-key"} {
|
||||||
|
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||||
|
key := f
|
||||||
|
switch f {
|
||||||
|
case "access-key":
|
||||||
|
key = "access_key"
|
||||||
|
case "secret-key":
|
||||||
|
key = "secret_key"
|
||||||
|
}
|
||||||
|
body[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("no-tls") {
|
||||||
|
v, _ := cmd.Flags().GetBool("no-tls")
|
||||||
|
body["use_tls"] = !v
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/backup-targets", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var btUpdateCmd = &cobra.Command{
|
||||||
|
Use: "update <id>",
|
||||||
|
Short: "Update a backup target (re-probed; empty keys keep stored credentials)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
// endpoint/bucket are required by the API — fall back to stored values.
|
||||||
|
for _, f := range []string{"name", "endpoint", "bucket", "region", "prefix", "access-key", "secret-key"} {
|
||||||
|
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||||
|
key := f
|
||||||
|
switch f {
|
||||||
|
case "access-key":
|
||||||
|
key = "access_key"
|
||||||
|
case "secret-key":
|
||||||
|
key = "secret_key"
|
||||||
|
}
|
||||||
|
body[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, hasEndpoint := body["endpoint"]; !hasEndpoint {
|
||||||
|
data, err := c.Do("GET", "/backup-targets", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "backup_targets")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, t := range items {
|
||||||
|
if str(t, "id") == args[0] {
|
||||||
|
body["endpoint"] = str(t, "endpoint")
|
||||||
|
if _, ok := body["bucket"]; !ok {
|
||||||
|
body["bucket"] = str(t, "bucket")
|
||||||
|
}
|
||||||
|
if _, ok := body["name"]; !ok {
|
||||||
|
body["name"] = str(t, "name")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("no-tls") {
|
||||||
|
v, _ := cmd.Flags().GetBool("no-tls")
|
||||||
|
body["use_tls"] = !v
|
||||||
|
}
|
||||||
|
data, err := c.Do("PUT", "/backup-targets/"+args[0], body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var btRemoveCmd = &cobra.Command{
|
||||||
|
Use: "remove <id>",
|
||||||
|
Short: "Delete a backup target (fails while databases still point at it)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !Confirm(fmt.Sprintf("Delete backup target %s?", args[0])) {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if _, err := c.Do("DELETE", "/backup-targets/"+args[0], nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||||
|
}
|
||||||
|
fmt.Printf("Deleted backup target %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var btTestCmd = &cobra.Command{
|
||||||
|
Use: "test <id>",
|
||||||
|
Short: "Re-probe a backup target's bucket access",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/backup-targets/"+args[0]+"/test", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m, _ := unwrapObject(data)
|
||||||
|
if str(m, "ok") == "false" {
|
||||||
|
return &APIError{Message: "probe failed: " + str(m, "error"), ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Target ok (%sms)\n", str(m, "latency_ms"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
btAddCmd.Flags().String("endpoint", "", "S3 endpoint host[:port] (e.g. s3.amazonaws.com, garage.local:3900)")
|
||||||
|
btAddCmd.Flags().String("bucket", "", "bucket name")
|
||||||
|
btAddCmd.Flags().String("region", "", "region (optional for most S3-compatible stores)")
|
||||||
|
btAddCmd.Flags().String("prefix", "", "key prefix for archives")
|
||||||
|
btAddCmd.Flags().String("access-key", "", "access key (stored encrypted)")
|
||||||
|
btAddCmd.Flags().String("secret-key", "", "secret key (stored encrypted)")
|
||||||
|
btAddCmd.Flags().Bool("no-tls", false, "use plain HTTP")
|
||||||
|
btUpdateCmd.Flags().String("name", "", "new name")
|
||||||
|
btUpdateCmd.Flags().String("endpoint", "", "S3 endpoint host[:port]")
|
||||||
|
btUpdateCmd.Flags().String("bucket", "", "bucket name")
|
||||||
|
btUpdateCmd.Flags().String("region", "", "region")
|
||||||
|
btUpdateCmd.Flags().String("prefix", "", "key prefix")
|
||||||
|
btUpdateCmd.Flags().String("access-key", "", "access key")
|
||||||
|
btUpdateCmd.Flags().String("secret-key", "", "secret key")
|
||||||
|
btUpdateCmd.Flags().Bool("no-tls", false, "use plain HTTP")
|
||||||
|
BackupTargetsCmd.AddCommand(btListCmd, btAddCmd, btUpdateCmd, btRemoveCmd, btTestCmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
bannerTitle string
|
||||||
|
bannerLevel string
|
||||||
|
bannerBody string
|
||||||
|
bannerActive bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// BannersCmd manages instance-wide announcement banners (admin).
|
||||||
|
var BannersCmd = &cobra.Command{
|
||||||
|
Use: "banners",
|
||||||
|
Short: "Manage announcement banners (admin)",
|
||||||
|
}
|
||||||
|
|
||||||
|
var bannerListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all banners",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/admin/banners", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "banners")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, b := range items {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(b, "id"), str(b, "title"), str(b, "level"), str(b, "active"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "TITLE", "LEVEL", "ACTIVE"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var bannerCreateCmd = &cobra.Command{
|
||||||
|
Use: "create <title>",
|
||||||
|
Short: "Create a banner (--body --level info|success|warning|error)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req := map[string]interface{}{"title": args[0]}
|
||||||
|
if cmd.Flags().Changed("body") {
|
||||||
|
req["body"] = bannerBody
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("level") {
|
||||||
|
req["level"] = bannerLevel
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/admin/banners", req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var bannerUpdateCmd = &cobra.Command{
|
||||||
|
Use: "update <id>",
|
||||||
|
Short: "Update a banner (--title --body --level --active/--inactive)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req := map[string]interface{}{}
|
||||||
|
if cmd.Flags().Changed("title") {
|
||||||
|
req["title"] = bannerTitle
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("body") {
|
||||||
|
req["body"] = bannerBody
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("level") {
|
||||||
|
req["level"] = bannerLevel
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("active") {
|
||||||
|
req["active"] = bannerActive
|
||||||
|
}
|
||||||
|
if len(req) == 0 {
|
||||||
|
return fmt.Errorf("nothing to update — pass --title/--body/--level/--active")
|
||||||
|
}
|
||||||
|
data, err := c.Do("PATCH", "/admin/banners/"+args[0], req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var bannerDeleteCmd = &cobra.Command{
|
||||||
|
Use: "delete <id>",
|
||||||
|
Short: "Delete a banner",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if !Confirm("Delete banner " + args[0] + "?") {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = c.Do("DELETE", "/admin/banners/"+args[0], nil)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
bannerCreateCmd.Flags().StringVar(&bannerBody, "body", "", "banner body text")
|
||||||
|
bannerCreateCmd.Flags().StringVar(&bannerLevel, "level", "info", "info|success|warning|error")
|
||||||
|
bannerUpdateCmd.Flags().StringVar(&bannerTitle, "title", "", "new title")
|
||||||
|
bannerUpdateCmd.Flags().StringVar(&bannerBody, "body", "", "new body")
|
||||||
|
bannerUpdateCmd.Flags().StringVar(&bannerLevel, "level", "", "info|success|warning|error")
|
||||||
|
bannerUpdateCmd.Flags().BoolVar(&bannerActive, "active", true, "set banner active/inactive")
|
||||||
|
BannersCmd.AddCommand(bannerListCmd, bannerCreateCmd, bannerUpdateCmd, bannerDeleteCmd)
|
||||||
|
}
|
||||||
@@ -156,6 +156,48 @@ func (c *Client) Do(method, path string, body interface{}) ([]byte, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DoRaw sends a non-JSON request body (file uploads) and returns the response.
|
||||||
|
func (c *Client) DoRaw(method, path string, body io.Reader, contentType string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequest(method, c.BaseURL+path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
hc := c.HTTPClient
|
||||||
|
if hc == nil {
|
||||||
|
hc = &http.Client{Timeout: 5 * time.Minute}
|
||||||
|
}
|
||||||
|
resp, err := hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &APIError{Status: 0, Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err), ExitCode: ExitUnavailable}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
msg := strings.TrimSpace(string(data))
|
||||||
|
var envelope struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &envelope) == nil && envelope.Error != "" {
|
||||||
|
msg = envelope.Error
|
||||||
|
if envelope.Code != "" {
|
||||||
|
msg = envelope.Code + ": " + msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, &APIError{Status: resp.StatusCode, Message: msg, ExitCode: exitCodeFor(resp.StatusCode)}
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DoJSON performs a request and unmarshals the response into out.
|
// DoJSON performs a request and unmarshals the response into out.
|
||||||
func (c *Client) DoJSON(method, path string, body interface{}, out interface{}) error {
|
func (c *Client) DoJSON(method, path string, body interface{}, out interface{}) error {
|
||||||
data, err := c.Do(method, path, body)
|
data, err := c.Do(method, path, body)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package commands
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -79,6 +80,9 @@ var dbCreateCmd = &cobra.Command{
|
|||||||
body[key] = v
|
body[key] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if v, _ := cmd.Flags().GetBool("public"); v {
|
||||||
|
body["public_port"] = true
|
||||||
|
}
|
||||||
data, err := c.Do("POST", "/databases", body)
|
data, err := c.Do("POST", "/databases", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -88,6 +92,43 @@ var dbCreateCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var dbUpdateCmd = &cobra.Command{
|
||||||
|
Use: "update <id>",
|
||||||
|
Short: "Update a database (--public/--public=false toggles the exposed host port)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
if cmd.Flags().Changed("public") {
|
||||||
|
v, _ := cmd.Flags().GetBool("public")
|
||||||
|
body["public_port"] = v
|
||||||
|
}
|
||||||
|
if v, _ := cmd.Flags().GetString("name"); v != "" {
|
||||||
|
body["name"] = v
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("backup-schedule") {
|
||||||
|
v, _ := cmd.Flags().GetString("backup-schedule")
|
||||||
|
body["backup_schedule"] = v
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("backup-target") {
|
||||||
|
v, _ := cmd.Flags().GetString("backup-target")
|
||||||
|
body["backup_target_id"] = v
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
return &APIError{Message: "nothing to update — pass --public/--private-style flags, --name, --backup-schedule, or --backup-target", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
data, err := c.Do("PUT", "/databases/"+args[0], body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
var dbDeleteCmd = &cobra.Command{
|
var dbDeleteCmd = &cobra.Command{
|
||||||
Use: "delete <id>",
|
Use: "delete <id>",
|
||||||
Short: "Delete a database and its data",
|
Short: "Delete a database and its data",
|
||||||
@@ -159,7 +200,36 @@ var dbRestoreCmd = &cobra.Command{
|
|||||||
if !Confirm(fmt.Sprintf("Restore database %s from backup %s? Current data will be overwritten.", args[0], args[1])) {
|
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}
|
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]})
|
data, err := c.Do("POST", "/databases/"+args[0]+"/restore", map[string]string{"backup_id": args[1], "database_id": args[0]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbImportBackupCmd = &cobra.Command{
|
||||||
|
Use: "import-backup <id> <file.tar.gz|->",
|
||||||
|
Short: "Import a .tar.gz archive as a restore point (use '-' for stdin)",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var src io.ReadCloser
|
||||||
|
if args[1] == "-" {
|
||||||
|
src = io.NopCloser(os.Stdin)
|
||||||
|
} else {
|
||||||
|
f, err := os.Open(args[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
src = f
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
data, err := c.DoRaw("POST", "/databases/"+args[0]+"/backups/import", src, "application/gzip")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -196,9 +266,101 @@ var dbDownloadCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
func init() {
|
||||||
dbCreateCmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
|
dbCreateCmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
|
||||||
dbCreateCmd.Flags().String("version", "", "engine version tag")
|
dbCreateCmd.Flags().String("version", "", "engine version tag")
|
||||||
dbCreateCmd.Flags().String("storage-size", "", "volume size (e.g. 10Gi)")
|
dbCreateCmd.Flags().String("storage-size", "", "volume size (e.g. 10Gi)")
|
||||||
DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbDownloadCmd)
|
dbCreateCmd.Flags().Bool("public", false, "bind the database port on all interfaces (default: localhost only)")
|
||||||
|
dbUpdateCmd.Flags().Bool("public", false, "bind the database port on all interfaces")
|
||||||
|
dbUpdateCmd.Flags().String("name", "", "rename the database")
|
||||||
|
dbUpdateCmd.Flags().String("backup-schedule", "", "cron expression for automatic backups (empty clears)")
|
||||||
|
dbUpdateCmd.Flags().String("backup-target", "", "backup target id for offsite archives (empty clears)")
|
||||||
|
for _, cmd := range []*cobra.Command{dbRegisterCmd, dbTestConnCmd} {
|
||||||
|
cmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
|
||||||
|
cmd.Flags().String("host", "", "database host")
|
||||||
|
cmd.Flags().Int("port", 0, "database port (default per type)")
|
||||||
|
cmd.Flags().String("database", "", "database name")
|
||||||
|
cmd.Flags().String("username", "", "database username")
|
||||||
|
cmd.Flags().String("password", "", "database password (stored encrypted on register)")
|
||||||
|
cmd.Flags().Bool("ssl", false, "require TLS")
|
||||||
|
}
|
||||||
|
DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbUpdateCmd, dbRegisterCmd, dbTestConnCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbImportBackupCmd, dbDownloadCmd)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var importVars []string
|
||||||
|
|
||||||
|
var ImportCmd = &cobra.Command{
|
||||||
|
Use: "import",
|
||||||
|
Short: "Import external definitions into Containr",
|
||||||
|
}
|
||||||
|
|
||||||
|
var importComposeCmd = &cobra.Command{
|
||||||
|
Use: "compose <project-id> <compose.yml|->",
|
||||||
|
Short: "Deploy a docker-compose file straight into a project (no template)",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var raw []byte
|
||||||
|
var err error
|
||||||
|
if args[1] == "-" {
|
||||||
|
raw, err = io.ReadAll(os.Stdin)
|
||||||
|
} else {
|
||||||
|
raw, err = os.ReadFile(args[1])
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot read compose file: %w", err)
|
||||||
|
}
|
||||||
|
vars, err := parseKVFlags(importVars)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{"compose_yaml": string(raw)}
|
||||||
|
if len(vars) > 0 {
|
||||||
|
body["variables"] = vars
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/projects/"+args[0]+"/import-compose", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
importComposeCmd.Flags().StringArrayVar(&importVars, "var", nil, "Variable override (KEY=VALUE, repeatable)")
|
||||||
|
ImportCmd.AddCommand(importComposeCmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OperationsCmd shows the cross-cutting operations view.
|
||||||
|
var OperationsCmd = &cobra.Command{
|
||||||
|
Use: "operations",
|
||||||
|
Aliases: []string{"ops"},
|
||||||
|
Short: "Show active and recent platform work (deploys, cron, backups)",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/operations", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -195,6 +195,14 @@ var servicesUpdateCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
body["volumes"] = volumes
|
body["volumes"] = volumes
|
||||||
}
|
}
|
||||||
|
if cmd.Flags().Changed("sleep") {
|
||||||
|
v, _ := cmd.Flags().GetBool("sleep")
|
||||||
|
body["sleep_enabled"] = v
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("sleep-idle") {
|
||||||
|
v, _ := cmd.Flags().GetInt("sleep-idle")
|
||||||
|
body["sleep_idle_minutes"] = v
|
||||||
|
}
|
||||||
if cmd.Flags().Changed("maintenance") {
|
if cmd.Flags().Changed("maintenance") {
|
||||||
v, _ := cmd.Flags().GetString("maintenance")
|
v, _ := cmd.Flags().GetString("maintenance")
|
||||||
body["maintenance_mode"] = v == "on" || v == "true"
|
body["maintenance_mode"] = v == "on" || v == "true"
|
||||||
@@ -226,6 +234,61 @@ var servicesUpdateCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// volumeFlagValues parses repeated "src:target[:ro]" flags into API mount
|
||||||
// objects. Bind mounts are type=bind, named volumes type=volume.
|
// objects. Bind mounts are type=bind, named volumes type=volume.
|
||||||
func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} {
|
func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} {
|
||||||
@@ -449,8 +512,10 @@ func init() {
|
|||||||
uf.String("memory-reserve", "", "soft memory 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-cmd", "", "build command for the static builder (empty clears)")
|
||||||
uf.String("static-dir", "", "output dir for the static builder (empty clears)")
|
uf.String("static-dir", "", "output dir for the static builder (empty clears)")
|
||||||
|
uf.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
|
||||||
|
uf.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)")
|
||||||
|
|
||||||
for _, a := range []string{"start", "stop", "restart", "redeploy"} {
|
for _, a := range []string{"start", "stop", "restart", "redeploy", "sleep", "wake"} {
|
||||||
verb := a
|
verb := a
|
||||||
ServicesCmd.AddCommand(&cobra.Command{
|
ServicesCmd.AddCommand(&cobra.Command{
|
||||||
Use: verb + " <id>",
|
Use: verb + " <id>",
|
||||||
@@ -459,7 +524,44 @@ func init() {
|
|||||||
RunE: serviceAction(verb),
|
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")
|
domainsAddCmd.Flags().Bool("default", false, "set as the default domain")
|
||||||
servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd)
|
servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd)
|
||||||
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd)
|
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd, servicesEnvCheckCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
var servicesEnvCheckCmd = &cobra.Command{
|
||||||
|
Use: "env-check <id>",
|
||||||
|
Short: "Diagnose service environment: empty vars, unresolved ${{refs}}",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/services/"+args[0]+"/env-check", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, _ := unwrapObject(data)
|
||||||
|
if b, ok := m["ok"].(bool); ok && b {
|
||||||
|
fmt.Println("env check: ok")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, group := range []string{"unresolved", "empty", "unreadable"} {
|
||||||
|
if list, ok := m[group].([]interface{}); ok {
|
||||||
|
for _, item := range list {
|
||||||
|
fmt.Printf("%s: %v\n", group, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &APIError{Message: "env check failed", ExitCode: ExitError}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -104,16 +107,31 @@ var templatesDeleteCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var tplDeployVars []string
|
||||||
|
var tplDeployName string
|
||||||
|
|
||||||
var templatesDeployCmd = &cobra.Command{
|
var templatesDeployCmd = &cobra.Command{
|
||||||
Use: "deploy <template-id> <project-id>",
|
Use: "deploy <template-id> <project-id>",
|
||||||
Short: "Deploy a template into a project",
|
Short: "Deploy a template into a project",
|
||||||
|
Long: "Deploys a single-service template or a multi-service graph (v2) template. Pass --name for single-service templates; graph members take names from the template. Use --var KEY=VALUE to override template variables.",
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := client()
|
c, err := client()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", map[string]string{"project_id": args[1]})
|
body := map[string]interface{}{"project_id": args[1]}
|
||||||
|
if tplDeployName != "" {
|
||||||
|
body["name"] = tplDeployName
|
||||||
|
}
|
||||||
|
vars, err := parseKVFlags(tplDeployVars)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(vars) > 0 {
|
||||||
|
body["variables"] = vars
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -122,6 +140,122 @@ var templatesDeployCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var templatesPlanCmd = &cobra.Command{
|
||||||
|
Use: "plan <template-id>",
|
||||||
|
Short: "Dry-resolve a template — expanded expressions, service refs, missing variables",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vars, err := parseKVFlags(tplDeployVars)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
if len(vars) > 0 {
|
||||||
|
body["variables"] = vars
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/templates/"+args[0]+"/plan", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesImportComposeCmd = &cobra.Command{
|
||||||
|
Use: "import-compose <compose.yml|->",
|
||||||
|
Short: "Convert a docker-compose file into a template graph config",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var raw []byte
|
||||||
|
var err error
|
||||||
|
if args[0] == "-" {
|
||||||
|
raw, err = io.ReadAll(os.Stdin)
|
||||||
|
} else {
|
||||||
|
raw, err = os.ReadFile(args[0])
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot read compose file: %w", err)
|
||||||
|
}
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/templates/import/compose", map[string]string{"compose_yaml": string(raw)})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesDeployGraphCmd = &cobra.Command{
|
||||||
|
Use: "deploy-graph <project-id> <config.json|->",
|
||||||
|
Short: "Deploy an ad-hoc service graph (v2 config with services[]) without a stored template",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var raw []byte
|
||||||
|
var err error
|
||||||
|
if args[1] == "-" {
|
||||||
|
raw, err = io.ReadAll(os.Stdin)
|
||||||
|
} else {
|
||||||
|
raw, err = os.ReadFile(args[1])
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot read config: %w", err)
|
||||||
|
}
|
||||||
|
var config json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &config); err != nil {
|
||||||
|
return fmt.Errorf("invalid config JSON: %w", err)
|
||||||
|
}
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vars, err := parseKVFlags(tplDeployVars)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{"project_id": args[0]}
|
||||||
|
var cfg map[string]interface{}
|
||||||
|
if err := json.Unmarshal(config, &cfg); err != nil {
|
||||||
|
return fmt.Errorf("invalid config JSON: %w", err)
|
||||||
|
}
|
||||||
|
body["config"] = cfg
|
||||||
|
if len(vars) > 0 {
|
||||||
|
body["variables"] = vars
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/templates/deploy", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKVFlags converts repeated --var KEY=VALUE flags into a map.
|
||||||
|
func parseKVFlags(flags []string) (map[string]string, error) {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, f := range flags {
|
||||||
|
idx := strings.Index(f, "=")
|
||||||
|
if idx <= 0 {
|
||||||
|
return nil, fmt.Errorf("invalid --var %q — expected KEY=VALUE", f)
|
||||||
|
}
|
||||||
|
out[f[:idx]] = f[idx+1:]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd)
|
templatesDeployCmd.Flags().StringVar(&tplDeployName, "name", "", "Name for the created service (single-service templates)")
|
||||||
|
templatesDeployCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
||||||
|
templatesPlanCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
||||||
|
templatesDeployGraphCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
||||||
|
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd, templatesPlanCmd, templatesImportComposeCmd, templatesDeployGraphCmd)
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ var varsSetCmd = &cobra.Command{
|
|||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
secret, _ := cmd.Flags().GetBool("secret")
|
secret, _ := cmd.Flags().GetBool("secret")
|
||||||
redeploy, _ := cmd.Flags().GetBool("redeploy")
|
redeploy, _ := cmd.Flags().GetBool("redeploy")
|
||||||
return mutateVars(args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
|
return mutateVars(serviceVarsPath(args[0]), args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
|
||||||
for _, pair := range args[1:] {
|
for _, pair := range args[1:] {
|
||||||
k, v, found := strings.Cut(pair, "=")
|
k, v, found := strings.Cut(pair, "=")
|
||||||
if !found || k == "" {
|
if !found || k == "" {
|
||||||
@@ -73,7 +73,7 @@ var varsUnsetCmd = &cobra.Command{
|
|||||||
drop[k] = true
|
drop[k] = true
|
||||||
}
|
}
|
||||||
redeploy, _ := cmd.Flags().GetBool("redeploy")
|
redeploy, _ := cmd.Flags().GetBool("redeploy")
|
||||||
return mutateVars(args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
|
return mutateVars(serviceVarsPath(args[0]), args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
|
||||||
out := vars[:0]
|
out := vars[:0]
|
||||||
for _, v := range vars {
|
for _, v := range vars {
|
||||||
if !drop[str(v, "key")] {
|
if !drop[str(v, "key")] {
|
||||||
@@ -85,20 +85,92 @@ var varsUnsetCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchVars(c *Client, serviceID string) ([]map[string]interface{}, error) {
|
// Shared variables — project scope, referenced as ${{shared.KEY}}.
|
||||||
data, err := c.Do("GET", "/services/"+serviceID+"/variables", nil)
|
|
||||||
if err != nil {
|
var varsSharedCmd = &cobra.Command{
|
||||||
return nil, err
|
Use: "shared",
|
||||||
}
|
Short: "Manage project-level shared variables (${{shared.KEY}})",
|
||||||
return unwrapList(data, "variables")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mutateVars(serviceID string, redeploy bool, mutate func([]map[string]interface{}) []map[string]interface{}) error {
|
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()
|
c, err := client()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
vars, err := fetchVars(c, serviceID)
|
data, err := c.Do("GET", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vars, err := unwrapList(data, "variables")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -115,7 +187,7 @@ func mutateVars(serviceID string, redeploy bool, mutate func([]map[string]interf
|
|||||||
if redeploy {
|
if redeploy {
|
||||||
body["redeploy"] = true
|
body["redeploy"] = true
|
||||||
}
|
}
|
||||||
data, err := c.Do("PUT", "/services/"+serviceID+"/variables", body)
|
data, err = c.Do("PUT", path, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -125,9 +197,9 @@ func mutateVars(serviceID string, redeploy bool, mutate func([]map[string]interf
|
|||||||
}
|
}
|
||||||
obj, _ := unwrapObject(data)
|
obj, _ := unwrapObject(data)
|
||||||
if obj["redeploy_queued"] == true {
|
if obj["redeploy_queued"] == true {
|
||||||
fmt.Printf("Variables updated on %s — redeploy queued\n", serviceID)
|
fmt.Printf("Variables updated on %s — redeploy queued\n", label)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("Variables updated on %s — redeploy to apply to running containers\n", serviceID)
|
fmt.Printf("Variables updated on %s — redeploy to apply to running containers\n", label)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -147,5 +219,7 @@ func init() {
|
|||||||
varsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
|
varsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
|
||||||
varsSetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
|
varsSetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
|
||||||
varsUnsetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
|
varsUnsetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
|
||||||
VariablesCmd.AddCommand(varsListCmd, varsSetCmd, varsUnsetCmd)
|
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,216 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var webhookEvents []string
|
||||||
|
var webhookHeaders []string
|
||||||
|
var webhookSecret string
|
||||||
|
var webhookName string
|
||||||
|
|
||||||
|
// WebhooksCmd manages outbound webhooks — signed HTTP event delivery.
|
||||||
|
var WebhooksCmd = &cobra.Command{
|
||||||
|
Use: "webhooks",
|
||||||
|
Aliases: []string{"hooks"},
|
||||||
|
Short: "Manage outbound webhooks (signed event delivery)",
|
||||||
|
}
|
||||||
|
|
||||||
|
var webhookListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List webhooks",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/webhooks", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "webhooks")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, w := range items {
|
||||||
|
events := ""
|
||||||
|
if ev, ok := w["events"].([]interface{}); ok {
|
||||||
|
names := make([]string, 0, len(ev))
|
||||||
|
for _, e := range ev {
|
||||||
|
if s, ok := e.(string); ok {
|
||||||
|
names = append(names, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events = strings.Join(names, ",")
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(w, "id"), str(w, "name"), str(w, "url"), events, str(w, "enabled"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "NAME", "URL", "EVENTS", "ENABLED"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func webhookBody(req map[string]interface{}) (map[string]interface{}, error) {
|
||||||
|
if len(webhookEvents) > 0 {
|
||||||
|
req["events"] = webhookEvents
|
||||||
|
}
|
||||||
|
if webhookSecret != "" {
|
||||||
|
req["secret"] = webhookSecret
|
||||||
|
}
|
||||||
|
if webhookName != "" {
|
||||||
|
req["name"] = webhookName
|
||||||
|
}
|
||||||
|
if len(webhookHeaders) > 0 {
|
||||||
|
hdrs, err := parseKVFlags(webhookHeaders)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req["headers"] = hdrs
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var webhookCreateCmd = &cobra.Command{
|
||||||
|
Use: "create <name> <url>",
|
||||||
|
Short: "Create a webhook (events repeat with --event; use \"*\" for all)",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(webhookEvents) == 0 {
|
||||||
|
return fmt.Errorf("at least one --event is required (e.g. --event 'service.*' or --event '*')")
|
||||||
|
}
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := webhookBody(map[string]interface{}{"name": args[0], "url": args[1]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/webhooks", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var webhookUpdateCmd = &cobra.Command{
|
||||||
|
Use: "update <id>",
|
||||||
|
Short: "Update a webhook (--name --url --event --secret --header --enabled/--disabled)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := webhookBody(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if url, _ := cmd.Flags().GetString("url"); url != "" {
|
||||||
|
body["url"] = url
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("enabled") {
|
||||||
|
body["enabled"] = true
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("disabled") {
|
||||||
|
body["enabled"] = false
|
||||||
|
}
|
||||||
|
data, err := c.Do("PATCH", "/webhooks/"+args[0], body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var webhookDeleteCmd = &cobra.Command{
|
||||||
|
Use: "delete <id>",
|
||||||
|
Short: "Delete a webhook",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if !Confirm("Delete webhook " + args[0] + "?") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("DELETE", "/webhooks/"+args[0], nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var webhookDeliveriesCmd = &cobra.Command{
|
||||||
|
Use: "deliveries <id>",
|
||||||
|
Short: "Show recent delivery attempts for a webhook",
|
||||||
|
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", "/webhooks/"+args[0]+"/deliveries", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "deliveries")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, d := range items {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(d, "id"), str(d, "event"), str(d, "status"),
|
||||||
|
str(d, "response_status"), str(d, "attempts"), relTime(d, "created_at"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "EVENT", "STATUS", "HTTP", "TRIES", "AGE"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var webhookTestCmd = &cobra.Command{
|
||||||
|
Use: "test <id>",
|
||||||
|
Short: "Queue a test ping delivery",
|
||||||
|
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", "/webhooks/"+args[0]+"/test", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
webhookCreateCmd.Flags().StringArrayVar(&webhookEvents, "event", nil, "Event subscription (e.g. 'service.*', 'deployment.fail', '*'; repeatable)")
|
||||||
|
webhookCreateCmd.Flags().StringVar(&webhookSecret, "secret", "", "Signing secret (auto-generated when omitted)")
|
||||||
|
webhookCreateCmd.Flags().StringArrayVar(&webhookHeaders, "header", nil, "Extra request header KEY=VALUE (repeatable)")
|
||||||
|
webhookUpdateCmd.Flags().StringVar(&webhookName, "name", "", "New name")
|
||||||
|
webhookUpdateCmd.Flags().String("url", "", "New URL")
|
||||||
|
webhookUpdateCmd.Flags().StringArrayVar(&webhookEvents, "event", nil, "Replace event subscriptions (repeatable)")
|
||||||
|
webhookUpdateCmd.Flags().StringVar(&webhookSecret, "secret", "", "New signing secret")
|
||||||
|
webhookUpdateCmd.Flags().StringArrayVar(&webhookHeaders, "header", nil, "Replace request headers KEY=VALUE (repeatable)")
|
||||||
|
webhookUpdateCmd.Flags().Bool("enabled", false, "Enable the webhook")
|
||||||
|
webhookUpdateCmd.Flags().Bool("disabled", false, "Disable the webhook")
|
||||||
|
|
||||||
|
WebhooksCmd.AddCommand(webhookListCmd, webhookCreateCmd, webhookUpdateCmd, webhookDeleteCmd, webhookDeliveriesCmd, webhookTestCmd)
|
||||||
|
}
|
||||||
@@ -74,9 +74,12 @@ func init() {
|
|||||||
rootCmd.AddCommand(commands.ExecCmd)
|
rootCmd.AddCommand(commands.ExecCmd)
|
||||||
rootCmd.AddCommand(commands.VariablesCmd)
|
rootCmd.AddCommand(commands.VariablesCmd)
|
||||||
rootCmd.AddCommand(commands.VolumesCmd)
|
rootCmd.AddCommand(commands.VolumesCmd)
|
||||||
|
rootCmd.AddCommand(commands.RegistriesCmd)
|
||||||
|
rootCmd.AddCommand(commands.BackupTargetsCmd)
|
||||||
rootCmd.AddCommand(commands.DatabasesCmd)
|
rootCmd.AddCommand(commands.DatabasesCmd)
|
||||||
rootCmd.AddCommand(commands.CronCmd)
|
rootCmd.AddCommand(commands.CronCmd)
|
||||||
rootCmd.AddCommand(commands.TemplatesCmd)
|
rootCmd.AddCommand(commands.TemplatesCmd)
|
||||||
|
rootCmd.AddCommand(commands.ImportCmd)
|
||||||
rootCmd.AddCommand(commands.NodesCmd)
|
rootCmd.AddCommand(commands.NodesCmd)
|
||||||
rootCmd.AddCommand(commands.ScalingCmd)
|
rootCmd.AddCommand(commands.ScalingCmd)
|
||||||
rootCmd.AddCommand(commands.HACmd)
|
rootCmd.AddCommand(commands.HACmd)
|
||||||
@@ -84,6 +87,9 @@ func init() {
|
|||||||
rootCmd.AddCommand(commands.GatewayCmd)
|
rootCmd.AddCommand(commands.GatewayCmd)
|
||||||
rootCmd.AddCommand(commands.AdminCmd)
|
rootCmd.AddCommand(commands.AdminCmd)
|
||||||
rootCmd.AddCommand(commands.NotificationsCmd)
|
rootCmd.AddCommand(commands.NotificationsCmd)
|
||||||
|
rootCmd.AddCommand(commands.WebhooksCmd)
|
||||||
|
rootCmd.AddCommand(commands.OperationsCmd)
|
||||||
|
rootCmd.AddCommand(commands.BannersCmd)
|
||||||
rootCmd.AddCommand(commands.UpCmd)
|
rootCmd.AddCommand(commands.UpCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: backup_targets.sql
|
||||||
|
|
||||||
|
package sqlcdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
const countDatabasesUsingBackupTarget = `-- name: CountDatabasesUsingBackupTarget :one
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM database_services
|
||||||
|
WHERE backup_target_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, countDatabasesUsingBackupTarget, targetID)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const createBackupTarget = `-- name: CreateBackupTarget :exec
|
||||||
|
INSERT INTO backup_targets (id, user_id, name, endpoint, bucket, region, prefix, access_key, secret_key, use_tls, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateBackupTargetParams struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Bucket string `json:"bucket"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
AccessKey string `json:"access_key"`
|
||||||
|
SecretKey string `json:"secret_key"`
|
||||||
|
UseTls bool `json:"use_tls"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, createBackupTarget,
|
||||||
|
arg.ID,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Endpoint,
|
||||||
|
arg.Bucket,
|
||||||
|
arg.Region,
|
||||||
|
arg.Prefix,
|
||||||
|
arg.AccessKey,
|
||||||
|
arg.SecretKey,
|
||||||
|
arg.UseTls,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteBackupTargetByIDAndUser = `-- name: DeleteBackupTargetByIDAndUser :exec
|
||||||
|
DELETE FROM backup_targets
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteBackupTargetByIDAndUserParams struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteBackupTargetByIDAndUser, arg.ID, arg.UserID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBackupTargetByID = `-- name: GetBackupTargetByID :one
|
||||||
|
SELECT backup_targets.id, backup_targets.user_id, backup_targets.name, backup_targets.endpoint, backup_targets.bucket, backup_targets.region, backup_targets.prefix, backup_targets.access_key, backup_targets.secret_key, backup_targets.use_tls, backup_targets.created_at, backup_targets.updated_at
|
||||||
|
FROM backup_targets
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getBackupTargetByID, id)
|
||||||
|
var i BackupTarget
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Endpoint,
|
||||||
|
&i.Bucket,
|
||||||
|
&i.Region,
|
||||||
|
&i.Prefix,
|
||||||
|
&i.AccessKey,
|
||||||
|
&i.SecretKey,
|
||||||
|
&i.UseTls,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBackupTargetByIDAndUser = `-- name: GetBackupTargetByIDAndUser :one
|
||||||
|
SELECT backup_targets.id, backup_targets.user_id, backup_targets.name, backup_targets.endpoint, backup_targets.bucket, backup_targets.region, backup_targets.prefix, backup_targets.access_key, backup_targets.secret_key, backup_targets.use_tls, backup_targets.created_at, backup_targets.updated_at
|
||||||
|
FROM backup_targets
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetBackupTargetByIDAndUserParams struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getBackupTargetByIDAndUser, arg.ID, arg.UserID)
|
||||||
|
var i BackupTarget
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Endpoint,
|
||||||
|
&i.Bucket,
|
||||||
|
&i.Region,
|
||||||
|
&i.Prefix,
|
||||||
|
&i.AccessKey,
|
||||||
|
&i.SecretKey,
|
||||||
|
&i.UseTls,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listBackupTargetsByUser = `-- name: ListBackupTargetsByUser :many
|
||||||
|
SELECT backup_targets.id, backup_targets.user_id, backup_targets.name, backup_targets.endpoint, backup_targets.bucket, backup_targets.region, backup_targets.prefix, backup_targets.access_key, backup_targets.secret_key, backup_targets.use_tls, backup_targets.created_at, backup_targets.updated_at
|
||||||
|
FROM backup_targets
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listBackupTargetsByUser, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []BackupTarget{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i BackupTarget
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Endpoint,
|
||||||
|
&i.Bucket,
|
||||||
|
&i.Region,
|
||||||
|
&i.Prefix,
|
||||||
|
&i.AccessKey,
|
||||||
|
&i.SecretKey,
|
||||||
|
&i.UseTls,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDatabaseBackupRemoteKeyByID = `-- name: SetDatabaseBackupRemoteKeyByID :exec
|
||||||
|
UPDATE database_backups
|
||||||
|
SET remote_key = $1
|
||||||
|
WHERE id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetDatabaseBackupRemoteKeyByIDParams struct {
|
||||||
|
RemoteKey sql.NullString `json:"remote_key"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, setDatabaseBackupRemoteKeyByID, arg.RemoteKey, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDatabaseBackupTargetByIDAndUser = `-- name: SetDatabaseBackupTargetByIDAndUser :exec
|
||||||
|
UPDATE database_services
|
||||||
|
SET backup_target_id = $1, updated_at = $2
|
||||||
|
WHERE id = $3 AND user_id = $4
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetDatabaseBackupTargetByIDAndUserParams struct {
|
||||||
|
BackupTargetID sql.NullString `json:"backup_target_id"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetDatabaseBackupTargetByIDAndUser(ctx context.Context, arg SetDatabaseBackupTargetByIDAndUserParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, setDatabaseBackupTargetByIDAndUser,
|
||||||
|
arg.BackupTargetID,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
arg.ID,
|
||||||
|
arg.UserID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateBackupTargetByIDAndUser = `-- name: UpdateBackupTargetByIDAndUser :exec
|
||||||
|
UPDATE backup_targets
|
||||||
|
SET name = $1, endpoint = $2, bucket = $3, region = $4, prefix = $5, access_key = $6, secret_key = $7, use_tls = $8, updated_at = $9
|
||||||
|
WHERE id = $10 AND user_id = $11
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateBackupTargetByIDAndUserParams struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Bucket string `json:"bucket"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
AccessKey string `json:"access_key"`
|
||||||
|
SecretKey string `json:"secret_key"`
|
||||||
|
UseTls bool `json:"use_tls"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, updateBackupTargetByIDAndUser,
|
||||||
|
arg.Name,
|
||||||
|
arg.Endpoint,
|
||||||
|
arg.Bucket,
|
||||||
|
arg.Region,
|
||||||
|
arg.Prefix,
|
||||||
|
arg.AccessKey,
|
||||||
|
arg.SecretKey,
|
||||||
|
arg.UseTls,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
arg.ID,
|
||||||
|
arg.UserID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: banners.sql
|
||||||
|
|
||||||
|
package sqlcdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createBanner = `-- name: CreateBanner :one
|
||||||
|
INSERT INTO banners (title, body, level, active, dismissible, created_by, starts_at, ends_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateBannerParams struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Dismissible bool `json:"dismissible"`
|
||||||
|
CreatedBy uuid.NullUUID `json:"created_by"`
|
||||||
|
StartsAt sql.NullTime `json:"starts_at"`
|
||||||
|
EndsAt sql.NullTime `json:"ends_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateBanner(ctx context.Context, arg CreateBannerParams) (Banner, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createBanner,
|
||||||
|
arg.Title,
|
||||||
|
arg.Body,
|
||||||
|
arg.Level,
|
||||||
|
arg.Active,
|
||||||
|
arg.Dismissible,
|
||||||
|
arg.CreatedBy,
|
||||||
|
arg.StartsAt,
|
||||||
|
arg.EndsAt,
|
||||||
|
)
|
||||||
|
var i Banner
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Body,
|
||||||
|
&i.Level,
|
||||||
|
&i.Active,
|
||||||
|
&i.Dismissible,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.StartsAt,
|
||||||
|
&i.EndsAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteBanner = `-- name: DeleteBanner :exec
|
||||||
|
DELETE FROM banners
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteBanner(ctx context.Context, id uuid.UUID) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteBanner, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBannerByID = `-- name: GetBannerByID :one
|
||||||
|
SELECT banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
|
||||||
|
FROM banners
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getBannerByID, id)
|
||||||
|
var i Banner
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Body,
|
||||||
|
&i.Level,
|
||||||
|
&i.Active,
|
||||||
|
&i.Dismissible,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.StartsAt,
|
||||||
|
&i.EndsAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listActiveBanners = `-- name: ListActiveBanners :many
|
||||||
|
SELECT banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
|
||||||
|
FROM banners
|
||||||
|
WHERE active
|
||||||
|
AND (starts_at IS NULL OR starts_at <= now())
|
||||||
|
AND (ends_at IS NULL OR ends_at > now())
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListActiveBanners(ctx context.Context) ([]Banner, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listActiveBanners)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Banner{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i Banner
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Body,
|
||||||
|
&i.Level,
|
||||||
|
&i.Active,
|
||||||
|
&i.Dismissible,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.StartsAt,
|
||||||
|
&i.EndsAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listBanners = `-- name: ListBanners :many
|
||||||
|
SELECT banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
|
||||||
|
FROM banners
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListBanners(ctx context.Context) ([]Banner, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listBanners)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Banner{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i Banner
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Body,
|
||||||
|
&i.Level,
|
||||||
|
&i.Active,
|
||||||
|
&i.Dismissible,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.StartsAt,
|
||||||
|
&i.EndsAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateBanner = `-- name: UpdateBanner :one
|
||||||
|
UPDATE banners
|
||||||
|
SET title = $2,
|
||||||
|
body = $3,
|
||||||
|
level = $4,
|
||||||
|
active = $5,
|
||||||
|
dismissible = $6,
|
||||||
|
starts_at = $7,
|
||||||
|
ends_at = $8,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateBannerParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Dismissible bool `json:"dismissible"`
|
||||||
|
StartsAt sql.NullTime `json:"starts_at"`
|
||||||
|
EndsAt sql.NullTime `json:"ends_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateBanner(ctx context.Context, arg UpdateBannerParams) (Banner, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, updateBanner,
|
||||||
|
arg.ID,
|
||||||
|
arg.Title,
|
||||||
|
arg.Body,
|
||||||
|
arg.Level,
|
||||||
|
arg.Active,
|
||||||
|
arg.Dismissible,
|
||||||
|
arg.StartsAt,
|
||||||
|
arg.EndsAt,
|
||||||
|
)
|
||||||
|
var i Banner
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Body,
|
||||||
|
&i.Level,
|
||||||
|
&i.Active,
|
||||||
|
&i.Dismissible,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.StartsAt,
|
||||||
|
&i.EndsAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ package sqlcdb
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
const countDatabaseServicesByUserAndName = `-- name: CountDatabaseServicesByUserAndName :one
|
const countDatabaseServicesByUserAndName = `-- name: CountDatabaseServicesByUserAndName :one
|
||||||
@@ -55,21 +57,23 @@ func (q *Queries) CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createDatabaseService = `-- name: CreateDatabaseService :exec
|
const createDatabaseService = `-- name: CreateDatabaseService :exec
|
||||||
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, created_at, updated_at)
|
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, project_id, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateDatabaseServiceParams struct {
|
type CreateDatabaseServiceParams struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Plan string `json:"plan"`
|
Plan string `json:"plan"`
|
||||||
Region string `json:"region"`
|
Region string `json:"region"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
PublicPort bool `json:"public_port"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
ProjectID uuid.NullUUID `json:"project_id"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error {
|
func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error {
|
||||||
@@ -82,6 +86,54 @@ func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseS
|
|||||||
arg.Version,
|
arg.Version,
|
||||||
arg.Plan,
|
arg.Plan,
|
||||||
arg.Region,
|
arg.Region,
|
||||||
|
arg.PublicPort,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const createExternalDatabaseService = `-- name: CreateExternalDatabaseService :exec
|
||||||
|
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region,
|
||||||
|
provider, external_host, external_port, external_name, external_username, external_password, external_ssl,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, 'running', $5, $6, $7, 'external', $8, $9, $10, $11, $12, $13, $14, $15)
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateExternalDatabaseServiceParams struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Plan string `json:"plan"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
ExternalHost sql.NullString `json:"external_host"`
|
||||||
|
ExternalPort sql.NullInt32 `json:"external_port"`
|
||||||
|
ExternalName sql.NullString `json:"external_name"`
|
||||||
|
ExternalUsername sql.NullString `json:"external_username"`
|
||||||
|
ExternalPassword string `json:"external_password"`
|
||||||
|
ExternalSsl bool `json:"external_ssl"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, createExternalDatabaseService,
|
||||||
|
arg.ID,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Type,
|
||||||
|
arg.Version,
|
||||||
|
arg.Plan,
|
||||||
|
arg.Region,
|
||||||
|
arg.ExternalHost,
|
||||||
|
arg.ExternalPort,
|
||||||
|
arg.ExternalName,
|
||||||
|
arg.ExternalUsername,
|
||||||
|
arg.ExternalPassword,
|
||||||
|
arg.ExternalSsl,
|
||||||
arg.CreatedAt,
|
arg.CreatedAt,
|
||||||
arg.UpdatedAt,
|
arg.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -106,6 +158,16 @@ func (q *Queries) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg Data
|
|||||||
return exists, err
|
return exists, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteDatabaseServiceByID = `-- name: DeleteDatabaseServiceByID :exec
|
||||||
|
DELETE FROM database_services
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteDatabaseServiceByID(ctx context.Context, id string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteDatabaseServiceByID, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const deleteDatabaseServiceByIDAndUser = `-- name: DeleteDatabaseServiceByIDAndUser :exec
|
const deleteDatabaseServiceByIDAndUser = `-- name: DeleteDatabaseServiceByIDAndUser :exec
|
||||||
DELETE FROM database_services
|
DELETE FROM database_services
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
@@ -122,7 +184,7 @@ func (q *Queries) DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg Dele
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDatabaseBackupByIDAndDatabaseAndUser = `-- name: GetDatabaseBackupByIDAndDatabaseAndUser :one
|
const getDatabaseBackupByIDAndDatabaseAndUser = `-- name: GetDatabaseBackupByIDAndDatabaseAndUser :one
|
||||||
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
|
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.remote_key, b.created_at, b.completed_at
|
||||||
FROM database_backups b
|
FROM database_backups b
|
||||||
JOIN database_services s ON s.id = b.database_id
|
JOIN database_services s ON s.id = b.database_id
|
||||||
WHERE b.id = $1 AND b.database_id = $2 AND s.user_id = $3
|
WHERE b.id = $1 AND b.database_id = $2 AND s.user_id = $3
|
||||||
@@ -143,6 +205,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
|
|||||||
&i.Size,
|
&i.Size,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
&i.BackupPath,
|
&i.BackupPath,
|
||||||
|
&i.RemoteKey,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.CompletedAt,
|
&i.CompletedAt,
|
||||||
)
|
)
|
||||||
@@ -150,7 +213,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.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -170,6 +233,16 @@ 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.PublicPort,
|
||||||
|
&i.BackupTargetID,
|
||||||
|
&i.ProjectID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -177,7 +250,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.public_port, database_services.backup_target_id, database_services.project_id, 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 +275,16 @@ 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.PublicPort,
|
||||||
|
&i.BackupTargetID,
|
||||||
|
&i.ProjectID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -222,7 +305,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.public_port, database_services.backup_target_id, database_services.project_id, 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 +331,16 @@ 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.PublicPort,
|
||||||
|
&i.BackupTargetID,
|
||||||
|
&i.ProjectID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -265,7 +358,7 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listDatabaseBackupsByDatabaseAndUser = `-- name: ListDatabaseBackupsByDatabaseAndUser :many
|
const listDatabaseBackupsByDatabaseAndUser = `-- name: ListDatabaseBackupsByDatabaseAndUser :many
|
||||||
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
|
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.remote_key, b.created_at, b.completed_at
|
||||||
FROM database_backups b
|
FROM database_backups b
|
||||||
JOIN database_services s ON s.id = b.database_id
|
JOIN database_services s ON s.id = b.database_id
|
||||||
WHERE b.database_id = $1 AND s.user_id = $2
|
WHERE b.database_id = $1 AND s.user_id = $2
|
||||||
@@ -294,6 +387,7 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
|
|||||||
&i.Size,
|
&i.Size,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
&i.BackupPath,
|
&i.BackupPath,
|
||||||
|
&i.RemoteKey,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.CompletedAt,
|
&i.CompletedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -310,8 +404,62 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listDatabaseServicesByProject = `-- name: ListDatabaseServicesByProject :many
|
||||||
|
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at
|
||||||
|
FROM database_services
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listDatabaseServicesByProject, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []DatabaseService{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i DatabaseService
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Type,
|
||||||
|
&i.Status,
|
||||||
|
&i.Version,
|
||||||
|
&i.Plan,
|
||||||
|
&i.Region,
|
||||||
|
&i.ConnectionUrl,
|
||||||
|
&i.BackupSchedule,
|
||||||
|
&i.NextBackupAt,
|
||||||
|
&i.Provider,
|
||||||
|
&i.ExternalHost,
|
||||||
|
&i.ExternalPort,
|
||||||
|
&i.ExternalName,
|
||||||
|
&i.ExternalUsername,
|
||||||
|
&i.ExternalPassword,
|
||||||
|
&i.ExternalSsl,
|
||||||
|
&i.PublicPort,
|
||||||
|
&i.BackupTargetID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const 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.public_port, database_services.backup_target_id, database_services.project_id, 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 +486,16 @@ 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.PublicPort,
|
||||||
|
&i.BackupTargetID,
|
||||||
|
&i.ProjectID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -453,6 +611,29 @@ func (q *Queries) SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNe
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setDatabaseServicePublicPortByIDAndUser = `-- name: SetDatabaseServicePublicPortByIDAndUser :exec
|
||||||
|
UPDATE database_services
|
||||||
|
SET public_port = $1, updated_at = $2
|
||||||
|
WHERE id = $3 AND user_id = $4
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetDatabaseServicePublicPortByIDAndUserParams struct {
|
||||||
|
PublicPort bool `json:"public_port"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetDatabaseServicePublicPortByIDAndUser(ctx context.Context, arg SetDatabaseServicePublicPortByIDAndUserParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, setDatabaseServicePublicPortByIDAndUser,
|
||||||
|
arg.PublicPort,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
arg.ID,
|
||||||
|
arg.UserID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const setDatabaseServiceStatusAndConnectionByID = `-- name: SetDatabaseServiceStatusAndConnectionByID :exec
|
const setDatabaseServiceStatusAndConnectionByID = `-- name: SetDatabaseServiceStatusAndConnectionByID :exec
|
||||||
UPDATE database_services
|
UPDATE database_services
|
||||||
SET status = $1, connection_url = $2, updated_at = $3
|
SET status = $1, connection_url = $2, updated_at = $3
|
||||||
|
|||||||
@@ -49,6 +49,35 @@ type AgentHeartbeat struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BackupTarget struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Bucket string `json:"bucket"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
AccessKey string `json:"access_key"`
|
||||||
|
SecretKey string `json:"secret_key"`
|
||||||
|
UseTls bool `json:"use_tls"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Banner struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Dismissible bool `json:"dismissible"`
|
||||||
|
CreatedBy uuid.NullUUID `json:"created_by"`
|
||||||
|
StartsAt sql.NullTime `json:"starts_at"`
|
||||||
|
EndsAt sql.NullTime `json:"ends_at"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type ContainerInstance struct {
|
type ContainerInstance struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -75,24 +104,35 @@ type DatabaseBackup struct {
|
|||||||
Size string `json:"size"`
|
Size string `json:"size"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
BackupPath sql.NullString `json:"backup_path"`
|
BackupPath sql.NullString `json:"backup_path"`
|
||||||
|
RemoteKey sql.NullString `json:"remote_key"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
CompletedAt sql.NullTime `json:"completed_at"`
|
CompletedAt sql.NullTime `json:"completed_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseService struct {
|
type DatabaseService struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Plan string `json:"plan"`
|
Plan string `json:"plan"`
|
||||||
Region string `json:"region"`
|
Region string `json:"region"`
|
||||||
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"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
Provider string `json:"provider"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
ExternalHost sql.NullString `json:"external_host"`
|
||||||
|
ExternalPort sql.NullInt32 `json:"external_port"`
|
||||||
|
ExternalName sql.NullString `json:"external_name"`
|
||||||
|
ExternalUsername sql.NullString `json:"external_username"`
|
||||||
|
ExternalPassword string `json:"external_password"`
|
||||||
|
ExternalSsl bool `json:"external_ssl"`
|
||||||
|
PublicPort bool `json:"public_port"`
|
||||||
|
BackupTargetID sql.NullString `json:"backup_target_id"`
|
||||||
|
ProjectID uuid.NullUUID `json:"project_id"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Deployment struct {
|
type Deployment struct {
|
||||||
@@ -149,6 +189,19 @@ type Notification struct {
|
|||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OutboundWebhook struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
Events json.RawMessage `json:"events"`
|
||||||
|
Headers pqtype.NullRawMessage `json:"headers"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type Project struct {
|
type Project struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -167,6 +220,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"`
|
||||||
@@ -204,6 +268,11 @@ type Service struct {
|
|||||||
Volumes json.RawMessage `json:"volumes"`
|
Volumes json.RawMessage `json:"volumes"`
|
||||||
MaintenanceMode bool `json:"maintenance_mode"`
|
MaintenanceMode bool `json:"maintenance_mode"`
|
||||||
BasicAuthUsers string `json:"basic_auth_users"`
|
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 {
|
type ServiceDomain struct {
|
||||||
@@ -244,3 +313,17 @@ type UserToken struct {
|
|||||||
RevokedAt sql.NullTime `json:"revoked_at"`
|
RevokedAt sql.NullTime `json:"revoked_at"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WebhookDelivery struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
WebhookID uuid.UUID `json:"webhook_id"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
Payload pqtype.NullRawMessage `json:"payload"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ResponseStatus sql.NullInt32 `json:"response_status"`
|
||||||
|
ResponseBody sql.NullString `json:"response_body"`
|
||||||
|
Attempts int32 `json:"attempts"`
|
||||||
|
DurationMs sql.NullInt32 `json:"duration_ms"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
DeliveredAt sql.NullTime `json:"delivered_at"`
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ package sqlcdb
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -13,28 +14,41 @@ import (
|
|||||||
type Querier interface {
|
type Querier interface {
|
||||||
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
|
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
|
||||||
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
|
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
|
||||||
|
CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error)
|
||||||
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
|
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
|
||||||
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
|
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
|
||||||
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
|
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
|
||||||
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
|
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
|
||||||
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
|
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
|
||||||
|
CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error
|
||||||
|
CreateBanner(ctx context.Context, arg CreateBannerParams) (Banner, error)
|
||||||
CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error)
|
CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error)
|
||||||
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
|
||||||
|
CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) 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)
|
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
|
||||||
|
CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) 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
|
||||||
|
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
|
||||||
|
DeleteBanner(ctx context.Context, id uuid.UUID) error
|
||||||
|
DeleteDatabaseServiceByID(ctx context.Context, id string) error
|
||||||
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
||||||
|
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
|
||||||
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
|
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||||
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
|
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
|
||||||
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
|
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
|
||||||
GetAgent(ctx context.Context, id string) (NodeAgent, error)
|
GetAgent(ctx context.Context, id string) (NodeAgent, error)
|
||||||
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
|
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
|
||||||
|
GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error)
|
||||||
|
GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error)
|
||||||
|
GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error)
|
||||||
GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error)
|
GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error)
|
||||||
GetContainer(ctx context.Context, id string) (ContainerInstance, error)
|
GetContainer(ctx context.Context, id string) (ContainerInstance, error)
|
||||||
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
|
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
|
||||||
@@ -42,6 +56,8 @@ type Querier interface {
|
|||||||
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
|
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
|
||||||
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
|
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
|
||||||
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
|
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
|
||||||
|
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
|
||||||
|
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
|
||||||
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
|
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
|
||||||
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
|
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
|
||||||
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||||
@@ -50,16 +66,22 @@ type Querier interface {
|
|||||||
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, 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
|
||||||
|
ListActiveBanners(ctx context.Context) ([]Banner, error)
|
||||||
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
||||||
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
|
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
|
||||||
ListAgents(ctx context.Context) ([]NodeAgent, error)
|
ListAgents(ctx context.Context) ([]NodeAgent, error)
|
||||||
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
|
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
|
||||||
|
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
|
||||||
|
ListBanners(ctx context.Context) ([]Banner, error)
|
||||||
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||||
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
|
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
|
||||||
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
|
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
|
||||||
|
ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error)
|
||||||
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
|
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
|
||||||
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
||||||
|
ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error)
|
||||||
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
|
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
|
||||||
|
ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error)
|
||||||
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||||
ListPendingProjects(ctx context.Context) ([]Project, error)
|
ListPendingProjects(ctx context.Context) ([]Project, error)
|
||||||
// Visibility model: a project is readable when it is approved for public
|
// Visibility model: a project is readable when it is approved for public
|
||||||
@@ -69,13 +91,17 @@ type Querier interface {
|
|||||||
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)
|
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
|
||||||
|
ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, 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)
|
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
|
||||||
|
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) 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
|
||||||
|
SetDatabaseBackupTargetByIDAndUser(ctx context.Context, arg SetDatabaseBackupTargetByIDAndUserParams) error
|
||||||
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
|
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
|
||||||
|
SetDatabaseServicePublicPortByIDAndUser(ctx context.Context, arg SetDatabaseServicePublicPortByIDAndUserParams) error
|
||||||
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
|
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
|
||||||
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
|
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
|
||||||
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
||||||
@@ -84,12 +110,16 @@ type Querier interface {
|
|||||||
TouchUserToken(ctx context.Context, id uuid.UUID) 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
|
||||||
|
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
|
||||||
|
UpdateBanner(ctx context.Context, arg UpdateBannerParams) (Banner, error)
|
||||||
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
||||||
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
|
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
|
||||||
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
||||||
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
|
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
|
||||||
|
UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error
|
||||||
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
|
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
|
||||||
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
|
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
|
||||||
|
UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error
|
||||||
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
|
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
|
||||||
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
|
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: webhooks.sql
|
||||||
|
|
||||||
|
package sqlcdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/sqlc-dev/pqtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createOutboundWebhook = `-- name: CreateOutboundWebhook :exec
|
||||||
|
INSERT INTO outbound_webhooks (id, user_id, name, url, secret, events, headers, enabled, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateOutboundWebhookParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
Events json.RawMessage `json:"events"`
|
||||||
|
Headers pqtype.NullRawMessage `json:"headers"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, createOutboundWebhook,
|
||||||
|
arg.ID,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Url,
|
||||||
|
arg.Secret,
|
||||||
|
arg.Events,
|
||||||
|
arg.Headers,
|
||||||
|
arg.Enabled,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const createWebhookDelivery = `-- name: CreateWebhookDelivery :exec
|
||||||
|
INSERT INTO webhook_deliveries (id, webhook_id, event, payload, status, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateWebhookDeliveryParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
WebhookID uuid.UUID `json:"webhook_id"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
Payload pqtype.NullRawMessage `json:"payload"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, createWebhookDelivery,
|
||||||
|
arg.ID,
|
||||||
|
arg.WebhookID,
|
||||||
|
arg.Event,
|
||||||
|
arg.Payload,
|
||||||
|
arg.Status,
|
||||||
|
arg.CreatedAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteOutboundWebhookByIDAndUser = `-- name: DeleteOutboundWebhookByIDAndUser :exec
|
||||||
|
DELETE FROM outbound_webhooks
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteOutboundWebhookByIDAndUserParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteOutboundWebhookByIDAndUser, arg.ID, arg.UserID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOutboundWebhookByID = `-- name: GetOutboundWebhookByID :one
|
||||||
|
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
|
||||||
|
FROM outbound_webhooks
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getOutboundWebhookByID, id)
|
||||||
|
var i OutboundWebhook
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Url,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Events,
|
||||||
|
&i.Headers,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOutboundWebhookByIDAndUser = `-- name: GetOutboundWebhookByIDAndUser :one
|
||||||
|
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
|
||||||
|
FROM outbound_webhooks
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetOutboundWebhookByIDAndUserParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getOutboundWebhookByIDAndUser, arg.ID, arg.UserID)
|
||||||
|
var i OutboundWebhook
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Url,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Events,
|
||||||
|
&i.Headers,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listEnabledOutboundWebhooks = `-- name: ListEnabledOutboundWebhooks :many
|
||||||
|
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
|
||||||
|
FROM outbound_webhooks
|
||||||
|
WHERE enabled
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listEnabledOutboundWebhooks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []OutboundWebhook{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i OutboundWebhook
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Url,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Events,
|
||||||
|
&i.Headers,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listOutboundWebhooksByUser = `-- name: ListOutboundWebhooksByUser :many
|
||||||
|
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
|
||||||
|
FROM outbound_webhooks
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listOutboundWebhooksByUser, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []OutboundWebhook{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i OutboundWebhook
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Url,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Events,
|
||||||
|
&i.Headers,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listWebhookDeliveriesByWebhook = `-- name: ListWebhookDeliveriesByWebhook :many
|
||||||
|
SELECT webhook_deliveries.id, webhook_deliveries.webhook_id, webhook_deliveries.event, webhook_deliveries.payload, webhook_deliveries.status, webhook_deliveries.response_status, webhook_deliveries.response_body, webhook_deliveries.attempts, webhook_deliveries.duration_ms, webhook_deliveries.created_at, webhook_deliveries.delivered_at
|
||||||
|
FROM webhook_deliveries
|
||||||
|
WHERE webhook_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listWebhookDeliveriesByWebhook, webhookID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []WebhookDelivery{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i WebhookDelivery
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WebhookID,
|
||||||
|
&i.Event,
|
||||||
|
&i.Payload,
|
||||||
|
&i.Status,
|
||||||
|
&i.ResponseStatus,
|
||||||
|
&i.ResponseBody,
|
||||||
|
&i.Attempts,
|
||||||
|
&i.DurationMs,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.DeliveredAt,
|
||||||
|
); 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 updateOutboundWebhook = `-- name: UpdateOutboundWebhook :exec
|
||||||
|
UPDATE outbound_webhooks
|
||||||
|
SET name = $1, url = $2, secret = $3, events = $4, headers = $5, enabled = $6, updated_at = $7
|
||||||
|
WHERE id = $8 AND user_id = $9
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateOutboundWebhookParams struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
Events json.RawMessage `json:"events"`
|
||||||
|
Headers pqtype.NullRawMessage `json:"headers"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, updateOutboundWebhook,
|
||||||
|
arg.Name,
|
||||||
|
arg.Url,
|
||||||
|
arg.Secret,
|
||||||
|
arg.Events,
|
||||||
|
arg.Headers,
|
||||||
|
arg.Enabled,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
arg.ID,
|
||||||
|
arg.UserID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateWebhookDeliveryResult = `-- name: UpdateWebhookDeliveryResult :exec
|
||||||
|
UPDATE webhook_deliveries
|
||||||
|
SET status = $1, response_status = $2, response_body = $3, attempts = $4, duration_ms = $5, delivered_at = $6
|
||||||
|
WHERE id = $7
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateWebhookDeliveryResultParams struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
ResponseStatus sql.NullInt32 `json:"response_status"`
|
||||||
|
ResponseBody sql.NullString `json:"response_body"`
|
||||||
|
Attempts int32 `json:"attempts"`
|
||||||
|
DurationMs sql.NullInt32 `json:"duration_ms"`
|
||||||
|
DeliveredAt sql.NullTime `json:"delivered_at"`
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, updateWebhookDeliveryResult,
|
||||||
|
arg.Status,
|
||||||
|
arg.ResponseStatus,
|
||||||
|
arg.ResponseBody,
|
||||||
|
arg.Attempts,
|
||||||
|
arg.DurationMs,
|
||||||
|
arg.DeliveredAt,
|
||||||
|
arg.ID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -155,9 +155,12 @@ type BuildConfig struct {
|
|||||||
StartCommand string `json:"start_command"`
|
StartCommand string `json:"start_command"`
|
||||||
BuildArgs map[string]string `json:"build_args,omitempty"`
|
BuildArgs map[string]string `json:"build_args,omitempty"`
|
||||||
Environment map[string]string `json:"environment"`
|
Environment map[string]string `json:"environment"`
|
||||||
Branch string `json:"branch"`
|
// Pull credentials for private prebuilt images.
|
||||||
Commit string `json:"commit"`
|
PullUsername string `json:"pull_username,omitempty"`
|
||||||
NoCache bool `json:"no_cache"`
|
PullPassword string `json:"pull_password,omitempty"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
NoCache bool `json:"no_cache"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TriggerConfig struct {
|
type TriggerConfig struct {
|
||||||
@@ -300,6 +303,8 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme
|
|||||||
StartCommand: buildConfig.StartCommand,
|
StartCommand: buildConfig.StartCommand,
|
||||||
BuildArgs: buildConfig.BuildArgs,
|
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,
|
||||||
|
|||||||
@@ -129,6 +129,27 @@ func (q *Queue) Cancel(serviceID, deploymentID uuid.UUID) CancelState {
|
|||||||
return NotFound
|
return NotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServiceQueueState reports queue depth for one service.
|
||||||
|
type ServiceQueueState struct {
|
||||||
|
ServiceID uuid.UUID `json:"service_id"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Queued int `json:"queued"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns per-service queue depth for observability.
|
||||||
|
func (q *Queue) Snapshot() []ServiceQueueState {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
out := make([]ServiceQueueState, 0, len(q.states))
|
||||||
|
for id, st := range q.states {
|
||||||
|
if !st.running && len(st.pending) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, ServiceQueueState{ServiceID: id, Running: st.running, Queued: len(st.pending)})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Do runs fn through the queue and waits for completion — for synchronous
|
// Do runs fn through the queue and waits for completion — for synchronous
|
||||||
// endpoints (redeploy) that still must not overlap an in-flight deployment.
|
// 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
|
// ctx bounds the wait, not the job; on timeout the job still runs to
|
||||||
|
|||||||
@@ -125,6 +125,40 @@ func TestCancelActiveCancelsContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSnapshot(t *testing.T) {
|
||||||
|
q := New()
|
||||||
|
if got := q.Snapshot(); len(got) != 0 {
|
||||||
|
t.Fatalf("empty queue snapshot = %v, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := uuid.New()
|
||||||
|
release := make(chan struct{})
|
||||||
|
q.Enqueue(svc, Job{Run: func(ctx context.Context) { <-release }})
|
||||||
|
q.Enqueue(svc, Job{Run: func(ctx context.Context) {}})
|
||||||
|
q.Enqueue(svc, Job{Run: func(ctx context.Context) {}})
|
||||||
|
|
||||||
|
deadline := time.After(2 * time.Second)
|
||||||
|
for {
|
||||||
|
snap := q.Snapshot()
|
||||||
|
if len(snap) == 1 && snap[0].Running {
|
||||||
|
if snap[0].ServiceID != svc {
|
||||||
|
t.Fatalf("snapshot service = %v, want %v", snap[0].ServiceID, svc)
|
||||||
|
}
|
||||||
|
if snap[0].Queued != 2 {
|
||||||
|
t.Fatalf("queued = %d, want 2", snap[0].Queued)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("snapshot never showed running job: %v", snap)
|
||||||
|
default:
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnqueuePosition(t *testing.T) {
|
func TestEnqueuePosition(t *testing.T) {
|
||||||
q := New()
|
q := New()
|
||||||
svc := uuid.New()
|
svc := uuid.New()
|
||||||
|
|||||||
@@ -238,6 +238,11 @@ func (c *Client) CopyFromContainer(ctx context.Context, containerID, srcPath str
|
|||||||
return reader, err
|
return reader, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CopyToContainer copies a tar archive stream into a container path.
|
||||||
|
func (c *Client) CopyToContainer(ctx context.Context, containerID, dstPath string, content io.Reader) error {
|
||||||
|
return c.cli.CopyToContainer(ctx, containerID, dstPath, content, container.CopyToContainerOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
// GetContainerStats returns real-time resource usage statistics for a container
|
// GetContainerStats returns real-time resource usage statistics for a container
|
||||||
func (c *Client) GetContainerStats(ctx context.Context, containerID string, stream bool) (*container.StatsResponseReader, error) {
|
func (c *Client) GetContainerStats(ctx context.Context, containerID string, stream bool) (*container.StatsResponseReader, error) {
|
||||||
resp, err := c.cli.ContainerStats(ctx, containerID, stream)
|
resp, err := c.cli.ContainerStats(ctx, containerID, stream)
|
||||||
@@ -366,6 +371,21 @@ func (c *Client) RemoveNetwork(ctx context.Context, networkID string) error {
|
|||||||
return c.cli.NetworkRemove(ctx, networkID)
|
return c.cli.NetworkRemove(ctx, networkID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NetworkGateway returns the IPAM gateway of a network — the address
|
||||||
|
// containers on it can use to reach host-published ports.
|
||||||
|
func (c *Client) NetworkGateway(ctx context.Context, networkID string) string {
|
||||||
|
resp, err := c.cli.NetworkInspect(ctx, networkID, network.InspectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, cfg := range resp.IPAM.Config {
|
||||||
|
if cfg.Gateway != "" {
|
||||||
|
return cfg.Gateway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// ConnectNetwork connects a container to a network
|
// ConnectNetwork connects a container to a network
|
||||||
func (c *Client) ConnectNetwork(ctx context.Context, networkID, containerID string, config network.EndpointSettings) error {
|
func (c *Client) ConnectNetwork(ctx context.Context, networkID, containerID string, config network.EndpointSettings) error {
|
||||||
return c.cli.NetworkConnect(ctx, networkID, containerID, &config)
|
return c.cli.NetworkConnect(ctx, networkID, containerID, &config)
|
||||||
|
|||||||
@@ -58,10 +58,15 @@ func init() {
|
|||||||
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
|
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_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_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_services_sleep", "Put a service to sleep immediately (scale to zero)", "POST", "/services/{id}/sleep", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_services_wake", "Wake a sleeping service", "POST", "/services/{id}/wake", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_services_env_check", "Diagnose service env: returns ok plus unresolved ${{refs}}, empty vars, and undecryptable secrets", "GET", "/services/{id}/env-check", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false})
|
register(spec{"containr_services_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_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_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})
|
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes.", "PUT", "/services/{id}", []string{"id"}, nil, false})
|
||||||
|
|
||||||
// Domains
|
// Domains
|
||||||
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
|
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
|
||||||
@@ -70,6 +75,12 @@ func init() {
|
|||||||
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_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})
|
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)
|
// 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_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})
|
register(spec{"containr_volumes_delete", "Delete an unused docker volume. Requires confirm:true. (admin)", "DELETE", "/admin/volumes/{name}", []string{"name"}, nil, true})
|
||||||
@@ -77,6 +88,8 @@ func init() {
|
|||||||
// Variables
|
// Variables
|
||||||
register(spec{"containr_variables_list", "List service environment variables (secrets masked)", "GET", "/services/{id}/variables", []string{"id"}, nil, false})
|
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_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
|
// 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_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})
|
||||||
@@ -90,12 +103,23 @@ func init() {
|
|||||||
// Databases
|
// Databases
|
||||||
register(spec{"containr_databases_list", "List managed databases", "GET", "/databases", nil, nil, false})
|
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_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_create", "Provision a managed database. Args: name, type (postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse), public_port (bind on all interfaces, default localhost-only).", "POST", "/databases", nil, nil, false})
|
||||||
|
register(spec{"containr_databases_update", "Update a database. Args: id, name, backup_schedule (empty clears), backup_target_id (empty clears), public_port — toggling recreates the container so the new bind scope applies.", "PUT", "/databases/{id}", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_databases_register_external", "Register a database hosted elsewhere — probed first, password stored encrypted. Args: name (required), type (required), host (required), port, database, username, password, ssl.", "POST", "/databases/register-external", nil, []string{"name", "type", "host"}, false})
|
||||||
|
register(spec{"containr_databases_test_connection", "Probe a DB connection ad-hoc. Args: type, host (required), port, database, username, password, ssl.", "POST", "/databases/test-connection", nil, []string{"type", "host"}, false})
|
||||||
|
register(spec{"containr_databases_test_connection_by_id", "Re-probe a registered external database with its stored credentials. Args: id.", "POST", "/databases/{id}/test-connection", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_databases_action", "Start/stop/restart a database. Args: id, action.", "POST", "/databases/{id}/action", []string{"id"}, []string{"action"}, false})
|
register(spec{"containr_databases_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_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_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})
|
register(spec{"containr_databases_delete", "Delete a database and its data. Requires confirm:true.", "DELETE", "/databases/{id}", []string{"id"}, nil, true})
|
||||||
|
|
||||||
|
// S3-compatible backup targets
|
||||||
|
register(spec{"containr_backup_targets_list", "List S3-compatible backup targets (credentials never returned)", "GET", "/backup-targets", nil, nil, false})
|
||||||
|
register(spec{"containr_backup_targets_create", "Register a backup target — probed first, keys stored encrypted. Args: name, endpoint (required), bucket (required), region, prefix, access_key, secret_key, use_tls.", "POST", "/backup-targets", nil, []string{"endpoint", "bucket"}, false})
|
||||||
|
register(spec{"containr_backup_targets_update", "Update a backup target; empty credentials keep stored keys. Args: id, name, endpoint, bucket, region, prefix, access_key, secret_key, use_tls.", "PUT", "/backup-targets/{id}", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_backup_targets_delete", "Delete a backup target — fails while databases still point at it. Requires confirm:true.", "DELETE", "/backup-targets/{id}", []string{"id"}, nil, true})
|
||||||
|
register(spec{"containr_backup_targets_test", "Re-probe a backup target's bucket access. Args: id.", "POST", "/backup-targets/{id}/test", []string{"id"}, nil, false})
|
||||||
|
|
||||||
// Cron
|
// Cron
|
||||||
register(spec{"containr_cron_list", "List cron jobs", "GET", "/cron-jobs", nil, nil, false})
|
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_create", "Create a cron job. Args: service_id, schedule (5-field), command, name, timezone.", "POST", "/cron-jobs", nil, nil, false})
|
||||||
@@ -107,11 +131,33 @@ func init() {
|
|||||||
register(spec{"containr_templates_list", "List templates", "GET", "/templates", nil, nil, false})
|
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_get", "Get a template definition", "GET", "/templates/{id}", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
|
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
|
||||||
|
register(spec{"containr_templates_plan", "Dry-resolve a template: expanded expressions, service refs, missing variables. Args: id, variables (object).", "POST", "/templates/{id}/plan", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_templates_import_compose", "Convert docker-compose YAML into a v2 template graph config. Args: compose_yaml.", "POST", "/templates/import/compose", nil, []string{"compose_yaml"}, false})
|
||||||
|
register(spec{"containr_templates_deploy_graph", "Deploy an ad-hoc service graph without a stored template. Args: project_id, config ({services:[...]}), variables, plan, region.", "POST", "/templates/deploy", nil, []string{"project_id", "config", "variables", "plan", "region"}, false})
|
||||||
|
register(spec{"containr_projects_import_compose", "Deploy a docker-compose file straight into a project. Args: id (project), compose_yaml, variables, plan, region.", "POST", "/projects/{id}/import-compose", []string{"id"}, []string{"compose_yaml", "variables", "plan", "region"}, false})
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
|
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})
|
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
|
||||||
|
|
||||||
|
register(spec{"containr_webhooks_list", "List outbound webhooks", "GET", "/webhooks", nil, nil, false})
|
||||||
|
register(spec{"containr_webhooks_create", "Create an outbound webhook. Args: name, url, events (list, e.g. [\"service.*\",\"*\"]), secret (auto-generated if omitted), headers, enabled.", "POST", "/webhooks", nil, []string{"name", "url", "events", "secret", "headers", "enabled"}, false})
|
||||||
|
register(spec{"containr_webhooks_update", "Update an outbound webhook. Args: id, plus name/url/secret/events/headers/enabled to change.", "PATCH", "/webhooks/{id}", []string{"id"}, []string{"name", "url", "secret", "events", "headers", "enabled"}, false})
|
||||||
|
register(spec{"containr_webhooks_delete", "Delete an outbound webhook. Args: id. Requires confirm=true.", "DELETE", "/webhooks/{id}", []string{"id"}, nil, true})
|
||||||
|
register(spec{"containr_webhooks_deliveries", "List recent delivery attempts for a webhook. Args: id.", "GET", "/webhooks/{id}/deliveries", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_webhooks_test", "Queue a test ping delivery for a webhook. Args: id.", "POST", "/webhooks/{id}/test", []string{"id"}, nil, false})
|
||||||
|
|
||||||
|
register(spec{"containr_operations_get", "Cross-cutting operations view: active deployments, deploy queue depth, recent failures, cron runs, backups.", "GET", "/operations", nil, nil, false})
|
||||||
|
|
||||||
|
register(spec{"containr_banners_active", "List active announcement banners shown to users.", "GET", "/banners/active", nil, nil, false})
|
||||||
|
register(spec{"containr_banners_list", "List all announcement banners (admin).", "GET", "/admin/banners", nil, nil, false})
|
||||||
|
register(spec{"containr_banners_create", "Create an announcement banner (admin). Args: title, body, level (info|success|warning|error), active, dismissible, starts_at, ends_at.", "POST", "/admin/banners", nil, []string{"title", "body", "level", "active", "dismissible", "starts_at", "ends_at"}, false})
|
||||||
|
register(spec{"containr_banners_update", "Update an announcement banner (admin). Args: id plus fields to change.", "PATCH", "/admin/banners/{id}", []string{"id"}, []string{"title", "body", "level", "active", "dismissible", "starts_at", "ends_at"}, false})
|
||||||
|
register(spec{"containr_banners_delete", "Delete an announcement banner (admin). Args: id. Requires confirm=true.", "DELETE", "/admin/banners/{id}", []string{"id"}, nil, true})
|
||||||
|
|
||||||
|
register(spec{"containr_branding_get", "Get instance white-label branding (product name, logo, accent, links).", "GET", "/branding", nil, nil, false})
|
||||||
|
register(spec{"containr_settings_update", "Update platform settings (admin). Args: signup_enabled (bool), cloudflare_tunnel_token (string, empty clears), branding (object: product_name, logo_url, favicon_url, accent_color, docs_url, support_url — empty string resets a field).", "PUT", "/settings", nil, []string{"signup_enabled", "cloudflare_tunnel_token", "branding"}, false})
|
||||||
|
|
||||||
// Nodes / agent tokens (admin)
|
// Nodes / agent tokens (admin)
|
||||||
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false})
|
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_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ type BuildRequest struct {
|
|||||||
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
|
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,20 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE IF NOT EXISTS registries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
host VARCHAR(255) NOT NULL,
|
||||||
|
username VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
password TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (owner_id, host)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_registries_owner ON registries(owner_id);
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE IF EXISTS registries;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE database_services
|
||||||
|
ADD COLUMN IF NOT EXISTS provider VARCHAR(20) NOT NULL DEFAULT 'managed',
|
||||||
|
ADD COLUMN IF NOT EXISTS external_host VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS external_port INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS external_name VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS external_username VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS external_password TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS external_ssl BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE database_services
|
||||||
|
DROP CONSTRAINT IF EXISTS database_services_plan_check,
|
||||||
|
ADD CONSTRAINT database_services_plan_check
|
||||||
|
CHECK (plan IN ('hobby', 'starter', 'standard', 'business', 'external'));
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE database_services
|
||||||
|
DROP CONSTRAINT IF EXISTS database_services_plan_check,
|
||||||
|
ADD CONSTRAINT database_services_plan_check
|
||||||
|
CHECK (plan IN ('hobby', 'starter', 'standard', 'business'));
|
||||||
|
|
||||||
|
ALTER TABLE database_services
|
||||||
|
DROP COLUMN IF EXISTS provider,
|
||||||
|
DROP COLUMN IF EXISTS external_host,
|
||||||
|
DROP COLUMN IF EXISTS external_port,
|
||||||
|
DROP COLUMN IF EXISTS external_name,
|
||||||
|
DROP COLUMN IF EXISTS external_username,
|
||||||
|
DROP COLUMN IF EXISTS external_password,
|
||||||
|
DROP COLUMN IF EXISTS external_ssl;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE database_services
|
||||||
|
ADD COLUMN IF NOT EXISTS public_port BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE database_services
|
||||||
|
DROP COLUMN IF EXISTS public_port;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_targets (
|
||||||
|
id VARCHAR(255) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
endpoint VARCHAR(512) NOT NULL,
|
||||||
|
bucket VARCHAR(255) NOT NULL,
|
||||||
|
region VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
prefix VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
access_key TEXT NOT NULL DEFAULT '',
|
||||||
|
secret_key TEXT NOT NULL DEFAULT '',
|
||||||
|
use_tls BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE database_services
|
||||||
|
ADD COLUMN IF NOT EXISTS backup_target_id VARCHAR(255);
|
||||||
|
|
||||||
|
ALTER TABLE database_backups
|
||||||
|
ADD COLUMN IF NOT EXISTS remote_key VARCHAR(512);
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE database_backups
|
||||||
|
DROP COLUMN IF EXISTS remote_key;
|
||||||
|
|
||||||
|
ALTER TABLE database_services
|
||||||
|
DROP COLUMN IF EXISTS backup_target_id;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS backup_targets;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS sleep_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS sleep_idle_minutes INTEGER NOT NULL DEFAULT 15;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS sleep_enabled;
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS sleep_idle_minutes;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Project-level shared variables (Railway-style). Services reference them via
|
||||||
|
-- ${{shared.KEY}} in environment values; secret entries are encrypted at rest
|
||||||
|
-- and masked in API responses exactly like service variables.
|
||||||
|
CREATE TABLE IF NOT EXISTS project_variables (
|
||||||
|
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
key varchar(255) NOT NULL,
|
||||||
|
value text NOT NULL DEFAULT '',
|
||||||
|
is_secret boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (project_id, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_project_variables_project ON project_variables(project_id);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS project_variables;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Link managed databases provisioned by graph template deploys / compose
|
||||||
|
-- imports to their project so deleting the project cleans them up too.
|
||||||
|
ALTER TABLE database_services
|
||||||
|
ADD COLUMN IF NOT EXISTS project_id uuid NULL REFERENCES projects(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_database_services_project_id
|
||||||
|
ON database_services(project_id);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP INDEX IF EXISTS idx_database_services_project_id;
|
||||||
|
ALTER TABLE database_services DROP COLUMN IF EXISTS project_id;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Outbound webhooks: user-owned HTTP endpoints that receive signed
|
||||||
|
-- platform events (resource.action), with a delivery log.
|
||||||
|
CREATE TABLE IF NOT EXISTS outbound_webhooks (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name varchar(255) NOT NULL,
|
||||||
|
url varchar(500) NOT NULL,
|
||||||
|
secret text NOT NULL DEFAULT '',
|
||||||
|
events jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
headers jsonb,
|
||||||
|
enabled boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz DEFAULT now(),
|
||||||
|
updated_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
webhook_id uuid NOT NULL REFERENCES outbound_webhooks(id) ON DELETE CASCADE,
|
||||||
|
event varchar(255) NOT NULL,
|
||||||
|
payload jsonb,
|
||||||
|
status varchar(50) NOT NULL DEFAULT 'pending',
|
||||||
|
response_status integer,
|
||||||
|
response_body text,
|
||||||
|
attempts integer NOT NULL DEFAULT 0,
|
||||||
|
duration_ms integer,
|
||||||
|
created_at timestamptz DEFAULT now(),
|
||||||
|
delivered_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_outbound_webhooks_user ON outbound_webhooks(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook ON webhook_deliveries(webhook_id, created_at DESC);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS webhook_deliveries;
|
||||||
|
DROP TABLE IF EXISTS outbound_webhooks;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Instance-wide announcement banners. Admins create them; active ones are
|
||||||
|
-- shown to every signed-in user in the web UI and surfaced in the CLI.
|
||||||
|
CREATE TABLE IF NOT EXISTS banners (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title varchar(255) NOT NULL,
|
||||||
|
body text NOT NULL DEFAULT '',
|
||||||
|
level varchar(20) NOT NULL DEFAULT 'info',
|
||||||
|
active boolean NOT NULL DEFAULT true,
|
||||||
|
dismissible boolean NOT NULL DEFAULT true,
|
||||||
|
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
starts_at timestamptz,
|
||||||
|
ends_at timestamptz,
|
||||||
|
created_at timestamptz DEFAULT now(),
|
||||||
|
updated_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_banners_active ON banners(active);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS banners;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- name: ListBackupTargetsByUser :many
|
||||||
|
SELECT backup_targets.*
|
||||||
|
FROM backup_targets
|
||||||
|
WHERE user_id = sqlc.arg(user_id)
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: GetBackupTargetByIDAndUser :one
|
||||||
|
SELECT backup_targets.*
|
||||||
|
FROM backup_targets
|
||||||
|
WHERE id = sqlc.arg(id) AND user_id = sqlc.arg(user_id);
|
||||||
|
|
||||||
|
-- name: GetBackupTargetByID :one
|
||||||
|
SELECT backup_targets.*
|
||||||
|
FROM backup_targets
|
||||||
|
WHERE id = sqlc.arg(id);
|
||||||
|
|
||||||
|
-- name: CreateBackupTarget :exec
|
||||||
|
INSERT INTO backup_targets (id, user_id, name, endpoint, bucket, region, prefix, access_key, secret_key, use_tls, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);
|
||||||
|
|
||||||
|
-- name: UpdateBackupTargetByIDAndUser :exec
|
||||||
|
UPDATE backup_targets
|
||||||
|
SET name = $1, endpoint = $2, bucket = $3, region = $4, prefix = $5, access_key = $6, secret_key = $7, use_tls = $8, updated_at = $9
|
||||||
|
WHERE id = $10 AND user_id = $11;
|
||||||
|
|
||||||
|
-- name: DeleteBackupTargetByIDAndUser :exec
|
||||||
|
DELETE FROM backup_targets
|
||||||
|
WHERE id = $1 AND user_id = $2;
|
||||||
|
|
||||||
|
-- name: SetDatabaseBackupTargetByIDAndUser :exec
|
||||||
|
UPDATE database_services
|
||||||
|
SET backup_target_id = $1, updated_at = $2
|
||||||
|
WHERE id = $3 AND user_id = $4;
|
||||||
|
|
||||||
|
-- name: SetDatabaseBackupRemoteKeyByID :exec
|
||||||
|
UPDATE database_backups
|
||||||
|
SET remote_key = $1
|
||||||
|
WHERE id = $2;
|
||||||
|
|
||||||
|
-- name: CountDatabasesUsingBackupTarget :one
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM database_services
|
||||||
|
WHERE backup_target_id = sqlc.arg(target_id);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- name: ListBanners :many
|
||||||
|
SELECT banners.*
|
||||||
|
FROM banners
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: ListActiveBanners :many
|
||||||
|
SELECT banners.*
|
||||||
|
FROM banners
|
||||||
|
WHERE active
|
||||||
|
AND (starts_at IS NULL OR starts_at <= now())
|
||||||
|
AND (ends_at IS NULL OR ends_at > now())
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: GetBannerByID :one
|
||||||
|
SELECT banners.*
|
||||||
|
FROM banners
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: CreateBanner :one
|
||||||
|
INSERT INTO banners (title, body, level, active, dismissible, created_by, starts_at, ends_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING banners.*;
|
||||||
|
|
||||||
|
-- name: UpdateBanner :one
|
||||||
|
UPDATE banners
|
||||||
|
SET title = $2,
|
||||||
|
body = $3,
|
||||||
|
level = $4,
|
||||||
|
active = $5,
|
||||||
|
dismissible = $6,
|
||||||
|
starts_at = $7,
|
||||||
|
ends_at = $8,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING banners.*;
|
||||||
|
|
||||||
|
-- name: DeleteBanner :exec
|
||||||
|
DELETE FROM banners
|
||||||
|
WHERE id = $1;
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -35,8 +35,23 @@ FROM database_services
|
|||||||
WHERE user_id = sqlc.arg(user_id) AND LOWER(name) = LOWER(sqlc.arg(name));
|
WHERE user_id = sqlc.arg(user_id) AND LOWER(name) = LOWER(sqlc.arg(name));
|
||||||
|
|
||||||
-- name: CreateDatabaseService :exec
|
-- name: CreateDatabaseService :exec
|
||||||
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, created_at, updated_at)
|
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, project_id, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);
|
||||||
|
|
||||||
|
-- name: ListDatabaseServicesByProject :many
|
||||||
|
SELECT database_services.*
|
||||||
|
FROM database_services
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: DeleteDatabaseServiceByID :exec
|
||||||
|
DELETE FROM database_services
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: SetDatabaseServicePublicPortByIDAndUser :exec
|
||||||
|
UPDATE database_services
|
||||||
|
SET public_port = $1, updated_at = $2
|
||||||
|
WHERE id = $3 AND user_id = $4;
|
||||||
|
|
||||||
-- name: UpdateDatabaseServiceNameAndPlanByIDAndUser :exec
|
-- name: UpdateDatabaseServiceNameAndPlanByIDAndUser :exec
|
||||||
UPDATE database_services
|
UPDATE database_services
|
||||||
@@ -73,7 +88,7 @@ SET status = $1, connection_url = $2, updated_at = $3
|
|||||||
WHERE id = $4;
|
WHERE id = $4;
|
||||||
|
|
||||||
-- name: ListDatabaseBackupsByDatabaseAndUser :many
|
-- name: ListDatabaseBackupsByDatabaseAndUser :many
|
||||||
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
|
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.remote_key, b.created_at, b.completed_at
|
||||||
FROM database_backups b
|
FROM database_backups b
|
||||||
JOIN database_services s ON s.id = b.database_id
|
JOIN database_services s ON s.id = b.database_id
|
||||||
WHERE b.database_id = $1 AND s.user_id = $2
|
WHERE b.database_id = $1 AND s.user_id = $2
|
||||||
@@ -81,7 +96,7 @@ ORDER BY b.created_at DESC
|
|||||||
LIMIT $3;
|
LIMIT $3;
|
||||||
|
|
||||||
-- name: GetDatabaseBackupByIDAndDatabaseAndUser :one
|
-- name: GetDatabaseBackupByIDAndDatabaseAndUser :one
|
||||||
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
|
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.remote_key, b.created_at, b.completed_at
|
||||||
FROM database_backups b
|
FROM database_backups b
|
||||||
JOIN database_services s ON s.id = b.database_id
|
JOIN database_services s ON s.id = b.database_id
|
||||||
WHERE b.id = $1 AND b.database_id = $2 AND s.user_id = $3;
|
WHERE b.id = $1 AND b.database_id = $2 AND s.user_id = $3;
|
||||||
@@ -110,3 +125,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,49 @@
|
|||||||
|
-- name: ListOutboundWebhooksByUser :many
|
||||||
|
SELECT outbound_webhooks.*
|
||||||
|
FROM outbound_webhooks
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: GetOutboundWebhookByIDAndUser :one
|
||||||
|
SELECT outbound_webhooks.*
|
||||||
|
FROM outbound_webhooks
|
||||||
|
WHERE id = $1 AND user_id = $2;
|
||||||
|
|
||||||
|
-- name: ListEnabledOutboundWebhooks :many
|
||||||
|
SELECT outbound_webhooks.*
|
||||||
|
FROM outbound_webhooks
|
||||||
|
WHERE enabled;
|
||||||
|
|
||||||
|
-- name: CreateOutboundWebhook :exec
|
||||||
|
INSERT INTO outbound_webhooks (id, user_id, name, url, secret, events, headers, enabled, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
|
||||||
|
|
||||||
|
-- name: UpdateOutboundWebhook :exec
|
||||||
|
UPDATE outbound_webhooks
|
||||||
|
SET name = $1, url = $2, secret = $3, events = $4, headers = $5, enabled = $6, updated_at = $7
|
||||||
|
WHERE id = $8 AND user_id = $9;
|
||||||
|
|
||||||
|
-- name: DeleteOutboundWebhookByIDAndUser :exec
|
||||||
|
DELETE FROM outbound_webhooks
|
||||||
|
WHERE id = $1 AND user_id = $2;
|
||||||
|
|
||||||
|
-- name: CreateWebhookDelivery :exec
|
||||||
|
INSERT INTO webhook_deliveries (id, webhook_id, event, payload, status, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6);
|
||||||
|
|
||||||
|
-- name: UpdateWebhookDeliveryResult :exec
|
||||||
|
UPDATE webhook_deliveries
|
||||||
|
SET status = $1, response_status = $2, response_body = $3, attempts = $4, duration_ms = $5, delivered_at = $6
|
||||||
|
WHERE id = $7;
|
||||||
|
|
||||||
|
-- name: ListWebhookDeliveriesByWebhook :many
|
||||||
|
SELECT webhook_deliveries.*
|
||||||
|
FROM webhook_deliveries
|
||||||
|
WHERE webhook_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100;
|
||||||
|
|
||||||
|
-- name: GetOutboundWebhookByID :one
|
||||||
|
SELECT outbound_webhooks.*
|
||||||
|
FROM outbound_webhooks
|
||||||
|
WHERE id = $1;
|
||||||
@@ -69,6 +69,18 @@ CREATE TABLE services (
|
|||||||
static_dir 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 (
|
CREATE TABLE service_domains (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||||
@@ -127,6 +139,16 @@ 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,
|
||||||
|
public_port BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
backup_target_id VARCHAR(255),
|
||||||
|
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
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()
|
||||||
);
|
);
|
||||||
@@ -137,10 +159,26 @@ CREATE TABLE database_backups (
|
|||||||
size VARCHAR(50) NOT NULL,
|
size VARCHAR(50) NOT NULL,
|
||||||
status VARCHAR(50) NOT NULL DEFAULT 'in_progress',
|
status VARCHAR(50) NOT NULL DEFAULT 'in_progress',
|
||||||
backup_path TEXT,
|
backup_path TEXT,
|
||||||
|
remote_key VARCHAR(512),
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
completed_at TIMESTAMP WITH TIME ZONE
|
completed_at TIMESTAMP WITH TIME ZONE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE backup_targets (
|
||||||
|
id VARCHAR(255) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
endpoint VARCHAR(512) NOT NULL,
|
||||||
|
bucket VARCHAR(255) NOT NULL,
|
||||||
|
region VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
prefix VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
access_key TEXT NOT NULL DEFAULT '',
|
||||||
|
secret_key TEXT NOT NULL DEFAULT '',
|
||||||
|
use_tls BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE node_agents (
|
CREATE TABLE node_agents (
|
||||||
id VARCHAR(255) PRIMARY KEY,
|
id VARCHAR(255) PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
@@ -237,3 +275,44 @@ CREATE TABLE user_tokens (
|
|||||||
revoked_at TIMESTAMP WITH TIME ZONE,
|
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE outbound_webhooks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
url VARCHAR(500) NOT NULL,
|
||||||
|
secret TEXT NOT NULL DEFAULT '',
|
||||||
|
events JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
headers JSONB,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE webhook_deliveries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
webhook_id UUID NOT NULL REFERENCES outbound_webhooks(id) ON DELETE CASCADE,
|
||||||
|
event VARCHAR(255) NOT NULL,
|
||||||
|
payload JSONB,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||||
|
response_status INTEGER,
|
||||||
|
response_body TEXT,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
delivered_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE banners (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
level VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
dismissible BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
starts_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
ends_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -9,8 +9,11 @@ import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetrics
|
|||||||
import { BuildsPage } from '@/features/builds/pages/BuildsPage';
|
import { BuildsPage } from '@/features/builds/pages/BuildsPage';
|
||||||
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
|
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
|
||||||
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
|
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
|
||||||
|
import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage';
|
||||||
|
import { OperationsPage } from '@/features/operations/pages/OperationsPage';
|
||||||
import { AdminPage } from '@/features/admin/pages/AdminPage';
|
import { AdminPage } from '@/features/admin/pages/AdminPage';
|
||||||
import { LandingPage } from '@/features/landing/LandingPage';
|
import { LandingPage } from '@/features/landing/LandingPage';
|
||||||
|
import { useBranding } from '@/lib/use-branding';
|
||||||
import {
|
import {
|
||||||
DocsPage,
|
DocsPage,
|
||||||
PeoplePage,
|
PeoplePage,
|
||||||
@@ -124,6 +127,7 @@ function NotFoundPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
useBranding();
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -141,6 +145,7 @@ export default function App() {
|
|||||||
<Route path="/templates" element={<TemplatesPage />} />
|
<Route path="/templates" element={<TemplatesPage />} />
|
||||||
<Route path="/databases" element={<DatabasesPage />} />
|
<Route path="/databases" element={<DatabasesPage />} />
|
||||||
<Route path="/ha" element={<HighAvailabilityPage />} />
|
<Route path="/ha" element={<HighAvailabilityPage />} />
|
||||||
|
<Route path="/operations" element={<OperationsPage />} />
|
||||||
<Route path="/security" element={<SecurityPage />} />
|
<Route path="/security" element={<SecurityPage />} />
|
||||||
<Route path="/usage" element={<UsagePage />} />
|
<Route path="/usage" element={<UsagePage />} />
|
||||||
<Route path="/docs" element={<DocsPage />} />
|
<Route path="/docs" element={<DocsPage />} />
|
||||||
@@ -151,6 +156,7 @@ export default function App() {
|
|||||||
<Route path="/people" element={<PeoplePage />} />
|
<Route path="/people" element={<PeoplePage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
|
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
|
||||||
|
<Route path="/settings/webhooks" element={<WebhooksPage />} />
|
||||||
<Route path="/showcase" element={<ComponentShowcase />} />
|
<Route path="/showcase" element={<ComponentShowcase />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
Bell,
|
Bell,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ChartBar,
|
ChartBar,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
UploadCloud,
|
UploadCloud,
|
||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
|
Webhook,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getCurrentUserProfile,
|
getCurrentUserProfile,
|
||||||
@@ -39,7 +41,7 @@ import {
|
|||||||
import { signOutAuthSession } from '@/lib/auth-client';
|
import { signOutAuthSession } from '@/lib/auth-client';
|
||||||
import { isDemoSearch } from '@/lib/demo-mode';
|
import { isDemoSearch } from '@/lib/demo-mode';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
import { useToast } from '@/shared/components';
|
import { BannerBar, BrandWordmark, useToast } from '@/shared/components';
|
||||||
|
|
||||||
type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string };
|
type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string };
|
||||||
type NavSection = { title: string; items: NavItem[] };
|
type NavSection = { title: string; items: NavItem[] };
|
||||||
@@ -56,6 +58,7 @@ const navSections: NavSection[] = [
|
|||||||
{
|
{
|
||||||
title: 'Operate',
|
title: 'Operate',
|
||||||
items: [
|
items: [
|
||||||
|
{ label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' },
|
||||||
{ label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
|
{ label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
|
||||||
{ label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' },
|
{ label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' },
|
||||||
{ label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' },
|
{ label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' },
|
||||||
@@ -67,6 +70,7 @@ const navSections: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'People', href: '/people', icon: Users, hint: 'Members & access' },
|
{ label: 'People', href: '/people', icon: Users, hint: 'Members & access' },
|
||||||
{ label: 'Audit Logs', href: '/settings/audit-logs', icon: ScrollText, hint: 'Activity trail' },
|
{ label: 'Audit Logs', href: '/settings/audit-logs', icon: ScrollText, hint: 'Activity trail' },
|
||||||
|
{ label: 'Webhooks', href: '/settings/webhooks', icon: Webhook, hint: 'Signed event delivery' },
|
||||||
{ label: 'Settings', href: '/settings', icon: Settings, hint: 'Platform preferences' },
|
{ label: 'Settings', href: '/settings', icon: Settings, hint: 'Platform preferences' },
|
||||||
{ label: 'Docs', href: '/docs', icon: BookOpen, hint: 'Guides & reference' },
|
{ label: 'Docs', href: '/docs', icon: BookOpen, hint: 'Guides & reference' },
|
||||||
],
|
],
|
||||||
@@ -383,6 +387,7 @@ export function PlatformShell() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden">
|
<div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<BannerBar />
|
||||||
<header
|
<header
|
||||||
className="hidden h-[46px] shrink-0 items-center border-b px-5 md:flex"
|
className="hidden h-[46px] shrink-0 items-center border-b px-5 md:flex"
|
||||||
style={{
|
style={{
|
||||||
@@ -393,7 +398,7 @@ export function PlatformShell() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<NavLink to={href('/projects')} className="mr-5 text-sm font-extrabold tracking-tight text-[var(--text-primary)]" style={{ fontFamily: 'Inter, sans-serif' }}>
|
<NavLink to={href('/projects')} className="mr-5 text-sm font-extrabold tracking-tight text-[var(--text-primary)]" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
contain<span style={{ color: 'var(--accent-primary)' }}>r</span>
|
<BrandWordmark />
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<div className="flex items-center gap-5">
|
<div className="flex items-center gap-5">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
|
Megaphone,
|
||||||
Pencil,
|
Pencil,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -12,14 +13,19 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
|
createBanner,
|
||||||
|
deleteBanner,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
getAdminOverview,
|
getAdminOverview,
|
||||||
|
listAdminBanners,
|
||||||
listAdminUsers,
|
listAdminUsers,
|
||||||
listProjects,
|
listProjects,
|
||||||
setProjectApproval,
|
setProjectApproval,
|
||||||
setUserAdmin,
|
setUserAdmin,
|
||||||
|
updateBanner,
|
||||||
updateProject,
|
updateProject,
|
||||||
type AdminUser,
|
type AdminUser,
|
||||||
|
type Banner,
|
||||||
type ProjectEntity,
|
type ProjectEntity,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
@@ -50,16 +56,50 @@ export function AdminPage() {
|
|||||||
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview, enabled: !isDemoMode });
|
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview, enabled: !isDemoMode });
|
||||||
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode });
|
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode });
|
||||||
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode });
|
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode });
|
||||||
|
const bannersQuery = useQuery({ queryKey: ['admin-banners'], queryFn: listAdminBanners, enabled: !isDemoMode });
|
||||||
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
|
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
|
||||||
const [editForm, setEditForm] = useState({ name: '', description: '' });
|
const [editForm, setEditForm] = useState({ name: '', description: '' });
|
||||||
|
const [bannerForm, setBannerForm] = useState({ title: '', body: '', level: 'info' as Banner['level'] });
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-overview'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-overview'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-projects'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-projects'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-banners'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['active-banners'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bannerCreateMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createBanner({
|
||||||
|
title: bannerForm.title.trim(),
|
||||||
|
body: bannerForm.body.trim(),
|
||||||
|
level: bannerForm.level,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
showToast('success', 'Banner published');
|
||||||
|
setBannerForm({ title: '', body: '', level: 'info' });
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => showToast('error', 'Create failed', error instanceof Error ? error.message : undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bannerToggleMutation = useMutation({
|
||||||
|
mutationFn: ({ id, active }: { id: string; active: boolean }) => updateBanner(id, { active }),
|
||||||
|
onSuccess: () => invalidate(),
|
||||||
|
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bannerDeleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteBanner(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
showToast('success', 'Banner deleted');
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined),
|
||||||
|
});
|
||||||
|
|
||||||
const approvalMutation = useMutation({
|
const approvalMutation = useMutation({
|
||||||
mutationFn: ({ id, approved }: { id: string; approved: boolean }) => setProjectApproval(id, approved),
|
mutationFn: ({ id, approved }: { id: string; approved: boolean }) => setProjectApproval(id, approved),
|
||||||
onSuccess: (_, vars) => {
|
onSuccess: (_, vars) => {
|
||||||
@@ -126,6 +166,7 @@ export function AdminPage() {
|
|||||||
const pending = overview?.pending_projects ?? [];
|
const pending = overview?.pending_projects ?? [];
|
||||||
const users = usersQuery.data ?? [];
|
const users = usersQuery.data ?? [];
|
||||||
const allProjects = projectsQuery.data ?? [];
|
const allProjects = projectsQuery.data ?? [];
|
||||||
|
const banners = bannersQuery.data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-5 py-8">
|
<div className="mx-auto max-w-6xl px-5 py-8">
|
||||||
@@ -258,6 +299,85 @@ export function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-8">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<Megaphone size={15} className="text-[var(--accent-primary)]" />
|
||||||
|
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Announcements</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
|
||||||
|
<input
|
||||||
|
value={bannerForm.title}
|
||||||
|
onChange={(e) => setBannerForm((f) => ({ ...f, title: e.target.value }))}
|
||||||
|
placeholder="Title"
|
||||||
|
className="h-9 min-w-48 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={bannerForm.body}
|
||||||
|
onChange={(e) => setBannerForm((f) => ({ ...f, body: e.target.value }))}
|
||||||
|
placeholder="Body (optional)"
|
||||||
|
className="h-9 min-w-48 flex-[2] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={bannerForm.level}
|
||||||
|
onChange={(e) => setBannerForm((f) => ({ ...f, level: e.target.value as Banner['level'] }))}
|
||||||
|
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="info">info</option>
|
||||||
|
<option value="success">success</option>
|
||||||
|
<option value="warning">warning</option>
|
||||||
|
<option value="error">error</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => bannerCreateMutation.mutate()}
|
||||||
|
disabled={bannerCreateMutation.isPending || bannerForm.title.trim().length === 0}
|
||||||
|
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{bannerCreateMutation.isPending ? 'Publishing…' : 'Publish'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{banners.map((banner) => (
|
||||||
|
<div
|
||||||
|
key={banner.id}
|
||||||
|
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-[13.5px] font-medium text-[var(--text-primary)]">{banner.title}</p>
|
||||||
|
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">
|
||||||
|
{banner.level}
|
||||||
|
{banner.body ? ` · ${banner.body}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => bannerToggleMutation.mutate({ id: banner.id, active: !banner.active })}
|
||||||
|
disabled={bannerToggleMutation.isPending}
|
||||||
|
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{banner.active ? 'Deactivate' : 'Activate'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete banner "${banner.title}"?`)) {
|
||||||
|
bannerDeleteMutation.mutate(banner.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={bannerDeleteMutation.isPending}
|
||||||
|
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
|
||||||
|
title="Delete banner"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{banners.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No announcements.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mt-8 pb-10">
|
<section className="mt-8 pb-10">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<Users size={15} className="text-[var(--accent-tertiary)]" />
|
<Users size={15} className="text-[var(--accent-tertiary)]" />
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { listProjects, listTemplates } from '@/lib/api-client';
|
|||||||
import { DocsBrowser } from '@/features/docs/DocsBrowser';
|
import { DocsBrowser } from '@/features/docs/DocsBrowser';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
import { useDemoMode } from '@/lib/demo-mode';
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
|
import { useBranding } from '@/lib/use-branding';
|
||||||
|
import { BrandWordmark } from '@/shared/components';
|
||||||
import { demoProjects, demoTemplates } from '@/lib/demo-data';
|
import { demoProjects, demoTemplates } from '@/lib/demo-data';
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
@@ -28,6 +30,7 @@ const features = [
|
|||||||
|
|
||||||
export function LandingPage() {
|
export function LandingPage() {
|
||||||
const isDemoMode = useDemoMode();
|
const isDemoMode = useDemoMode();
|
||||||
|
const { logoUrl, productName } = useBranding();
|
||||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||||
const signedIn = !isDemoMode && Boolean(sessionQuery.data);
|
const signedIn = !isDemoMode && Boolean(sessionQuery.data);
|
||||||
const demo = isDemoMode ? '?demo=1' : '';
|
const demo = isDemoMode ? '?demo=1' : '';
|
||||||
@@ -45,9 +48,9 @@ export function LandingPage() {
|
|||||||
<header className="sticky top-0 z-40 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 backdrop-blur-xl">
|
<header className="sticky top-0 z-40 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 backdrop-blur-xl">
|
||||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
|
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
|
||||||
<Link to="/" className="flex items-center gap-2.5">
|
<Link to="/" className="flex items-center gap-2.5">
|
||||||
<img src="/containr.svg" alt="Containr" className="h-7 w-7 rounded-lg" />
|
<img src={logoUrl || '/containr.svg'} alt={productName} className="h-7 w-7 rounded-lg object-contain" />
|
||||||
<span className="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]">
|
<span className="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]">
|
||||||
contain<span style={{ color: 'var(--accent-primary)' }}>r</span>
|
<BrandWordmark />
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-1 text-[13px]">
|
<nav className="flex items-center gap-1 text-[13px]">
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { getOperations, type OperationsView } from '@/lib/api-client';
|
||||||
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
|
import { formatRelative } from '@/lib/time';
|
||||||
|
import { DemoRestricted } from '@/shared/components';
|
||||||
|
import { Activity, AlertTriangle, Clock, Database, Loader2, Timer } from 'lucide-react';
|
||||||
|
|
||||||
|
function statusClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'deployed':
|
||||||
|
case 'completed':
|
||||||
|
case 'success':
|
||||||
|
return 'text-[var(--success)]';
|
||||||
|
case 'failed':
|
||||||
|
case 'cancelled':
|
||||||
|
case 'rolled_back':
|
||||||
|
return 'text-[var(--error)]';
|
||||||
|
case 'building':
|
||||||
|
case 'deploying':
|
||||||
|
case 'in_progress':
|
||||||
|
case 'running':
|
||||||
|
return 'text-[var(--accent-primary)]';
|
||||||
|
default:
|
||||||
|
return 'text-[var(--warning)]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, icon: Icon, count, children }: {
|
||||||
|
title: string;
|
||||||
|
icon: typeof Activity;
|
||||||
|
count: number;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="panel p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Icon size={16} className="text-[var(--accent-primary)]" />
|
||||||
|
<h2 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h2>
|
||||||
|
<span className="text-xs text-[var(--text-muted)] mono">({count})</span>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyLine({ text }: { text: string }) {
|
||||||
|
return <p className="text-xs text-[var(--text-muted)]">{text}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OperationsPage() {
|
||||||
|
const isDemoMode = useDemoMode();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const opsQuery = useQuery({
|
||||||
|
queryKey: ['operations'],
|
||||||
|
queryFn: getOperations,
|
||||||
|
enabled: !isDemoMode,
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
(query.state.data?.active_deployments?.length ?? 0) > 0 ? 3000 : 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDemoMode) {
|
||||||
|
return <DemoRestricted feature="Operations" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ops: OperationsView | undefined = opsQuery.data;
|
||||||
|
const active = ops?.active_deployments ?? [];
|
||||||
|
const failed = ops?.recent_failures ?? [];
|
||||||
|
const cron = ops?.cron_runs ?? [];
|
||||||
|
const backups = ops?.backups ?? [];
|
||||||
|
const queue = ops?.deploy_queue ?? [];
|
||||||
|
const queuedTotal = queue.reduce((sum, q) => sum + q.queued, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<div className="border-b border-[var(--border-subtle)]">
|
||||||
|
<div className="w-full px-8 py-5">
|
||||||
|
<h1 className="v-title">Operations<span className="v-cursor">_</span></h1>
|
||||||
|
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||||
|
Live view of deploys, queues, cron runs, and backups
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{opsQuery.isLoading ? (
|
||||||
|
<div className="w-full px-8 py-12 flex justify-center">
|
||||||
|
<Loader2 size={20} className="animate-spin text-[var(--text-muted)]" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full px-8 py-6 space-y-6">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="panel-soft p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">In flight</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-[var(--accent-primary)]">{active.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="panel-soft p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Queued</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-[var(--warning)]">{queuedTotal}</p>
|
||||||
|
</div>
|
||||||
|
<div className="panel-soft p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Failed 24h</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-[var(--error)]">{failed.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="panel-soft p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Backups 24h</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-[var(--text-secondary)]">{backups.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||||
|
<Section title="Active deployments" icon={Activity} count={active.length}>
|
||||||
|
{active.length === 0 ? (
|
||||||
|
<EmptyLine text="Nothing deploying right now." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{active.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.id}
|
||||||
|
onClick={() => navigate(`/services/${d.service_id}`)}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 rounded-[var(--radius-md)] hover:bg-[var(--surface-muted)] text-left transition-colors"
|
||||||
|
>
|
||||||
|
<Loader2 size={13} className="animate-spin text-[var(--accent-primary)] shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-sm text-[var(--text-primary)]">{d.service_name}</span>
|
||||||
|
<span className="text-xs text-[var(--text-muted)]"> · {d.project_name}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span>
|
||||||
|
<span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Failed — last 24h" icon={AlertTriangle} count={failed.length}>
|
||||||
|
{failed.length === 0 ? (
|
||||||
|
<EmptyLine text="No recent failures." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{failed.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.id}
|
||||||
|
onClick={() => navigate(`/services/${d.service_id}`)}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 rounded-[var(--radius-md)] hover:bg-[var(--surface-muted)] text-left transition-colors"
|
||||||
|
>
|
||||||
|
<AlertTriangle size={13} className="text-[var(--error)] shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-sm text-[var(--text-primary)]">{d.service_name}</span>
|
||||||
|
<span className="text-xs text-[var(--text-muted)]"> · {d.project_name}</span>
|
||||||
|
{d.error && (
|
||||||
|
<p className="text-[11px] text-[var(--error)]/80 truncate">{d.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs mono ${statusClass(d.status)}`}>{d.status}</span>
|
||||||
|
<span className="text-xs text-[var(--text-muted)]">{formatRelative(d.created_at)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Cron — last 24h" icon={Timer} count={cron.length}>
|
||||||
|
{cron.length === 0 ? (
|
||||||
|
<EmptyLine text="No cron runs in the last day." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{cron.map((r) => (
|
||||||
|
<div key={r.id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
|
||||||
|
<span className={`mono ${statusClass(r.status)}`}>{r.status}</span>
|
||||||
|
<span className="text-[var(--text-primary)]">{r.job_name}</span>
|
||||||
|
<span className="mono text-[var(--text-muted)]">{r.schedule}</span>
|
||||||
|
{r.error && <span className="text-[var(--error)]/80 truncate">{r.error}</span>}
|
||||||
|
<span className="ml-auto text-[var(--text-muted)]">{formatRelative(r.started_at)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Backups — last 24h" icon={Database} count={backups.length}>
|
||||||
|
{backups.length === 0 ? (
|
||||||
|
<EmptyLine text="No backups in the last day." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{backups.map((b) => (
|
||||||
|
<div key={b.id} className="flex items-center gap-3 px-3 py-1.5 text-xs">
|
||||||
|
<span className={`mono ${statusClass(b.status)}`}>{b.status}</span>
|
||||||
|
<span className="text-[var(--text-primary)]">{b.database_name}</span>
|
||||||
|
<span className="text-[var(--text-muted)]">{b.size}</span>
|
||||||
|
<span className="ml-auto text-[var(--text-muted)]">{formatRelative(b.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{queue.length > 0 && (
|
||||||
|
<Section title="Deploy queue depth" icon={Clock} count={queue.length}>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{queue.map((q) => (
|
||||||
|
<div key={q.service_id} className="flex items-center gap-3 px-3 py-1.5 text-xs mono">
|
||||||
|
<span className="text-[var(--text-secondary)]">{q.service_id.slice(0, 8)}</span>
|
||||||
|
<span className={q.running ? 'text-[var(--accent-primary)]' : 'text-[var(--text-muted)]'}>
|
||||||
|
{q.running ? 'running' : 'idle'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--warning)]">{q.queued} queued</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,12 +20,26 @@ import {
|
|||||||
createUserToken,
|
createUserToken,
|
||||||
listUserTokens,
|
listUserTokens,
|
||||||
revokeUserToken,
|
revokeUserToken,
|
||||||
|
listRegistries,
|
||||||
|
createRegistry,
|
||||||
|
deleteRegistry,
|
||||||
|
type Registry,
|
||||||
listDatabases,
|
listDatabases,
|
||||||
databaseAction,
|
databaseAction,
|
||||||
updateDatabaseBackupSchedule,
|
updateDatabaseBackupSchedule,
|
||||||
createDatabaseBackup,
|
createDatabaseBackup,
|
||||||
restoreDatabaseBackup,
|
restoreDatabaseBackup,
|
||||||
createManagedDatabase,
|
createManagedDatabase,
|
||||||
|
registerExternalDatabase,
|
||||||
|
importDatabaseBackup,
|
||||||
|
updateDatabase,
|
||||||
|
listBackupTargets,
|
||||||
|
createBackupTarget,
|
||||||
|
deleteBackupTarget,
|
||||||
|
testBackupTarget,
|
||||||
|
testDatabaseConnection,
|
||||||
|
testDatabaseConnectionByID,
|
||||||
|
type ExternalDatabaseInput,
|
||||||
listServicesByProject,
|
listServicesByProject,
|
||||||
listServiceVariables,
|
listServiceVariables,
|
||||||
updateServiceVariables,
|
updateServiceVariables,
|
||||||
@@ -34,6 +48,8 @@ import {
|
|||||||
updateCurrentUserProfile,
|
updateCurrentUserProfile,
|
||||||
getPlatformSettings,
|
getPlatformSettings,
|
||||||
updatePlatformSettings,
|
updatePlatformSettings,
|
||||||
|
type Branding,
|
||||||
|
type BrandingInput,
|
||||||
getHAStatus,
|
getHAStatus,
|
||||||
setHAEnabled,
|
setHAEnabled,
|
||||||
triggerFailover,
|
triggerFailover,
|
||||||
@@ -117,6 +133,7 @@ import {
|
|||||||
HeartPulse,
|
HeartPulse,
|
||||||
Cloud,
|
Cloud,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Upload,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
||||||
@@ -1143,6 +1160,7 @@ function PlatformSettingsSection() {
|
|||||||
|
|
||||||
const [signupDraft, setSignupDraft] = useState<boolean | null>(null);
|
const [signupDraft, setSignupDraft] = useState<boolean | null>(null);
|
||||||
const [tokenDraft, setTokenDraft] = useState('');
|
const [tokenDraft, setTokenDraft] = useState('');
|
||||||
|
const [brandDraft, setBrandDraft] = useState<BrandingInput>({});
|
||||||
const [feedback, setFeedback] = useState<string | null>(null);
|
const [feedback, setFeedback] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -1153,8 +1171,10 @@ function PlatformSettingsSection() {
|
|||||||
mutationFn: updatePlatformSettings,
|
mutationFn: updatePlatformSettings,
|
||||||
onSuccess: (settings) => {
|
onSuccess: (settings) => {
|
||||||
queryClient.setQueryData(['platform-settings'], settings);
|
queryClient.setQueryData(['platform-settings'], settings);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['branding'] });
|
||||||
setSignupDraft(null);
|
setSignupDraft(null);
|
||||||
setTokenDraft('');
|
setTokenDraft('');
|
||||||
|
setBrandDraft({});
|
||||||
setFeedback('Settings saved.');
|
setFeedback('Settings saved.');
|
||||||
setError(null);
|
setError(null);
|
||||||
},
|
},
|
||||||
@@ -1264,6 +1284,59 @@ function PlatformSettingsSection() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Branding</span>
|
||||||
|
<span className="text-xs text-[var(--text-tertiary)]">leave blank to keep Containr defaults</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||||
|
White-label this instance — product name, logo, accent color and links apply
|
||||||
|
to the shell, login page and document title.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 max-sm:grid-cols-1">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['productName', 'Product name'],
|
||||||
|
['logoUrl', 'Logo URL'],
|
||||||
|
['faviconUrl', 'Favicon URL'],
|
||||||
|
['accentColor', 'Accent color (#hex)'],
|
||||||
|
['docsUrl', 'Docs URL'],
|
||||||
|
['supportUrl', 'Support URL'],
|
||||||
|
] as const
|
||||||
|
).map(([field, label]) => (
|
||||||
|
<label key={field} className="block">
|
||||||
|
<span className="mb-1 block text-[11px] text-[var(--text-tertiary)]">{label}</span>
|
||||||
|
<input
|
||||||
|
value={brandDraft[field] ?? settingsQuery.data?.branding[field] ?? ''}
|
||||||
|
onChange={(e) => setBrandDraft((d) => ({ ...d, [field]: e.target.value }))}
|
||||||
|
className="h-9 w-full px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const b = { ...(settingsQuery.data?.branding ?? {}), ...brandDraft } as Branding;
|
||||||
|
saveMutation.mutate({ branding: b });
|
||||||
|
}}
|
||||||
|
disabled={saveMutation.isPending || Object.keys(brandDraft).length === 0}
|
||||||
|
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-colors"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
Save branding
|
||||||
|
</button>
|
||||||
|
{Object.keys(brandDraft).length > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setBrandDraft({})}
|
||||||
|
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)]"
|
||||||
|
>
|
||||||
|
Reset draft
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{feedback ? <p className="text-xs text-[var(--success)]">{feedback}</p> : null}
|
{feedback ? <p className="text-xs text-[var(--success)]">{feedback}</p> : null}
|
||||||
{error ? <p className="text-xs text-[var(--error)]">{error}</p> : null}
|
{error ? <p className="text-xs text-[var(--error)]">{error}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -1430,6 +1503,268 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RegistriesSection() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const registriesQuery = useQuery({
|
||||||
|
queryKey: ['registries'],
|
||||||
|
queryFn: listRegistries,
|
||||||
|
});
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [host, setHost] = useState('');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createRegistry({
|
||||||
|
name: name.trim(),
|
||||||
|
host: host.trim(),
|
||||||
|
username: username.trim() || undefined,
|
||||||
|
password: password || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setName('');
|
||||||
|
setHost('');
|
||||||
|
setUsername('');
|
||||||
|
setPassword('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['registries'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteRegistry(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['registries'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const registries = registriesQuery.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||||
|
<Archive size={18} className="text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Registry credentials</h2>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">Pull private images on deploy - matched by image host, stored encrypted</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Name (e.g. ghcr)"
|
||||||
|
className="h-9 flex-1 min-w-[120px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => setHost(e.target.value)}
|
||||||
|
placeholder="Host (ghcr.io, docker.io)"
|
||||||
|
className="h-9 flex-1 min-w-[160px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Username"
|
||||||
|
className="h-9 w-40 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Password / token"
|
||||||
|
className="h-9 w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
disabled={createMutation.isPending || !name.trim() || !host.trim()}
|
||||||
|
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Adding…' : 'Add registry'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createMutation.isError ? (
|
||||||
|
<p className="text-xs text-[var(--error)]">
|
||||||
|
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add registry'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{registriesQuery.isLoading ? (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
) : registries.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
No credentials. Image pulls are anonymous unless a registry matching the image host exists here.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
|
||||||
|
{registries.map((r: Registry) => (
|
||||||
|
<div key={r.id} className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-[var(--text-primary)]">{r.name}</span>
|
||||||
|
<span className="ml-2 mono text-[var(--text-muted)]">{r.host}</span>
|
||||||
|
{r.username ? <span className="ml-2 text-[var(--text-secondary)]">{r.username}</span> : null}
|
||||||
|
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
|
||||||
|
{r.has_password ? 'password set' : 'anonymous'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Remove credentials for ${r.host}?`)) {
|
||||||
|
deleteMutation.mutate(r.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackupTargetsSection() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const targetsQuery = useQuery({
|
||||||
|
queryKey: ['backup-targets'],
|
||||||
|
queryFn: listBackupTargets,
|
||||||
|
});
|
||||||
|
const [form, setForm] = useState({ name: '', endpoint: '', bucket: '', region: '', prefix: '', accessKey: '', secretKey: '', noTls: false });
|
||||||
|
const [testResult, setTestResult] = useState<Record<string, { ok: boolean; latency_ms?: number; error?: string }>>({});
|
||||||
|
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['backup-targets'] });
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createBackupTarget({
|
||||||
|
name: form.name.trim(),
|
||||||
|
endpoint: form.endpoint.trim(),
|
||||||
|
bucket: form.bucket.trim(),
|
||||||
|
region: form.region.trim() || undefined,
|
||||||
|
prefix: form.prefix.trim() || undefined,
|
||||||
|
access_key: form.accessKey || undefined,
|
||||||
|
secret_key: form.secretKey || undefined,
|
||||||
|
use_tls: !form.noTls,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setForm({ name: '', endpoint: '', bucket: '', region: '', prefix: '', accessKey: '', secretKey: '', noTls: false });
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteBackupTarget(id),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => testBackupTarget(id),
|
||||||
|
onSuccess: (result, id) => setTestResult((prev) => ({ ...prev, [id]: result })),
|
||||||
|
onError: (error, id) =>
|
||||||
|
setTestResult((prev) => ({ ...prev, [id]: { ok: false, error: error instanceof Error ? error.message : 'probe failed' } })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const targets = targetsQuery.data ?? [];
|
||||||
|
const fieldCls = 'h-9 flex-1 min-w-[130px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||||
|
<HardDrive size={18} className="text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Backup targets</h2>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">S3-compatible destinations for database archives — keys stored encrypted</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Name (e.g. garage)" className={fieldCls} />
|
||||||
|
<input value={form.endpoint} onChange={(e) => setForm((f) => ({ ...f, endpoint: e.target.value }))} placeholder="Endpoint host:port" className={fieldCls} />
|
||||||
|
<input value={form.bucket} onChange={(e) => setForm((f) => ({ ...f, bucket: e.target.value }))} placeholder="Bucket" className="h-9 w-36 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all" />
|
||||||
|
<input value={form.accessKey} onChange={(e) => setForm((f) => ({ ...f, accessKey: e.target.value }))} placeholder="Access key" className={fieldCls} />
|
||||||
|
<input type="password" value={form.secretKey} onChange={(e) => setForm((f) => ({ ...f, secretKey: e.target.value }))} placeholder="Secret key" className={fieldCls} />
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
<input type="checkbox" checked={form.noTls} onChange={(e) => setForm((f) => ({ ...f, noTls: e.target.checked }))} className="accent-[var(--accent-primary)]" />
|
||||||
|
No TLS
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
disabled={createMutation.isPending || !form.name.trim() || !form.endpoint.trim() || !form.bucket.trim()}
|
||||||
|
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Checking…' : 'Add target'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createMutation.isError ? (
|
||||||
|
<p className="text-xs text-[var(--error)]">
|
||||||
|
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add target'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{targetsQuery.isLoading ? (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
) : targets.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
No backup targets. Database archives stay in the local backups volume until a target is configured.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
|
||||||
|
{targets.map((t) => (
|
||||||
|
<div key={t.id} className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-[var(--text-primary)]">{t.name}</span>
|
||||||
|
<span className="ml-2 mono text-[var(--text-muted)]">{t.endpoint}/{t.bucket}{t.prefix ? `/${t.prefix}` : ''}</span>
|
||||||
|
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
|
||||||
|
{t.has_credentials ? 'keys set' : 'anonymous'}{t.use_tls ? '' : ' · no TLS'}
|
||||||
|
</span>
|
||||||
|
{testResult[t.id ?? ''] && (
|
||||||
|
<span className={`ml-2 ${testResult[t.id ?? '']?.ok ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
|
||||||
|
{testResult[t.id ?? '']?.ok ? `reachable (${testResult[t.id ?? '']?.latency_ms ?? 0}ms)` : `unreachable — ${testResult[t.id ?? '']?.error ?? 'probe failed'}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => testMutation.mutate(t.id ?? '')}
|
||||||
|
disabled={testMutation.isPending}
|
||||||
|
className="text-[var(--accent-primary)] hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete backup target ${t.name}?`)) {
|
||||||
|
deleteMutation.mutate(t.id ?? '');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="text-[var(--error)] hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -1603,6 +1938,8 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
{/* Personal Access Tokens */}
|
{/* Personal Access Tokens */}
|
||||||
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
||||||
|
<RegistriesSection />
|
||||||
|
<BackupTargetsSection />
|
||||||
|
|
||||||
{/* Platform Settings - owner only */}
|
{/* Platform Settings - owner only */}
|
||||||
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
||||||
@@ -1767,7 +2104,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 [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' });
|
const [createMode, setCreateMode] = useState<'managed' | 'external'>('managed');
|
||||||
|
const [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local', public: false });
|
||||||
|
const [externalForm, setExternalForm] = useState({ name: '', type: 'postgresql', host: '', port: '', database: '', username: '', password: '', ssl: false });
|
||||||
|
const [externalProbe, setExternalProbe] = useState<{ ok: boolean; latency_ms?: number; error?: string } | null>(null);
|
||||||
|
const [externalTestResult, setExternalTestResult] = useState<Record<string, { ok: boolean; latency_ms?: number; error?: string }>>({});
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
const databasesQuery = useQuery({
|
const databasesQuery = useQuery({
|
||||||
queryKey: ['databases'],
|
queryKey: ['databases'],
|
||||||
@@ -1775,6 +2116,11 @@ export function DatabasesPage() {
|
|||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
enabled: !isDemoMode,
|
enabled: !isDemoMode,
|
||||||
});
|
});
|
||||||
|
const backupTargetsQuery = useQuery({
|
||||||
|
queryKey: ['backup-targets'],
|
||||||
|
queryFn: listBackupTargets,
|
||||||
|
enabled: !isDemoMode,
|
||||||
|
});
|
||||||
|
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['databases'] });
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['databases'] });
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
@@ -1784,10 +2130,11 @@ export function DatabasesPage() {
|
|||||||
type: createForm.type as CreateDatabaseInput['type'],
|
type: createForm.type as CreateDatabaseInput['type'],
|
||||||
plan: createForm.plan as CreateDatabaseInput['plan'],
|
plan: createForm.plan as CreateDatabaseInput['plan'],
|
||||||
region: createForm.region.trim() || 'local',
|
region: createForm.region.trim() || 'local',
|
||||||
|
public_port: createForm.public,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setCreateForm({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' });
|
setCreateForm({ name: '', type: 'postgresql', plan: 'hobby', region: 'local', public: false });
|
||||||
setCreateError(null);
|
setCreateError(null);
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
@@ -1795,6 +2142,54 @@ 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 publicPortMutation = useMutation({
|
||||||
|
mutationFn: ({ id, publicPort }: { id: string; publicPort: boolean }) =>
|
||||||
|
updateDatabase(id, { public_port: publicPort }),
|
||||||
|
onSuccess: () => window.setTimeout(invalidate, 1500),
|
||||||
|
});
|
||||||
|
const backupTargetMutation = useMutation({
|
||||||
|
mutationFn: ({ id, targetId }: { id: string; targetId: string }) =>
|
||||||
|
updateDatabase(id, { backup_target_id: targetId }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
const importBackupMutation = useMutation({
|
||||||
|
mutationFn: ({ id, file }: { id: string; file: File }) => importDatabaseBackup(id, file),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
const testByIdMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => testDatabaseConnectionByID(id),
|
||||||
|
onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })),
|
||||||
|
onError: (error, id) =>
|
||||||
|
setExternalTestResult((prev) => ({ ...prev, [id]: { ok: false, error: error instanceof Error ? error.message : 'probe failed' } })),
|
||||||
|
});
|
||||||
const actionMutation = useMutation({
|
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),
|
||||||
@@ -1880,9 +2275,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>
|
||||||
@@ -1910,6 +2312,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}
|
||||||
@@ -1931,6 +2352,15 @@ export function DatabasesPage() {
|
|||||||
>
|
>
|
||||||
<RefreshCw size={11} /> Restart
|
<RefreshCw size={11} /> Restart
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (window.confirm(`${db.public_port ? 'Stop exposing' : 'Expose'} ${db.name} on all interfaces? The container will be recreated; data persists.`)) publicPortMutation.mutate({ id: db.id ?? '', publicPort: !db.public_port }) }}
|
||||||
|
disabled={publicPortMutation.isPending}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border text-xs font-medium transition-colors disabled:opacity-40 ${db.public_port ? 'border-[var(--warning)] text-[var(--warning)]' : 'border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--border-default)]'}`}
|
||||||
|
>
|
||||||
|
<Link2 size={11} /> {db.public_port ? 'Public ✓' : 'Local only'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<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}
|
||||||
@@ -1973,10 +2403,43 @@ 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>
|
||||||
{signedIn ? (
|
{signedIn ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(backupTargetsQuery.data?.length ?? 0) > 0 && (
|
||||||
|
<select
|
||||||
|
value={db.backup_target_id ?? ''}
|
||||||
|
onChange={(e) => backupTargetMutation.mutate({ id: db.id ?? '', targetId: e.target.value })}
|
||||||
|
title="Offsite archive destination"
|
||||||
|
className="h-7 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-xs text-[var(--text-secondary)]"
|
||||||
|
>
|
||||||
|
<option value="">local only</option>
|
||||||
|
{(backupTargetsQuery.data ?? []).map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name} ({t.bucket})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<label
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium border border-[var(--border-subtle)] text-[var(--text-secondary)] transition-all ${importBackupMutation.isPending ? 'opacity-50' : 'cursor-pointer hover:text-[var(--text-primary)]'}`}
|
||||||
|
title="Import a .tar.gz archive as a restore point"
|
||||||
|
>
|
||||||
|
<Upload size={11} />
|
||||||
|
{importBackupMutation.isPending ? 'Importing…' : 'Import'}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".tar.gz,application/gzip"
|
||||||
|
className="hidden"
|
||||||
|
disabled={importBackupMutation.isPending}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) importBackupMutation.mutate({ id: db.id ?? '', file: f });
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => backupMutation.mutate(db.id ?? '')}
|
onClick={() => backupMutation.mutate(db.id ?? '')}
|
||||||
disabled={!running || backupMutation.isPending}
|
disabled={!running || backupMutation.isPending}
|
||||||
@@ -1986,6 +2449,7 @@ export function DatabasesPage() {
|
|||||||
<Archive size={11} />
|
<Archive size={11} />
|
||||||
{backupMutation.isPending ? 'Starting…' : 'New backup'}
|
{backupMutation.isPending ? 'Starting…' : 'New backup'}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<BackupScheduleRow
|
<BackupScheduleRow
|
||||||
@@ -2002,6 +2466,7 @@ export function DatabasesPage() {
|
|||||||
<span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span>
|
<span className="text-[var(--text-secondary)]">{formatRelative(b.created_at)}</span>
|
||||||
<span className="mono text-[var(--text-muted)]">{b.size}</span>
|
<span className="mono text-[var(--text-muted)]">{b.size}</span>
|
||||||
<span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span>
|
<span className={`${b.status === 'completed' ? 'text-[var(--success)]' : b.status === 'in_progress' ? 'text-[var(--warning)]' : 'text-[var(--error)]'}`}>{b.status}</span>
|
||||||
|
{b.remote && <span className="rounded bg-[var(--surface-card)] px-1.5 py-0.5 text-[var(--accent-primary)]" title="Also shipped to the configured backup target">offsite</span>}
|
||||||
{b.status === 'completed' && (
|
{b.status === 'completed' && (
|
||||||
<span className="ml-auto flex items-center gap-3">
|
<span className="ml-auto flex items-center gap-3">
|
||||||
<a
|
<a
|
||||||
@@ -2029,6 +2494,7 @@ export function DatabasesPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2043,18 +2509,126 @@ export function DatabasesPage() {
|
|||||||
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
<div className="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>
|
||||||
@@ -2090,6 +2664,17 @@ export function DatabasesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-[var(--text-secondary)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createForm.public}
|
||||||
|
onChange={(e) => setCreateForm((f) => ({ ...f, public: e.target.checked }))}
|
||||||
|
className="accent-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
Expose port on all interfaces (default: localhost only)
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{createError ? (
|
{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}
|
||||||
@@ -2104,12 +2689,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>
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ import {
|
|||||||
updateService,
|
updateService,
|
||||||
restartService,
|
restartService,
|
||||||
stopService,
|
stopService,
|
||||||
|
sleepService,
|
||||||
|
wakeService,
|
||||||
getServiceRuntime,
|
getServiceRuntime,
|
||||||
|
getServiceEnvCheck,
|
||||||
type CronJobEntity,
|
type CronJobEntity,
|
||||||
type ServiceVolume,
|
type ServiceVolume,
|
||||||
type ServiceDomain,
|
type ServiceDomain,
|
||||||
@@ -43,6 +46,9 @@ import {
|
|||||||
removeServiceDomain,
|
removeServiceDomain,
|
||||||
setDefaultServiceDomain,
|
setDefaultServiceDomain,
|
||||||
checkServiceDomains,
|
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';
|
||||||
@@ -184,6 +190,12 @@ export function ServiceDetailPage() {
|
|||||||
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'variables',
|
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'variables',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const envCheckQuery = useQuery({
|
||||||
|
queryKey: ['service-env-check', serviceId],
|
||||||
|
queryFn: () => getServiceEnvCheck(serviceId),
|
||||||
|
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'variables',
|
||||||
|
});
|
||||||
|
|
||||||
const saveVariablesMutation = useMutation({
|
const saveVariablesMutation = useMutation({
|
||||||
mutationFn: (rows: VariableDraft[]) =>
|
mutationFn: (rows: VariableDraft[]) =>
|
||||||
updateServiceVariables(
|
updateServiceVariables(
|
||||||
@@ -412,6 +424,36 @@ export function ServiceDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const projectsQuery = useQuery({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: () => listProjects({ limit: 100 }),
|
||||||
|
enabled: !isDemoMode && activeSection === 'settings',
|
||||||
|
});
|
||||||
|
|
||||||
|
const cloneMutation = useMutation({
|
||||||
|
mutationFn: (input: { name?: string; project_id?: string }) => cloneService(serviceId, input),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||||
|
const targetProject = lifecycleForm?.cloneProject || projectId;
|
||||||
|
navigate(`/projects/${targetProject}/services/${data.service_id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveMutation = useMutation({
|
||||||
|
mutationFn: (targetProjectId: string) => moveService(serviceId, targetProjectId),
|
||||||
|
onSuccess: (_data, targetProjectId) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
navigate(`/projects/${targetProjectId}/services/${serviceId}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [lifecycleForm, setLifecycleForm] = useState<{
|
||||||
|
cloneName: string;
|
||||||
|
cloneProject: string;
|
||||||
|
moveTarget: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const invalidateService = () => {
|
const invalidateService = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
||||||
@@ -426,10 +468,19 @@ export function ServiceDetailPage() {
|
|||||||
mutationFn: () => stopService(serviceId),
|
mutationFn: () => stopService(serviceId),
|
||||||
onSuccess: invalidateService,
|
onSuccess: invalidateService,
|
||||||
});
|
});
|
||||||
|
const sleepMutation = useMutation({
|
||||||
|
mutationFn: () => sleepService(serviceId),
|
||||||
|
onSuccess: invalidateService,
|
||||||
|
});
|
||||||
|
const wakeMutation = useMutation({
|
||||||
|
mutationFn: () => wakeService(serviceId),
|
||||||
|
onSuccess: invalidateService,
|
||||||
|
});
|
||||||
const updateServiceMutation = useMutation({
|
const updateServiceMutation = useMutation({
|
||||||
mutationFn: (input: Parameters<typeof updateService>[1]) => updateService(serviceId, input),
|
mutationFn: (input: Parameters<typeof updateService>[1]) => updateService(serviceId, input),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setNetworkForm(null);
|
setNetworkForm(null);
|
||||||
|
setSleepDraft(null);
|
||||||
invalidateService();
|
invalidateService();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -448,6 +499,7 @@ export function ServiceDetailPage() {
|
|||||||
const [domainInput, setDomainInput] = useState('');
|
const [domainInput, setDomainInput] = useState('');
|
||||||
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
|
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
|
||||||
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
|
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
|
||||||
|
const [sleepDraft, setSleepDraft] = useState<{ id: string; enabled: boolean; minutes: string } | null>(null);
|
||||||
const [buildForm, setBuildForm] = useState<{
|
const [buildForm, setBuildForm] = useState<{
|
||||||
builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string;
|
builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
@@ -468,6 +520,9 @@ export function ServiceDetailPage() {
|
|||||||
|
|
||||||
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;
|
||||||
|
const sleepDraftCurrent = sleepDraft?.id === serviceId ? sleepDraft : null;
|
||||||
|
const sleepEnabled = sleepDraftCurrent?.enabled ?? Boolean(service?.sleepEnabled);
|
||||||
|
const sleepMinutes = sleepDraftCurrent?.minutes ?? String(service?.sleepIdleMinutes ?? 15);
|
||||||
|
|
||||||
const varRows: VariableDraft[] = useMemo(
|
const varRows: VariableDraft[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -656,7 +711,7 @@ export function ServiceDetailPage() {
|
|||||||
className={`badge-${service.status === 'running' ? 'active' : service.status === 'degraded' ? 'degraded' : 'stopped'}`}
|
className={`badge-${service.status === 'running' ? 'active' : service.status === 'degraded' ? 'degraded' : 'stopped'}`}
|
||||||
>
|
>
|
||||||
{service.status === 'running' && <span className="live-dot" />}
|
{service.status === 'running' && <span className="live-dot" />}
|
||||||
{service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : 'Stopped'}
|
{service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : service.status === 'sleeping' ? 'Sleeping' : 'Stopped'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
|
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
|
||||||
@@ -1222,8 +1277,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>
|
||||||
@@ -1254,6 +1310,23 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{envCheckQuery.data && !envCheckQuery.data.ok && (
|
||||||
|
<div className="mb-4 rounded-[var(--radius-md)] border border-[var(--warning)]/40 bg-[var(--warning-soft)] p-4">
|
||||||
|
<p className="text-xs font-semibold text-[var(--warning)] mb-2">Environment issues detected</p>
|
||||||
|
<ul className="space-y-1 text-xs text-[var(--text-secondary)]">
|
||||||
|
{(envCheckQuery.data.unresolved ?? []).map((u) => (
|
||||||
|
<li key={u} className="mono">Unresolved reference: {u}</li>
|
||||||
|
))}
|
||||||
|
{(envCheckQuery.data.empty ?? []).map((k) => (
|
||||||
|
<li key={k} className="mono">Empty value: {k}</li>
|
||||||
|
))}
|
||||||
|
{(envCheckQuery.data.unreadable ?? []).map((k) => (
|
||||||
|
<li key={k} className="mono">Cannot decrypt secret (key rotated?): {k}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{bulkOpen && (
|
{bulkOpen && (
|
||||||
<div className="mb-4 panel-soft p-4">
|
<div className="mb-4 panel-soft p-4">
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||||
@@ -2306,6 +2379,80 @@ 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-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-[var(--text-primary)]">Sleep</h3>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)] mt-0.5">
|
||||||
|
Scale to zero after idle minutes without traffic. Requests resume the service automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{service.status === 'sleeping' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={wakeMutation.isPending}
|
||||||
|
onClick={() => wakeMutation.mutate()}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{wakeMutation.isPending ? 'Waking…' : 'Wake now'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={sleepMutation.isPending || service.status !== 'running'}
|
||||||
|
onClick={() => sleepMutation.mutate()}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sleepMutation.isPending ? 'Sleeping…' : 'Sleep now'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs">
|
||||||
|
<label className="flex items-center gap-2 text-[var(--text-primary)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sleepEnabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSleepDraft({ id: serviceId, enabled: e.target.checked, minutes: sleepMinutes })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Sleep on idle
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-[var(--text-tertiary)]">
|
||||||
|
after
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={1440}
|
||||||
|
value={sleepMinutes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSleepDraft({ id: serviceId, enabled: sleepEnabled, minutes: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-20 px-2 py-1 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
min
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={updateServiceMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
updateServiceMutation.mutate({
|
||||||
|
sleep_enabled: sleepEnabled,
|
||||||
|
sleep_idle_minutes: Math.max(1, Math.min(1440, Number(sleepMinutes) || 15)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
{sleepDraftCurrent && (
|
||||||
|
<span className="text-[var(--text-tertiary)]">unsaved</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isDemoMode && (
|
{!isDemoMode && (
|
||||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -2632,6 +2779,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>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
deployTemplate,
|
deployTemplate,
|
||||||
getCurrentUserProfile,
|
getCurrentUserProfile,
|
||||||
getTemplateById,
|
getTemplateById,
|
||||||
|
importComposeTemplate,
|
||||||
listProjects,
|
listProjects,
|
||||||
listTemplates,
|
listTemplates,
|
||||||
updateTemplate,
|
updateTemplate,
|
||||||
@@ -36,6 +37,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
|
FileCode,
|
||||||
|
Network,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
function toServiceName(value: string): string {
|
function toServiceName(value: string): string {
|
||||||
@@ -147,10 +150,16 @@ export function TemplatesPage() {
|
|||||||
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
|
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
|
||||||
Record<string, Record<string, string>>
|
Record<string, Record<string, string>>
|
||||||
>({});
|
>({});
|
||||||
|
const [composeOpen, setComposeOpen] = useState(false);
|
||||||
|
const [composeYaml, setComposeYaml] = useState('');
|
||||||
|
const [composeWarnings, setComposeWarnings] = useState<string[]>([]);
|
||||||
|
const [composeError, setComposeError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [lastDeployment, setLastDeployment] = useState<{
|
const [lastDeployment, setLastDeployment] = useState<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
serviceId: string;
|
serviceId?: string;
|
||||||
serviceName: string;
|
serviceName: string;
|
||||||
|
created?: { key: string; kind: string; id: string; name: string; deploymentId?: string }[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const templatesQuery = useQuery({
|
const templatesQuery = useQuery({
|
||||||
@@ -281,6 +290,12 @@ export function TemplatesPage() {
|
|||||||
});
|
});
|
||||||
}, [selectedDetail, variableValues]);
|
}, [selectedDetail, variableValues]);
|
||||||
|
|
||||||
|
const graphMembers = useMemo(
|
||||||
|
() => selectedDetail?.config.services ?? [],
|
||||||
|
[selectedDetail],
|
||||||
|
);
|
||||||
|
const isGraphTemplate = graphMembers.length > 0;
|
||||||
|
|
||||||
const deployMutation = useMutation({
|
const deployMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!selectedTemplateId) {
|
if (!selectedTemplateId) {
|
||||||
@@ -296,17 +311,21 @@ export function TemplatesPage() {
|
|||||||
|
|
||||||
return deployTemplate(selectedTemplateId, {
|
return deployTemplate(selectedTemplateId, {
|
||||||
projectId: deployProjectId,
|
projectId: deployProjectId,
|
||||||
name: deployName.trim(),
|
name: isGraphTemplate ? undefined : deployName.trim(),
|
||||||
variables,
|
variables,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['databases'] });
|
||||||
setLastDeployment({
|
setLastDeployment({
|
||||||
projectId: deployProjectId,
|
projectId: deployProjectId,
|
||||||
serviceId: result.serviceId,
|
serviceId: result.serviceId,
|
||||||
serviceName: deployName.trim(),
|
serviceName: isGraphTemplate
|
||||||
|
? `${result.created?.length ?? 0} resources`
|
||||||
|
: deployName.trim(),
|
||||||
|
created: result.created,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -314,7 +333,7 @@ export function TemplatesPage() {
|
|||||||
const isDeployDisabled =
|
const isDeployDisabled =
|
||||||
!selectedTemplateId ||
|
!selectedTemplateId ||
|
||||||
!deployProjectId ||
|
!deployProjectId ||
|
||||||
deployName.trim().length === 0 ||
|
(!isGraphTemplate && deployName.trim().length === 0) ||
|
||||||
missingRequiredVariables.length > 0 ||
|
missingRequiredVariables.length > 0 ||
|
||||||
deployMutation.isPending;
|
deployMutation.isPending;
|
||||||
|
|
||||||
@@ -344,6 +363,37 @@ export function TemplatesPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const composeImportMutation = useMutation({
|
||||||
|
mutationFn: (yaml: string) => importComposeTemplate(yaml),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setComposeWarnings(result.warnings);
|
||||||
|
setComposeError(null);
|
||||||
|
setComposeOpen(false);
|
||||||
|
// Load the converted graph into the template editor for review/save.
|
||||||
|
setEditingTemplate(null);
|
||||||
|
setEditorIsPublic(false);
|
||||||
|
setEditorError(null);
|
||||||
|
setEditorJson(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: 'compose-stack',
|
||||||
|
description: 'Imported from docker-compose',
|
||||||
|
category: 'stack',
|
||||||
|
logo: '',
|
||||||
|
config: result.config,
|
||||||
|
variables: [],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setEditorOpen(true);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setComposeError(error instanceof Error ? error.message : 'Failed to convert compose file');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const openCreateEditor = () => {
|
const openCreateEditor = () => {
|
||||||
setEditingTemplate(null);
|
setEditingTemplate(null);
|
||||||
setEditorJson(EMPTY_TEMPLATE_JSON);
|
setEditorJson(EMPTY_TEMPLATE_JSON);
|
||||||
@@ -438,13 +488,27 @@ export function TemplatesPage() {
|
|||||||
<Layers size={16} />
|
<Layers size={16} />
|
||||||
<span>{filteredTemplates.length} templates</span>
|
<span>{filteredTemplates.length} templates</span>
|
||||||
{signedIn && !isDemoMode ? (
|
{signedIn && !isDemoMode ? (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
onClick={openCreateEditor}
|
type="button"
|
||||||
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 text-[12.5px] font-semibold text-[var(--accent-on)]"
|
onClick={() => {
|
||||||
>
|
setComposeYaml('');
|
||||||
<Plus size={14} /> New template
|
setComposeWarnings([]);
|
||||||
</button>
|
setComposeError(null);
|
||||||
|
setComposeOpen(true);
|
||||||
|
}}
|
||||||
|
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3.5 text-[12.5px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<FileCode size={14} /> Import Compose
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCreateEditor}
|
||||||
|
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 text-[12.5px] font-semibold text-[var(--accent-on)]"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> New template
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -725,6 +789,42 @@ export function TemplatesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isGraphTemplate ? (
|
||||||
|
<div className="panel-soft p-4 mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Network size={14} className="text-[var(--accent-primary)]" />
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||||
|
Stack — {graphMembers.length} members
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{graphMembers.map((member) => (
|
||||||
|
<div key={member.key} className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{member.type === 'database' ? (
|
||||||
|
<Database size={13} className="text-[var(--info)] shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Box size={13} className="text-[var(--text-tertiary)] shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="mono text-[var(--text-primary)] truncate">{member.name || member.key}</span>
|
||||||
|
{member.dependsOn && member.dependsOn.length > 0 ? (
|
||||||
|
<span className="text-[10px] text-[var(--text-tertiary)]">
|
||||||
|
after {member.dependsOn.join(', ')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="mono text-[11px] text-[var(--text-tertiary)] truncate">
|
||||||
|
{member.type === 'database' ? `managed ${member.runtime}` : member.runtime || member.repo || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)] mt-3">
|
||||||
|
Members join a private project network; references like {'{{service.db.url}}'} resolve to the member key.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Deploy Section */}
|
{/* Deploy Section */}
|
||||||
<div className="border-t border-[var(--border-subtle)] pt-6">
|
<div className="border-t border-[var(--border-subtle)] pt-6">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
@@ -733,7 +833,9 @@ export function TemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3>
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Configure and create a new service</p>
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{isGraphTemplate ? 'Deploy all members into a project' : 'Configure and create a new service'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -767,6 +869,7 @@ export function TemplatesPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{!isGraphTemplate ? (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||||
Service Name
|
Service Name
|
||||||
@@ -784,6 +887,7 @@ export function TemplatesPage() {
|
|||||||
placeholder="service-name"
|
placeholder="service-name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedDetail.variables.length > 0 && (
|
{selectedDetail.variables.length > 0 && (
|
||||||
@@ -846,15 +950,30 @@ export function TemplatesPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Check size={16} />
|
<Check size={16} />
|
||||||
<span>
|
<span>
|
||||||
Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created
|
{lastDeployment.created
|
||||||
|
? `Stack deployed — ${lastDeployment.created.length} resources`
|
||||||
|
: <>Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created</>}
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<Link
|
||||||
to={`/projects/${lastDeployment.projectId}/services/${lastDeployment.serviceId}${isDemoMode ? '?demo=1' : ''}`}
|
to={lastDeployment.created
|
||||||
|
? `/projects/${lastDeployment.projectId}${isDemoMode ? '?demo=1' : ''}`
|
||||||
|
: `/projects/${lastDeployment.projectId}/services/${lastDeployment.serviceId}${isDemoMode ? '?demo=1' : ''}`}
|
||||||
className="flex items-center gap-1 ml-2 underline underline-offset-2 hover:no-underline"
|
className="flex items-center gap-1 ml-2 underline underline-offset-2 hover:no-underline"
|
||||||
>
|
>
|
||||||
View <ArrowRight size={12} />
|
View <ArrowRight size={12} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
{lastDeployment.created ? (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{lastDeployment.created.map((m) => (
|
||||||
|
<div key={m.id} className="flex items-center gap-2 text-xs">
|
||||||
|
{m.kind === 'database' ? <Database size={11} /> : <Box size={11} />}
|
||||||
|
<span className="mono">{m.name}</span>
|
||||||
|
<span className="opacity-70">({m.kind})</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -868,12 +987,12 @@ export function TemplatesPage() {
|
|||||||
{deployMutation.isPending ? (
|
{deployMutation.isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 size={16} className="animate-spin" />
|
<Loader2 size={16} className="animate-spin" />
|
||||||
Creating...
|
Deploying...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Play size={16} />
|
<Play size={16} />
|
||||||
Create Service
|
{isGraphTemplate ? 'Deploy Stack' : 'Create Service'}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -929,6 +1048,14 @@ export function TemplatesPage() {
|
|||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
|
className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
|
||||||
/>
|
/>
|
||||||
|
{composeWarnings.length > 0 ? (
|
||||||
|
<div className="border-t border-[var(--border-subtle)] bg-[var(--warning-soft)]/60 px-5 py-2.5 text-xs text-[var(--warning)]">
|
||||||
|
<p className="font-medium mb-1">Compose import notes</p>
|
||||||
|
{composeWarnings.map((w) => (
|
||||||
|
<p key={w}>• {w}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{editorError ? (
|
{editorError ? (
|
||||||
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
|
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
|
||||||
{editorError}
|
{editorError}
|
||||||
@@ -966,6 +1093,59 @@ export function TemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Compose import */}
|
||||||
|
{composeOpen ? (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||||
|
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--border-subtle)] px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Import docker-compose</h2>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
Paste a compose file — known database images become managed databases, everything else becomes services.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setComposeOpen(false)}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={composeYaml}
|
||||||
|
onChange={(e) => setComposeYaml(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'}
|
||||||
|
className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
|
||||||
|
/>
|
||||||
|
{composeError ? (
|
||||||
|
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
|
||||||
|
{composeError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setComposeOpen(false)}
|
||||||
|
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => composeImportMutation.mutate(composeYaml)}
|
||||||
|
disabled={!composeYaml.trim() || composeImportMutation.isPending}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />}
|
||||||
|
Convert to template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
createWebhook,
|
||||||
|
deleteWebhook,
|
||||||
|
listWebhookDeliveries,
|
||||||
|
listWebhooks,
|
||||||
|
testWebhook,
|
||||||
|
updateWebhook,
|
||||||
|
type OutboundWebhook,
|
||||||
|
type WebhookDelivery,
|
||||||
|
} from '@/lib/api-client';
|
||||||
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
|
import { formatRelative } from '@/lib/time';
|
||||||
|
import { DemoRestricted, useToast } from '@/shared/components';
|
||||||
|
import {
|
||||||
|
Webhook,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Send,
|
||||||
|
Loader2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Copy,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all';
|
||||||
|
|
||||||
|
const eventSuggestions = ['*', 'service.*', 'project.*', 'database.*', 'deployment.*', 'template.*'];
|
||||||
|
|
||||||
|
function statusClass(status: WebhookDelivery['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'success':
|
||||||
|
return 'text-[var(--success)]';
|
||||||
|
case 'failed':
|
||||||
|
return 'text-[var(--error)]';
|
||||||
|
default:
|
||||||
|
return 'text-[var(--warning)]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebhookRow({
|
||||||
|
webhook,
|
||||||
|
onDelete,
|
||||||
|
onToggle,
|
||||||
|
onTest,
|
||||||
|
testing,
|
||||||
|
}: {
|
||||||
|
webhook: OutboundWebhook;
|
||||||
|
onDelete: () => void;
|
||||||
|
onToggle: () => void;
|
||||||
|
onTest: () => void;
|
||||||
|
testing: boolean;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const deliveriesQuery = useQuery({
|
||||||
|
queryKey: ['webhook-deliveries', webhook.id],
|
||||||
|
queryFn: () => listWebhookDeliveries(webhook.id),
|
||||||
|
enabled: expanded,
|
||||||
|
refetchInterval: expanded ? 10000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => setExpanded((v) => !v)} className="text-[var(--text-tertiary)]">
|
||||||
|
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-[var(--text-primary)]">{webhook.name}</span>
|
||||||
|
{!webhook.enabled && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-muted)]">
|
||||||
|
disabled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--text-muted)] mono truncate">{webhook.url}</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:flex gap-1">
|
||||||
|
{webhook.events.map((ev) => (
|
||||||
|
<span
|
||||||
|
key={ev}
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] mono"
|
||||||
|
>
|
||||||
|
{ev}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={onTest}
|
||||||
|
disabled={testing || !webhook.enabled}
|
||||||
|
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--accent-primary)] disabled:opacity-40 transition-colors"
|
||||||
|
title="Send test ping"
|
||||||
|
>
|
||||||
|
{testing ? <Loader2 size={15} className="animate-spin" /> : <Send size={15} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors text-xs"
|
||||||
|
>
|
||||||
|
{webhook.enabled ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--error)] transition-colors"
|
||||||
|
title="Delete webhook"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||||
|
Recent deliveries
|
||||||
|
</p>
|
||||||
|
{deliveriesQuery.isLoading ? (
|
||||||
|
<Loader2 size={14} className="animate-spin text-[var(--text-muted)]" />
|
||||||
|
) : (deliveriesQuery.data ?? []).length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">No deliveries yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(deliveriesQuery.data ?? []).slice(0, 20).map((d) => (
|
||||||
|
<div key={d.id} className="flex items-center gap-3 text-xs mono">
|
||||||
|
<span className={statusClass(d.status)}>{d.status}</span>
|
||||||
|
<span className="text-[var(--text-secondary)]">{d.event}</span>
|
||||||
|
{d.response_status != null && (
|
||||||
|
<span className="text-[var(--text-muted)]">HTTP {d.response_status}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[var(--text-muted)]">{d.attempts} tries</span>
|
||||||
|
{d.duration_ms != null && (
|
||||||
|
<span className="text-[var(--text-muted)]">{d.duration_ms}ms</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[var(--text-muted)] ml-auto">{formatRelative(d.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebhooksPage() {
|
||||||
|
const isDemoMode = useDemoMode();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const toast = useToast();
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [form, setForm] = useState({ name: '', url: '', events: [] as string[] });
|
||||||
|
const [secret, setSecret] = useState<string | null>(null);
|
||||||
|
const [testingId, setTestingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const webhooksQuery = useQuery({
|
||||||
|
queryKey: ['webhooks'],
|
||||||
|
queryFn: listWebhooks,
|
||||||
|
enabled: !isDemoMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['webhooks'] });
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createWebhook(form),
|
||||||
|
onSuccess: (created) => {
|
||||||
|
invalidate();
|
||||||
|
setCreating(false);
|
||||||
|
setForm({ name: '', url: '', events: [] });
|
||||||
|
if (created.secret) {
|
||||||
|
setSecret(created.secret);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Failed to create webhook', 'error'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteWebhook(id),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Delete failed', 'error'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: (w: OutboundWebhook) => updateWebhook(w.id, { enabled: !w.enabled }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => testWebhook(id),
|
||||||
|
onMutate: (id) => setTestingId(id),
|
||||||
|
onSettled: () => setTestingId(null),
|
||||||
|
onSuccess: () => toast.showToast('Test ping queued', 'info'),
|
||||||
|
onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Test failed', 'error'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDemoMode) {
|
||||||
|
return <DemoRestricted feature="Outbound webhooks" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleEvent = (ev: string) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
events: f.events.includes(ev) ? f.events.filter((e) => e !== ev) : [...f.events, ev],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<div className="border-b border-[var(--border-subtle)]">
|
||||||
|
<div className="w-full px-8 py-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="v-title">Webhooks<span className="v-cursor">_</span></h1>
|
||||||
|
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||||
|
Signed HTTP delivery for every platform event — deploys, restarts, deletes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
className="flex items-center gap-1.5 h-9 px-4 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)]"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
<Plus size={15} />
|
||||||
|
New webhook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full px-8 py-6 space-y-4 max-w-4xl">
|
||||||
|
{secret && (
|
||||||
|
<div className="panel-soft p-4 flex items-center gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-[var(--text-primary)]">Signing secret — shown once</p>
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">
|
||||||
|
Verify deliveries with <span className="mono">X-Containr-Signature</span>
|
||||||
|
</p>
|
||||||
|
<code className="block mt-2 text-xs mono text-[var(--accent-primary)] break-all">{secret}</code>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard?.writeText(secret);
|
||||||
|
toast.showToast('Secret copied', 'success');
|
||||||
|
}}
|
||||||
|
className="p-2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<Copy size={15} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setSecret(null)} className="p-2 text-[var(--text-tertiary)]">
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{webhooksQuery.isLoading ? (
|
||||||
|
<Loader2 size={18} className="animate-spin text-[var(--text-muted)]" />
|
||||||
|
) : (webhooksQuery.data ?? []).length === 0 ? (
|
||||||
|
<div className="panel p-8 text-center">
|
||||||
|
<Webhook size={28} className="mx-auto text-[var(--text-muted)] mb-3" />
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">No webhooks yet.</p>
|
||||||
|
<p className="text-xs text-[var(--text-muted)] mt-1">
|
||||||
|
Subscribe an HTTP endpoint to platform events like <span className="mono">service.*</span> or{' '}
|
||||||
|
<span className="mono">deployment.fail</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(webhooksQuery.data ?? []).map((w) => (
|
||||||
|
<WebhookRow
|
||||||
|
key={w.id}
|
||||||
|
webhook={w}
|
||||||
|
testing={testingId === w.id}
|
||||||
|
onTest={() => testMutation.mutate(w.id)}
|
||||||
|
onToggle={() => toggleMutation.mutate(w)}
|
||||||
|
onDelete={() => {
|
||||||
|
if (window.confirm(`Delete webhook "${w.name}"?`)) {
|
||||||
|
deleteMutation.mutate(w.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreating(false)} />
|
||||||
|
<div className="relative w-full max-w-md panel p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">New webhook</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
className={`${inputClass} w-full`}
|
||||||
|
placeholder="deploy-alerts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||||
|
Endpoint URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={form.url}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
|
||||||
|
className={`${inputClass} w-full mono`}
|
||||||
|
placeholder="https://example.com/hooks/containr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||||
|
Events
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{eventSuggestions.map((ev) => (
|
||||||
|
<button
|
||||||
|
key={ev}
|
||||||
|
onClick={() => toggleEvent(ev)}
|
||||||
|
className={`px-2.5 py-1 rounded-[var(--radius-md)] border text-xs mono transition-colors ${
|
||||||
|
form.events.includes(ev)
|
||||||
|
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||||
|
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ev}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11px] text-[var(--text-muted)]">
|
||||||
|
Events are <span className="mono">resource.action</span> — wildcards like{' '}
|
||||||
|
<span className="mono">service.*</span> match the whole resource.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCreating(false)}
|
||||||
|
className="h-10 px-4 rounded-[var(--radius-md)] text-sm text-[var(--text-secondary)]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
disabled={
|
||||||
|
createMutation.isPending ||
|
||||||
|
!form.name.trim() ||
|
||||||
|
!form.url.trim() ||
|
||||||
|
form.events.length === 0
|
||||||
|
}
|
||||||
|
className="h-10 px-5 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50 flex items-center gap-2"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{createMutation.isPending && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
createManagedDatabase,
|
createManagedDatabase,
|
||||||
createService,
|
createService,
|
||||||
getProjectById,
|
getProjectById,
|
||||||
|
importComposeIntoProject,
|
||||||
listConnectedGitRepositories,
|
listConnectedGitRepositories,
|
||||||
listGitBranches,
|
listGitBranches,
|
||||||
listGitProviders,
|
listGitProviders,
|
||||||
@@ -26,6 +27,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,
|
||||||
@@ -44,6 +46,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Container,
|
Container,
|
||||||
|
FileUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
|
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
|
||||||
@@ -472,6 +475,8 @@ export function ProjectWorkspacePage() {
|
|||||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
const [composeYaml, setComposeYaml] = useState('');
|
||||||
|
|
||||||
const openAddService = () => {
|
const openAddService = () => {
|
||||||
if (!signedIn) {
|
if (!signedIn) {
|
||||||
@@ -555,6 +560,25 @@ export function ProjectWorkspacePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const composeImportMutation = useMutation({
|
||||||
|
mutationFn: (yaml: string) => importComposeIntoProject(projectId, yaml),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setImportOpen(false);
|
||||||
|
setComposeYaml('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
const count = result.created.length;
|
||||||
|
const warn = result.warnings.length > 0 ? ` (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})` : '';
|
||||||
|
toast.showToast(`Imported ${count} resource${count === 1 ? '' : 's'} from compose${warn}`, 'success');
|
||||||
|
for (const warning of result.warnings) {
|
||||||
|
toast.showToast(warning, 'warning');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.showToast(err instanceof Error ? err.message : 'Compose import failed', 'error');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const project = useMemo(
|
const project = useMemo(
|
||||||
() => (isDemoMode ? getDemoProjectById(projectId) : projectQuery.data),
|
() => (isDemoMode ? getDemoProjectById(projectId) : projectQuery.data),
|
||||||
[isDemoMode, projectId, projectQuery.data],
|
[isDemoMode, projectId, projectQuery.data],
|
||||||
@@ -720,14 +744,24 @@ export function ProjectWorkspacePage() {
|
|||||||
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
|
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
|
||||||
</button>
|
</button>
|
||||||
{!isDemoMode && signedIn && (
|
{!isDemoMode && signedIn && (
|
||||||
<button
|
<>
|
||||||
onClick={openAddService}
|
<button
|
||||||
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
|
onClick={() => setImportOpen(true)}
|
||||||
style={{ background: 'var(--accent-primary)' }}
|
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
>
|
title="Deploy a docker-compose file into this project"
|
||||||
<Plus size={14} />
|
>
|
||||||
<span className="hidden sm:inline">Add Service</span>
|
<FileUp size={14} />
|
||||||
</button>
|
<span className="hidden sm:inline">Compose</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={openAddService}
|
||||||
|
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
<span className="hidden sm:inline">Add Service</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -935,6 +969,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
|
||||||
@@ -970,6 +1006,49 @@ export function ProjectWorkspacePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Compose Import */}
|
||||||
|
{importOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setImportOpen(false)} />
|
||||||
|
<div className="relative w-full max-w-2xl panel p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||||
|
<FileUp size={20} className="text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Import docker-compose</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
Paste a docker-compose.yml. Services deploy in dependency order; postgres, mysql, redis, mongo,
|
||||||
|
mariadb, clickhouse and dragonfly images become managed databases reachable by service name.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={composeYaml}
|
||||||
|
onChange={(e) => setComposeYaml(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
className="mt-5 w-full h-72 px-4 py-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm resize-none"
|
||||||
|
placeholder={'services:\n web:\n image: nginx:alpine\n ports:\n - "80"\n db:\n image: postgres:16-alpine\n environment:\n POSTGRES_PASSWORD: secret'}
|
||||||
|
/>
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setImportOpen(false)}
|
||||||
|
className="h-10 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => composeImportMutation.mutate(composeYaml)}
|
||||||
|
disabled={composeImportMutation.isPending || composeYaml.trim().length === 0}
|
||||||
|
className="h-10 px-5 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50 transition-all flex items-center gap-2"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{composeImportMutation.isPending && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
Deploy stack
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Command Palette */}
|
{/* Command Palette */}
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
open={commandPaletteOpen}
|
open={commandPaletteOpen}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
|||||||
"path": "guides/CLI.md",
|
"path": "guides/CLI.md",
|
||||||
"section": "guides",
|
"section": "guides",
|
||||||
"title": "Containr CLI",
|
"title": "Containr CLI",
|
||||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add <id> app.example.com --default\ncontainr services domains check <id> # DNS preflight per domain\ncontainr services domains remove <id> <domain-id>\n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update <id> --maintenance on\ncontainr services update <id> --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdiLine truncated
|
"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)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\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\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\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\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nup
Line truncated
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||||
|
|||||||
@@ -398,6 +398,10 @@ textarea {
|
|||||||
background: var(--text-tertiary);
|
background: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-sleeping {
|
||||||
|
background: var(--info, #6366f1);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse-ring {
|
@keyframes pulse-ring {
|
||||||
0% { transform: scale(1); opacity: 0.7; }
|
0% { transform: scale(1); opacity: 0.7; }
|
||||||
70% { transform: scale(2); opacity: 0; }
|
70% { transform: scale(2); opacity: 0; }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { components, paths } from '@/generated/api-types';
|
import type { components, paths } from '@/generated/api-types';
|
||||||
|
|
||||||
export type ServiceStatus = 'running' | 'degraded' | 'stopped' | 'building' | 'failed' | 'unknown';
|
export type ServiceStatus = 'running' | 'degraded' | 'stopped' | 'building' | 'failed' | 'sleeping' | 'waking' | 'unknown';
|
||||||
|
|
||||||
export type ProjectStats = components['schemas']['ProjectStats'];
|
export type ProjectStats = components['schemas']['ProjectStats'];
|
||||||
|
|
||||||
@@ -24,6 +24,24 @@ export type UserProfile = {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Branding = {
|
||||||
|
productName: string;
|
||||||
|
logoUrl: string;
|
||||||
|
faviconUrl: string;
|
||||||
|
accentColor: string;
|
||||||
|
docsUrl: string;
|
||||||
|
supportUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrandingInput = Partial<{
|
||||||
|
productName: string;
|
||||||
|
logoUrl: string;
|
||||||
|
faviconUrl: string;
|
||||||
|
accentColor: string;
|
||||||
|
docsUrl: string;
|
||||||
|
supportUrl: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type PlatformSettings = {
|
export type PlatformSettings = {
|
||||||
signupEnabled: boolean;
|
signupEnabled: boolean;
|
||||||
cloudflareTunnel: {
|
cloudflareTunnel: {
|
||||||
@@ -31,6 +49,7 @@ export type PlatformSettings = {
|
|||||||
source: 'app' | 'env' | 'none';
|
source: 'app' | 'env' | 'none';
|
||||||
container: string;
|
container: string;
|
||||||
};
|
};
|
||||||
|
branding: Branding;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServiceEntity = {
|
export type ServiceEntity = {
|
||||||
@@ -63,6 +82,8 @@ export type ServiceEntity = {
|
|||||||
memoryReserve?: string;
|
memoryReserve?: string;
|
||||||
staticBuildCmd?: string;
|
staticBuildCmd?: string;
|
||||||
staticDir?: string;
|
staticDir?: string;
|
||||||
|
sleepEnabled?: boolean;
|
||||||
|
sleepIdleMinutes?: number;
|
||||||
publicUrl?: string;
|
publicUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,7 +185,20 @@ export type TemplateEntity = {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TemplateServiceSpecEntity = {
|
||||||
|
key: string;
|
||||||
|
name?: string;
|
||||||
|
type: string;
|
||||||
|
runtime?: string;
|
||||||
|
repo?: string;
|
||||||
|
branch?: string;
|
||||||
|
port?: number;
|
||||||
|
dependsOn?: string[];
|
||||||
|
environment: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
export type TemplateConfigEntity = {
|
export type TemplateConfigEntity = {
|
||||||
|
version?: number;
|
||||||
type: string;
|
type: string;
|
||||||
runtime: string;
|
runtime: string;
|
||||||
buildCommand: string;
|
buildCommand: string;
|
||||||
@@ -174,6 +208,7 @@ export type TemplateConfigEntity = {
|
|||||||
environment: Record<string, string>;
|
environment: Record<string, string>;
|
||||||
dockerfile?: string;
|
dockerfile?: string;
|
||||||
nixpacksConfig: Record<string, string>;
|
nixpacksConfig: Record<string, string>;
|
||||||
|
services?: TemplateServiceSpecEntity[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TemplateVariableEntity = {
|
export type TemplateVariableEntity = {
|
||||||
@@ -197,12 +232,15 @@ export type ListTemplatesInput = {
|
|||||||
|
|
||||||
export type DeployTemplateInput = {
|
export type DeployTemplateInput = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
name: string;
|
name?: string;
|
||||||
variables?: Record<string, string>;
|
variables?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeployTemplateResult = {
|
export type DeployTemplateResult = {
|
||||||
serviceId: string;
|
resource: string;
|
||||||
|
serviceId?: string;
|
||||||
|
databaseId?: string;
|
||||||
|
created?: { key: string; kind: string; id: string; name: string; deploymentId?: string }[];
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -523,6 +561,37 @@ function normalizeUserProfile(profile: RawUserProfile): UserProfile | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeBranding(raw: {
|
||||||
|
product_name?: string;
|
||||||
|
logo_url?: string;
|
||||||
|
favicon_url?: string;
|
||||||
|
accent_color?: string;
|
||||||
|
docs_url?: string;
|
||||||
|
support_url?: string;
|
||||||
|
} | undefined | null): Branding {
|
||||||
|
return {
|
||||||
|
productName: raw?.product_name ?? 'Containr',
|
||||||
|
logoUrl: raw?.logo_url ?? '',
|
||||||
|
faviconUrl: raw?.favicon_url ?? '',
|
||||||
|
accentColor: raw?.accent_color ?? '',
|
||||||
|
docsUrl: raw?.docs_url ?? '',
|
||||||
|
supportUrl: raw?.support_url ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /branding is public — used before login to theme the shell.
|
||||||
|
export async function getBranding(): Promise<Branding> {
|
||||||
|
const raw = await requestJson<{
|
||||||
|
product_name?: string;
|
||||||
|
logo_url?: string;
|
||||||
|
favicon_url?: string;
|
||||||
|
accent_color?: string;
|
||||||
|
docs_url?: string;
|
||||||
|
support_url?: string;
|
||||||
|
}>('/branding');
|
||||||
|
return normalizeBranding(raw);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePlatformSettings(raw: components['schemas']['PlatformSettings']): PlatformSettings {
|
function normalizePlatformSettings(raw: components['schemas']['PlatformSettings']): PlatformSettings {
|
||||||
return {
|
return {
|
||||||
signupEnabled: raw.signup_enabled ?? false,
|
signupEnabled: raw.signup_enabled ?? false,
|
||||||
@@ -531,6 +600,7 @@ function normalizePlatformSettings(raw: components['schemas']['PlatformSettings'
|
|||||||
source: raw.cloudflare_tunnel?.source ?? 'none',
|
source: raw.cloudflare_tunnel?.source ?? 'none',
|
||||||
container: raw.cloudflare_tunnel?.container ?? 'missing',
|
container: raw.cloudflare_tunnel?.container ?? 'missing',
|
||||||
},
|
},
|
||||||
|
branding: normalizeBranding(raw.branding),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,6 +612,7 @@ export async function getPlatformSettings(): Promise<PlatformSettings> {
|
|||||||
export async function updatePlatformSettings(input: {
|
export async function updatePlatformSettings(input: {
|
||||||
signupEnabled?: boolean;
|
signupEnabled?: boolean;
|
||||||
cloudflareTunnelToken?: string;
|
cloudflareTunnelToken?: string;
|
||||||
|
branding?: BrandingInput;
|
||||||
}): Promise<PlatformSettings> {
|
}): Promise<PlatformSettings> {
|
||||||
const body: Record<string, unknown> = {};
|
const body: Record<string, unknown> = {};
|
||||||
if (input.signupEnabled !== undefined) {
|
if (input.signupEnabled !== undefined) {
|
||||||
@@ -550,6 +621,16 @@ export async function updatePlatformSettings(input: {
|
|||||||
if (input.cloudflareTunnelToken !== undefined) {
|
if (input.cloudflareTunnelToken !== undefined) {
|
||||||
body.cloudflare_tunnel_token = input.cloudflareTunnelToken;
|
body.cloudflare_tunnel_token = input.cloudflareTunnelToken;
|
||||||
}
|
}
|
||||||
|
if (input.branding !== undefined) {
|
||||||
|
const b: Record<string, string> = {};
|
||||||
|
if (input.branding.productName !== undefined) b.product_name = input.branding.productName;
|
||||||
|
if (input.branding.logoUrl !== undefined) b.logo_url = input.branding.logoUrl;
|
||||||
|
if (input.branding.faviconUrl !== undefined) b.favicon_url = input.branding.faviconUrl;
|
||||||
|
if (input.branding.accentColor !== undefined) b.accent_color = input.branding.accentColor;
|
||||||
|
if (input.branding.docsUrl !== undefined) b.docs_url = input.branding.docsUrl;
|
||||||
|
if (input.branding.supportUrl !== undefined) b.support_url = input.branding.supportUrl;
|
||||||
|
body.branding = b;
|
||||||
|
}
|
||||||
const raw = await requestJson<components['schemas']['PlatformSettings']>('/settings', {
|
const raw = await requestJson<components['schemas']['PlatformSettings']>('/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -594,6 +675,8 @@ function normalizeService(service: RawService): ServiceEntity | null {
|
|||||||
memoryReserve: service.memory_reserve,
|
memoryReserve: service.memory_reserve,
|
||||||
staticBuildCmd: service.static_build_cmd,
|
staticBuildCmd: service.static_build_cmd,
|
||||||
staticDir: service.static_dir,
|
staticDir: service.static_dir,
|
||||||
|
sleepEnabled: service.sleep_enabled,
|
||||||
|
sleepIdleMinutes: service.sleep_idle_minutes,
|
||||||
publicUrl: service.public_url,
|
publicUrl: service.public_url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -696,7 +779,21 @@ function normalizeStringRecord(raw: unknown): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEntity {
|
function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEntity {
|
||||||
|
const services = Array.isArray(config?.services)
|
||||||
|
? config.services.map((s) => ({
|
||||||
|
key: s?.key ?? '',
|
||||||
|
name: s?.name ?? undefined,
|
||||||
|
type: s?.type ?? 'web',
|
||||||
|
runtime: s?.runtime ?? undefined,
|
||||||
|
repo: s?.repo ?? undefined,
|
||||||
|
branch: s?.branch ?? undefined,
|
||||||
|
port: s?.port ?? undefined,
|
||||||
|
dependsOn: Array.isArray(s?.depends_on) ? s.depends_on.filter((d): d is string => typeof d === 'string') : undefined,
|
||||||
|
environment: normalizeStringRecord(s?.environment),
|
||||||
|
}))
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
|
version: config?.version ?? undefined,
|
||||||
type: config?.type ?? '',
|
type: config?.type ?? '',
|
||||||
runtime: config?.runtime ?? '',
|
runtime: config?.runtime ?? '',
|
||||||
buildCommand: config?.build_command ?? '',
|
buildCommand: config?.build_command ?? '',
|
||||||
@@ -706,6 +803,7 @@ function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEnti
|
|||||||
environment: normalizeStringRecord(config?.environment),
|
environment: normalizeStringRecord(config?.environment),
|
||||||
dockerfile: config?.dockerfile ?? undefined,
|
dockerfile: config?.dockerfile ?? undefined,
|
||||||
nixpacksConfig: normalizeStringRecord(config?.nixpacks_config),
|
nixpacksConfig: normalizeStringRecord(config?.nixpacks_config),
|
||||||
|
services,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1185,6 +1283,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.
|
||||||
@@ -1196,13 +1356,26 @@ export async function getServiceRuntime(serviceId: string): Promise<ServiceRunti
|
|||||||
return normalizeRuntime(payload.runtime);
|
return normalizeRuntime(payload.runtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart'): Promise<void> {
|
async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart' | 'sleep' | 'wake'): Promise<void> {
|
||||||
await requestJson<{ message?: string }>(`/services/${serviceId}/${action}`, { method: 'POST' });
|
await requestJson<{ message?: string }>(`/services/${serviceId}/${action}`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export const startService = (serviceId: string) => serviceAction(serviceId, 'start');
|
export const startService = (serviceId: string) => serviceAction(serviceId, 'start');
|
||||||
export const stopService = (serviceId: string) => serviceAction(serviceId, 'stop');
|
export const stopService = (serviceId: string) => serviceAction(serviceId, 'stop');
|
||||||
export const restartService = (serviceId: string) => serviceAction(serviceId, 'restart');
|
export const restartService = (serviceId: string) => serviceAction(serviceId, 'restart');
|
||||||
|
export const sleepService = (serviceId: string) => serviceAction(serviceId, 'sleep');
|
||||||
|
export const wakeService = (serviceId: string) => serviceAction(serviceId, 'wake');
|
||||||
|
|
||||||
|
export type EnvCheckResult = {
|
||||||
|
ok: boolean;
|
||||||
|
unresolved?: string[];
|
||||||
|
empty?: string[];
|
||||||
|
unreadable?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getServiceEnvCheck(serviceId: string): Promise<EnvCheckResult> {
|
||||||
|
return requestJson<EnvCheckResult>(`/services/${serviceId}/env-check`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function redeployService(serviceId: string): Promise<ServiceRuntime> {
|
export async function redeployService(serviceId: string): Promise<ServiceRuntime> {
|
||||||
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, {
|
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, {
|
||||||
@@ -1248,6 +1421,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) {
|
||||||
@@ -1524,21 +1713,265 @@ export async function deployTemplate(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
project_id: input.projectId,
|
project_id: input.projectId,
|
||||||
name: input.name,
|
name: input.name ?? '',
|
||||||
variables: input.variables ?? {},
|
variables: input.variables ?? {},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!payload.service_id) {
|
const created = Array.isArray(payload.created)
|
||||||
throw new ApiError('Template deployment response is invalid', 500);
|
? payload.created
|
||||||
}
|
.filter((m) => m && typeof m.id === 'string')
|
||||||
|
.map((m) => ({
|
||||||
|
key: m.key ?? '',
|
||||||
|
kind: m.kind ?? '',
|
||||||
|
id: m.id ?? '',
|
||||||
|
name: m.name ?? '',
|
||||||
|
deploymentId: m.deployment_id ?? undefined,
|
||||||
|
}))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serviceId: payload.service_id,
|
resource: payload.resource ?? 'service',
|
||||||
message: payload.message ?? 'Service created from template',
|
serviceId: payload.service_id ?? undefined,
|
||||||
|
databaseId: payload.database_id ?? undefined,
|
||||||
|
created,
|
||||||
|
message: payload.message ?? 'Template deployed',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TemplatePlanMember = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
runtime?: string;
|
||||||
|
port?: number;
|
||||||
|
dependsOn?: string[];
|
||||||
|
environment: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TemplatePlanResult = {
|
||||||
|
members: TemplatePlanMember[];
|
||||||
|
missingVariables: string[];
|
||||||
|
unresolved: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function planTemplate(
|
||||||
|
templateId: string,
|
||||||
|
input: { projectId?: string; variables?: Record<string, string> },
|
||||||
|
): Promise<TemplatePlanResult> {
|
||||||
|
const payload = await requestJson<{
|
||||||
|
members?: {
|
||||||
|
key?: string;
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
runtime?: string;
|
||||||
|
port?: number;
|
||||||
|
depends_on?: string[];
|
||||||
|
environment?: Record<string, string>;
|
||||||
|
}[];
|
||||||
|
missing_variables?: string[];
|
||||||
|
unresolved?: string[];
|
||||||
|
}>(`/templates/${templateId}/plan`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
project_id: input.projectId ?? '',
|
||||||
|
variables: input.variables ?? {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
members: (payload.members ?? []).map((m) => ({
|
||||||
|
key: m.key ?? '',
|
||||||
|
name: m.name ?? '',
|
||||||
|
type: m.type ?? '',
|
||||||
|
runtime: m.runtime ?? undefined,
|
||||||
|
port: m.port ?? undefined,
|
||||||
|
dependsOn: m.depends_on ?? undefined,
|
||||||
|
environment: normalizeStringRecord(m.environment),
|
||||||
|
})),
|
||||||
|
missingVariables: payload.missing_variables ?? [],
|
||||||
|
unresolved: payload.unresolved ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComposeImportResult = {
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function importComposeTemplate(composeYaml: string): Promise<ComposeImportResult> {
|
||||||
|
const payload = await requestJson<{ config?: Record<string, unknown>; warnings?: string[] }>(
|
||||||
|
'/templates/import/compose',
|
||||||
|
{ method: 'POST', body: JSON.stringify({ compose_yaml: composeYaml }) },
|
||||||
|
);
|
||||||
|
if (!payload.config || typeof payload.config !== 'object') {
|
||||||
|
throw new ApiError('Compose import response is invalid', 500);
|
||||||
|
}
|
||||||
|
return { config: payload.config, warnings: payload.warnings ?? [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectComposeImportResult = {
|
||||||
|
created: Array<{ key: string; kind: string; id: string; name: string; deployment_id?: string }>;
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function importComposeIntoProject(
|
||||||
|
projectId: string,
|
||||||
|
composeYaml: string,
|
||||||
|
): Promise<ProjectComposeImportResult> {
|
||||||
|
const payload = await requestJson<{
|
||||||
|
created?: ProjectComposeImportResult['created'];
|
||||||
|
warnings?: string[];
|
||||||
|
}>(`/projects/${projectId}/import-compose`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ compose_yaml: composeYaml }),
|
||||||
|
});
|
||||||
|
return { created: payload.created ?? [], warnings: payload.warnings ?? [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OutboundWebhook = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
secret?: string;
|
||||||
|
events: string[];
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebhookDelivery = {
|
||||||
|
id: string;
|
||||||
|
webhook_id: string;
|
||||||
|
event: string;
|
||||||
|
status: 'pending' | 'success' | 'failed';
|
||||||
|
response_status?: number | null;
|
||||||
|
response_body?: string | null;
|
||||||
|
attempts: number;
|
||||||
|
duration_ms?: number | null;
|
||||||
|
created_at: string;
|
||||||
|
delivered_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listWebhooks(): Promise<OutboundWebhook[]> {
|
||||||
|
const payload = await requestJson<{ webhooks?: OutboundWebhook[] }>('/webhooks');
|
||||||
|
return payload.webhooks ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebhookInput = {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
events: string[];
|
||||||
|
secret?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createWebhook(input: WebhookInput): Promise<OutboundWebhook> {
|
||||||
|
return requestJson<OutboundWebhook>('/webhooks', { method: 'POST', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWebhook(id: string, input: Partial<WebhookInput>): Promise<OutboundWebhook> {
|
||||||
|
return requestJson<OutboundWebhook>(`/webhooks/${id}`, { method: 'PATCH', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWebhook(id: string): Promise<void> {
|
||||||
|
await requestJson(`/webhooks/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWebhookDeliveries(id: string): Promise<WebhookDelivery[]> {
|
||||||
|
const payload = await requestJson<{ deliveries?: WebhookDelivery[] }>(`/webhooks/${id}/deliveries`);
|
||||||
|
return payload.deliveries ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testWebhook(id: string): Promise<void> {
|
||||||
|
await requestJson(`/webhooks/${id}/test`, { method: 'POST', body: JSON.stringify({}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OperationsView = {
|
||||||
|
active_deployments: Array<{
|
||||||
|
id: string;
|
||||||
|
service_id: string;
|
||||||
|
service_name: string;
|
||||||
|
project_name: string;
|
||||||
|
status: string;
|
||||||
|
image: string;
|
||||||
|
error?: string | null;
|
||||||
|
started_at?: string | null;
|
||||||
|
completed_at?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}>;
|
||||||
|
deploy_queue: Array<{ service_id: string; running: boolean; queued: number }>;
|
||||||
|
recent_failures: OperationsView['active_deployments'];
|
||||||
|
cron_runs: Array<{
|
||||||
|
id: string;
|
||||||
|
job_name: string;
|
||||||
|
schedule: string;
|
||||||
|
status: string;
|
||||||
|
started_at: string;
|
||||||
|
finished_at?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
}>;
|
||||||
|
backups: Array<{
|
||||||
|
id: string;
|
||||||
|
database_id: string;
|
||||||
|
database_name: string;
|
||||||
|
status: string;
|
||||||
|
size: string;
|
||||||
|
created_at: string;
|
||||||
|
completed_at?: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getOperations(): Promise<OperationsView> {
|
||||||
|
return requestJson<OperationsView>('/operations');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Banner = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
level: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
active: boolean;
|
||||||
|
dismissible: boolean;
|
||||||
|
starts_at?: string | null;
|
||||||
|
ends_at?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BannerInput = {
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
level?: Banner['level'];
|
||||||
|
active?: boolean;
|
||||||
|
dismissible?: boolean;
|
||||||
|
starts_at?: string;
|
||||||
|
ends_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listActiveBanners(): Promise<Banner[]> {
|
||||||
|
const payload = await requestJson<{ banners?: Banner[] }>('/banners/active');
|
||||||
|
return payload.banners ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminBanners(): Promise<Banner[]> {
|
||||||
|
const payload = await requestJson<{ banners?: Banner[] }>('/admin/banners');
|
||||||
|
return payload.banners ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBanner(input: BannerInput): Promise<Banner> {
|
||||||
|
return requestJson<Banner>('/admin/banners', { method: 'POST', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBanner(id: string, input: BannerInput): Promise<Banner> {
|
||||||
|
return requestJson<Banner>(`/admin/banners/${id}`, { method: 'PATCH', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBanner(id: string): Promise<void> {
|
||||||
|
await requestJson(`/admin/banners/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
export function serviceStatusClass(status: ServiceStatus): string {
|
export function serviceStatusClass(status: ServiceStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'running':
|
case 'running':
|
||||||
@@ -1549,6 +1982,10 @@ export function serviceStatusClass(status: ServiceStatus): string {
|
|||||||
return 'status-building';
|
return 'status-building';
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return 'status-failed';
|
return 'status-failed';
|
||||||
|
case 'sleeping':
|
||||||
|
return 'status-sleeping';
|
||||||
|
case 'waking':
|
||||||
|
return 'status-building';
|
||||||
case 'stopped':
|
case 'stopped':
|
||||||
return 'status-stopped';
|
return 'status-stopped';
|
||||||
default:
|
default:
|
||||||
@@ -1630,6 +2067,13 @@ export async function getDatabase(id: string): Promise<DatabaseEntity> {
|
|||||||
return requestJson<DatabaseEntity>(`/databases/${encodeURIComponent(id)}`);
|
return requestJson<DatabaseEntity>(`/databases/${encodeURIComponent(id)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateDatabase(id: string, input: components['schemas']['UpdateDatabaseRequest']): Promise<void> {
|
||||||
|
await requestJson(`/databases/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function databaseAction(id: string, action: 'start' | 'stop' | 'restart'): Promise<void> {
|
export async function databaseAction(id: string, action: 'start' | 'stop' | 'restart'): Promise<void> {
|
||||||
await requestJson(`/databases/${encodeURIComponent(id)}/action`, {
|
await requestJson(`/databases/${encodeURIComponent(id)}/action`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1658,6 +2102,21 @@ export async function restoreDatabaseBackup(id: string, backupId: string): Promi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function importDatabaseBackup(id: string, file: File): Promise<{ backup_id: string; size: string }> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const resp = await fetch(`${getApiBaseUrl()}/databases/${encodeURIComponent(id)}/backups/import`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const payload = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new ApiError(payload.error || 'Import failed', resp.status);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createManagedDatabase(
|
export async function createManagedDatabase(
|
||||||
input: CreateDatabaseInput,
|
input: CreateDatabaseInput,
|
||||||
): Promise<{ id: string; status: string }> {
|
): Promise<{ id: string; status: string }> {
|
||||||
@@ -1671,6 +2130,73 @@ 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 BackupTarget = components['schemas']['BackupTarget'];
|
||||||
|
export type BackupTargetInput = components['schemas']['BackupTargetRequest'];
|
||||||
|
|
||||||
|
export async function listBackupTargets(): Promise<BackupTarget[]> {
|
||||||
|
const payload = await requestJson<{ backup_targets?: BackupTarget[] }>('/backup-targets');
|
||||||
|
return payload.backup_targets ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBackupTarget(input: BackupTargetInput): Promise<BackupTarget> {
|
||||||
|
const payload = await requestJson<{ backup_target?: BackupTarget }>('/backup-targets', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!payload.backup_target) {
|
||||||
|
throw new ApiError('Backup target response is invalid', 500);
|
||||||
|
}
|
||||||
|
return payload.backup_target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBackupTarget(id: string, input: BackupTargetInput): Promise<void> {
|
||||||
|
await requestJson(`/backup-targets/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBackupTarget(id: string): Promise<void> {
|
||||||
|
await requestJson(`/backup-targets/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testBackupTarget(id: string): Promise<{ ok: boolean; latency_ms?: number; error?: string }> {
|
||||||
|
return requestJson(`/backup-targets/${encodeURIComponent(id)}/test`, { 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,54 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getBranding, type Branding } from '@/lib/api-client';
|
||||||
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
|
|
||||||
|
const DEFAULT_TITLE = 'containr';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the instance's white-label branding (public endpoint) and applies
|
||||||
|
* it: document title, favicon, and the accent CSS variable. Defaults keep
|
||||||
|
* the app Containr-branded.
|
||||||
|
*/
|
||||||
|
export function useBranding(): Branding {
|
||||||
|
const isDemoMode = useDemoMode();
|
||||||
|
const brandingQuery = useQuery({
|
||||||
|
queryKey: ['branding'],
|
||||||
|
queryFn: getBranding,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
enabled: !isDemoMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const branding = brandingQuery.data ?? {
|
||||||
|
productName: 'Containr',
|
||||||
|
logoUrl: '',
|
||||||
|
faviconUrl: '',
|
||||||
|
accentColor: '',
|
||||||
|
docsUrl: '',
|
||||||
|
supportUrl: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const name = branding.productName;
|
||||||
|
document.title = name && name !== 'Containr' ? name : DEFAULT_TITLE;
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (branding.accentColor && /^#[0-9a-fA-F]{3,8}$/.test(branding.accentColor)) {
|
||||||
|
root.style.setProperty('--accent-primary', branding.accentColor);
|
||||||
|
} else {
|
||||||
|
root.style.removeProperty('--accent-primary');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branding.faviconUrl) {
|
||||||
|
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement('link');
|
||||||
|
link.rel = 'icon';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
link.href = branding.faviconUrl;
|
||||||
|
}
|
||||||
|
}, [branding.productName, branding.accentColor, branding.faviconUrl]);
|
||||||
|
|
||||||
|
return branding;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { AlertTriangle, CheckCircle2, Info, Megaphone, X, XCircle } from 'lucide-react';
|
||||||
|
import { listActiveBanners, type Banner } from '@/lib/api-client';
|
||||||
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
|
|
||||||
|
const DISMISSED_KEY = 'containr.dismissed-banners';
|
||||||
|
|
||||||
|
function dismissedIds(): Set<string> {
|
||||||
|
try {
|
||||||
|
return new Set(JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? '[]'));
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelStyle: Record<Banner['level'], { bg: string; fg: string; icon: typeof Info }> = {
|
||||||
|
info: { bg: 'var(--accent-primary-soft)', fg: 'var(--accent-primary)', icon: Info },
|
||||||
|
success: { bg: 'var(--success-soft)', fg: 'var(--success)', icon: CheckCircle2 },
|
||||||
|
warning: { bg: 'var(--warning-soft)', fg: 'var(--warning)', icon: AlertTriangle },
|
||||||
|
error: { bg: 'var(--error-soft)', fg: 'var(--error)', icon: XCircle },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BannerBar() {
|
||||||
|
const isDemoMode = useDemoMode();
|
||||||
|
const [dismissed, setDismissed] = useState<Set<string>>(dismissedIds);
|
||||||
|
|
||||||
|
const bannersQuery = useQuery({
|
||||||
|
queryKey: ['active-banners'],
|
||||||
|
queryFn: listActiveBanners,
|
||||||
|
enabled: !isDemoMode,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const banners = (bannersQuery.data ?? []).filter((b) => !dismissed.has(b.id));
|
||||||
|
if (banners.length === 0) return null;
|
||||||
|
|
||||||
|
const dismiss = (id: string) => {
|
||||||
|
setDismissed((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(id);
|
||||||
|
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...next]));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shrink-0">
|
||||||
|
{banners.map((banner) => {
|
||||||
|
const style = levelStyle[banner.level] ?? levelStyle.info;
|
||||||
|
const Icon = banner.level === 'info' ? Megaphone : style.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={banner.id}
|
||||||
|
className="flex items-center gap-3 border-b border-[var(--border-subtle)] px-5 py-2"
|
||||||
|
style={{ background: style.bg }}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<Icon size={14} className="shrink-0" style={{ color: style.fg }} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-[12.5px] font-semibold" style={{ color: style.fg }}>
|
||||||
|
{banner.title}
|
||||||
|
</span>
|
||||||
|
{banner.body ? (
|
||||||
|
<span className="ml-2 text-[12px] text-[var(--text-secondary)]">{banner.body}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{banner.dismissible ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismiss(banner.id)}
|
||||||
|
className="shrink-0 rounded p-0.5 hover:bg-black/10"
|
||||||
|
style={{ color: style.fg }}
|
||||||
|
aria-label="Dismiss announcement"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useBranding } from '@/lib/use-branding';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instance wordmark — renders the configured logo when set, otherwise the
|
||||||
|
* product name with the signature accent on the last character
|
||||||
|
* ("contain[r]"). Falls back to Containr branding.
|
||||||
|
*/
|
||||||
|
export function BrandWordmark({ className = '' }: { className?: string }) {
|
||||||
|
const branding = useBranding();
|
||||||
|
|
||||||
|
if (branding.logoUrl) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={branding.logoUrl}
|
||||||
|
alt={branding.productName}
|
||||||
|
className={`h-6 w-auto object-contain ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = branding.productName;
|
||||||
|
return (
|
||||||
|
<span className={className}>
|
||||||
|
{name.slice(0, -1)}
|
||||||
|
<span style={{ color: 'var(--accent-primary)' }}>{name.slice(-1)}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './BannerBar';
|
||||||
export * from './DemoGate';
|
export * from './DemoGate';
|
||||||
export * from './ErrorBoundary';
|
export * from './ErrorBoundary';
|
||||||
export * from './LoadingSpinner';
|
export * from './LoadingSpinner';
|
||||||
@@ -9,3 +10,4 @@ export * from './CommandPalette';
|
|||||||
export * from './StatusBadge';
|
export * from './StatusBadge';
|
||||||
export { useToast } from '../hooks/use-toast';
|
export { useToast } from '../hooks/use-toast';
|
||||||
export type { ToastType, ToastContextValue } from '../hooks/use-toast';
|
export type { ToastType, ToastContextValue } from '../hooks/use-toast';
|
||||||
|
export * from './BrandWordmark';
|
||||||
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
|
const { defineConfig } = require('eslint/config');
|
||||||
|
const expoConfig = require("eslint-config-expo/flat");
|
||||||
|
|
||||||
|
module.exports = defineConfig([
|
||||||
|
expoConfig,
|
||||||
|
{
|
||||||
|
ignores: ["dist/*"],
|
||||||
|
}
|
||||||
|
]);
|
||||||
Generated
+11420
File diff suppressed because it is too large.
Load diff
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user