mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e3994e937 | ||
|
|
355eb2c9ab | ||
|
|
2be1715015 | ||
|
|
e67a39436c | ||
|
|
4734be0d9d | ||
|
|
4e8d364911 | ||
|
|
f53695be4f | ||
|
|
925199633b | ||
|
|
1054679ca2 | ||
|
|
57f8b4f56e | ||
|
|
dd1bbaf5a2 | ||
|
|
58424ed0ab | ||
|
|
25fa8c47e6 | ||
|
|
f83ad81f73 | ||
|
|
2049087ccd | ||
|
|
5dc9b5f115 | ||
|
|
3664eaed1a |
No files matched your search
@@ -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
|
||||||
|
|||||||
+98
-62
@@ -121,16 +121,16 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
|
|||||||
- `openapi.yaml` stays the single source of truth; UI, CLI, MCP are thin
|
- `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).
|
||||||
|
|
||||||
@@ -318,6 +308,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.
|
||||||
|
|
||||||
@@ -335,6 +351,26 @@ and wired refs, one click or one CLI call.
|
|||||||
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 →
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"archive/tar"
|
"archive/tar"
|
||||||
"containr/internal/database/sqlcdb"
|
"containr/internal/database/sqlcdb"
|
||||||
"containr/internal/docker"
|
"containr/internal/docker"
|
||||||
|
"containr/internal/secrets"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
@@ -51,12 +52,30 @@ type DatabaseService struct {
|
|||||||
Region string `json:"region" db:"region"`
|
Region string `json:"region" db:"region"`
|
||||||
BackupSchedule string `json:"backup_schedule,omitempty"`
|
BackupSchedule string `json:"backup_schedule,omitempty"`
|
||||||
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
|
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
|
||||||
|
Provider string `json:"provider"` // managed | external
|
||||||
|
External *DatabaseExternalRef `json:"external,omitempty"` // connection target for external DBs
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
ConnectionURL string `json:"connection_url"`
|
ConnectionURL string `json:"connection_url"`
|
||||||
Metrics DatabaseMetrics `json:"metrics"`
|
Metrics DatabaseMetrics `json:"metrics"`
|
||||||
Backups DatabaseBackupConfig `json:"backups"`
|
Backups DatabaseBackupConfig `json:"backups"`
|
||||||
Settings DatabaseSettings `json:"settings"`
|
Settings DatabaseSettings `json:"settings"`
|
||||||
|
|
||||||
|
// externalPasswordEnc is the stored ciphertext — needed to compose the
|
||||||
|
// connection URL on privileged reads, never serialized.
|
||||||
|
externalPasswordEnc string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseExternalRef describes a registered external database's connection
|
||||||
|
// target. The password is stored encrypted and never returned — has_password
|
||||||
|
// signals presence.
|
||||||
|
type DatabaseExternalRef struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
HasPassword bool `json:"has_password"`
|
||||||
|
SSL bool `json:"ssl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseMetrics represents database performance metrics
|
// DatabaseMetrics represents database performance metrics
|
||||||
@@ -467,21 +486,20 @@ func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) {
|
|||||||
databaseID := c.Param("id")
|
databaseID := c.Param("id")
|
||||||
userID = h.effectiveUserID(c, userID, databaseID)
|
userID = h.effectiveUserID(c, userID, databaseID)
|
||||||
|
|
||||||
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
|
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||||
ID: databaseID,
|
ID: databaseID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if row.Provider != "external" && h.dockerClient != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.dockerClient != nil {
|
|
||||||
if err := h.deleteManagedDatabaseRuntime(databaseID); err != nil {
|
if err := h.deleteManagedDatabaseRuntime(databaseID); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete runtime database"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete runtime database"})
|
||||||
return
|
return
|
||||||
@@ -515,17 +533,24 @@ func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
|
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||||
ID: databaseID,
|
ID: databaseID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if row.Provider == "external" {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "Lifecycle actions apply to managed databases only — external databases are run elsewhere",
|
||||||
|
"code": "VALIDATION",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,6 +644,13 @@ func (h *DatabaseHandler) CreateBackup(c *gin.Context) {
|
|||||||
c.JSON(http.StatusConflict, gin.H{"error": "Database is still provisioning"})
|
c.JSON(http.StatusConflict, gin.H{"error": "Database is still provisioning"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if row.Provider == "external" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "Backups run against the managed dump pipeline — external database backups are not supported yet",
|
||||||
|
"code": "VALIDATION",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
backupID := generateDatabaseBackupID(databaseID)
|
backupID := generateDatabaseBackupID(databaseID)
|
||||||
archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID))
|
archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID))
|
||||||
@@ -659,17 +691,23 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
|
dbRow, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||||
ID: databaseID,
|
ID: databaseID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if dbRow.Provider == "external" {
|
||||||
if !exists {
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
"error": "Restores run against the managed dump pipeline — external database restores are not supported yet",
|
||||||
|
"code": "VALIDATION",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,7 +825,7 @@ func (h *DatabaseHandler) DownloadBackup(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics {
|
func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics {
|
||||||
if h.dockerClient == nil {
|
if h.dockerClient == nil || db.Provider == "external" {
|
||||||
return h.generateMockMetrics()
|
return h.generateMockMetrics()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,7 +983,11 @@ func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
|
|||||||
|
|
||||||
// resolveConnectionURL returns the stored connection URL for privileged
|
// resolveConnectionURL returns the stored connection URL for privileged
|
||||||
// viewers, or "" when the runtime has not produced one (not provisioned yet).
|
// viewers, or "" when the runtime has not produced one (not provisioned yet).
|
||||||
|
// External databases compose it from the registered fields on demand.
|
||||||
func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string {
|
func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string {
|
||||||
|
if db.Provider == "external" && db.External != nil {
|
||||||
|
return externalConnectionURL(db.Type, db.External, secrets.Decrypt(db.externalPasswordEnc))
|
||||||
|
}
|
||||||
return strings.TrimSpace(db.ConnectionURL)
|
return strings.TrimSpace(db.ConnectionURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -987,7 +1029,7 @@ func rewriteLoopbackURL(rawURL, host string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService {
|
func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService {
|
||||||
if h.dockerClient == nil {
|
if h.dockerClient == nil || db.Provider == "external" {
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1054,7 +1096,7 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
|
func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
|
||||||
return DatabaseService{
|
svc := DatabaseService{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
OwnerID: row.UserID,
|
OwnerID: row.UserID,
|
||||||
Name: row.Name,
|
Name: row.Name,
|
||||||
@@ -1065,10 +1107,23 @@ func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
|
|||||||
Region: row.Region,
|
Region: row.Region,
|
||||||
BackupSchedule: databaseNullString(row.BackupSchedule),
|
BackupSchedule: databaseNullString(row.BackupSchedule),
|
||||||
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
|
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
|
||||||
|
Provider: row.Provider,
|
||||||
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
||||||
CreatedAt: databaseNullTime(row.CreatedAt),
|
CreatedAt: databaseNullTime(row.CreatedAt),
|
||||||
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
||||||
}
|
}
|
||||||
|
if svc.Provider == "external" {
|
||||||
|
svc.External = &DatabaseExternalRef{
|
||||||
|
Host: databaseNullString(row.ExternalHost),
|
||||||
|
Port: int(row.ExternalPort.Int32),
|
||||||
|
Database: databaseNullString(row.ExternalName),
|
||||||
|
Username: databaseNullString(row.ExternalUsername),
|
||||||
|
HasPassword: row.ExternalPassword != "",
|
||||||
|
SSL: row.ExternalSsl,
|
||||||
|
}
|
||||||
|
svc.externalPasswordEnc = row.ExternalPassword
|
||||||
|
}
|
||||||
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
func databaseNullTime(value sql.NullTime) time.Time {
|
func databaseNullTime(value sql.NullTime) time.Time {
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database/sqlcdb"
|
||||||
|
"containr/internal/secrets"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// externalDatabaseRequest is the shared payload for registering an external
|
||||||
|
// database and for ad-hoc connection tests.
|
||||||
|
type externalDatabaseRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type" binding:"required"`
|
||||||
|
Host string `json:"host" binding:"required"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
SSL bool `json:"ssl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var externalDefaultPorts = map[string]int{
|
||||||
|
"postgresql": 5432,
|
||||||
|
"mysql": 3306,
|
||||||
|
"mariadb": 3306,
|
||||||
|
"mongodb": 27017,
|
||||||
|
"redis": 6379,
|
||||||
|
"dragonfly": 6379,
|
||||||
|
"clickhouse": 9000,
|
||||||
|
}
|
||||||
|
|
||||||
|
// externalConnectionURL composes a DSN for display/bind use. The password is
|
||||||
|
// embedded only on privileged reads — the value is decrypted at that point.
|
||||||
|
func externalConnectionURL(dbType string, ext *DatabaseExternalRef, password string) string {
|
||||||
|
host := net.JoinHostPort(ext.Host, fmt.Sprintf("%d", ext.Port))
|
||||||
|
creds := ""
|
||||||
|
if ext.Username != "" {
|
||||||
|
creds = url.QueryEscape(ext.Username)
|
||||||
|
if password != "" {
|
||||||
|
creds += ":" + url.QueryEscape(password)
|
||||||
|
}
|
||||||
|
creds += "@"
|
||||||
|
}
|
||||||
|
switch dbType {
|
||||||
|
case "postgresql":
|
||||||
|
u := fmt.Sprintf("postgres://%s%s/%s", creds, host, ext.Database)
|
||||||
|
if ext.SSL {
|
||||||
|
u += "?sslmode=require"
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
case "mysql", "mariadb":
|
||||||
|
return fmt.Sprintf("mysql://%s%s/%s", creds, host, ext.Database)
|
||||||
|
case "mongodb":
|
||||||
|
u := fmt.Sprintf("mongodb://%s%s/%s", creds, host, ext.Database)
|
||||||
|
if ext.SSL {
|
||||||
|
u += "?tls=true"
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
case "redis", "dragonfly":
|
||||||
|
scheme := "redis"
|
||||||
|
if ext.SSL {
|
||||||
|
scheme = "rediss"
|
||||||
|
}
|
||||||
|
// Redis auth is password-only; username support is ACL-era.
|
||||||
|
if password != "" {
|
||||||
|
creds = ":" + url.QueryEscape(password) + "@"
|
||||||
|
if ext.Username != "" {
|
||||||
|
creds = url.QueryEscape(ext.Username) + creds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s%s", scheme, creds, host)
|
||||||
|
case "clickhouse":
|
||||||
|
return fmt.Sprintf("clickhouse://%s%s/%s", creds, host, ext.Database)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s%s", dbType, creds, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeExternalDatabase verifies reachability. postgres and redis-family get
|
||||||
|
// real protocol checks via the vendored drivers; everything else gets a TCP
|
||||||
|
// dial — enough to catch typos and firewall issues without new dependencies.
|
||||||
|
func probeExternalDatabase(ctx context.Context, req externalDatabaseRequest) (time.Duration, error) {
|
||||||
|
addr := net.JoinHostPort(req.Host, fmt.Sprintf("%d", req.Port))
|
||||||
|
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
switch req.Type {
|
||||||
|
case "postgresql":
|
||||||
|
sslmode := "disable"
|
||||||
|
if req.SSL {
|
||||||
|
sslmode = "require"
|
||||||
|
}
|
||||||
|
dsn := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s&connect_timeout=5",
|
||||||
|
url.QueryEscape(req.Username), url.QueryEscape(req.Password), addr,
|
||||||
|
url.QueryEscape(req.Database), sslmode)
|
||||||
|
conn, err := sql.Open("postgres", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if err := conn.PingContext(ctx); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
case "redis", "dragonfly":
|
||||||
|
client := redis.NewClient(&redis.Options{
|
||||||
|
Addr: addr,
|
||||||
|
Username: req.Username,
|
||||||
|
Password: req.Password,
|
||||||
|
DialTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 5 * time.Second,
|
||||||
|
TLSConfig: redisTLSConfig(req.SSL, req.Host),
|
||||||
|
})
|
||||||
|
defer client.Close()
|
||||||
|
if err := client.Ping(ctx).Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
return time.Since(start), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func redisTLSConfig(enabled bool, host string) *tls.Config {
|
||||||
|
if !enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTestDatabaseConnection probes arbitrary connection parameters — used
|
||||||
|
// by the register form's "test" button and by agents before registering.
|
||||||
|
func (h *DatabaseHandler) TestDatabaseConnection(c *gin.Context) {
|
||||||
|
if _, ok := requireAuthenticatedUserID(c); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req externalDatabaseRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := normalizeExternalRequest(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
latency, err := probeExternalDatabase(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDatabaseConnectionByID re-probes a registered external database using
|
||||||
|
// its stored (decrypted) credentials.
|
||||||
|
func (h *DatabaseHandler) TestDatabaseConnectionByID(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
databaseID := c.Param("id")
|
||||||
|
userID = h.effectiveUserID(c, userID, databaseID)
|
||||||
|
|
||||||
|
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||||
|
ID: databaseID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.Provider != "external" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "test-connection applies to external databases", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := externalDatabaseRequest{
|
||||||
|
Type: row.Type,
|
||||||
|
Host: databaseNullString(row.ExternalHost),
|
||||||
|
Port: int(row.ExternalPort.Int32),
|
||||||
|
Database: databaseNullString(row.ExternalName),
|
||||||
|
Username: databaseNullString(row.ExternalUsername),
|
||||||
|
Password: secrets.Decrypt(row.ExternalPassword),
|
||||||
|
SSL: row.ExternalSsl,
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
latency, probeErr := probeExternalDatabase(ctx, req)
|
||||||
|
if probeErr != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": false, "error": probeErr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeExternalRequest(req *externalDatabaseRequest) error {
|
||||||
|
req.Type = normalizeDatabaseType(req.Type)
|
||||||
|
if _, ok := supportedDatabaseTypes[req.Type]; !ok {
|
||||||
|
return fmt.Errorf("%w: %s", errUnsupportedDatabaseType, req.Type)
|
||||||
|
}
|
||||||
|
req.Host = strings.TrimSpace(req.Host)
|
||||||
|
if req.Host == "" {
|
||||||
|
return errors.New("host is required")
|
||||||
|
}
|
||||||
|
if req.Port <= 0 {
|
||||||
|
req.Port = externalDefaultPorts[req.Type]
|
||||||
|
}
|
||||||
|
if req.Port <= 0 || req.Port > 65535 {
|
||||||
|
return errors.New("port must be 1-65535")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterExternalDatabase records a database hosted elsewhere — same list,
|
||||||
|
// same bind surface, no container. The connection is probed first so typos
|
||||||
|
// fail at registration, not at the first service bind.
|
||||||
|
func (h *DatabaseHandler) RegisterExternalDatabase(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req externalDatabaseRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := normalizeExternalRequest(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := probeExternalDatabase(probeCtx, req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": fmt.Sprintf("Connection check failed: %v", err),
|
||||||
|
"code": "DEPENDENCY_UNAVAILABLE",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := h.queries.CountDatabaseServicesByUserAndName(c.Request.Context(), sqlcdb.CountDatabaseServicesByUserAndNameParams{
|
||||||
|
UserID: userID,
|
||||||
|
Name: req.Name,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate database name"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "database name already exists", "code": "CONFLICT"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseID := generateDatabaseID(req.Name)
|
||||||
|
now := sql.NullTime{Time: time.Now(), Valid: true}
|
||||||
|
encPassword := ""
|
||||||
|
if req.Password != "" {
|
||||||
|
encPassword = secrets.Encrypt(req.Password)
|
||||||
|
}
|
||||||
|
if err := h.queries.CreateExternalDatabaseService(c.Request.Context(), sqlcdb.CreateExternalDatabaseServiceParams{
|
||||||
|
ID: databaseID,
|
||||||
|
UserID: userID,
|
||||||
|
Name: req.Name,
|
||||||
|
Type: req.Type,
|
||||||
|
Version: "",
|
||||||
|
Plan: "external",
|
||||||
|
Region: "external",
|
||||||
|
ExternalHost: sql.NullString{String: req.Host, Valid: true},
|
||||||
|
ExternalPort: sql.NullInt32{Int32: int32(req.Port), Valid: true},
|
||||||
|
ExternalName: sql.NullString{String: req.Database, Valid: req.Database != ""},
|
||||||
|
ExternalUsername: sql.NullString{String: req.Username, Valid: req.Username != ""},
|
||||||
|
ExternalPassword: encPassword,
|
||||||
|
ExternalSsl: req.SSL,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register database"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogAudit(userID, "database", databaseID, "register-external", map[string]interface{}{
|
||||||
|
"name": req.Name,
|
||||||
|
"type": req.Type,
|
||||||
|
"host": req.Host,
|
||||||
|
})
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"id": databaseID,
|
||||||
|
"message": "External database registered",
|
||||||
|
"status": "running",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeExternalRequestDefaults(t *testing.T) {
|
||||||
|
req := externalDatabaseRequest{Type: "PostgreSQL", Host: " db.example.com ", Port: 0}
|
||||||
|
if err := normalizeExternalRequest(&req); err != nil {
|
||||||
|
t.Fatalf("normalize failed: %v", err)
|
||||||
|
}
|
||||||
|
if req.Type != "postgresql" {
|
||||||
|
t.Fatalf("type not normalized: %q", req.Type)
|
||||||
|
}
|
||||||
|
if req.Host != "db.example.com" {
|
||||||
|
t.Fatalf("host not trimmed: %q", req.Host)
|
||||||
|
}
|
||||||
|
if req.Port != 5432 {
|
||||||
|
t.Fatalf("default port = %d, want 5432", req.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeExternalRequestRejects(t *testing.T) {
|
||||||
|
cases := []externalDatabaseRequest{
|
||||||
|
{Type: "sqlite", Host: "x"}, // unsupported type
|
||||||
|
{Type: "postgresql", Host: " "}, // empty host
|
||||||
|
{Type: "postgresql", Host: "h", Port: 70000}, // out of range
|
||||||
|
}
|
||||||
|
for i, req := range cases {
|
||||||
|
if err := normalizeExternalRequest(&req); err == nil {
|
||||||
|
t.Fatalf("case %d: expected error", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalConnectionURLMasking(t *testing.T) {
|
||||||
|
ext := &DatabaseExternalRef{
|
||||||
|
Host: "db.example.com",
|
||||||
|
Port: 5432,
|
||||||
|
Database: "appdb",
|
||||||
|
Username: "svc",
|
||||||
|
SSL: true,
|
||||||
|
}
|
||||||
|
u := externalConnectionURL("postgresql", ext, "s3cret")
|
||||||
|
if u != "postgres://svc:[email protected]:5432/appdb?sslmode=require" {
|
||||||
|
t.Fatalf("unexpected pg url: %s", u)
|
||||||
|
}
|
||||||
|
u = externalConnectionURL("postgresql", ext, "")
|
||||||
|
if u != "postgres://[email protected]:5432/appdb?sslmode=require" {
|
||||||
|
t.Fatalf("unexpected passwordless url: %s", u)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &DatabaseExternalRef{Host: "cache.internal", Port: 6379, SSL: true}
|
||||||
|
u = externalConnectionURL("redis", r, "pw")
|
||||||
|
if u != "rediss://:[email protected]:6379" {
|
||||||
|
t.Fatalf("unexpected redis url: %s", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeExternalDatabaseTCP(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
// TCP fallback path — mysql uses a plain dial.
|
||||||
|
latency, err := probeExternalDatabase(context.Background(), externalDatabaseRequest{
|
||||||
|
Type: "mysql", Host: "127.0.0.1", Port: port,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("probe should succeed against open listener: %v", err)
|
||||||
|
}
|
||||||
|
if latency <= 0 {
|
||||||
|
t.Fatalf("latency not recorded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closed port must fail fast.
|
||||||
|
if _, err := probeExternalDatabase(context.Background(), externalDatabaseRequest{
|
||||||
|
Type: "mysql", Host: "127.0.0.1", Port: 1,
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatalf("probe should fail against closed port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedisTLSConfig(t *testing.T) {
|
||||||
|
if redisTLSConfig(false, "h") != nil {
|
||||||
|
t.Fatalf("disabled ssl must produce nil tls config")
|
||||||
|
}
|
||||||
|
cfg := redisTLSConfig(true, "db.example.com")
|
||||||
|
if cfg == nil || cfg.ServerName != "db.example.com" {
|
||||||
|
t.Fatalf("expected ServerName passthrough, got %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -336,6 +336,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
authed.POST("/services/:id/stop", handleServiceStop)
|
authed.POST("/services/:id/stop", handleServiceStop)
|
||||||
authed.POST("/services/:id/restart", handleServiceRestart)
|
authed.POST("/services/:id/restart", handleServiceRestart)
|
||||||
authed.POST("/services/:id/redeploy", handleServiceRedeploy)
|
authed.POST("/services/:id/redeploy", handleServiceRedeploy)
|
||||||
|
authed.POST("/services/:id/clone", handleCloneService)
|
||||||
|
authed.POST("/services/:id/move", handleMoveService)
|
||||||
authed.POST("/services/:id/deployments", handleCreateDeployment)
|
authed.POST("/services/:id/deployments", handleCreateDeployment)
|
||||||
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
||||||
authed.POST("/deployments/:id/cancel", handleCancelDeployment)
|
authed.POST("/deployments/:id/cancel", handleCancelDeployment)
|
||||||
@@ -344,6 +346,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 +358,12 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// One-off exec console (docker exec, 30s ceiling)
|
// One-off exec console (docker exec, 30s ceiling)
|
||||||
authed.POST("/services/:id/exec", handleExecInService)
|
authed.POST("/services/:id/exec", handleExecInService)
|
||||||
|
|
||||||
|
// Private-image registries — owner-scoped, credentials encrypted at rest.
|
||||||
|
authed.GET("/registries", handleListRegistries)
|
||||||
|
authed.POST("/registries", handleCreateRegistry)
|
||||||
|
authed.PUT("/registries/:id", handleUpdateRegistry)
|
||||||
|
authed.DELETE("/registries/:id", handleDeleteRegistry)
|
||||||
|
|
||||||
// Git integration — providers carry per-user credentials.
|
// Git integration — providers carry per-user credentials.
|
||||||
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
|
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
|
||||||
authed.POST("/git/github-app/connect", handleConnectGitHubApp)
|
authed.POST("/git/github-app/connect", handleConnectGitHubApp)
|
||||||
@@ -375,6 +387,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
authed.POST("/builds/plan", buildHandler.GetBuildPlan)
|
authed.POST("/builds/plan", buildHandler.GetBuildPlan)
|
||||||
|
|
||||||
authed.POST("/databases", databaseHandler.CreateDatabase)
|
authed.POST("/databases", databaseHandler.CreateDatabase)
|
||||||
|
authed.POST("/databases/register-external", databaseHandler.RegisterExternalDatabase)
|
||||||
|
authed.POST("/databases/test-connection", databaseHandler.TestDatabaseConnection)
|
||||||
|
authed.POST("/databases/:id/test-connection", databaseHandler.TestDatabaseConnectionByID)
|
||||||
authed.PUT("/databases/:id", databaseHandler.UpdateDatabase)
|
authed.PUT("/databases/:id", databaseHandler.UpdateDatabase)
|
||||||
authed.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
|
authed.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
|
||||||
authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
|
authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,9 +196,96 @@ 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)
|
for _, cmd := range []*cobra.Command{dbRegisterCmd, dbTestConnCmd} {
|
||||||
|
cmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
|
||||||
|
cmd.Flags().String("host", "", "database host")
|
||||||
|
cmd.Flags().Int("port", 0, "database port (default per type)")
|
||||||
|
cmd.Flags().String("database", "", "database name")
|
||||||
|
cmd.Flags().String("username", "", "database username")
|
||||||
|
cmd.Flags().String("password", "", "database password (stored encrypted on register)")
|
||||||
|
cmd.Flags().Bool("ssl", false, "require TLS")
|
||||||
|
}
|
||||||
|
DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbRegisterCmd, dbTestConnCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbDownloadCmd)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
@@ -226,6 +226,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{} {
|
||||||
@@ -459,7 +514,11 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -74,6 +74,7 @@ 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.DatabasesCmd)
|
rootCmd.AddCommand(commands.DatabasesCmd)
|
||||||
rootCmd.AddCommand(commands.CronCmd)
|
rootCmd.AddCommand(commands.CronCmd)
|
||||||
rootCmd.AddCommand(commands.TemplatesCmd)
|
rootCmd.AddCommand(commands.TemplatesCmd)
|
||||||
|
|||||||
@@ -88,6 +88,52 @@ func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseS
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createExternalDatabaseService = `-- name: CreateExternalDatabaseService :exec
|
||||||
|
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region,
|
||||||
|
provider, external_host, external_port, external_name, external_username, external_password, external_ssl,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, 'running', $5, $6, $7, 'external', $8, $9, $10, $11, $12, $13, $14, $15)
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateExternalDatabaseServiceParams struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Plan string `json:"plan"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
ExternalHost sql.NullString `json:"external_host"`
|
||||||
|
ExternalPort sql.NullInt32 `json:"external_port"`
|
||||||
|
ExternalName sql.NullString `json:"external_name"`
|
||||||
|
ExternalUsername sql.NullString `json:"external_username"`
|
||||||
|
ExternalPassword string `json:"external_password"`
|
||||||
|
ExternalSsl bool `json:"external_ssl"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, createExternalDatabaseService,
|
||||||
|
arg.ID,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Type,
|
||||||
|
arg.Version,
|
||||||
|
arg.Plan,
|
||||||
|
arg.Region,
|
||||||
|
arg.ExternalHost,
|
||||||
|
arg.ExternalPort,
|
||||||
|
arg.ExternalName,
|
||||||
|
arg.ExternalUsername,
|
||||||
|
arg.ExternalPassword,
|
||||||
|
arg.ExternalSsl,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const databaseServiceExistsByIDAndUser = `-- name: DatabaseServiceExistsByIDAndUser :one
|
const databaseServiceExistsByIDAndUser = `-- name: DatabaseServiceExistsByIDAndUser :one
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
SELECT 1 FROM database_services WHERE id = $1 AND user_id = $2
|
SELECT 1 FROM database_services WHERE id = $1 AND user_id = $2
|
||||||
@@ -150,7 +196,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
|
const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
|
||||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.created_at, database_services.updated_at
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -170,6 +216,13 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
|
|||||||
&i.ConnectionUrl,
|
&i.ConnectionUrl,
|
||||||
&i.BackupSchedule,
|
&i.BackupSchedule,
|
||||||
&i.NextBackupAt,
|
&i.NextBackupAt,
|
||||||
|
&i.Provider,
|
||||||
|
&i.ExternalHost,
|
||||||
|
&i.ExternalPort,
|
||||||
|
&i.ExternalName,
|
||||||
|
&i.ExternalUsername,
|
||||||
|
&i.ExternalPassword,
|
||||||
|
&i.ExternalSsl,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -177,7 +230,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
|
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
|
||||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.created_at, database_services.updated_at
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
`
|
`
|
||||||
@@ -202,6 +255,13 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
|
|||||||
&i.ConnectionUrl,
|
&i.ConnectionUrl,
|
||||||
&i.BackupSchedule,
|
&i.BackupSchedule,
|
||||||
&i.NextBackupAt,
|
&i.NextBackupAt,
|
||||||
|
&i.Provider,
|
||||||
|
&i.ExternalHost,
|
||||||
|
&i.ExternalPort,
|
||||||
|
&i.ExternalName,
|
||||||
|
&i.ExternalUsername,
|
||||||
|
&i.ExternalPassword,
|
||||||
|
&i.ExternalSsl,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -222,7 +282,7 @@ func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (str
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
|
const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
|
||||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.created_at, database_services.updated_at
|
||||||
FROM database_services
|
FROM database_services
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
@@ -248,6 +308,13 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic
|
|||||||
&i.ConnectionUrl,
|
&i.ConnectionUrl,
|
||||||
&i.BackupSchedule,
|
&i.BackupSchedule,
|
||||||
&i.NextBackupAt,
|
&i.NextBackupAt,
|
||||||
|
&i.Provider,
|
||||||
|
&i.ExternalHost,
|
||||||
|
&i.ExternalPort,
|
||||||
|
&i.ExternalName,
|
||||||
|
&i.ExternalUsername,
|
||||||
|
&i.ExternalPassword,
|
||||||
|
&i.ExternalSsl,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -311,7 +378,7 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
|
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
|
||||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.created_at, database_services.updated_at
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -338,6 +405,13 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
|
|||||||
&i.ConnectionUrl,
|
&i.ConnectionUrl,
|
||||||
&i.BackupSchedule,
|
&i.BackupSchedule,
|
||||||
&i.NextBackupAt,
|
&i.NextBackupAt,
|
||||||
|
&i.Provider,
|
||||||
|
&i.ExternalHost,
|
||||||
|
&i.ExternalPort,
|
||||||
|
&i.ExternalName,
|
||||||
|
&i.ExternalUsername,
|
||||||
|
&i.ExternalPassword,
|
||||||
|
&i.ExternalSsl,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -80,19 +80,26 @@ type DatabaseBackup struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Deployment struct {
|
type Deployment struct {
|
||||||
@@ -167,6 +174,17 @@ type ProjectMember struct {
|
|||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
OwnerID uuid.UUID `json:"owner_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -204,6 +222,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 {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Querier interface {
|
|||||||
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
|
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
|
||||||
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
|
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
|
||||||
CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error
|
CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error
|
||||||
|
CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error
|
||||||
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
|
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
|
||||||
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||||
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ func init() {
|
|||||||
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_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).", "PUT", "/services/{id}", []string{"id"}, nil, false})
|
||||||
@@ -70,6 +72,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 +85,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})
|
||||||
@@ -91,6 +101,9 @@ func init() {
|
|||||||
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).", "POST", "/databases", nil, nil, false})
|
||||||
|
register(spec{"containr_databases_register_external", "Register a database hosted elsewhere — probed first, password stored encrypted. Args: name (required), type (required), host (required), port, database, username, password, ssl.", "POST", "/databases/register-external", nil, []string{"name", "type", "host"}, false})
|
||||||
|
register(spec{"containr_databases_test_connection", "Probe a DB connection ad-hoc. Args: type, host (required), port, database, username, password, ssl.", "POST", "/databases/test-connection", nil, []string{"type", "host"}, false})
|
||||||
|
register(spec{"containr_databases_test_connection_by_id", "Re-probe a registered external database with its stored credentials. Args: id.", "POST", "/databases/{id}/test-connection", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_databases_action", "Start/stop/restart a database. Args: id, action.", "POST", "/databases/{id}/action", []string{"id"}, []string{"action"}, false})
|
register(spec{"containr_databases_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})
|
||||||
|
|||||||
@@ -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,19 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Project-level shared variables (Railway-style). Services reference them via
|
||||||
|
-- ${{shared.KEY}} in environment values; secret entries are encrypted at rest
|
||||||
|
-- and masked in API responses exactly like service variables.
|
||||||
|
CREATE TABLE IF NOT EXISTS project_variables (
|
||||||
|
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
key varchar(255) NOT NULL,
|
||||||
|
value text NOT NULL DEFAULT '',
|
||||||
|
is_secret boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (project_id, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_project_variables_project ON project_variables(project_id);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS project_variables;
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
-- name: ListDatabaseServicesByUser :many
|
-- name: ListDatabaseServicesByUser :many
|
||||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
SELECT database_services.*
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY created_at DESC;
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: ListAllDatabaseServices :many
|
-- name: ListAllDatabaseServices :many
|
||||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
SELECT database_services.*
|
||||||
FROM database_services
|
FROM database_services
|
||||||
ORDER BY created_at DESC;
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: GetDatabaseServiceByID :one
|
-- name: GetDatabaseServiceByID :one
|
||||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
SELECT database_services.*
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ FROM database_services
|
|||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
-- name: GetDatabaseServiceByIDAndUser :one
|
-- name: GetDatabaseServiceByIDAndUser :one
|
||||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
SELECT database_services.*
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE id = $1 AND user_id = $2;
|
WHERE id = $1 AND user_id = $2;
|
||||||
|
|
||||||
@@ -110,3 +110,9 @@ WHERE backup_schedule IS NOT NULL AND backup_schedule <> ''
|
|||||||
UPDATE database_services
|
UPDATE database_services
|
||||||
SET next_backup_at = $1
|
SET next_backup_at = $1
|
||||||
WHERE id = $2;
|
WHERE id = $2;
|
||||||
|
|
||||||
|
-- name: CreateExternalDatabaseService :exec
|
||||||
|
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region,
|
||||||
|
provider, external_host, external_port, external_name, external_username, external_password, external_ssl,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, 'running', $5, $6, $7, 'external', $8, $9, $10, $11, $12, $13, $14, $15);
|
||||||
@@ -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,13 @@ CREATE TABLE database_services (
|
|||||||
connection_url TEXT,
|
connection_url TEXT,
|
||||||
backup_schedule VARCHAR(100),
|
backup_schedule VARCHAR(100),
|
||||||
next_backup_at TIMESTAMP WITH TIME ZONE,
|
next_backup_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
provider VARCHAR(20) NOT NULL DEFAULT 'managed',
|
||||||
|
external_host VARCHAR(255),
|
||||||
|
external_port INTEGER,
|
||||||
|
external_name VARCHAR(255),
|
||||||
|
external_username VARCHAR(255),
|
||||||
|
external_password TEXT NOT NULL DEFAULT '',
|
||||||
|
external_ssl BOOLEAN NOT NULL DEFAULT false,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,12 +20,20 @@ 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,
|
||||||
|
testDatabaseConnection,
|
||||||
|
testDatabaseConnectionByID,
|
||||||
|
type ExternalDatabaseInput,
|
||||||
listServicesByProject,
|
listServicesByProject,
|
||||||
listServiceVariables,
|
listServiceVariables,
|
||||||
updateServiceVariables,
|
updateServiceVariables,
|
||||||
@@ -1430,6 +1438,135 @@ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -1603,6 +1740,7 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
{/* Personal Access Tokens */}
|
{/* Personal Access Tokens */}
|
||||||
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
||||||
|
<RegistriesSection />
|
||||||
|
|
||||||
{/* Platform Settings - owner only */}
|
{/* Platform Settings - owner only */}
|
||||||
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
||||||
@@ -1767,7 +1905,11 @@ export function DatabasesPage() {
|
|||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
const [bindDb, setBindDb] = useState<DatabaseEntity | null>(null);
|
const [bindDb, setBindDb] = useState<DatabaseEntity | null>(null);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createMode, setCreateMode] = useState<'managed' | 'external'>('managed');
|
||||||
const [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' });
|
const [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' });
|
||||||
|
const [externalForm, setExternalForm] = useState({ name: '', type: 'postgresql', host: '', port: '', database: '', username: '', password: '', ssl: false });
|
||||||
|
const [externalProbe, setExternalProbe] = useState<{ ok: boolean; latency_ms?: number; error?: string } | null>(null);
|
||||||
|
const [externalTestResult, setExternalTestResult] = useState<Record<string, { ok: boolean; latency_ms?: number; error?: string }>>({});
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
const databasesQuery = useQuery({
|
const databasesQuery = useQuery({
|
||||||
queryKey: ['databases'],
|
queryKey: ['databases'],
|
||||||
@@ -1795,6 +1937,40 @@ export function DatabasesPage() {
|
|||||||
setCreateError(error instanceof Error ? error.message : 'Failed to create database');
|
setCreateError(error instanceof Error ? error.message : 'Failed to create database');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const externalPayload = (): ExternalDatabaseInput => ({
|
||||||
|
name: externalForm.name.trim(),
|
||||||
|
type: externalForm.type,
|
||||||
|
host: externalForm.host.trim(),
|
||||||
|
port: Number(externalForm.port) || undefined,
|
||||||
|
database: externalForm.database.trim() || undefined,
|
||||||
|
username: externalForm.username.trim() || undefined,
|
||||||
|
password: externalForm.password || undefined,
|
||||||
|
ssl: externalForm.ssl,
|
||||||
|
});
|
||||||
|
const registerExternalMutation = useMutation({
|
||||||
|
mutationFn: () => registerExternalDatabase(externalPayload()),
|
||||||
|
onSuccess: () => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
setExternalForm({ name: '', type: 'postgresql', host: '', port: '', database: '', username: '', password: '', ssl: false });
|
||||||
|
setExternalProbe(null);
|
||||||
|
setCreateError(null);
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setCreateError(error instanceof Error ? error.message : 'Failed to register database');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const probeMutation = useMutation({
|
||||||
|
mutationFn: () => testDatabaseConnection(externalPayload()),
|
||||||
|
onSuccess: (result) => setExternalProbe(result),
|
||||||
|
onError: (error) => setExternalProbe({ ok: false, error: error instanceof Error ? error.message : 'probe failed' }),
|
||||||
|
});
|
||||||
|
const testByIdMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => testDatabaseConnectionByID(id),
|
||||||
|
onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })),
|
||||||
|
onError: (error, id) =>
|
||||||
|
setExternalTestResult((prev) => ({ ...prev, [id]: { ok: false, error: error instanceof Error ? error.message : 'probe failed' } })),
|
||||||
|
});
|
||||||
const actionMutation = useMutation({
|
const actionMutation = useMutation({
|
||||||
mutationFn: ({ id, action }: { id: string; action: 'start' | 'stop' | 'restart' }) =>
|
mutationFn: ({ id, action }: { id: string; action: 'start' | 'stop' | 'restart' }) =>
|
||||||
databaseAction(id, action),
|
databaseAction(id, action),
|
||||||
@@ -1880,9 +2056,16 @@ export function DatabasesPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-[var(--text-primary)]">{db.name}</span>
|
<span className="text-sm font-medium text-[var(--text-primary)]">{db.name}</span>
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] mono">{db.type}{db.version ? ` ${db.version}` : ''}</span>
|
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] mono">{db.type}{db.version ? ` ${db.version}` : ''}</span>
|
||||||
|
{db.provider === 'external' && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]">external</span>
|
||||||
|
)}
|
||||||
<span className={`text-xs px-1.5 py-0.5 rounded ${dbStatusClass[db.status ?? ''] ?? 'bg-[var(--surface-muted)] text-[var(--text-muted)]'}`}>{db.status}</span>
|
<span className={`text-xs px-1.5 py-0.5 rounded ${dbStatusClass[db.status ?? ''] ?? 'bg-[var(--surface-muted)] text-[var(--text-muted)]'}`}>{db.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 text-xs text-[var(--text-muted)]">{db.plan} · {db.region}</p>
|
<p className="mt-0.5 text-xs text-[var(--text-muted)]">
|
||||||
|
{db.provider === 'external' && db.external
|
||||||
|
? `${db.external.host}:${db.external.port}${db.external.database ? ` · ${db.external.database}` : ''}`
|
||||||
|
: `${db.plan} · ${db.region}`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown size={15} className={`shrink-0 text-[var(--text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
<ChevronDown size={15} className={`shrink-0 text-[var(--text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
@@ -1910,6 +2093,25 @@ export function DatabasesPage() {
|
|||||||
{signedIn ? (
|
{signedIn ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mr-1">Actions</span>
|
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mr-1">Actions</span>
|
||||||
|
{db.provider === 'external' ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => testByIdMutation.mutate(db.id ?? '')}
|
||||||
|
disabled={testByIdMutation.isPending}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={11} /> Test connection
|
||||||
|
</button>
|
||||||
|
{externalTestResult[db.id ?? ''] && (
|
||||||
|
<span className={`text-xs ${externalTestResult[db.id ?? '']?.ok ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
|
||||||
|
{externalTestResult[db.id ?? '']?.ok
|
||||||
|
? `reachable (${externalTestResult[db.id ?? '']?.latency_ms ?? 0}ms)`
|
||||||
|
: `unreachable — ${externalTestResult[db.id ?? '']?.error ?? 'probe failed'}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
|
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
|
||||||
disabled={running || actionMutation.isPending}
|
disabled={running || actionMutation.isPending}
|
||||||
@@ -1931,6 +2133,8 @@ export function DatabasesPage() {
|
|||||||
>
|
>
|
||||||
<RefreshCw size={11} /> Restart
|
<RefreshCw size={11} /> Restart
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setBindDb(bindDb?.id === db.id ? null : db)}
|
onClick={() => setBindDb(bindDb?.id === db.id ? null : db)}
|
||||||
disabled={!db.connection_url}
|
disabled={!db.connection_url}
|
||||||
@@ -1973,6 +2177,7 @@ export function DatabasesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{db.provider !== 'external' && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p>
|
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p>
|
||||||
@@ -2029,6 +2234,7 @@ export function DatabasesPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2043,18 +2249,125 @@ export function DatabasesPage() {
|
|||||||
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
||||||
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
|
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
|
||||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">New database</h2>
|
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">New database</h2>
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">Provision a managed database service.</p>
|
<div className="mt-3 flex gap-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-1">
|
||||||
|
{(['managed', 'external'] as const).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setCreateMode(mode); setCreateError(null); }}
|
||||||
|
className={`flex-1 rounded-[calc(var(--radius-md)-2px)] px-3 py-1.5 text-xs font-medium capitalize transition-colors ${createMode === mode ? 'bg-[var(--bg-base)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
||||||
|
>
|
||||||
|
{mode}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-[var(--text-tertiary)]">
|
||||||
|
{createMode === 'managed' ? 'Provision a managed database service.' : 'Register a database hosted outside Containr.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 px-5 py-4">
|
<div className="space-y-4 px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Name</label>
|
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Name</label>
|
||||||
<input
|
<input
|
||||||
value={createForm.name}
|
value={createMode === 'managed' ? createForm.name : externalForm.name}
|
||||||
onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))}
|
onChange={(e) => (createMode === 'managed' ? setCreateForm((f) => ({ ...f, name: e.target.value })) : setExternalForm((f) => ({ ...f, name: e.target.value })))}
|
||||||
placeholder="my-postgres"
|
placeholder="my-postgres"
|
||||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{createMode === 'external' ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</label>
|
||||||
|
<select
|
||||||
|
value={externalForm.type}
|
||||||
|
onChange={(e) => setExternalForm((f) => ({ ...f, type: e.target.value }))}
|
||||||
|
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-sm focus:border-[var(--accent-primary)]"
|
||||||
|
>
|
||||||
|
{DATABASE_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Host</label>
|
||||||
|
<input
|
||||||
|
value={externalForm.host}
|
||||||
|
onChange={(e) => setExternalForm((f) => ({ ...f, host: e.target.value }))}
|
||||||
|
placeholder="db.example.com"
|
||||||
|
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Port</label>
|
||||||
|
<input
|
||||||
|
value={externalForm.port}
|
||||||
|
onChange={(e) => setExternalForm((f) => ({ ...f, port: e.target.value }))}
|
||||||
|
placeholder="default"
|
||||||
|
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Database</label>
|
||||||
|
<input
|
||||||
|
value={externalForm.database}
|
||||||
|
onChange={(e) => setExternalForm((f) => ({ ...f, database: e.target.value }))}
|
||||||
|
placeholder="optional"
|
||||||
|
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Username</label>
|
||||||
|
<input
|
||||||
|
value={externalForm.username}
|
||||||
|
onChange={(e) => setExternalForm((f) => ({ ...f, username: e.target.value }))}
|
||||||
|
placeholder="optional"
|
||||||
|
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={externalForm.password}
|
||||||
|
onChange={(e) => setExternalForm((f) => ({ ...f, password: e.target.value }))}
|
||||||
|
placeholder="optional"
|
||||||
|
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-[var(--text-secondary)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={externalForm.ssl}
|
||||||
|
onChange={(e) => setExternalForm((f) => ({ ...f, ssl: e.target.checked }))}
|
||||||
|
className="accent-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
Require TLS/SSL
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => probeMutation.mutate()}
|
||||||
|
disabled={probeMutation.isPending || externalForm.host.trim().length === 0}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{probeMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : null}
|
||||||
|
Test connection
|
||||||
|
</button>
|
||||||
|
{externalProbe && (
|
||||||
|
<span className={`text-xs ${externalProbe.ok ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
|
||||||
|
{externalProbe.ok ? `reachable (${externalProbe.latency_ms ?? 0}ms)` : `unreachable — ${externalProbe.error ?? 'probe failed'}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</label>
|
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</label>
|
||||||
@@ -2090,6 +2403,7 @@ export function DatabasesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{createError ? (
|
{createError ? (
|
||||||
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-3.5 py-2.5 text-xs text-[var(--error)]">{createError}</div>
|
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-3.5 py-2.5 text-xs text-[var(--error)]">{createError}</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -2104,12 +2418,15 @@ export function DatabasesPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => createMutation.mutate()}
|
onClick={() => (createMode === 'managed' ? createMutation.mutate() : registerExternalMutation.mutate())}
|
||||||
disabled={createMutation.isPending || createForm.name.trim().length === 0}
|
disabled={
|
||||||
|
(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ||
|
||||||
|
(createMode === 'managed' ? createForm.name.trim().length === 0 : externalForm.name.trim().length === 0 || externalForm.host.trim().length === 0)
|
||||||
|
}
|
||||||
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{createMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
|
{(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||||
Create database
|
{createMode === 'managed' ? 'Create database' : 'Register database'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,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';
|
||||||
@@ -412,6 +415,36 @@ export function ServiceDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const projectsQuery = useQuery({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: () => listProjects({ limit: 100 }),
|
||||||
|
enabled: !isDemoMode && activeSection === 'settings',
|
||||||
|
});
|
||||||
|
|
||||||
|
const cloneMutation = useMutation({
|
||||||
|
mutationFn: (input: { name?: string; project_id?: string }) => cloneService(serviceId, input),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||||
|
const targetProject = lifecycleForm?.cloneProject || projectId;
|
||||||
|
navigate(`/projects/${targetProject}/services/${data.service_id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveMutation = useMutation({
|
||||||
|
mutationFn: (targetProjectId: string) => moveService(serviceId, targetProjectId),
|
||||||
|
onSuccess: (_data, targetProjectId) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
navigate(`/projects/${targetProjectId}/services/${serviceId}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [lifecycleForm, setLifecycleForm] = useState<{
|
||||||
|
cloneName: string;
|
||||||
|
cloneProject: string;
|
||||||
|
moveTarget: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const invalidateService = () => {
|
const invalidateService = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
||||||
@@ -1222,8 +1255,9 @@ export function ServiceDetailPage() {
|
|||||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
Runtime values injected at deploy. Reference another service's variable with{' '}
|
Runtime values injected at deploy. Reference another service's variable with{' '}
|
||||||
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> — e.g.{' '}
|
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> or a project
|
||||||
<code className="mono text-[var(--accent-primary)]">{'${{postgres.POSTGRES_PASSWORD}}'}</code>
|
shared variable with{' '}
|
||||||
|
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2632,6 +2666,135 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isDemoMode && signedIn && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-[var(--text-primary)]">Lifecycle</h3>
|
||||||
|
{lifecycleForm === null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLifecycleForm({ cloneName: `${service.name}-copy`, cloneProject: '', moveTarget: '' })}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lifecycleForm === null ? (
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
Duplicate this service into the same or another project, move it between projects, or delete it.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Clone</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={lifecycleForm.cloneName}
|
||||||
|
onChange={(e) => setLifecycleForm({ ...lifecycleForm, cloneName: e.target.value })}
|
||||||
|
placeholder={`${service.name}-copy`}
|
||||||
|
className="w-52 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={lifecycleForm.cloneProject}
|
||||||
|
onChange={(e) => setLifecycleForm({ ...lifecycleForm, cloneProject: e.target.value })}
|
||||||
|
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<option value="">same project</option>
|
||||||
|
{(projectsQuery.data ?? [])
|
||||||
|
.filter((p) => p.id !== projectId)
|
||||||
|
.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={cloneMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
cloneMutation.mutate({
|
||||||
|
name: lifecycleForm.cloneName.trim() || undefined,
|
||||||
|
project_id: lifecycleForm.cloneProject || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{cloneMutation.isPending ? 'Cloning…' : 'Clone'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
|
||||||
|
Copies config, volumes, domains, and variables. The clone starts stopped.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Move to project</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={lifecycleForm.moveTarget}
|
||||||
|
onChange={(e) => setLifecycleForm({ ...lifecycleForm, moveTarget: e.target.value })}
|
||||||
|
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<option value="">choose a project…</option>
|
||||||
|
{(projectsQuery.data ?? [])
|
||||||
|
.filter((p) => p.id !== projectId)
|
||||||
|
.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={moveMutation.isPending || !lifecycleForm.moveTarget}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Move ${service.name} to another project?`)) {
|
||||||
|
moveMutation.mutate(lifecycleForm.moveTarget);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{moveMutation.isPending ? 'Moving…' : 'Move'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
|
||||||
|
Variables, domains, and volumes move with the service. Running containers keep running.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-3 border-t border-[var(--border-subtle)]">
|
||||||
|
<label className="block text-xs text-[var(--error)] mb-1">Delete service</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={deleteServiceMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete ${service.name}? This removes its containers and cannot be undone.`)) {
|
||||||
|
deleteServiceMutation.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{deleteServiceMutation.isPending ? 'Deleting…' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLifecycleForm(null)}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
{(cloneMutation.isError || moveMutation.isError) && (
|
||||||
|
<p className="text-xs text-[var(--error)]">
|
||||||
|
{((cloneMutation.error ?? moveMutation.error) as Error)?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { listProjectVariables, updateProjectVariables } from '@/lib/api-client';
|
||||||
|
import { Loader2, Plus, Save, Trash2, KeyRound } from 'lucide-react';
|
||||||
|
|
||||||
|
type Row = { key: string; value: string; isSecret: boolean };
|
||||||
|
|
||||||
|
// SharedVariablesSection edits project-level variables referenced from
|
||||||
|
// service env values as ${{shared.KEY}}.
|
||||||
|
export function SharedVariablesSection({ projectId }: { projectId: string }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [drafts, setDrafts] = useState<Row[] | null>(null);
|
||||||
|
|
||||||
|
const varsQuery = useQuery({
|
||||||
|
queryKey: ['project-variables', projectId],
|
||||||
|
queryFn: () => listProjectVariables(projectId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (rows: Row[]) =>
|
||||||
|
updateProjectVariables(
|
||||||
|
projectId,
|
||||||
|
rows
|
||||||
|
.filter((r) => r.key.trim())
|
||||||
|
.map((r) => ({ key: r.key.trim(), value: r.value, is_secret: r.isSecret })),
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
setDrafts(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-variables', projectId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows: Row[] = drafts ?? (varsQuery.data ?? []).map((v) => ({ key: v.key, value: v.value, isSecret: v.isSecret }));
|
||||||
|
const dirty = drafts !== null;
|
||||||
|
|
||||||
|
const updateRow = (i: number, patch: Partial<Row>) => {
|
||||||
|
const next = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
|
||||||
|
setDrafts(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Shared Variables</h3>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||||
|
Reference from any service as{' '}
|
||||||
|
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next
|
||||||
|
deploy/redeploy — not live-updated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => saveMutation.mutate(rows)}
|
||||||
|
disabled={!dirty || saveMutation.isPending}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-50 transition-all"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{varsQuery.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-6 text-[var(--text-muted)]">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">No shared variables.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<div key={i} className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
|
||||||
|
<input
|
||||||
|
value={row.key}
|
||||||
|
onChange={(e) => updateRow(i, { key: e.target.value })}
|
||||||
|
placeholder="KEY"
|
||||||
|
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={row.value}
|
||||||
|
onChange={(e) => updateRow(i, { value: e.target.value })}
|
||||||
|
type={row.isSecret ? 'password' : 'text'}
|
||||||
|
placeholder="value"
|
||||||
|
autoComplete="off"
|
||||||
|
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={row.isSecret ? 'Secret — masked on save' : 'Mark as secret'}
|
||||||
|
onClick={() => updateRow(i, { isSecret: !row.isSecret })}
|
||||||
|
className={`w-9 h-9 rounded-[var(--radius-md)] border flex items-center justify-center transition-colors ${
|
||||||
|
row.isSecret
|
||||||
|
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||||
|
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-default)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<KeyRound size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDrafts(rows.filter((_, idx) => idx !== i))}
|
||||||
|
className="w-9 h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{saveMutation.isError && (
|
||||||
|
<p className="mt-2 text-xs text-[var(--error)]">
|
||||||
|
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save shared variables'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import type { ServiceVariable } from '../auto-connections';
|
|||||||
import { ProjectCanvas } from '../canvas/ProjectCanvas';
|
import { ProjectCanvas } from '../canvas/ProjectCanvas';
|
||||||
import { canvasStorageKey, clearCanvasMetadata } from '../storage';
|
import { canvasStorageKey, clearCanvasMetadata } from '../storage';
|
||||||
import { MetricsDashboard } from '../components/MetricsDashboard';
|
import { MetricsDashboard } from '../components/MetricsDashboard';
|
||||||
|
import { SharedVariablesSection } from '../components/SharedVariablesSection';
|
||||||
import { CommandPalette, StatusBadge, LiveIndicator, useToast } from '@/shared/components';
|
import { CommandPalette, StatusBadge, LiveIndicator, useToast } from '@/shared/components';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -935,6 +936,8 @@ export function ProjectWorkspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isDemoMode && signedIn && <SharedVariablesSection projectId={project.id} />}
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3>
|
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -971,6 +971,276 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/services/{id}/clone": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Clone service
|
||||||
|
* @description Duplicates config, volumes, domains, access gates, builder settings, and variables into the same or another project. Containers are not cloned — the copy starts stopped.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @description Name for the clone (default "<name>-copy") */
|
||||||
|
name?: string;
|
||||||
|
/**
|
||||||
|
* Format: uuid
|
||||||
|
* @description Target project (default same project; caller must own it)
|
||||||
|
*/
|
||||||
|
project_id?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
environment?: "production" | "preview" | "development";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Clone created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** Format: uuid */
|
||||||
|
service_id?: string;
|
||||||
|
name?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Name already exists in target project */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/services/{id}/move": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Move service
|
||||||
|
* @description Re-parents the service to another project the caller owns. Variables, domains, and volumes follow the service; running containers keep running and join the new project's network on next deploy.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** Format: uuid */
|
||||||
|
project_id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Service moved */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Name conflict in target project */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/registries": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List registry credentials
|
||||||
|
* @description Private-image pull credentials for the caller. Passwords are never returned — `has_password` signals presence.
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Registry list */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
registries?: components["schemas"]["Registry"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Add registry credential
|
||||||
|
* @description Credentials are matched by image host (e.g. ghcr.io, registry.example.com, docker.io for Docker Hub) on prebuilt-image deploys. Password stored encrypted at rest.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RegistryRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Registry created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description A registry for this host already exists */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/registries/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/**
|
||||||
|
* Update registry credential
|
||||||
|
* @description Empty password keeps the stored credential — delete and recreate to remove a password.
|
||||||
|
*/
|
||||||
|
put: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RegistryRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Registry updated */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Registry not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
post?: never;
|
||||||
|
/** Remove registry credential */
|
||||||
|
delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Registry removed */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Registry not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/services/{id}/domains": {
|
"/services/{id}/domains": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2764,6 +3034,154 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/databases/register-external": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Register external database
|
||||||
|
* @description Record a database hosted elsewhere — connection is probed first (postgres/redis get protocol-level checks, others TCP). Password stored encrypted, never returned.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ExternalDatabaseRequest"] & Record<string, never>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description External database registered */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Validation failed or connection probe failed */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Name already in use */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/databases/test-connection": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Probe a database connection
|
||||||
|
* @description Ad-hoc connectivity check — postgres via libpq ping, redis/dragonfly via PING, other engines via TCP dial. Always 200; `ok` carries the result.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ExternalDatabaseRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Probe result */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
ok?: boolean;
|
||||||
|
latency_ms?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/databases/{id}/test-connection": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Re-probe a registered external database
|
||||||
|
* @description Uses the stored credentials. External databases only.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Probe result (`ok` carries the outcome) */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Database not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/databases/{id}": {
|
"/databases/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3570,6 +3988,98 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/projects/{id}/variables": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List shared project variables
|
||||||
|
* @description Project-level variables shared across services. Services reference them as `${{shared.KEY}}` in environment values — resolved at deploy/reconcile, not live-updated. Secret values are masked.
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Variables retrieved */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
variables?: components["schemas"]["ServiceVariable"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Project not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Replace shared project variables
|
||||||
|
* @description Replace the full shared-variable set for a project. Fetch the current list first and merge — the server replaces wholesale. Masked secret placeholders (`********`) keep the stored value.
|
||||||
|
*/
|
||||||
|
put: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UpdateVariablesRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Variables updated */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
variables?: components["schemas"]["ServiceVariable"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/git/github-app/install-url": {
|
"/git/github-app/install-url": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -8221,6 +8731,26 @@ export interface components {
|
|||||||
/** @description Plaintext on write — stored as bcrypt htpasswd; never returned */
|
/** @description Plaintext on write — stored as bcrypt htpasswd; never returned */
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
Registry: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
/** @description Registry host matched against the image reference — ghcr.io, registry.example.com, docker.io for Docker Hub */
|
||||||
|
host?: string;
|
||||||
|
username?: string;
|
||||||
|
has_password?: boolean;
|
||||||
|
/** Format: date-time */
|
||||||
|
created_at?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
RegistryRequest: {
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
username?: string;
|
||||||
|
/** @description Plaintext on write — stored encrypted; never returned. Empty on update keeps the stored credential. */
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
ServiceVolume: {
|
ServiceVolume: {
|
||||||
/**
|
/**
|
||||||
* @description volume = docker named volume, bind = host path
|
* @description volume = docker named volume, bind = host path
|
||||||
@@ -8650,6 +9180,12 @@ export interface components {
|
|||||||
region?: string;
|
region?: string;
|
||||||
/** @description Cron expression for automatic backups, empty when unset */
|
/** @description Cron expression for automatic backups, empty when unset */
|
||||||
backup_schedule?: string;
|
backup_schedule?: string;
|
||||||
|
/**
|
||||||
|
* @description managed = Containr-provisioned container; external = registered connection to a database hosted elsewhere
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
provider?: "managed" | "external";
|
||||||
|
external?: components["schemas"]["DatabaseExternal"];
|
||||||
/**
|
/**
|
||||||
* Format: date-time
|
* Format: date-time
|
||||||
* @description Next scheduled backup run
|
* @description Next scheduled backup run
|
||||||
@@ -8685,6 +9221,28 @@ export interface components {
|
|||||||
/** @description Database region */
|
/** @description Database region */
|
||||||
region: string;
|
region: string;
|
||||||
};
|
};
|
||||||
|
DatabaseExternal: {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
database?: string;
|
||||||
|
username?: string;
|
||||||
|
has_password?: boolean;
|
||||||
|
ssl?: boolean;
|
||||||
|
};
|
||||||
|
ExternalDatabaseRequest: {
|
||||||
|
/** @description Display name (required for register, unused for probes) */
|
||||||
|
name?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
type: "postgresql" | "redis" | "mysql" | "mariadb" | "mongodb" | "clickhouse" | "dragonfly";
|
||||||
|
host: string;
|
||||||
|
/** @description Defaults to the engine's standard port */
|
||||||
|
port?: number;
|
||||||
|
database?: string;
|
||||||
|
username?: string;
|
||||||
|
/** @description Stored encrypted; never returned */
|
||||||
|
password?: string;
|
||||||
|
ssl?: boolean;
|
||||||
|
};
|
||||||
UpdateDatabaseRequest: {
|
UpdateDatabaseRequest: {
|
||||||
/** @description Updated database name */
|
/** @description Updated database name */
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
@@ -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)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add <id> app.example.com --default\ncontainr services domains check <id> # DNS preflight per domain\ncontainr services domains remove <id> <domain-id>\n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update <id> --maintenance on\ncontainr services update <id> --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate eLine truncated
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||||
|
|||||||
@@ -1185,6 +1185,68 @@ export async function deleteService(serviceId: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CloneServiceInput = {
|
||||||
|
name?: string;
|
||||||
|
project_id?: string;
|
||||||
|
environment?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function cloneService(serviceId: string, input: CloneServiceInput): Promise<{ service_id: string; name: string }> {
|
||||||
|
return requestJson<{ service_id: string; name: string }>(`/services/${serviceId}/clone`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveService(serviceId: string, projectId: string): Promise<void> {
|
||||||
|
await requestJson(`/services/${serviceId}/move`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ project_id: projectId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Registry = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
username?: string;
|
||||||
|
has_password: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistryInput = {
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listRegistries(): Promise<Registry[]> {
|
||||||
|
const payload = await requestJson<{ registries?: Registry[] }>('/registries');
|
||||||
|
return payload.registries ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRegistry(input: RegistryInput): Promise<Registry> {
|
||||||
|
const payload = await requestJson<{ registry: Registry }>('/registries', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
return payload.registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRegistry(id: string, input: RegistryInput): Promise<Registry> {
|
||||||
|
const payload = await requestJson<{ registry: Registry }>(`/registries/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
return payload.registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRegistry(id: string): Promise<void> {
|
||||||
|
await requestJson(`/registries/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRuntime(runtime?: ServiceRuntime): ServiceRuntime {
|
function normalizeRuntime(runtime?: ServiceRuntime): ServiceRuntime {
|
||||||
const state = runtime ?? { desired: 0, status: 'stopped' as const, urls: [], ports: [], containers: [] };
|
const state = runtime ?? { desired: 0, status: 'stopped' as const, urls: [], ports: [], containers: [] };
|
||||||
// The API emits null (not []) for empty collections — coerce at the boundary.
|
// The API emits null (not []) for empty collections — coerce at the boundary.
|
||||||
@@ -1248,6 +1310,22 @@ export async function updateServiceVariables(
|
|||||||
return normalizeServiceVariables(payload.variables);
|
return normalizeServiceVariables(payload.variables);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listProjectVariables(projectId: string): Promise<ServiceVariable[]> {
|
||||||
|
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(`/projects/${projectId}/variables`);
|
||||||
|
return normalizeServiceVariables(payload.variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProjectVariables(
|
||||||
|
projectId: string,
|
||||||
|
variables: UpdateServiceVariableInput[],
|
||||||
|
): Promise<ServiceVariable[]> {
|
||||||
|
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(
|
||||||
|
`/projects/${projectId}/variables`,
|
||||||
|
{ method: 'PUT', body: JSON.stringify({ variables }) },
|
||||||
|
);
|
||||||
|
return normalizeServiceVariables(payload.variables);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listAuditLogs(input: ListAuditLogsInput = {}): Promise<AuditLogEntity[]> {
|
export async function listAuditLogs(input: ListAuditLogsInput = {}): Promise<AuditLogEntity[]> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (input.resource) {
|
if (input.resource) {
|
||||||
@@ -1671,6 +1749,39 @@ export async function createManagedDatabase(
|
|||||||
return { id: payload.id, status: payload.status ?? 'building' };
|
return { id: payload.id, status: payload.status ?? 'building' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExternalDatabaseInput = {
|
||||||
|
name?: string;
|
||||||
|
type: string;
|
||||||
|
host: string;
|
||||||
|
port?: number;
|
||||||
|
database?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
ssl?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function registerExternalDatabase(input: ExternalDatabaseInput): Promise<{ id: string }> {
|
||||||
|
const payload = await requestJson<{ id?: string }>('/databases/register-external', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!payload.id) {
|
||||||
|
throw new ApiError('Database response is invalid', 500);
|
||||||
|
}
|
||||||
|
return { id: payload.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testDatabaseConnection(input: ExternalDatabaseInput): Promise<{ ok: boolean; latency_ms?: number; error?: string }> {
|
||||||
|
return requestJson('/databases/test-connection', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testDatabaseConnectionByID(id: string): Promise<{ ok: boolean; latency_ms?: number; error?: string }> {
|
||||||
|
return requestJson(`/databases/${encodeURIComponent(id)}/test-connection`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoRequest'];
|
export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoRequest'];
|
||||||
export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest'];
|
export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest'];
|
||||||
export type GitWebhookEntity = components['schemas']['GitWebhook'];
|
export type GitWebhookEntity = components['schemas']['GitWebhook'];
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
This is an Expo/React Native mobile application — the Containr companion client. Prioritize mobile-first patterns, performance, and cross-platform compatibility.
|
||||||
|
|
||||||
|
## Expo has changed — do not trust your training data
|
||||||
|
|
||||||
|
Expo ships breaking changes every SDK release. APIs you remember are likely renamed, moved, or removed. Before writing any code that touches an Expo, EAS, or React Native API:
|
||||||
|
|
||||||
|
1. Read the major version of the `expo` package in `package.json`.
|
||||||
|
2. Fetch the matching versioned docs: `https://docs.expo.dev/versions/v<major>.0.0/`
|
||||||
|
3. For anything else, fetch https://docs.expo.dev/llms.txt — an index of all Expo docs with corrections to common LLM misconceptions. Follow its links to the specific page you need; never answer from memory.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install <package> # ALWAYS use instead of npm/yarn/pnpm/bun add — resolves SDK-compatible versions
|
||||||
|
npx expo start # start the dev server
|
||||||
|
npx expo lint # lint
|
||||||
|
npx tsc --noEmit # typecheck
|
||||||
|
npx expo-doctor # diagnose dependency and config issues
|
||||||
|
npx expo install --fix # fix incompatible package versions
|
||||||
|
```
|
||||||
|
|
||||||
|
Run lint and typecheck before declaring any task done.
|
||||||
|
|
||||||
|
## Navigation & Routing
|
||||||
|
|
||||||
|
- Use **Expo Router** for all navigation. Routes live in `src/app/` — every file there is a screen, `_layout.tsx` files define navigators. Keep non-route code (components, hooks, utils) outside `src/app/`.
|
||||||
|
- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`.
|
||||||
|
- Docs: https://docs.expo.dev/router/introduction.md
|
||||||
|
|
||||||
|
## Building with EAS
|
||||||
|
|
||||||
|
Use EAS to build, sign, and submit the app in the cloud (`eas build`, `eas submit`) and to ship over-the-air updates (`eas update`) — no local Xcode or Android Studio required. Run EAS CLI as `npx eas-cli@latest <command>`.
|
||||||
|
Docs: https://docs.expo.dev/eas/index.md
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- If `ios/` and `android/` directories do not exist, they are generated (Continuous Native Generation). Never create or edit them by hand — configure native behavior in `app.json` and config plugins. They are gitignored.
|
||||||
|
- Expo Go only includes its bundled native modules. After adding a library with native code, the app needs a development build: `npx expo run:ios|android` locally, or `eas build --profile development`.
|
||||||
|
- Prefer recommended Expo modules over third-party libraries. Docs: https://docs.expo.dev/versions/latest/index.md
|
||||||
|
- The app speaks only to the Containr REST API (`/api/v1`) with a `cnp_` personal access token — same contract as the CLI and MCP server. Add endpoints there, never to the mobile client alone.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Containr",
|
||||||
|
"slug": "containr-mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"scheme": "containr",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "dark",
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "ai.containr.mobile"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "ai.containr.mobile",
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#0B0F14",
|
||||||
|
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"predictiveBackGestureEnabled": false
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-secure-store",
|
||||||
|
"expo-font"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
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
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "containr-mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.0.2",
|
||||||
|
"@tanstack/react-query": "^5.104.1",
|
||||||
|
"expo": "~57.0.27",
|
||||||
|
"expo-constants": "~57.0.21",
|
||||||
|
"expo-font": "~57.0.4",
|
||||||
|
"expo-linking": "~57.0.12",
|
||||||
|
"expo-router": "~57.0.25",
|
||||||
|
"expo-secure-store": "~57.0.4",
|
||||||
|
"expo-status-bar": "~57.0.1",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-native": "0.86.3",
|
||||||
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
|
"react-native-screens": "~4.26.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "~19.2.2",
|
||||||
|
"eslint": "^9.39.5",
|
||||||
|
"eslint-config-expo": "~57.0.2",
|
||||||
|
"typescript": "~6.0.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"lint": "expo lint"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
image?: string;
|
||||||
|
environment?: string;
|
||||||
|
domain?: string;
|
||||||
|
public_url?: string;
|
||||||
|
port?: number;
|
||||||
|
replicas?: number;
|
||||||
|
cpu?: string;
|
||||||
|
memory?: string;
|
||||||
|
sleep_enabled?: boolean;
|
||||||
|
sleep_idle_minutes?: number;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Deployment {
|
||||||
|
id: string;
|
||||||
|
service_id: string;
|
||||||
|
service_name?: string;
|
||||||
|
project_name?: string;
|
||||||
|
status: string;
|
||||||
|
image_name?: string;
|
||||||
|
created_at?: string;
|
||||||
|
completed_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Database {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
version?: string;
|
||||||
|
plan?: string;
|
||||||
|
connection_url?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvCheck {
|
||||||
|
ok: boolean;
|
||||||
|
unresolved: string[];
|
||||||
|
empty: string[];
|
||||||
|
unreadable: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
message: string;
|
||||||
|
stream: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Template {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Api {
|
||||||
|
constructor(
|
||||||
|
private baseUrl: string,
|
||||||
|
private token: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async req<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `HTTP ${res.status}`;
|
||||||
|
try {
|
||||||
|
const j = (await res.json()) as { error?: string };
|
||||||
|
if (j.error) msg = j.error;
|
||||||
|
} catch {
|
||||||
|
// non-JSON error body
|
||||||
|
}
|
||||||
|
throw new ApiError(res.status, msg);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
ping() {
|
||||||
|
return fetch(`${this.baseUrl}/health`).then((r) => r.ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
projects() {
|
||||||
|
return this.req<{ projects: Project[] }>('GET', '/projects?limit=100').then(
|
||||||
|
(r) => r.projects ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
projectServices(projectId: string) {
|
||||||
|
return this.req<{ services: Service[] }>(
|
||||||
|
'GET',
|
||||||
|
`/projects/${projectId}/services?limit=200`,
|
||||||
|
).then((r) => r.services ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
service(id: string) {
|
||||||
|
return this.req<{ service: Service }>('GET', `/services/${id}`).then((r) => r.service);
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceAction(id: string, action: 'start' | 'stop' | 'restart' | 'redeploy' | 'sleep' | 'wake') {
|
||||||
|
return this.req<{ status?: string; message?: string }>(
|
||||||
|
'POST',
|
||||||
|
`/services/${id}/${action}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceLogs(id: string, tail = 300) {
|
||||||
|
return this.req<{ logs: LogEntry[]; message?: string }>(
|
||||||
|
'GET',
|
||||||
|
`/services/${id}/logs?tail=${tail}`,
|
||||||
|
).then((r) => r.logs ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
envCheck(id: string) {
|
||||||
|
return this.req<EnvCheck>('GET', `/services/${id}/env-check`);
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceDeployments(id: string) {
|
||||||
|
return this.req<{ deployments: Deployment[] }>(
|
||||||
|
'GET',
|
||||||
|
`/services/${id}/deployments?limit=20`,
|
||||||
|
).then((r) => r.deployments ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
recentDeployments() {
|
||||||
|
return this.req<{ deployments: Deployment[] }>(
|
||||||
|
'GET',
|
||||||
|
'/deployments?limit=20',
|
||||||
|
).then((r) => r.deployments ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
deploymentAction(id: string, action: 'rollback' | 'cancel') {
|
||||||
|
return this.req<unknown>('POST', `/deployments/${id}/${action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
databases() {
|
||||||
|
return this.req<{ databases: Database[] }>('GET', '/databases').then(
|
||||||
|
(r) => r.databases ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseBackup(id: string) {
|
||||||
|
return this.req<unknown>('POST', `/databases/${id}/backup`);
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseAction(id: string, action: 'start' | 'stop' | 'restart') {
|
||||||
|
return this.req<unknown>('POST', `/databases/${id}/action`, { action });
|
||||||
|
}
|
||||||
|
|
||||||
|
templates() {
|
||||||
|
return this.req<{ templates: Template[] }>('GET', '/templates?limit=100').then(
|
||||||
|
(r) => r.templates ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Tabs } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
|
||||||
|
export default function TabsLayout() {
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: colors.bg },
|
||||||
|
headerTintColor: colors.text,
|
||||||
|
tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border },
|
||||||
|
tabBarActiveTintColor: colors.accent,
|
||||||
|
tabBarInactiveTintColor: colors.textDim,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: 'Overview',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="grid-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="services"
|
||||||
|
options={{
|
||||||
|
title: 'Services',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="cube-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="databases"
|
||||||
|
options={{
|
||||||
|
title: 'Databases',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="server-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="settings"
|
||||||
|
options={{
|
||||||
|
title: 'Settings',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="settings-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Alert, FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../../session';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { relTime } from '../../time';
|
||||||
|
import { ActionButton, Card, Empty, ErrorBox, Loading, StatusBadge } from '../../components';
|
||||||
|
|
||||||
|
export default function Databases() {
|
||||||
|
const api = useApi();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const dbs = useQuery({ queryKey: ['databases'], queryFn: () => api.databases() });
|
||||||
|
|
||||||
|
const action = useMutation({
|
||||||
|
mutationFn: ({ id, act }: { id: string; act: 'start' | 'stop' | 'restart' }) =>
|
||||||
|
api.databaseAction(id, act),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['databases'] }),
|
||||||
|
onError: (e) => Alert.alert('Action failed', e instanceof Error ? e.message : String(e)),
|
||||||
|
});
|
||||||
|
const backup = useMutation({
|
||||||
|
mutationFn: (id: string) => api.databaseBackup(id),
|
||||||
|
onSuccess: () => Alert.alert('Backup started', 'Snapshot queued for this database.'),
|
||||||
|
onError: (e) => Alert.alert('Backup failed', e instanceof Error ? e.message : String(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dbs.isLoading) return <Loading />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
style={styles.root}
|
||||||
|
contentContainerStyle={{ padding: 16 }}
|
||||||
|
data={dbs.data ?? []}
|
||||||
|
keyExtractor={(d) => d.id}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={dbs.isRefetching}
|
||||||
|
onRefresh={() => dbs.refetch()}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
dbs.error ? <ErrorBox error={dbs.error} /> : <Empty text="No databases" />
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Card>
|
||||||
|
<View style={styles.head}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.name}>{item.name}</Text>
|
||||||
|
<Text style={styles.meta}>
|
||||||
|
{item.type}
|
||||||
|
{item.version ? ` ${item.version}` : ''} · created {relTime(item.created_at)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{item.status === 'running' ? (
|
||||||
|
<ActionButton
|
||||||
|
label="Backup now"
|
||||||
|
kind="primary"
|
||||||
|
disabled={backup.isPending}
|
||||||
|
onPress={() => backup.mutate(item.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ActionButton
|
||||||
|
label="Start"
|
||||||
|
kind="primary"
|
||||||
|
disabled={action.isPending}
|
||||||
|
onPress={() => action.mutate({ id: item.id, act: 'start' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.status === 'running' ? (
|
||||||
|
<>
|
||||||
|
<ActionButton
|
||||||
|
label="Restart"
|
||||||
|
disabled={action.isPending}
|
||||||
|
onPress={() => action.mutate({ id: item.id, act: 'restart' })}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label="Stop"
|
||||||
|
kind="danger"
|
||||||
|
disabled={action.isPending}
|
||||||
|
onPress={() => action.mutate({ id: item.id, act: 'stop' })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
head: { flexDirection: 'row', alignItems: 'center', marginBottom: 10 },
|
||||||
|
name: { color: colors.text, fontSize: 16, fontWeight: '600' },
|
||||||
|
meta: { color: colors.textDim, fontSize: 12, marginTop: 2 },
|
||||||
|
actions: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../../session';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { relTime } from '../../time';
|
||||||
|
import { Card, Empty, ErrorBox, Row, SectionTitle, StatusBadge, StatusDot } from '../../components';
|
||||||
|
|
||||||
|
export default function Overview() {
|
||||||
|
const api = useApi();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const projects = useQuery({ queryKey: ['projects'], queryFn: () => api.projects() });
|
||||||
|
const deployments = useQuery({
|
||||||
|
queryKey: ['deployments'],
|
||||||
|
queryFn: () => api.recentDeployments(),
|
||||||
|
});
|
||||||
|
const servicesQueries = useQueries({
|
||||||
|
queries: (projects.data ?? []).map((p) => ({
|
||||||
|
queryKey: ['services', p.id],
|
||||||
|
queryFn: () => api.projectServices(p.id),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const allServices = servicesQueries.flatMap((q) => q.data ?? []);
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const s of allServices) counts[s.status] = (counts[s.status] ?? 0) + 1;
|
||||||
|
|
||||||
|
const refetch = () => {
|
||||||
|
projects.refetch();
|
||||||
|
deployments.refetch();
|
||||||
|
servicesQueries.forEach((q) => q.refetch());
|
||||||
|
};
|
||||||
|
const refreshing = projects.isRefetching || deployments.isRefetching;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
style={styles.root}
|
||||||
|
contentContainerStyle={{ padding: 16 }}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={refetch} tintColor={colors.accent} />
|
||||||
|
}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<>
|
||||||
|
{projects.error ? <ErrorBox error={projects.error} /> : null}
|
||||||
|
<View style={styles.statRow}>
|
||||||
|
<Stat label="Running" value={counts.running ?? 0} color={colors.ok} />
|
||||||
|
<Stat label="Sleeping" value={counts.sleeping ?? 0} color={colors.info} />
|
||||||
|
<Stat label="Failed" value={counts.failed ?? 0} color={colors.err} />
|
||||||
|
<Stat label="Stopped" value={counts.stopped ?? 0} color={colors.textDim} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SectionTitle>Projects</SectionTitle>
|
||||||
|
{projects.data?.length === 0 ? <Empty text="No projects yet" /> : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
data={projects.data ?? []}
|
||||||
|
keyExtractor={(p) => p.id}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const idx = (projects.data ?? []).findIndex((p) => p.id === item.id);
|
||||||
|
const svcs = servicesQueries[idx]?.data ?? [];
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Row
|
||||||
|
title={item.name}
|
||||||
|
subtitle={`${svcs.length} service${svcs.length === 1 ? '' : 's'}`}
|
||||||
|
onPress={() => router.push(`/project/${item.id}?name=${encodeURIComponent(item.name)}`)}
|
||||||
|
right={
|
||||||
|
<View style={styles.dots}>
|
||||||
|
{svcs.slice(0, 6).map((s) => (
|
||||||
|
<StatusDot key={s.id} status={s.status} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ListFooterComponent={
|
||||||
|
<>
|
||||||
|
<SectionTitle>Recent deployments</SectionTitle>
|
||||||
|
{deployments.data?.length === 0 ? <Empty text="No deployments" /> : null}
|
||||||
|
{(deployments.data ?? []).slice(0, 10).map((d) => (
|
||||||
|
<Row
|
||||||
|
key={d.id}
|
||||||
|
title={`${d.service_name ?? d.service_id}`}
|
||||||
|
subtitle={`${d.project_name ?? ''} · ${relTime(d.created_at)}`}
|
||||||
|
right={<StatusBadge status={d.status} />}
|
||||||
|
onPress={() => router.push(`/service/${d.service_id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.stat, { borderColor: color }]}>
|
||||||
|
<Text style={[styles.statValue, { color }]}>{value}</Text>
|
||||||
|
<Text style={styles.statLabel}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
statRow: { flexDirection: 'row', gap: 8, marginBottom: 4 },
|
||||||
|
stat: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
statValue: { fontSize: 20, fontWeight: '800' },
|
||||||
|
statLabel: { color: colors.textDim, fontSize: 11, marginTop: 2 },
|
||||||
|
dots: { flexDirection: 'row', gap: 4 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FlatList, RefreshControl, StyleSheet } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../../session';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { Empty, ErrorBox, Loading, Row, StatusBadge } from '../../components';
|
||||||
|
|
||||||
|
export default function Services() {
|
||||||
|
const api = useApi();
|
||||||
|
const router = useRouter();
|
||||||
|
const projects = useQuery({ queryKey: ['projects'], queryFn: () => api.projects() });
|
||||||
|
const servicesQueries = useQueries({
|
||||||
|
queries: (projects.data ?? []).map((p) => ({
|
||||||
|
queryKey: ['services', p.id],
|
||||||
|
queryFn: () => api.projectServices(p.id),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = servicesQueries.flatMap((q, i) =>
|
||||||
|
(q.data ?? []).map((s) => ({ ...s, _project: projects.data?.[i]?.name ?? '' })),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (projects.isLoading) return <Loading />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
style={styles.root}
|
||||||
|
contentContainerStyle={{ padding: 16 }}
|
||||||
|
data={rows}
|
||||||
|
keyExtractor={(s) => s.id}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={servicesQueries.some((q) => q.isRefetching)}
|
||||||
|
onRefresh={() => servicesQueries.forEach((q) => q.refetch())}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
projects.error ? <ErrorBox error={projects.error} /> : <Empty text="No services" />
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Row
|
||||||
|
title={item.name}
|
||||||
|
subtitle={`${item._project} · ${item.type}${item.domain ? ` · ${item.domain}` : ''}`}
|
||||||
|
right={<StatusBadge status={item.status} />}
|
||||||
|
onPress={() => router.push(`/service/${item.id}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: colors.bg } });
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Alert, ScrollView, StyleSheet, Text } from 'react-native';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useSession } from '../../session';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { ActionButton, Card, SectionTitle } from '../../components';
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const { serverUrl, signOut } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.root} contentContainerStyle={{ padding: 16 }}>
|
||||||
|
<SectionTitle>Connection</SectionTitle>
|
||||||
|
<Card>
|
||||||
|
<Text style={styles.label}>Server</Text>
|
||||||
|
<Text style={styles.value}>{serverUrl}</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<SectionTitle>App</SectionTitle>
|
||||||
|
<Card>
|
||||||
|
<Text style={styles.label}>Version</Text>
|
||||||
|
<Text style={styles.value}>
|
||||||
|
{Constants.expoConfig?.version ?? 'dev'} · Expo SDK{' '}
|
||||||
|
{Constants.expoConfig?.sdkVersion ?? '?'}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
label="Disconnect"
|
||||||
|
kind="danger"
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert('Disconnect', 'Remove the stored server and token?', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Disconnect',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await signOut();
|
||||||
|
router.replace('/login');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
label: { color: colors.textDim, fontSize: 12, marginBottom: 4 },
|
||||||
|
value: { color: colors.text, fontSize: 15 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Stack, useRouter, useSegments } from 'expo-router';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { SessionProvider, useSession } from '../session';
|
||||||
|
import { colors } from '../theme';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { staleTime: 15_000, retry: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function AuthGate() {
|
||||||
|
const { ready, api } = useSession();
|
||||||
|
const segments = useSegments();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
const inLogin = segments[0] === 'login';
|
||||||
|
if (!api && !inLogin) router.replace('/login');
|
||||||
|
else if (api && inLogin) router.replace('/');
|
||||||
|
}, [ready, api, segments, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: colors.bg },
|
||||||
|
headerTintColor: colors.text,
|
||||||
|
contentStyle: { backgroundColor: colors.bg },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="project/[id]" options={{ title: 'Project' }} />
|
||||||
|
<Stack.Screen name="service/[id]" options={{ title: 'Service' }} />
|
||||||
|
<Stack.Screen name="service/logs" options={{ title: 'Logs' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SessionProvider>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
<AuthGate />
|
||||||
|
</SessionProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Redirect } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return <Redirect href="/(tabs)" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSession, normalizeServerUrl } from '../session';
|
||||||
|
import { colors } from '../theme';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { signIn } = useSession();
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const normalized = normalizeServerUrl(url);
|
||||||
|
const res = await fetch(`${normalized}/api/v1/projects?limit=1`, {
|
||||||
|
headers: { Authorization: `Bearer ${token.trim()}` },
|
||||||
|
});
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
throw new Error('Token rejected. Use a personal access token (cnp_…).');
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`Server responded ${res.status}`);
|
||||||
|
await signIn(normalized, token);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Connection failed');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.root}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
<Text style={styles.logo}>Containr</Text>
|
||||||
|
<Text style={styles.sub}>Self-hosted platform console</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Server URL</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="https://containr.example.com"
|
||||||
|
placeholderTextColor={colors.textDim}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
value={url}
|
||||||
|
onChangeText={setUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Personal access token</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="cnp_…"
|
||||||
|
placeholderTextColor={colors.textDim}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
secureTextEntry
|
||||||
|
value={token}
|
||||||
|
onChangeText={setToken}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <Text style={styles.err}>{error}</Text> : null}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.btn, (!url.trim() || !token.trim() || busy) && { opacity: 0.5 }]}
|
||||||
|
onPress={connect}
|
||||||
|
disabled={!url.trim() || !token.trim() || busy}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<ActivityIndicator color={colors.text} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.btnText}>Connect</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
Create a token in the web UI under Settings → Access Tokens.
|
||||||
|
</Text>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg, padding: 24, justifyContent: 'center' },
|
||||||
|
logo: { color: colors.text, fontSize: 34, fontWeight: '800', letterSpacing: -0.5 },
|
||||||
|
sub: { color: colors.textDim, fontSize: 14, marginBottom: 32, marginTop: 4 },
|
||||||
|
label: {
|
||||||
|
color: colors.textDim,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 10,
|
||||||
|
color: colors.text,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 15,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
err: { color: colors.err, marginBottom: 12 },
|
||||||
|
btn: {
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||||
|
hint: { color: colors.textDim, fontSize: 12, marginTop: 20, textAlign: 'center' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { FlatList, RefreshControl, StyleSheet } from 'react-native';
|
||||||
|
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../../session';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { Empty, ErrorBox, Loading, Row, StatusBadge } from '../../components';
|
||||||
|
|
||||||
|
export default function ProjectDetail() {
|
||||||
|
const { id, name } = useLocalSearchParams<{ id: string; name?: string }>();
|
||||||
|
const api = useApi();
|
||||||
|
const router = useRouter();
|
||||||
|
const nav = useNavigation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (name) nav.setOptions({ title: name });
|
||||||
|
}, [name, nav]);
|
||||||
|
|
||||||
|
const services = useQuery({
|
||||||
|
queryKey: ['services', id],
|
||||||
|
queryFn: () => api.projectServices(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (services.isLoading) return <Loading />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
style={styles.root}
|
||||||
|
contentContainerStyle={{ padding: 16 }}
|
||||||
|
data={services.data ?? []}
|
||||||
|
keyExtractor={(s) => s.id}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={services.isRefetching}
|
||||||
|
onRefresh={() => services.refetch()}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
services.error ? <ErrorBox error={services.error} /> : <Empty text="No services" />
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Row
|
||||||
|
title={item.name}
|
||||||
|
subtitle={`${item.type} · ${item.environment ?? 'production'}${
|
||||||
|
item.domain ? ` · ${item.domain}` : ''
|
||||||
|
}`}
|
||||||
|
right={<StatusBadge status={item.status} />}
|
||||||
|
onPress={() => router.push(`/service/${item.id}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: colors.bg } });
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Alert, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../../session';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { relTime } from '../../time';
|
||||||
|
import {
|
||||||
|
ActionButton,
|
||||||
|
Card,
|
||||||
|
Empty,
|
||||||
|
ErrorBox,
|
||||||
|
Loading,
|
||||||
|
SectionTitle,
|
||||||
|
StatusBadge,
|
||||||
|
} from '../../components';
|
||||||
|
|
||||||
|
export default function ServiceDetail() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const api = useApi();
|
||||||
|
const router = useRouter();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const service = useQuery({
|
||||||
|
queryKey: ['service', id],
|
||||||
|
queryFn: () => api.service(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
const envCheck = useQuery({
|
||||||
|
queryKey: ['env-check', id],
|
||||||
|
queryFn: () => api.envCheck(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const deployments = useQuery({
|
||||||
|
queryKey: ['service-deployments', id],
|
||||||
|
queryFn: () => api.serviceDeployments(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['service', id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['services'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['service-deployments', id] });
|
||||||
|
};
|
||||||
|
const onErr = (e: unknown) =>
|
||||||
|
Alert.alert('Action failed', e instanceof Error ? e.message : String(e));
|
||||||
|
|
||||||
|
const action = useMutation({
|
||||||
|
mutationFn: (act: 'start' | 'stop' | 'restart' | 'redeploy' | 'sleep' | 'wake') =>
|
||||||
|
api.serviceAction(id!, act),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
const rollback = useMutation({
|
||||||
|
mutationFn: (depId: string) => api.deploymentAction(depId, 'rollback'),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
|
||||||
|
const s = service.data;
|
||||||
|
if (service.isLoading) return <Loading />;
|
||||||
|
if (!s) return <ErrorBox error={service.error ?? new Error('Not found')} />;
|
||||||
|
|
||||||
|
const busy = action.isPending;
|
||||||
|
const canStart = s.status === 'stopped' || s.status === 'failed';
|
||||||
|
const canStop = s.status === 'running';
|
||||||
|
const canWake = s.status === 'sleeping';
|
||||||
|
const lastDeploy = deployments.data?.[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.root}
|
||||||
|
contentContainerStyle={{ padding: 16 }}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={service.isRefetching}
|
||||||
|
onRefresh={() => service.refetch()}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={styles.head}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.name}>{s.name}</Text>
|
||||||
|
<Text style={styles.meta}>
|
||||||
|
{s.type} · {s.environment ?? 'production'}
|
||||||
|
{s.image ? `\n${s.image}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{envCheck.data && !envCheck.data.ok ? (
|
||||||
|
<Card style={{ borderColor: colors.warn }}>
|
||||||
|
<Text style={styles.warnTitle}>Environment issues</Text>
|
||||||
|
{envCheck.data.unresolved.map((v) => (
|
||||||
|
<Text key={v} style={styles.warnItem}>
|
||||||
|
Unresolved reference: {v}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{envCheck.data.empty.map((v) => (
|
||||||
|
<Text key={v} style={styles.warnItem}>
|
||||||
|
Empty value: {v}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{envCheck.data.unreadable.map((v) => (
|
||||||
|
<Text key={v} style={styles.warnItem}>
|
||||||
|
Unreadable secret (rotate): {v}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SectionTitle>Actions</SectionTitle>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{canStart ? (
|
||||||
|
<ActionButton label="Start" kind="primary" disabled={busy} onPress={() => action.mutate('start')} />
|
||||||
|
) : null}
|
||||||
|
{canStop ? (
|
||||||
|
<ActionButton label="Stop" kind="danger" disabled={busy} onPress={() => action.mutate('stop')} />
|
||||||
|
) : null}
|
||||||
|
{canWake ? (
|
||||||
|
<ActionButton label="Wake" kind="primary" disabled={busy} onPress={() => action.mutate('wake')} />
|
||||||
|
) : null}
|
||||||
|
{s.status === 'running' ? (
|
||||||
|
<>
|
||||||
|
<ActionButton label="Restart" disabled={busy} onPress={() => action.mutate('restart')} />
|
||||||
|
<ActionButton label="Redeploy" disabled={busy} onPress={() => action.mutate('redeploy')} />
|
||||||
|
{s.sleep_enabled ? (
|
||||||
|
<ActionButton label="Sleep" disabled={busy} onPress={() => action.mutate('sleep')} />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<ActionButton
|
||||||
|
label="View logs"
|
||||||
|
onPress={() => router.push(`/service/logs?id=${s.id}&name=${encodeURIComponent(s.name)}`)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{s.sleep_enabled ? (
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
Sleep enabled — scales to zero after {s.sleep_idle_minutes}m idle, wakes on traffic.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SectionTitle>Deployments</SectionTitle>
|
||||||
|
{deployments.data?.length === 0 ? <Empty text="No deployments" /> : null}
|
||||||
|
{(deployments.data ?? []).slice(0, 8).map((d) => (
|
||||||
|
<Card key={d.id}>
|
||||||
|
<View style={styles.depRow}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.depTitle}>{d.image_name ?? d.id.slice(0, 8)}</Text>
|
||||||
|
<Text style={styles.meta}>{relTime(d.created_at)}</Text>
|
||||||
|
</View>
|
||||||
|
<StatusBadge status={d.status} />
|
||||||
|
</View>
|
||||||
|
{d.id !== lastDeploy?.id && d.status === 'success' ? (
|
||||||
|
<ActionButton
|
||||||
|
label="Rollback to this"
|
||||||
|
disabled={rollback.isPending}
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert('Rollback', 'Roll back to this deployment?', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: 'Rollback', onPress: () => rollback.mutate(d.id) },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
head: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
|
||||||
|
name: { color: colors.text, fontSize: 24, fontWeight: '800' },
|
||||||
|
meta: { color: colors.textDim, fontSize: 12, marginTop: 4 },
|
||||||
|
warnTitle: { color: colors.warn, fontWeight: '700', marginBottom: 6 },
|
||||||
|
warnItem: { color: colors.text, fontSize: 13, marginTop: 2 },
|
||||||
|
actions: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||||
|
hint: { color: colors.textDim, fontSize: 12, marginTop: 4 },
|
||||||
|
depRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
|
||||||
|
depTitle: { color: colors.text, fontSize: 14, fontWeight: '600' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { FlatList, RefreshControl, StyleSheet, Text } from 'react-native';
|
||||||
|
import { useLocalSearchParams, useNavigation } from 'expo-router';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../../session';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { Empty, ErrorBox, Loading } from '../../components';
|
||||||
|
|
||||||
|
export default function ServiceLogs() {
|
||||||
|
const { id, name } = useLocalSearchParams<{ id: string; name?: string }>();
|
||||||
|
const api = useApi();
|
||||||
|
const nav = useNavigation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (name) nav.setOptions({ title: `${name} logs` });
|
||||||
|
}, [name, nav]);
|
||||||
|
|
||||||
|
const logs = useQuery({
|
||||||
|
queryKey: ['logs', id],
|
||||||
|
queryFn: () => api.serviceLogs(id!, 300),
|
||||||
|
enabled: !!id,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (logs.isLoading) return <Loading />;
|
||||||
|
const entries = logs.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
style={styles.root}
|
||||||
|
contentContainerStyle={{ padding: 12 }}
|
||||||
|
data={[...entries].reverse()}
|
||||||
|
inverted
|
||||||
|
keyExtractor={(_, i) => String(i)}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={logs.isRefetching}
|
||||||
|
onRefresh={() => logs.refetch()}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
logs.error ? (
|
||||||
|
<ErrorBox error={logs.error} />
|
||||||
|
) : (
|
||||||
|
<Empty text="No log output yet" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.line,
|
||||||
|
item.stream === 'stderr' && { color: colors.err },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
line: {
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
paddingVertical: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { colors, statusColor } from './theme';
|
||||||
|
|
||||||
|
export function StatusDot({ status }: { status: string }) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.dot, { backgroundColor: statusColor[status] ?? colors.textDim }]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
|
const c = statusColor[status] ?? colors.textDim;
|
||||||
|
return (
|
||||||
|
<View style={[styles.badge, { borderColor: c }]}>
|
||||||
|
<View style={[styles.dot, { backgroundColor: c }]} />
|
||||||
|
<Text style={[styles.badgeText, { color: c }]}>{status}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, style }: { children: React.ReactNode; style?: object }) {
|
||||||
|
return <View style={[styles.card, style]}>{children}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||||
|
return <Text style={styles.section}>{children}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Empty({ text }: { text: string }) {
|
||||||
|
return <Text style={styles.empty}>{text}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBox({ error }: { error: unknown }) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
return <Text style={styles.err}>{msg}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionButton({
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
kind = 'default',
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
kind?: 'default' | 'primary' | 'danger';
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const bg =
|
||||||
|
kind === 'primary' ? colors.accent : kind === 'danger' ? colors.err : colors.surfaceAlt;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.btn, { backgroundColor: bg, opacity: disabled ? 0.4 : 1 }]}
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text style={styles.btnText}>{label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Row({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
right,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
onPress?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.row} onPress={onPress} disabled={!onPress}>
|
||||||
|
<View style={{ flex: 1, marginRight: 8 }}>
|
||||||
|
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle ? (
|
||||||
|
<Text style={styles.rowSub} numberOfLines={1}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{right}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Loading() {
|
||||||
|
return (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<ActivityIndicator color={colors.accent} size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
dot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
|
badge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
},
|
||||||
|
badgeText: { fontSize: 11, fontWeight: '600', textTransform: 'uppercase' },
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
color: colors.textDim,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginTop: 14,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
empty: { color: colors.textDim, fontSize: 14, paddingVertical: 12 },
|
||||||
|
err: { color: colors.err, fontSize: 13, paddingVertical: 8 },
|
||||||
|
btn: {
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
btnText: { color: colors.text, fontWeight: '600', fontSize: 13 },
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
rowTitle: { color: colors.text, fontSize: 15, fontWeight: '500' },
|
||||||
|
rowSub: { color: colors.textDim, fontSize: 12, marginTop: 2 },
|
||||||
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { Api } from './api';
|
||||||
|
|
||||||
|
const KEY_URL = 'containr.server_url';
|
||||||
|
const KEY_TOKEN = 'containr.token';
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
ready: boolean;
|
||||||
|
api: Api | null;
|
||||||
|
serverUrl: string | null;
|
||||||
|
signIn: (serverUrl: string, token: string) => Promise<void>;
|
||||||
|
signOut: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<Session>({
|
||||||
|
ready: false,
|
||||||
|
api: null,
|
||||||
|
serverUrl: null,
|
||||||
|
signIn: async () => {},
|
||||||
|
signOut: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function normalizeServerUrl(raw: string): string {
|
||||||
|
let u = raw.trim();
|
||||||
|
if (!/^https?:\/\//i.test(u)) u = `https://${u}`;
|
||||||
|
return u.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [serverUrl, setServerUrl] = useState<string | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [u, t] = await Promise.all([
|
||||||
|
SecureStore.getItemAsync(KEY_URL),
|
||||||
|
SecureStore.getItemAsync(KEY_TOKEN),
|
||||||
|
]);
|
||||||
|
setServerUrl(u);
|
||||||
|
setToken(t);
|
||||||
|
} finally {
|
||||||
|
setReady(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<Session>(
|
||||||
|
() => ({
|
||||||
|
ready,
|
||||||
|
serverUrl,
|
||||||
|
api: serverUrl && token ? new Api(serverUrl, token) : null,
|
||||||
|
signIn: async (url, tok) => {
|
||||||
|
const normalized = normalizeServerUrl(url);
|
||||||
|
await SecureStore.setItemAsync(KEY_URL, normalized);
|
||||||
|
await SecureStore.setItemAsync(KEY_TOKEN, tok.trim());
|
||||||
|
setServerUrl(normalized);
|
||||||
|
setToken(tok.trim());
|
||||||
|
},
|
||||||
|
signOut: async () => {
|
||||||
|
await SecureStore.deleteItemAsync(KEY_URL);
|
||||||
|
await SecureStore.deleteItemAsync(KEY_TOKEN);
|
||||||
|
setServerUrl(null);
|
||||||
|
setToken(null);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[ready, serverUrl, token],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
return useContext(Ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApi(): Api {
|
||||||
|
const { api } = useSession();
|
||||||
|
if (!api) throw new Error('not signed in');
|
||||||
|
return api;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export const colors = {
|
||||||
|
bg: '#0B0F14',
|
||||||
|
surface: '#131A22',
|
||||||
|
surfaceAlt: '#1A232E',
|
||||||
|
border: '#26313D',
|
||||||
|
text: '#E8EEF4',
|
||||||
|
textDim: '#8A97A5',
|
||||||
|
accent: '#4F8FF7',
|
||||||
|
accentDim: '#2A4A7F',
|
||||||
|
ok: '#3FB68B',
|
||||||
|
warn: '#E0A93E',
|
||||||
|
err: '#E05C5C',
|
||||||
|
info: '#4FB7D9',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statusColor: Record<string, string> = {
|
||||||
|
running: colors.ok,
|
||||||
|
deployed: colors.ok,
|
||||||
|
success: colors.ok,
|
||||||
|
building: colors.warn,
|
||||||
|
deploying: colors.warn,
|
||||||
|
pending: colors.warn,
|
||||||
|
waking: colors.warn,
|
||||||
|
sleeping: colors.info,
|
||||||
|
stopped: colors.textDim,
|
||||||
|
queued: colors.textDim,
|
||||||
|
failed: colors.err,
|
||||||
|
error: colors.err,
|
||||||
|
cancelled: colors.textDim,
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export function relTime(iso?: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (Number.isNaN(t)) return '—';
|
||||||
|
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
|
||||||
|
if (s < 60) return `${s}s ago`;
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
if (d < 30) return `${d}d ago`;
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -667,6 +667,167 @@ paths:
|
|||||||
'200':
|
'200':
|
||||||
description: Reconciled runtime state
|
description: Reconciled runtime state
|
||||||
|
|
||||||
|
/services/{id}/clone:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Services
|
||||||
|
summary: Clone service
|
||||||
|
description: Duplicates config, volumes, domains, access gates, builder settings, and variables into the same or another project. Containers are not cloned — the copy starts stopped.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Name for the clone (default "<name>-copy")
|
||||||
|
project_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Target project (default same project; caller must own it)
|
||||||
|
environment:
|
||||||
|
type: string
|
||||||
|
enum: [production, preview, development]
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Clone created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
service_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
'409':
|
||||||
|
description: Name already exists in target project
|
||||||
|
|
||||||
|
/services/{id}/move:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Services
|
||||||
|
summary: Move service
|
||||||
|
description: Re-parents the service to another project the caller owns. Variables, domains, and volumes follow the service; running containers keep running and join the new project's network on next deploy.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [project_id]
|
||||||
|
properties:
|
||||||
|
project_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Service moved
|
||||||
|
'409':
|
||||||
|
description: Name conflict in target project
|
||||||
|
|
||||||
|
/registries:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Registries
|
||||||
|
summary: List registry credentials
|
||||||
|
description: Private-image pull credentials for the caller. Passwords are never returned — `has_password` signals presence.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Registry list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
registries:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Registry'
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Registries
|
||||||
|
summary: Add registry credential
|
||||||
|
description: Credentials are matched by image host (e.g. ghcr.io, registry.example.com, docker.io for Docker Hub) on prebuilt-image deploys. Password stored encrypted at rest.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RegistryRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Registry created
|
||||||
|
'409':
|
||||||
|
description: A registry for this host already exists
|
||||||
|
|
||||||
|
/registries/{id}:
|
||||||
|
put:
|
||||||
|
tags:
|
||||||
|
- Registries
|
||||||
|
summary: Update registry credential
|
||||||
|
description: Empty password keeps the stored credential — delete and recreate to remove a password.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RegistryRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Registry updated
|
||||||
|
'404':
|
||||||
|
description: Registry not found
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- Registries
|
||||||
|
summary: Remove registry credential
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Registry removed
|
||||||
|
'404':
|
||||||
|
description: Registry not found
|
||||||
|
|
||||||
/services/{id}/domains:
|
/services/{id}/domains:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -1889,6 +2050,80 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/DatabaseCreateResponse'
|
$ref: '#/components/schemas/DatabaseCreateResponse'
|
||||||
|
|
||||||
|
/databases/register-external:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Databases
|
||||||
|
summary: Register external database
|
||||||
|
description: Record a database hosted elsewhere — connection is probed first (postgres/redis get protocol-level checks, others TCP). Password stored encrypted, never returned.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ExternalDatabaseRequest'
|
||||||
|
- type: object
|
||||||
|
required: [name]
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: External database registered
|
||||||
|
'400':
|
||||||
|
description: Validation failed or connection probe failed
|
||||||
|
'409':
|
||||||
|
description: Name already in use
|
||||||
|
|
||||||
|
/databases/test-connection:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Databases
|
||||||
|
summary: Probe a database connection
|
||||||
|
description: Ad-hoc connectivity check — postgres via libpq ping, redis/dragonfly via PING, other engines via TCP dial. Always 200; `ok` carries the result.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ExternalDatabaseRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Probe result
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
ok:
|
||||||
|
type: boolean
|
||||||
|
latency_ms:
|
||||||
|
type: integer
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
/databases/{id}/test-connection:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Databases
|
||||||
|
summary: Re-probe a registered external database
|
||||||
|
description: Uses the stored credentials. External databases only.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Probe result (`ok` carries the outcome)
|
||||||
|
'404':
|
||||||
|
description: Database not found
|
||||||
|
|
||||||
/databases/{id}:
|
/databases/{id}:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -2388,6 +2623,78 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Error'
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
/projects/{id}/variables:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Projects
|
||||||
|
summary: List shared project variables
|
||||||
|
description: Project-level variables shared across services. Services reference them as `${{shared.KEY}}` in environment values — resolved at deploy/reconcile, not live-updated. Secret values are masked.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Variables retrieved
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
variables:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ServiceVariable'
|
||||||
|
'404':
|
||||||
|
description: Project not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
put:
|
||||||
|
tags:
|
||||||
|
- Projects
|
||||||
|
summary: Replace shared project variables
|
||||||
|
description: Replace the full shared-variable set for a project. Fetch the current list first and merge — the server replaces wholesale. Masked secret placeholders (`********`) keep the stored value.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UpdateVariablesRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Variables updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
variables:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ServiceVariable'
|
||||||
|
'400':
|
||||||
|
description: Invalid input
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
# Git integration
|
# Git integration
|
||||||
/git/github-app/install-url:
|
/git/github-app/install-url:
|
||||||
get:
|
get:
|
||||||
@@ -5413,6 +5720,44 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: Plaintext on write — stored as bcrypt htpasswd; never returned
|
description: Plaintext on write — stored as bcrypt htpasswd; never returned
|
||||||
|
|
||||||
|
Registry:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
host:
|
||||||
|
type: string
|
||||||
|
description: Registry host matched against the image reference — ghcr.io, registry.example.com, docker.io for Docker Hub
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
has_password:
|
||||||
|
type: boolean
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
RegistryRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- host
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
host:
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
description: Plaintext on write — stored encrypted; never returned. Empty on update keeps the stored credential.
|
||||||
|
|
||||||
ServiceVolume:
|
ServiceVolume:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -6042,6 +6387,12 @@ components:
|
|||||||
backup_schedule:
|
backup_schedule:
|
||||||
type: string
|
type: string
|
||||||
description: Cron expression for automatic backups, empty when unset
|
description: Cron expression for automatic backups, empty when unset
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
enum: [managed, external]
|
||||||
|
description: managed = Containr-provisioned container; external = registered connection to a database hosted elsewhere
|
||||||
|
external:
|
||||||
|
$ref: '#/components/schemas/DatabaseExternal'
|
||||||
next_backup_at:
|
next_backup_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -6084,6 +6435,49 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: Database region
|
description: Database region
|
||||||
|
|
||||||
|
DatabaseExternal:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
host:
|
||||||
|
type: string
|
||||||
|
port:
|
||||||
|
type: integer
|
||||||
|
database:
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
has_password:
|
||||||
|
type: boolean
|
||||||
|
ssl:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
ExternalDatabaseRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- type
|
||||||
|
- host
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Display name (required for register, unused for probes)
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly]
|
||||||
|
host:
|
||||||
|
type: string
|
||||||
|
port:
|
||||||
|
type: integer
|
||||||
|
description: Defaults to the engine's standard port
|
||||||
|
database:
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
description: Stored encrypted; never returned
|
||||||
|
ssl:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
UpdateDatabaseRequest:
|
UpdateDatabaseRequest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -7732,5 +8126,7 @@ tags:
|
|||||||
description: Vulnerability scans and compliance reporting
|
description: Vulnerability scans and compliance reporting
|
||||||
- name: Cron
|
- name: Cron
|
||||||
description: Scheduled jobs
|
description: Scheduled jobs
|
||||||
|
- name: Registries
|
||||||
|
description: Private-image pull credentials
|
||||||
- name: Gateway
|
- name: Gateway
|
||||||
description: API gateway — upstream services, keys, analytics
|
description: API gateway — upstream services, keys, analytics
|
||||||
@@ -38,14 +38,19 @@ auth login|logout|status token auth and profiles
|
|||||||
tokens list|create|revoke personal access tokens
|
tokens list|create|revoke personal access tokens
|
||||||
projects list|get|create|delete
|
projects list|get|create|delete
|
||||||
services list|get|create|update|delete|start|stop|restart|redeploy
|
services list|get|create|update|delete|start|stop|restart|redeploy
|
||||||
|
services clone|move duplicate config or re-parent a service
|
||||||
services domains list|add|remove|default|check multi-domain + DNS preflight
|
services domains list|add|remove|default|check multi-domain + DNS preflight
|
||||||
|
registries list|add|update|remove private-image pull credentials
|
||||||
volumes list|delete docker volume inventory + cleanup (admin)
|
volumes list|delete docker volume inventory + cleanup (admin)
|
||||||
deploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)
|
deploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)
|
||||||
deployments list|get|logs|rollback|cancel
|
deployments list|get|logs|rollback|cancel
|
||||||
logs <service-id> [--follow] runtime logs (SSE follow)
|
logs <service-id> [--follow] runtime logs (SSE follow)
|
||||||
exec <service-id> -- <cmd> one-off container command
|
exec <service-id> -- <cmd> one-off container command
|
||||||
variables list|set|unset env vars (secrets masked; --redeploy applies them)
|
variables list|set|unset env vars (secrets masked; --redeploy applies them)
|
||||||
|
variables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}
|
||||||
databases list|get|create|delete|action|backup|restore|download-backup
|
databases list|get|create|delete|action|backup|restore|download-backup
|
||||||
|
databases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)
|
||||||
|
databases test-connection probe a connection — by id, or ad-hoc flags
|
||||||
cron list|get|create|delete|trigger|executions
|
cron list|get|create|delete|trigger|executions
|
||||||
templates list|get|create|delete|deploy
|
templates list|get|create|delete|deploy
|
||||||
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
|
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
|
||||||
@@ -143,6 +148,30 @@ instance-level defaults used at service creation;
|
|||||||
`app_settings.capacity_policy=block` fails deployments whose requested
|
`app_settings.capacity_policy=block` fails deployments whose requested
|
||||||
memory exceeds node capacity (default `warn` logs only).
|
memory exceeds node capacity (default `warn` logs only).
|
||||||
|
|
||||||
|
## Clone, move, registries
|
||||||
|
|
||||||
|
`services clone` duplicates config, volumes, domains, access gates,
|
||||||
|
builder settings, and variables — ciphertext secret values are copied
|
||||||
|
verbatim (no decrypt/re-encrypt). The clone starts stopped:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
containr services clone <id> --name staging-api --project <other-project-id> --environment preview
|
||||||
|
containr services move <id> <project-id> # name conflicts → 409
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: cloned domains are verbatim copies — the same hostname attached to
|
||||||
|
two services makes Traefik routing ambiguous. Re-domain or undeploy one.
|
||||||
|
|
||||||
|
`registries` stores private-image pull credentials, owner-scoped and
|
||||||
|
encrypted at rest. Matched by image host on every image-sourced deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
containr registries add ghcr ghcr.io --username <user> --password <pat>
|
||||||
|
containr registries add hub docker.io --username <user> --password <pw> # Docker Hub
|
||||||
|
containr registries update <id> --username <new-user> # empty --password keeps stored
|
||||||
|
containr registries remove <id>
|
||||||
|
```
|
||||||
|
|
||||||
## MCP server
|
## MCP server
|
||||||
|
|
||||||
`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools
|
`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools
|
||||||
|
|||||||
@@ -16,14 +16,18 @@ Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
|
|||||||
- **Idempotency**: send `Idempotency-Key: <uuid>` on POSTs — the first response replays for 24h; concurrent duplicates get 409. Always safe to send.
|
- **Idempotency**: send `Idempotency-Key: <uuid>` on POSTs — the first response replays for 24h; concurrent duplicates get 409. Always safe to send.
|
||||||
- **Pagination**: `?limit=` + `?cursor=` (pass `pagination.next_cursor` verbatim; legacy `?page=` also works). Invalid cursors fall back to page 1.
|
- **Pagination**: `?limit=` + `?cursor=` (pass `pagination.next_cursor` verbatim; legacy `?page=` also works). Invalid cursors fall back to page 1.
|
||||||
- **Deployments**: serialized per service — a deploy on a busy service returns `status: queued` and runs when the active one finishes. `POST /deployments/{id}/cancel` drops queued (`200 cancelled`) or aborts running (`202 cancelling`) work. `no_cache: true` on create disables the Docker layer cache. `PUT /services/{id}/variables` accepts `redeploy: true` to apply env changes to running containers.
|
- **Deployments**: serialized per service — a deploy on a busy service returns `status: queued` and runs when the active one finishes. `POST /deployments/{id}/cancel` drops queued (`200 cancelled`) or aborts running (`202 cancelling`) work. `no_cache: true` on create disables the Docker layer cache. `PUT /services/{id}/variables` accepts `redeploy: true` to apply env changes to running containers.
|
||||||
|
- **Variables**: service env values support `${{service.KEY}}` cross-service refs (builtins HOST/PORT) and `${{shared.KEY}}` project shared variables (`GET|PUT /projects/{id}/variables`, CLI `variables shared …`). Resolved at deploy/reconcile, not live-updated.
|
||||||
- **Volumes**: service mounts via `PUT /services/{id}` `volumes: [{type: volume|bind, source, target, read_only}]` — replaces the whole list (`[]` clears), applied on next deploy. `GET /admin/volumes` inventories node volumes with `in_use` flags; `DELETE /admin/volumes/{name}` removes unused ones (admin).
|
- **Volumes**: service mounts via `PUT /services/{id}` `volumes: [{type: volume|bind, source, target, read_only}]` — replaces the whole list (`[]` clears), applied on next deploy. `GET /admin/volumes` inventories node volumes with `in_use` flags; `DELETE /admin/volumes/{name}` removes unused ones (admin).
|
||||||
- **Domains**: multiple hostnames per service via `GET|POST /services/{id}/domains`, `DELETE /services/{id}/domains/{domain_id}`, `POST …/{domain_id}/default` (mirrors `services.domain`). `GET /services/{id}/domains/check` returns `ok|wrong-target|pending` per domain vs `app_settings.public_ip`/`PUBLIC_IP`. `PUT` `maintenance_mode` redirects all traffic to `GET /maintenance` (needs `base_url`/`BASE_URL` setting); `basic_auth: [{username,password}]` stores bcrypt htpasswd and gates the route — responses return usernames only.
|
- **Domains**: multiple hostnames per service via `GET|POST /services/{id}/domains`, `DELETE /services/{id}/domains/{domain_id}`, `POST …/{domain_id}/default` (mirrors `services.domain`). `GET /services/{id}/domains/check` returns `ok|wrong-target|pending` per domain vs `app_settings.public_ip`/`PUBLIC_IP`. `PUT` `maintenance_mode` redirects all traffic to `GET /maintenance` (needs `base_url`/`BASE_URL` setting); `basic_auth: [{username,password}]` stores bcrypt htpasswd and gates the route — responses return usernames only.
|
||||||
- **Builders & resources**: service field `builder` = `auto|railpack|nixpacks|dockerfile|static` (auto detects; static builds assets with `static_build_cmd` and serves `static_dir` via nginx:80). `cpu`/`memory` are hard caps; `cpu_reserve`/`memory_reserve` are soft reservations (CPU shares / MemoryReservation) — empty string clears. `app_settings.default_cpu`/`default_memory` set instance defaults; `app_settings.capacity_policy=block` rejects deploys exceeding node memory (default `warn`).
|
- **Builders & resources**: service field `builder` = `auto|railpack|nixpacks|dockerfile|static` (auto detects; static builds assets with `static_build_cmd` and serves `static_dir` via nginx:80). `cpu`/`memory` are hard caps; `cpu_reserve`/`memory_reserve` are soft reservations (CPU shares / MemoryReservation) — empty string clears. `app_settings.default_cpu`/`default_memory` set instance defaults; `app_settings.capacity_policy=block` rejects deploys exceeding node memory (default `warn`).
|
||||||
|
- **Clone & move**: `POST /services/{id}/clone` `{name?, project_id?, environment?}` copies config, volumes, domains, access gates, and variables (secret ciphertext verbatim; clone starts stopped). `POST /services/{id}/move` `{project_id}` re-parents a service — both require ownership of source and target; name conflicts → `CONFLICT`. Cloned domains are verbatim copies: the same hostname on two services makes routing ambiguous.
|
||||||
|
- **Registries**: `GET|POST /registries`, `PUT|DELETE /registries/{id}` — owner-scoped private-image pull credentials, encrypted at rest, never returned (`has_password` signals presence). `host` matched against the image reference on image deploys (`docker.io` for Docker Hub; scheme/case normalized). Empty `password` on update keeps the stored credential.
|
||||||
|
- **External databases**: `POST /databases/register-external` (probes first; `{name,type,host,port,database,username,password,ssl}`), `POST /databases/test-connection` (ad-hoc probe → `{ok,latency_ms,error}`), `POST /databases/{id}/test-connection` (re-probe with stored credentials). Rows carry `provider:"external"` + `external{host,port,database,username,has_password,ssl}`; passwords encrypted at rest, never returned. Lifecycle actions and backups reject external rows — no container exists.
|
||||||
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
|
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
|
||||||
|
|
||||||
## Core objects
|
## Core objects
|
||||||
|
|
||||||
`projects` → `services` (git/app deploys) → `deployments`; plus `databases`, `templates`, `cron-jobs`, `variables`, `volumes`, `preview-environments`, `nodes`, `scaling`, `security`, `gateway`, `admin`, `notifications`, `user/tokens`.
|
`projects` → `services` (git/app deploys) → `deployments`; plus `databases`, `templates`, `cron-jobs`, `variables`, `volumes`, `domains`, `registries`, `preview-environments`, `nodes`, `scaling`, `security`, `gateway`, `admin`, `notifications`, `user/tokens`.
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -21,18 +21,25 @@ for platform in $PLATFORMS; do
|
|||||||
GOARCH=${platform#*/}
|
GOARCH=${platform#*/}
|
||||||
|
|
||||||
OUTPUT_NAME="containr-${GOOS}-${GOARCH}"
|
OUTPUT_NAME="containr-${GOOS}-${GOARCH}"
|
||||||
|
MCP_NAME="containr-mcp-${GOOS}-${GOARCH}"
|
||||||
if [ "$GOOS" = "windows" ]; then
|
if [ "$GOOS" = "windows" ]; then
|
||||||
OUTPUT_NAME="${OUTPUT_NAME}.exe"
|
OUTPUT_NAME="${OUTPUT_NAME}.exe"
|
||||||
|
MCP_NAME="${MCP_NAME}.exe"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📦 Building for ${platform}..."
|
echo "📦 Building CLI for ${platform}..."
|
||||||
|
|
||||||
GOOS=$GOOS GOARCH=$GOARCH go build \
|
GOOS=$GOOS GOARCH=$GOARCH go build \
|
||||||
-ldflags "$LDFLAGS" \
|
-ldflags "$LDFLAGS" \
|
||||||
-o "bin/${OUTPUT_NAME}" \
|
-o "bin/${OUTPUT_NAME}" \
|
||||||
./cmd/cli
|
./cmd/cli
|
||||||
|
|
||||||
echo "✅ Built bin/${OUTPUT_NAME}"
|
echo "📦 Building MCP server for ${platform}..."
|
||||||
|
GOOS=$GOOS GOARCH=$GOARCH go build \
|
||||||
|
-ldflags "$LDFLAGS" \
|
||||||
|
-o "bin/${MCP_NAME}" \
|
||||||
|
./cmd/mcp
|
||||||
|
|
||||||
|
echo "✅ Built bin/${OUTPUT_NAME} + bin/${MCP_NAME}"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "🎉 CLI build complete!"
|
echo "🎉 CLI build complete!"
|
||||||
|
|||||||
Reference in new issue
Block a user