Compare commits

...
17 Commits
Author SHA1 Message Date
Tomáš Dvořák 9e3994e937 Merge pull request #34 from Dvorinka/feat/mobile-app
feat(mobile): Containr companion app (Expo)
2026-10-09 05:20:17 +02:00
Tomáš Dvořák 355eb2c9ab Merge pull request #24 from Dvorinka/feat/external-databases
feat: external databases — register, probe, encrypted credentials
2026-10-09 05:19:31 +02:00
Tomas Dvorak 2be1715015 Merge remote-tracking branch 'origin/main' into feat/mobile-app 2026-10-09 05:12:38 +02:00
Tomas Dvorak e67a39436c Merge remote-tracking branch 'origin/main' into feat/external-databases 2026-10-09 05:12:01 +02:00
Tomáš Dvořák 4734be0d9d Merge pull request #33 from Dvorinka/feat/release-mcp
ci: MCP release binaries + docs-only path gate
2026-10-09 05:11:14 +02:00
Tomáš Dvořák 4e8d364911 Merge pull request #32 from Dvorinka/feat/shared-vars
feat(vars): shared project variables — ${{shared.KEY}} refs
2026-10-09 05:11:08 +02:00
Tomáš Dvořák f53695be4f Merge pull request #23 from Dvorinka/feat/clone-move-registries
feat: service clone/move + private registry credentials (C4)
2026-10-09 05:10:27 +02:00
Tomas DvorakandDevin 925199633b feat(mobile): Containr companion app — Expo/RN thin client
Phone app v1 (Phase I): PAT auth against any instance, secure-store
credentials, dark UI matching the design system.

- Overview: service status counts, per-project health dots, deploy feed
- Services/Projects: status badges, drill-down detail
- Service detail: start/stop/restart/redeploy/sleep/wake, env-doctor
  issues, deployment history + rollback
- Logs: 300-line tail, 5s poll, stderr highlighted
- Databases: status, start/stop/restart, backup-now
- Settings: server info, disconnect

CI: mobile job (tsc + expo lint) gated on a dedicated app/mobile paths
filter so non-mobile PRs skip it entirely.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 05:08:48 +02:00
Tomas Dvorak 1054679ca2 Merge branch 'feat/shared-vars' into feat/mobile-app 2026-10-09 01:17:57 +02:00
Tomas DvorakandDevin 57f8b4f56e ci: release MCP binaries + path-gate heavyweight jobs
- build-cli.sh also builds containr-mcp-<os>-<arch> per platform; the
  existing containr-* asset glob in the release job picks them up
- paths-filter 'changes' job gates backend/frontend/smoke/docker on PRs —
  docs-only PRs skip ~10min of compute; skipped checks satisfy protection
- docker job requires success() explicitly (skipped parents would pass
  '!= failure'); release requires docker success to never release stale images

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 01:07:18 +02:00
Tomas Dvorak dd1bbaf5a2 Merge remote-tracking branch 'origin/main' into feat/external-databases 2026-10-09 01:04:41 +02:00
Tomas Dvorak 58424ed0ab Merge remote-tracking branch 'origin/main' into feat/clone-move-registries 2026-10-09 01:04:41 +02:00
Tomas DvorakandDevin 25fa8c47e6 feat(vars): project shared variables — ${{shared.KEY}} refs, CRUD, UI
- project_variables table (secret entries encrypted at rest, masked in API)
- resolveServiceEnv resolves shared.* before service lookup
- GET|PUT /projects/{id}/variables; CLI 'variables shared list|set|unset';
  MCP containr_shared_variables_{list,set}
- Settings tab → Shared Variables editor; env help text mentions shared refs

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 01:04:19 +02:00
Tomas Dvorak f83ad81f73 Merge remote-tracking branch 'origin/main' into feat/external-databases
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:08 +02:00
Tomas Dvorak 2049087ccd Merge remote-tracking branch 'origin/main' into feat/clone-move-registries
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:06 +02:00
Tomas DvorakandDevin 5dc9b5f115 feat: external databases — register, probe, encrypted credentials
Databases hosted outside Containr can now be registered alongside
managed ones. The connection is probed before storing (protocol-level
for postgres/redis-family via real drivers, TCP dial for the rest),
credentials are encrypted at rest and never returned, and external rows
skip managed lifecycle actions, backups, and Docker metrics.

- POST /databases/register-external — probe + create
- POST /databases/test-connection — ad-hoc probe (ok/latency_ms/error)
- POST /databases/{id}/test-connection — re-probe stored credentials
- provider/external fields on database services; privileged reads get a
  composed connection URL with loopback hosts rewritten to the caller's
- CLI: databases register + test-connection; MCP: 3 new tools
- UI: managed/external toggle in the create modal, external badge and
  host display, per-row test button, backups section hidden
- Migration widens the plan CHECK to admit 'external'

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 22:09:42 +02:00
Tomas DvorakandDevin 3664eaed1a feat(services): clone/move ops + owner-scoped registry credentials (C4)
Service lifecycle: POST /services/:id/clone duplicates config, volumes,
domains, access gates, builder settings, and variables (secret ciphertext
copied verbatim); POST /services/:id/move re-parents to another owned
project. Both enforce ownership, reject name conflicts, and audit.

Registries: CRUD at /registries — host-matched pull credentials for
prebuilt-image deploys, encrypted at rest via secrets pkg, never returned
(has_password flag). Empty password on update keeps the stored credential;
host normalized (scheme/case/trailing slash). Credentials thread through
BuildConfig → BuildRequest → pull auth config, used only for the pull.

Surfaces: CLI services clone|move + registries group, 7 MCP tools,
OpenAPI paths/schemas, frontend Lifecycle card (clone/move/delete) and
Settings registries section, llms.txt + CLI.md.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 17:21:27 +02:00
68 changed files with 16578 additions and 143 deletions

No files matched your search

+60 -1
View File
@@ -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
View File
@@ -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 →
+72 -17
View File
@@ -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 {
+319
View File
@@ -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)
}
}
+12 -7
View File
@@ -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
+180
View File
@@ -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
}
+193
View File
@@ -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,
}
}
+15
View File
@@ -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)
+3
View File
@@ -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
+226
View File
@@ -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
}
+1 -1
View File
@@ -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)
} }
+88 -1
View File
@@ -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)
}
+60 -1
View File
@@ -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)
} }
+88 -14
View File
@@ -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)
} }
+1
View File
@@ -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 {
+36 -13
View File
@@ -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
+8 -3
View File
@@ -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,
+13
View File
@@ -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})
+4
View File
@@ -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;
+10 -4
View File
@@ -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);
+19
View File
@@ -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()
); );
+325 -8
View File
@@ -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
+558
View File
@@ -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;
+1 -1
View File
@@ -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",
+111
View File
@@ -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'];
+41
View File
@@ -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
+40
View File
@@ -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.
+33
View File
@@ -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

+10
View File
@@ -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/*"],
}
]);
+11420
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -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
}
+185
View File
@@ -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 ?? [],
);
}
}
+55
View File
@@ -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>
);
}
+100
View File
@@ -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' },
});
+122
View File
@@ -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 },
});
+54
View File
@@ -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 } });
+55
View File
@@ -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 },
});
+52
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { Redirect } from 'expo-router';
export default function Index() {
return <Redirect href="/(tabs)" />;
}
+126
View File
@@ -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' },
});
+57
View File
@@ -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 } });
+191
View File
@@ -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' },
});
+71
View File
@@ -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,
},
});
+150
View File
@@ -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' },
});
+83
View File
@@ -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;
}
+30
View File
@@ -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,
};
+14
View File
@@ -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();
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}
+396
View File
@@ -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
+29
View File
@@ -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
+5 -1
View File
@@ -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
View File
@@ -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!"