mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e3994e937 | ||
|
|
355eb2c9ab | ||
|
|
2be1715015 | ||
|
|
e67a39436c | ||
|
|
4734be0d9d | ||
|
|
4e8d364911 | ||
|
|
f53695be4f | ||
|
|
925199633b | ||
|
|
1054679ca2 | ||
|
|
57f8b4f56e | ||
|
|
dd1bbaf5a2 | ||
|
|
58424ed0ab | ||
|
|
25fa8c47e6 | ||
|
|
b3da014d26 | ||
|
|
f83ad81f73 | ||
|
|
2049087ccd | ||
|
|
01edb677e5 | ||
|
|
5dc9b5f115 | ||
|
|
3664eaed1a | ||
|
|
912f6f5142 |
No files matched your search
@@ -17,6 +17,30 @@ permissions:
|
||||
contents: read
|
||||
|
||||
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:
|
||||
name: Infra (Compose + Scripts)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -48,6 +72,8 @@ jobs:
|
||||
backend:
|
||||
name: Backend (Go)
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: app/backend
|
||||
@@ -70,6 +96,8 @@ jobs:
|
||||
frontend:
|
||||
name: Frontend (React)
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: app/frontend
|
||||
@@ -96,10 +124,40 @@ jobs:
|
||||
- name: 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:
|
||||
name: Compose boot smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -137,6 +195,7 @@ jobs:
|
||||
name: Build & publish images
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend, smoke]
|
||||
if: needs.backend.result == 'success' && needs.frontend.result == 'success' && needs.smoke.result == 'success'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -227,7 +286,7 @@ jobs:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker]
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' && needs.docker.result == 'success'
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
+98
-62
@@ -121,16 +121,16 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
|
||||
- `openapi.yaml` stays the single source of truth; UI, CLI, MCP are thin
|
||||
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`
|
||||
(`read`/`write`/`admin`, later resource-scoped), `expires_at`,
|
||||
`last_used_at`. Model on `agent_auth_tokens` (raw shown once, hash stored).
|
||||
`POST/GET/DELETE /api/v1/user/tokens`; `middleware.Auth` accepts
|
||||
`Bearer cnp_…`; Settings → Access tokens UI with one-time display +
|
||||
copyable CLI/MCP config snippets.
|
||||
- [ ] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client:
|
||||
- [x] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client:
|
||||
- `auth login [--token] [--url] [--profile]` (multi-instance profiles).
|
||||
- Resources: `projects services deploy logs exec variables databases
|
||||
cron templates nodes scaling ha security gateway notifications
|
||||
@@ -143,16 +143,16 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
|
||||
0/1/2/3/4 (ok/err/not-found/unauthorized/conflict).
|
||||
- GoReleaser → GH releases + `install.sh`; `version` command.
|
||||
- Conventions from docsync/envdiff/cix/repolint.
|
||||
- [ ] **MCP server** (`cmd/mcp`, same module, stdio). Tools mirror the
|
||||
- [x] **MCP server** (`cmd/mcp`, same module, stdio). Tools mirror the
|
||||
CLI: `list_projects create_service deploy get_logs set_variable
|
||||
restart rollback create_database trigger_cron deploy_template exec
|
||||
get_metrics security_scan …`. Auth: `CONTAINR_URL` + `CONTAINR_TOKEN`
|
||||
or shared `~/.containr/config`. `containr mcp serve` + install snippets
|
||||
for Devin/Claude/Cursor.
|
||||
- [ ] **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);
|
||||
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.
|
||||
|
||||
**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
|
||||
goroutines; this fixes correctness *and* unlocks the ops surface.
|
||||
|
||||
- [ ] **Deploy job queue.** Lightweight internal queue (Postgres
|
||||
`deployment_queue` table + single worker loop — no new infra; Redis
|
||||
exists but don't add BullMQ-style sprawl). Serialize per service;
|
||||
optional per-node serialization. Status gains `queued`.
|
||||
- [ ] **Cancel deployments.** Route `POST /deployments/:id/cancel` →
|
||||
engine `CancelDeployment` (exists, unrouted). Queued jobs cancel
|
||||
cleanly; building jobs cancel context where safe.
|
||||
- [ ] **Cache modes.** `?cache=full` on redeploy → rebuild from existing
|
||||
image layers vs clean pull/checkout. Webhook-triggered deploys default
|
||||
to no-cache (dflow convention — prevents stale-layer surprises).
|
||||
- [ ] **Queued→building→running status + persisted step logs.** Already
|
||||
mostly there; add `queued`, stream over existing `/ws`.
|
||||
- [ ] **Env var apply → redeploy.** Saving variables asks "restart to
|
||||
apply?" (or `--apply` flag in CLI); backend triggers redeploy honoring
|
||||
running state. Currently rows update but containers keep stale env —
|
||||
silent-drift bug class, not just UX.
|
||||
- [ ] **Encrypt variables at rest.** `environment_variables.value` is
|
||||
plaintext today (verified). Envelope-encrypt `is_secret` values
|
||||
(AES-GCM, key from `JWT_SECRET` or dedicated `SECRETS_KEY`); dflow
|
||||
encrypts every var, we encrypt secrets — migration = transparent
|
||||
encrypt-on-write + decrypt-on-read.
|
||||
- [x] **Deploy job queue.** Lightweight internal queue — per-service
|
||||
FIFO in `internal/deployqueue`; status gains `queued`.
|
||||
- [x] **Cancel deployments.** `POST /deployments/:id/cancel` — queued
|
||||
jobs cancel immediately, active jobs cancel context; `cancelled` status.
|
||||
- [x] **Cache modes.** `no_cache` on deploys; webhook-triggered deploys
|
||||
default to no-cache (dflow convention — prevents stale-layer surprises).
|
||||
- [x] **Queued→building→running status + persisted step logs.** `queued`
|
||||
state lands; streaming over existing log surface.
|
||||
- [x] **Env var apply → redeploy.** `--redeploy` flag on variable writes;
|
||||
backend requeues honoring running state.
|
||||
- [x] **Encrypt variables at rest.** `is_secret` values encrypted
|
||||
AES-GCM `enc:v1:` (key from `SECRETS_KEY`); masked-echo preserves
|
||||
ciphertext.
|
||||
|
||||
## 4. Phase C — Service configuration parity
|
||||
## 4. Phase C — Service configuration parity ✅ SHIPPED (PRs #20–#23)
|
||||
|
||||
- [ ] **Volumes on services** — `services.volumes` jsonb
|
||||
`[{volume|host_path, container_path, read_only}]`; engine accepts
|
||||
`VolumeMounts` already. Volumes tab + orphan-volume listing
|
||||
(`docker volume ls -f dangling`) with delete/attach.
|
||||
- [ ] **Multi-domain** — `service_domains(service_id, domain, is_default,
|
||||
cert_type, status, last_checked_at)`. Domains tab: add/remove/
|
||||
set-default/DNS-status badge. Keep `services.domain` as derived
|
||||
default for compat.
|
||||
- [ ] **DNS preflight** — `GET /services/:id/domains/check` resolves and
|
||||
- [x] **Volumes on services** — `services.volumes` jsonb
|
||||
`[{volume|host_path, container_path, read_only}]`; Volumes tab +
|
||||
admin volume inventory.
|
||||
- [x] **Multi-domain** — `service_domains(service_id, domain, is_default,
|
||||
cert_type, status, last_checked_at)`. Domains UI: add/remove/
|
||||
set-default/DNS-status badge. `services.domain` stays derived default.
|
||||
- [x] **DNS preflight** — `GET /services/:id/domains/check` resolves and
|
||||
compares vs edge IP → `ok|wrong-target|pending`.
|
||||
- [ ] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
|
||||
- [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
|
||||
no proxy code needed.
|
||||
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with
|
||||
allowlist validation (middlewares, headers, redirects).
|
||||
- [ ] **Builder override** — `services.builder` enum
|
||||
`auto|railpack|nixpacks|dockerfile|static`; fix hardcoded `nixpacks`
|
||||
at `deployments.go:406`. Static builder = build + nginx sidecar.
|
||||
- [ ] **Resource limits + reserves** — adopt dflow-style explicit units
|
||||
(`"0.5"`/`"512m"`); add `cpu_reserve`/`memory_reserve` →
|
||||
`NanoCPUs`/`Memory`/`MemoryReservation`; instance defaults in
|
||||
`app_settings`.
|
||||
- [ ] **Pre-deploy capacity check** — sum reservations vs node capacity
|
||||
(agent telemetry); warn or block per policy flag.
|
||||
- [ ] **Service clone** + **move between projects**.
|
||||
- [ ] **Private registries** — `registry_credentials`
|
||||
(server/username/secret, encrypted); `docker login` at pull time;
|
||||
- [x] **Builder override** — `services.builder` enum
|
||||
`auto|railpack|nixpacks|dockerfile|static`; static builder = build +
|
||||
nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug.
|
||||
- [x] **Resource limits + reserves** — `cpu`/`memory` caps +
|
||||
`cpu_reserve`/`memory_reserve` → NanoCPUs/Memory/MemoryReservation;
|
||||
instance defaults in `app_settings`.
|
||||
- [x] **Pre-deploy capacity check** — reservations vs node capacity;
|
||||
`app_settings.capacity_policy=block` rejects, default warns.
|
||||
- [x] **Service clone** + **move between projects** — copies volumes,
|
||||
domains, access gates, vars (ciphertext verbatim); audit-logged.
|
||||
- [x] **Private registries** — `registry_credentials`
|
||||
(server/username/secret, encrypted); pull auth resolved at deploy;
|
||||
per-service registry link.
|
||||
- [ ] **Service detail tabs to match dflow's surface**: add Domains,
|
||||
Volumes, Proxy/access-controls, Backups sections to the service page
|
||||
(Cron/Previews/Scaling already exist).
|
||||
- [x] **Service detail tabs to match dflow's surface**: Domains,
|
||||
Volumes, access-controls sections added to the service page.
|
||||
|
||||
## 5. Phase D — Data layer maturity
|
||||
|
||||
- [ ] **External DB registration** — `provider='managed'|'external'`,
|
||||
conn fields, `POST /databases/test-connection`, same bind flow.
|
||||
- [ ] **Public DB port expose** — opt-in Traefik TCP route or host port;
|
||||
default stays loopback.
|
||||
- [ ] **Offsite backups** — `backup_targets` (S3-compatible:
|
||||
endpoint/bucket/keys encrypted); per-DB destination; ship archives via
|
||||
utility container + s3 SDK; restore-from-S3.
|
||||
- [x] **External DB registration** — `provider='managed'|'external'`,
|
||||
conn fields, `POST /databases/test-connection`, same bind flow. (PR #24)
|
||||
- [x] **Public DB port expose** — `public_port` opt-in `0.0.0.0` host
|
||||
bind; default stays loopback. (PR #25)
|
||||
- [x] **Offsite backups** — `backup_targets` (S3-compatible:
|
||||
endpoint/bucket/keys encrypted); per-DB destination; archives shipped
|
||||
via holder container + minio-go; restore-from-S3 fallback. (PR #26)
|
||||
- [ ] **Cross-node DB migration** — `POST /databases/:id/migrate
|
||||
{target_node_id}`: dump → stream transfer → provision → restore →
|
||||
re-point bound services. 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
|
||||
migrators).
|
||||
|
||||
@@ -318,6 +308,32 @@ and wired refs, one click or one CLI call.
|
||||
audit logs, deployments, notifications tables.
|
||||
- [ ] **Notification producers round-out** — backups, agent offline,
|
||||
upgrade available, security findings.
|
||||
- [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) —
|
||||
`services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper
|
||||
polls container NetworkIO deltas; idle past threshold → workload
|
||||
removed, status `sleeping`. Wake-on-traffic via per-service
|
||||
placeholder container on the edge network carrying the same Traefik
|
||||
host rule at higher priority — serves a waking page, calls
|
||||
`/internal/wake/:id`, exits; Traefik falls back once the app is up.
|
||||
Verified `running → sleeping → waking → running` end to end.
|
||||
- [x] **Env doctor** — `GET /services/:id/env-check` surfaces
|
||||
unresolved `${{…}}` refs, empty values, and unreadable
|
||||
(rotated-key) secrets; template `plan` endpoint reports missing
|
||||
required vars before deploy (PR #30). Frontend Variables section
|
||||
shows the banner.
|
||||
- [x] **Shared project variables** — `project_variables` table,
|
||||
`GET/PUT /projects/:id/variables`, `${{shared.KEY}}` refs resolved
|
||||
at deploy/reconcile, secrets `enc:v1:` at rest and masked (PR #32).
|
||||
- [x] **One-click install** — `install.sh` (curl|sh): detects docker,
|
||||
writes compose + .env with generated secrets incl. `SECRETS_KEY`,
|
||||
pulls images, runs migrations, prints admin URL. GHCR images mean
|
||||
installs never build from source.
|
||||
- [x] **CI/CD v2** — required checks (Go test/vet, frontend
|
||||
lint/build/test, compose boot smoke), `dorny/paths-filter` gate so
|
||||
docs-only PRs skip heavyweight jobs, and release on main/tag:
|
||||
conventional-commit version bump → tag → GHCR images (backend,
|
||||
frontend) → cross-platform CLI + MCP binaries → SHA-256 checksums
|
||||
→ GitHub Release → Trivy scans (PR #33).
|
||||
- [ ] **Custom roles/RBAC** — defer until demanded; PAT scopes designed
|
||||
forward-compatible now.
|
||||
|
||||
@@ -335,6 +351,26 @@ and wired refs, one click or one CLI call.
|
||||
fetch/publish; bundled catalog works offline (dflow's self-contained
|
||||
principle — nothing phones home).
|
||||
|
||||
## 9b. Phase I — Companion surfaces
|
||||
|
||||
- [x] **Phone app (v1)** — `app/mobile`, Expo/React Native thin client
|
||||
over the agent-shaped API; dark UI mirroring the design system. PAT
|
||||
auth against any instance URL, stored in platform secure storage.
|
||||
- **Dashboard** — service status counts (running/sleeping/failed/
|
||||
stopped), per-project service health dots, recent deployments feed.
|
||||
- **Operate** — start/stop/restart/redeploy, sleep/wake, rollback to
|
||||
a prior deployment, database start/stop/restart/backup-now.
|
||||
- **Observe** — polling log tail (300 lines, 5s refresh), per-service
|
||||
deployment history, env-doctor issues surfaced on the service view.
|
||||
- [ ] **env var edit + apply-redeploy, domain management, replica
|
||||
scaling, cron execution history** — next iteration.
|
||||
- [ ] **Push notifications** — zero-infra path = self-hosted
|
||||
ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional.
|
||||
- [ ] **Offline resilience** — cached last-known state + unreachable
|
||||
banner.
|
||||
- [ ] **Status/offline resilience** — app + CLI degrade gracefully when
|
||||
the instance is unreachable (cached last-known state, clear banner).
|
||||
|
||||
## 10. Carried-over debt (ROADMAP.md items still open)
|
||||
|
||||
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit →
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"archive/tar"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/docker"
|
||||
"containr/internal/secrets"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
@@ -51,12 +52,30 @@ type DatabaseService struct {
|
||||
Region string `json:"region" db:"region"`
|
||||
BackupSchedule string `json:"backup_schedule,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"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
ConnectionURL string `json:"connection_url"`
|
||||
Metrics DatabaseMetrics `json:"metrics"`
|
||||
Backups DatabaseBackupConfig `json:"backups"`
|
||||
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
|
||||
@@ -467,21 +486,20 @@ func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) {
|
||||
databaseID := c.Param("id")
|
||||
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,
|
||||
UserID: userID,
|
||||
})
|
||||
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"})
|
||||
return
|
||||
}
|
||||
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if h.dockerClient != nil {
|
||||
if row.Provider != "external" && h.dockerClient != nil {
|
||||
if err := h.deleteManagedDatabaseRuntime(databaseID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete runtime database"})
|
||||
return
|
||||
@@ -515,17 +533,24 @@ func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
|
||||
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"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
||||
return
|
||||
}
|
||||
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
||||
if row.Provider == "external" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Lifecycle actions apply to managed databases only — external databases are run elsewhere",
|
||||
"code": "VALIDATION",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -619,6 +644,13 @@ func (h *DatabaseHandler) CreateBackup(c *gin.Context) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Database is still provisioning"})
|
||||
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)
|
||||
archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID))
|
||||
@@ -659,17 +691,23 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
|
||||
dbRow, 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"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
|
||||
return
|
||||
}
|
||||
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
|
||||
if dbRow.Provider == "external" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Restores run against the managed dump pipeline — external database restores are not supported yet",
|
||||
"code": "VALIDATION",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -787,7 +825,7 @@ func (h *DatabaseHandler) DownloadBackup(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics {
|
||||
if h.dockerClient == nil {
|
||||
if h.dockerClient == nil || db.Provider == "external" {
|
||||
return h.generateMockMetrics()
|
||||
}
|
||||
|
||||
@@ -945,7 +983,11 @@ func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
|
||||
|
||||
// resolveConnectionURL returns the stored connection URL for privileged
|
||||
// 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 {
|
||||
if db.Provider == "external" && db.External != nil {
|
||||
return externalConnectionURL(db.Type, db.External, secrets.Decrypt(db.externalPasswordEnc))
|
||||
}
|
||||
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 {
|
||||
if h.dockerClient == nil {
|
||||
if h.dockerClient == nil || db.Provider == "external" {
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -1054,7 +1096,7 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
|
||||
}
|
||||
|
||||
func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
|
||||
return DatabaseService{
|
||||
svc := DatabaseService{
|
||||
ID: row.ID,
|
||||
OwnerID: row.UserID,
|
||||
Name: row.Name,
|
||||
@@ -1065,10 +1107,23 @@ func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
|
||||
Region: row.Region,
|
||||
BackupSchedule: databaseNullString(row.BackupSchedule),
|
||||
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
|
||||
Provider: row.Provider,
|
||||
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
||||
CreatedAt: databaseNullTime(row.CreatedAt),
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
"containr/internal/docker"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -232,6 +234,9 @@ func handleCreateDeployment(c *gin.Context) {
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||
COALESCE(s.static_dir, ''),
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
@@ -242,7 +247,9 @@ func handleCreateDeployment(c *gin.Context) {
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectOwner,
|
||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -401,6 +408,13 @@ func runDeploymentAndSyncWithImage(
|
||||
// ephemeral ports.
|
||||
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
|
||||
|
||||
publicPort := int32(service.Port)
|
||||
if service.Builder == "static" && publicPort == 0 {
|
||||
// The static builder's image is nginx — route to its default port
|
||||
// unless the user picked another.
|
||||
publicPort = 80
|
||||
}
|
||||
|
||||
maintenanceMode, basicAuthUsers := serviceAccess(db, service.ID)
|
||||
maintURL := ""
|
||||
if maintenanceMode {
|
||||
@@ -417,7 +431,7 @@ func runDeploymentAndSyncWithImage(
|
||||
Command: command,
|
||||
Environment: env,
|
||||
Replicas: replicas,
|
||||
PublicPort: int32(service.Port),
|
||||
PublicPort: publicPort,
|
||||
PublishedPort: publishedPort,
|
||||
Domain: service.Domain,
|
||||
HealthPath: service.HealthCheckPath,
|
||||
@@ -425,8 +439,10 @@ func runDeploymentAndSyncWithImage(
|
||||
VolumeMounts: loadServiceVolumes(db, service.ID),
|
||||
Domains: serviceDomainNames(db, service.ID, service.Domain),
|
||||
Resources: deployment.ResourceLimits{
|
||||
MemoryBytes: parseMemoryLimit(service.Memory),
|
||||
CPUQuota: parseCPULimit(service.CPU),
|
||||
MemoryBytes: parseMemoryLimit(service.Memory),
|
||||
MemoryReservation: parseMemoryLimit(service.MemoryReserve),
|
||||
CPUQuota: parseCPULimit(service.CPU),
|
||||
CPUShares: parseCPUReserveShares(service.CPUReserve),
|
||||
},
|
||||
Maintenance: maintenanceMode,
|
||||
MaintenanceURL: maintURL,
|
||||
@@ -440,25 +456,50 @@ func runDeploymentAndSyncWithImage(
|
||||
},
|
||||
}
|
||||
|
||||
if imageOverride != "" {
|
||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||
BuildType: "prebuilt",
|
||||
PrebuiltImage: imageOverride,
|
||||
if imageOverride != "" || service.GitRepo == "" {
|
||||
// Prebuilt image deploy (rollback, redeploy, or image-sourced
|
||||
// service): resolve private-registry credentials by image host.
|
||||
image := imageOverride
|
||||
if image == "" {
|
||||
image = service.Image
|
||||
}
|
||||
} else if service.GitRepo == "" {
|
||||
// Image-sourced service: pull the image instead of building.
|
||||
var ownerID string
|
||||
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
|
||||
auth := registryAuthFor(db, ownerID, image)
|
||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||
BuildType: "prebuilt",
|
||||
PrebuiltImage: service.Image,
|
||||
PrebuiltImage: image,
|
||||
PullUsername: auth.Username,
|
||||
PullPassword: auth.Password,
|
||||
}
|
||||
} else {
|
||||
// "auto" leaves BuildType empty so the manager detects it; the static
|
||||
// builder additionally feeds its generated Dockerfile the service's
|
||||
// build command and output dir.
|
||||
buildType := ""
|
||||
if service.Builder != "" && service.Builder != "auto" {
|
||||
buildType = service.Builder
|
||||
}
|
||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||
BuildType: "nixpacks",
|
||||
BuildType: buildType,
|
||||
SourcePath: sourcePath,
|
||||
Branch: req.Branch,
|
||||
Commit: req.CommitHash,
|
||||
NoCache: req.NoCache,
|
||||
}
|
||||
if buildType == "static" {
|
||||
deployReq.BuildConfig.BuildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build")
|
||||
deployReq.BuildConfig.BuildArgs = map[string]string{"STATIC_DIR": firstNonEmpty(service.StaticDir, "dist")}
|
||||
}
|
||||
}
|
||||
|
||||
if msg := capacityCheck(parentCtx, db, engine.DockerClient(), service, replicas); msg != "" {
|
||||
failedAt := time.Now()
|
||||
_, _ = db.Exec(
|
||||
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
||||
msg, failedAt, dbDeployment.ID,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
engineDeployment, err := engine.Deploy(ctx, deployReq)
|
||||
@@ -822,6 +863,9 @@ func handleRollbackDeployment(c *gin.Context) {
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||
COALESCE(s.static_dir, ''),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s WHERE s.id = $1`, serviceID,
|
||||
).Scan(
|
||||
@@ -829,7 +873,9 @@ func handleRollbackDeployment(c *gin.Context) {
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||
&service.CreatedAt, &service.UpdatedAt,
|
||||
)
|
||||
|
||||
rollbackReq := CreateDeploymentRequest{
|
||||
@@ -863,3 +909,35 @@ func handleRollbackDeployment(c *gin.Context) {
|
||||
"message": "Rollback initiated",
|
||||
})
|
||||
}
|
||||
|
||||
// capacityCheck compares the requested per-replica memory (limit, falling
|
||||
// back to reservation) against node capacity. app_settings.capacity_policy
|
||||
// = "block" fails the deployment; the default "warn" only logs. CPU is not
|
||||
// gated — shares are a weight, not a reservation.
|
||||
func capacityCheck(ctx context.Context, db *database.DB, client *docker.Client, service Service, replicas int) string {
|
||||
if client == nil {
|
||||
return ""
|
||||
}
|
||||
requested := parseMemoryLimit(service.Memory)
|
||||
if requested == 0 {
|
||||
requested = parseMemoryLimit(service.MemoryReserve)
|
||||
}
|
||||
if requested == 0 {
|
||||
return ""
|
||||
}
|
||||
info, err := client.GetSystemInfo(ctx)
|
||||
if err != nil || info.MemTotal <= 0 {
|
||||
return ""
|
||||
}
|
||||
need := requested * int64(replicas)
|
||||
if need <= info.MemTotal {
|
||||
return ""
|
||||
}
|
||||
msg := fmt.Sprintf("requested %d bytes x %d replicas exceeds node memory (%d bytes)",
|
||||
requested, replicas, info.MemTotal)
|
||||
if strings.ToLower(settingValue(db, "capacity_policy", "", "warn")) == "block" {
|
||||
return msg
|
||||
}
|
||||
slog.Warn("capacity check exceeded", "service_id", service.ID, "detail", msg)
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/secrets"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Project-level shared variables — referenced from service env values as
|
||||
// ${{shared.KEY}} and resolved at runtime in resolveServiceEnv.
|
||||
|
||||
type ProjectVariable struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
|
||||
Key string `json:"key" db:"key"`
|
||||
Value string `json:"value" db:"value"`
|
||||
IsSecret bool `json:"is_secret" db:"is_secret"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
func handleGetProjectVariables(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
||||
return
|
||||
}
|
||||
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT id, project_id, key, value, is_secret, created_at, updated_at
|
||||
FROM project_variables WHERE project_id = $1 ORDER BY key ASC`,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
variables := []ProjectVariable{}
|
||||
for rows.Next() {
|
||||
var v ProjectVariable
|
||||
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan variable"})
|
||||
return
|
||||
}
|
||||
if v.IsSecret {
|
||||
v.Value = maskedSecretValue
|
||||
}
|
||||
variables = append(variables, v)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"variables": variables})
|
||||
}
|
||||
|
||||
func handleUpdateProjectVariables(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
||||
return
|
||||
}
|
||||
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateVariablesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
storedSecrets := map[string]string{}
|
||||
secretRows, err := db.(*database.DB).Query(
|
||||
`SELECT key, value FROM project_variables WHERE project_id = $1 AND is_secret = TRUE`,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load existing variables"})
|
||||
return
|
||||
}
|
||||
for secretRows.Next() {
|
||||
var k, v string
|
||||
if err := secretRows.Scan(&k, &v); err == nil {
|
||||
storedSecrets[k] = v
|
||||
}
|
||||
}
|
||||
secretRows.Close()
|
||||
|
||||
tx, err := db.(*database.DB).Begin()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err = tx.Exec(`DELETE FROM project_variables WHERE project_id = $1`, projectID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for _, v := range req.Variables {
|
||||
value := v.Value
|
||||
if v.IsSecret && value == maskedSecretValue {
|
||||
if stored, ok := storedSecrets[v.Key]; ok {
|
||||
value = stored
|
||||
}
|
||||
}
|
||||
if v.IsSecret {
|
||||
value = secrets.Encrypt(value)
|
||||
}
|
||||
if _, err = tx.Exec(
|
||||
`INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
uuid.New(), projectID, v.Key, value, v.IsSecret, now, now,
|
||||
); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT id, project_id, key, value, is_secret, created_at, updated_at
|
||||
FROM project_variables WHERE project_id = $1 ORDER BY key ASC`,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
variables := []ProjectVariable{}
|
||||
for rows.Next() {
|
||||
var v ProjectVariable
|
||||
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
if v.IsSecret {
|
||||
v.Value = maskedSecretValue
|
||||
}
|
||||
variables = append(variables, v)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Project variables updated successfully"})
|
||||
}
|
||||
|
||||
// resolveSharedVar returns the decrypted value of a project variable.
|
||||
func resolveSharedVar(db *database.DB, projectID uuid.UUID, key string) (string, bool) {
|
||||
var value string
|
||||
err := db.QueryRow(
|
||||
`SELECT value FROM project_variables WHERE project_id = $1 AND key = $2`,
|
||||
projectID, key,
|
||||
).Scan(&value)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return secrets.Decrypt(value), true
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/secrets"
|
||||
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Registry is a stored credential for pulling private images. Password is
|
||||
// encrypted at rest and never returned — `has_password` signals presence.
|
||||
type Registry struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"` // e.g. ghcr.io, registry.example.com; docker.io for Docker Hub
|
||||
Username string `json:"username"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type registryRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=255"`
|
||||
Host string `json:"host" binding:"required,min=1,max=255"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func handleListRegistries(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
userID, _ := c.Get("user_id")
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT id, name, host, username, password <> '', created_at, updated_at
|
||||
FROM registries WHERE owner_id = $1 ORDER BY created_at DESC`,
|
||||
userID.(string),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list registries"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
registries := []Registry{}
|
||||
for rows.Next() {
|
||||
var r Registry
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt); err == nil {
|
||||
registries = append(registries, r)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"registries": registries})
|
||||
}
|
||||
|
||||
func handleCreateRegistry(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
userID, _ := c.Get("user_id")
|
||||
|
||||
var req registryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
|
||||
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
|
||||
req.Host = strings.TrimSuffix(req.Host, "/")
|
||||
|
||||
// Encrypt only a real credential — encrypting "" would still satisfy
|
||||
// `password <> ''` and report has_password for anonymous registries.
|
||||
password := ""
|
||||
if req.Password != "" {
|
||||
password = secrets.Encrypt(req.Password)
|
||||
}
|
||||
var r Registry
|
||||
err := db.(*database.DB).QueryRow(
|
||||
`INSERT INTO registries (owner_id, name, host, username, password)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
|
||||
userID.(string), req.Name, req.Host, req.Username, password,
|
||||
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "unique") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "A registry for this host already exists", "code": "CONFLICT"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create registry"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"registry": r})
|
||||
}
|
||||
|
||||
func handleUpdateRegistry(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
userID, _ := c.Get("user_id")
|
||||
registryID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
|
||||
var req registryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
|
||||
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
|
||||
req.Host = strings.TrimSuffix(req.Host, "/")
|
||||
|
||||
// Empty password keeps the stored credential — there is no "clear"
|
||||
// operation; delete + recreate to remove a password.
|
||||
var passwordArg interface{}
|
||||
if req.Password != "" {
|
||||
passwordArg = secrets.Encrypt(req.Password)
|
||||
}
|
||||
|
||||
var r Registry
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`UPDATE registries SET name = $1, host = $2, username = $3,
|
||||
password = COALESCE($4, password), updated_at = now()
|
||||
WHERE id = $5 AND owner_id = $6
|
||||
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
|
||||
req.Name, req.Host, req.Username, passwordArg, registryID, userID.(string),
|
||||
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"registry": r})
|
||||
}
|
||||
|
||||
func handleDeleteRegistry(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
userID, _ := c.Get("user_id")
|
||||
registryID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
res, err := db.(*database.DB).Exec(
|
||||
`DELETE FROM registries WHERE id = $1 AND owner_id = $2`,
|
||||
registryID, userID.(string),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete registry"})
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Registry deleted"})
|
||||
}
|
||||
|
||||
// registryAuthFor resolves pull credentials for an image reference: the
|
||||
// caller's registry whose host matches, else anonymous. docker.io covers
|
||||
// bare image names (nginx:latest).
|
||||
func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig {
|
||||
host := registryHost(imageRef)
|
||||
var username, password string
|
||||
err := db.QueryRow(
|
||||
`SELECT username, password FROM registries WHERE owner_id = $1 AND host = $2`,
|
||||
ownerID, host,
|
||||
).Scan(&username, &password)
|
||||
if err != nil {
|
||||
return registry.AuthConfig{}
|
||||
}
|
||||
return registry.AuthConfig{
|
||||
Username: username,
|
||||
Password: secrets.Decrypt(password),
|
||||
ServerAddress: host,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseCPUReserveShares(t *testing.T) {
|
||||
cases := map[string]int64{
|
||||
"": 0,
|
||||
"0": 0,
|
||||
"-1": 0,
|
||||
"abc": 0,
|
||||
"0.25": 256,
|
||||
"1": 1024,
|
||||
"2.5": 2560,
|
||||
" 0.5 ": 512,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := parseCPUReserveShares(in); got != want {
|
||||
t.Errorf("parseCPUReserveShares(%q) = %d, want %d", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResourceSpecs(t *testing.T) {
|
||||
if err := validateCPUSpec(""); err != nil {
|
||||
t.Error("empty cpu should pass")
|
||||
}
|
||||
if err := validateCPUSpec("0.5"); err != nil {
|
||||
t.Error("0.5 should be valid cpu")
|
||||
}
|
||||
if err := validateCPUSpec("512mb"); err == nil {
|
||||
t.Error("512mb should fail cpu validation")
|
||||
}
|
||||
if err := validateMemorySpec(""); err != nil {
|
||||
t.Error("empty memory should pass")
|
||||
}
|
||||
if err := validateMemorySpec("512Mi"); err != nil {
|
||||
t.Error("512Mi should be valid memory")
|
||||
}
|
||||
if err := validateMemorySpec("1g"); err != nil {
|
||||
t.Error("1g should be valid memory")
|
||||
}
|
||||
if err := validateMemorySpec("0.5"); err == nil {
|
||||
t.Error("0.5 should fail memory validation (parses to <1 byte)")
|
||||
}
|
||||
if err := validateMemorySpec("lots"); err == nil {
|
||||
t.Error("'lots' should fail memory validation")
|
||||
}
|
||||
}
|
||||
@@ -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/restart", handleServiceRestart)
|
||||
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("/deployments/:id/rollback", handleRollbackDeployment)
|
||||
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.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,
|
||||
// so these stay behind authentication like build logs.
|
||||
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)
|
||||
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.
|
||||
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
|
||||
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("/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.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
|
||||
authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
|
||||
|
||||
@@ -53,6 +53,13 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
|
||||
}
|
||||
spec.NanoCPUs = parseCPULimit(service.CPU)
|
||||
spec.MemoryBytes = parseMemoryLimit(service.Memory)
|
||||
spec.MemoryReserve = parseMemoryLimit(service.MemoryReserve)
|
||||
spec.CPUShares = parseCPUReserveShares(service.CPUReserve)
|
||||
if service.Builder == "static" && spec.Port == 0 {
|
||||
// The static builder's image is nginx; expose its default port so
|
||||
// Traefik has something to route to without extra config.
|
||||
spec.Port = 80
|
||||
}
|
||||
spec.Volumes = loadServiceVolumes(db, service.ID)
|
||||
spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
|
||||
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
|
||||
@@ -112,6 +119,9 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
|
||||
}
|
||||
|
||||
resolve := func(serviceName, key string) (string, bool) {
|
||||
if strings.EqualFold(serviceName, "shared") {
|
||||
return resolveSharedVar(db, service.ProjectID, key)
|
||||
}
|
||||
id, ok := byName[strings.ToLower(serviceName)]
|
||||
if !ok {
|
||||
return "", false
|
||||
@@ -160,6 +170,32 @@ func parseCPULimit(cpu string) int64 {
|
||||
return int64(v * 1e9)
|
||||
}
|
||||
|
||||
// parseCPUReserveShares maps a cpu fraction to Docker's relative share
|
||||
// weight: 1024 shares ≈ one CPU.
|
||||
func parseCPUReserveShares(cpu string) int64 {
|
||||
v, err := strconv.ParseFloat(strings.TrimSpace(cpu), 64)
|
||||
if err != nil || v <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(v * 1024)
|
||||
}
|
||||
|
||||
// validateCPUSpec and validateMemorySpec reject values that silently parse
|
||||
// to zero — a typo like "512mb" must fail loudly instead of dropping the limit.
|
||||
func validateCPUSpec(v string) error {
|
||||
if s := strings.TrimSpace(v); s != "" && parseCPULimit(s) <= 0 {
|
||||
return fmt.Errorf("must be a positive cpu fraction (e.g. 0.5, 2)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMemorySpec(v string) error {
|
||||
if s := strings.TrimSpace(v); s != "" && parseMemoryLimit(s) <= 0 {
|
||||
return fmt.Errorf("must be bytes with unit (e.g. 256Mi, 1g)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var memUnits = map[string]int64{"k": 1e3, "m": 1e6, "g": 1e9, "ki": 1 << 10, "mi": 1 << 20, "gi": 1 << 30}
|
||||
|
||||
func parseMemoryLimit(mem string) int64 {
|
||||
@@ -215,6 +251,9 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||
COALESCE(s.static_dir, ''),
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
@@ -224,7 +263,9 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner,
|
||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||
&service.CreatedAt, &service.UpdatedAt, &owner,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
@@ -258,6 +299,9 @@ func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||
COALESCE(s.static_dir, ''),
|
||||
s.created_at, s.updated_at, s.project_id
|
||||
FROM services s
|
||||
WHERE s.id = $1`,
|
||||
@@ -267,7 +311,9 @@ func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectID,
|
||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||
&service.CreatedAt, &service.UpdatedAt, &projectID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
@@ -549,6 +595,9 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||
COALESCE(s.static_dir, ''),
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`, serviceID,
|
||||
@@ -557,7 +606,9 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner,
|
||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||
&service.CreatedAt, &service.UpdatedAt, &owner,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CloneServiceRequest struct {
|
||||
Name string `json:"name"`
|
||||
ProjectID uuid.UUID `json:"project_id"` // empty = clone within the same project
|
||||
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
||||
}
|
||||
|
||||
// handleCloneService duplicates a service's full configuration — image/git
|
||||
// source, volumes, domains, access gates, builder settings, and variables —
|
||||
// into the same or another project. Containers are not cloned; the copy
|
||||
// starts stopped until its first deploy.
|
||||
func handleCloneService(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
userID, _ := c.Get("user_id")
|
||||
|
||||
var req CloneServiceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
|
||||
source, ok := loadOwnedService(c, db.(*database.DB))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
targetProjectID := source.ProjectID
|
||||
if req.ProjectID != uuid.Nil {
|
||||
targetProjectID = req.ProjectID
|
||||
}
|
||||
if !ownsProject(db.(*database.DB), targetProjectID, userID.(string), contextIsAdmin(c)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = source.Name + "-copy"
|
||||
}
|
||||
var count int
|
||||
_ = db.(*database.DB).QueryRow(
|
||||
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
|
||||
targetProjectID, name,
|
||||
).Scan(&count)
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in target project", "code": "CONFLICT"})
|
||||
return
|
||||
}
|
||||
|
||||
environment := source.Environment
|
||||
if req.Environment != "" {
|
||||
environment = req.Environment
|
||||
}
|
||||
if environment == "" {
|
||||
environment = "production"
|
||||
}
|
||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), targetProjectID, environment)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
|
||||
return
|
||||
}
|
||||
|
||||
maintenance, basicAuth := serviceAccess(db.(*database.DB), source.ID)
|
||||
newID := uuid.New()
|
||||
now := time.Now()
|
||||
// string() so pq sends text — []byte would go as bytea and fail ::jsonb.
|
||||
volumesJSON := string(loadServiceVolumesJSON(db.(*database.DB), source.ID))
|
||||
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO services
|
||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||
build_command, start_command, type, status, image, command, environment,
|
||||
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||
healthcheck_path, restart_policy, volumes, maintenance_mode, basic_auth_users,
|
||||
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
||||
created_at, updated_at)
|
||||
SELECT $1, $2, $3, $4, service_type, source_type, source_url, image_name,
|
||||
build_command, start_command, type, 'stopped', image, command, $5,
|
||||
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||
healthcheck_path, restart_policy, $6::jsonb, $7, $8,
|
||||
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
||||
$9, $9
|
||||
FROM services WHERE id = $10`,
|
||||
newID, targetProjectID, name, environmentID, environment, volumesJSON,
|
||||
maintenance, basicAuth, now, source.ID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := db.(*database.DB).Exec(
|
||||
`INSERT INTO service_domains (service_id, domain, is_default, cert_type)
|
||||
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
|
||||
newID, source.ID,
|
||||
); err != nil {
|
||||
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service domains"})
|
||||
return
|
||||
}
|
||||
// Variable values are copied as stored — ciphertext stays ciphertext.
|
||||
if _, err := db.(*database.DB).Exec(
|
||||
`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
|
||||
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
|
||||
newID, source.ID,
|
||||
); err != nil {
|
||||
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service variables"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
|
||||
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
|
||||
}
|
||||
|
||||
type MoveServiceRequest struct {
|
||||
ProjectID uuid.UUID `json:"project_id" binding:"required"`
|
||||
}
|
||||
|
||||
// handleMoveService re-parents a service to another project owned by the
|
||||
// caller. Variables, domains, and volumes follow via the service id;
|
||||
// running containers keep running (they join the new project's network on
|
||||
// the next deploy).
|
||||
func handleMoveService(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
userID, _ := c.Get("user_id")
|
||||
serviceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
|
||||
var req MoveServiceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
|
||||
service, ok := loadOwnedService(c, db.(*database.DB))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if req.ProjectID == service.ProjectID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Service is already in that project", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
if !ownsProject(db.(*database.DB), req.ProjectID, userID.(string), contextIsAdmin(c)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
|
||||
return
|
||||
}
|
||||
|
||||
var count int
|
||||
_ = db.(*database.DB).QueryRow(
|
||||
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
|
||||
req.ProjectID, service.Name,
|
||||
).Scan(&count)
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "A service with this name already exists in the target project", "code": "CONFLICT"})
|
||||
return
|
||||
}
|
||||
|
||||
envName := service.Environment
|
||||
if envName == "" {
|
||||
envName = "production"
|
||||
}
|
||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), req.ProjectID, envName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.(*database.DB).Exec(
|
||||
`UPDATE services SET project_id = $1, environment_id = $2, updated_at = $3 WHERE id = $4`,
|
||||
req.ProjectID, environmentID, time.Now(), serviceID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to move service"})
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found", "code": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAuditWithRequest(c, "service", serviceID.String(), "service.move", map[string]interface{}{"to_project": req.ProjectID.String()})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Service moved", "project_id": req.ProjectID})
|
||||
}
|
||||
|
||||
// ownsProject is true when the user owns the project or is an admin.
|
||||
func ownsProject(db *database.DB, projectID uuid.UUID, userID string, isAdmin bool) bool {
|
||||
if isAdmin {
|
||||
return true
|
||||
}
|
||||
var owner string
|
||||
err := db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, projectID).Scan(&owner)
|
||||
return err == nil && owner == userID
|
||||
}
|
||||
|
||||
// loadServiceVolumesJSON returns the raw stored mounts for a verbatim copy.
|
||||
func loadServiceVolumesJSON(db *database.DB, serviceID uuid.UUID) []byte {
|
||||
var raw []byte
|
||||
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil || len(raw) == 0 {
|
||||
return []byte("[]")
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -41,6 +41,11 @@ type Service struct {
|
||||
Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
|
||||
MaintenanceMode bool `json:"maintenance_mode" db:"-"`
|
||||
BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes
|
||||
Builder string `json:"builder" db:"builder"` // auto|railpack|nixpacks|dockerfile|static
|
||||
CPUReserve string `json:"cpu_reserve,omitempty" db:"cpu_reserve"`
|
||||
MemoryReserve string `json:"memory_reserve,omitempty" db:"memory_reserve"`
|
||||
StaticBuildCmd string `json:"static_build_cmd,omitempty" db:"static_build_cmd"`
|
||||
StaticDir string `json:"static_dir,omitempty" db:"static_dir"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
@@ -117,6 +122,11 @@ type CreateServiceRequest struct {
|
||||
Volumes []ServiceVolume `json:"volumes"`
|
||||
MaintenanceMode bool `json:"maintenance_mode"`
|
||||
BasicAuth []BasicAuthCred `json:"basic_auth"`
|
||||
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
|
||||
CPUReserve string `json:"cpu_reserve"`
|
||||
MemoryReserve string `json:"memory_reserve"`
|
||||
StaticBuildCmd string `json:"static_build_cmd"`
|
||||
StaticDir string `json:"static_dir"`
|
||||
}
|
||||
|
||||
// UpdateServiceRequest represents a request to update a service
|
||||
@@ -139,6 +149,11 @@ type UpdateServiceRequest struct {
|
||||
Volumes *[]ServiceVolume `json:"volumes"`
|
||||
MaintenanceMode *bool `json:"maintenance_mode"`
|
||||
BasicAuth *[]BasicAuthCred `json:"basic_auth"`
|
||||
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
|
||||
CPUReserve *string `json:"cpu_reserve"`
|
||||
MemoryReserve *string `json:"memory_reserve"`
|
||||
StaticBuildCmd *string `json:"static_build_cmd"`
|
||||
StaticDir *string `json:"static_dir"`
|
||||
}
|
||||
|
||||
// handleGetServices retrieves all services for a project
|
||||
@@ -183,6 +198,9 @@ func handleGetServices(c *gin.Context) {
|
||||
COALESCE(replicas, 1), COALESCE(port, 0),
|
||||
COALESCE(domain, ''), COALESCE(healthcheck_path, ''),
|
||||
COALESCE(restart_policy, 'unless-stopped'),
|
||||
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''),
|
||||
COALESCE(memory_reserve, ''), COALESCE(static_build_cmd, ''),
|
||||
COALESCE(static_dir, ''),
|
||||
created_at, updated_at
|
||||
FROM services
|
||||
WHERE project_id = $1
|
||||
@@ -203,7 +221,9 @@ func handleGetServices(c *gin.Context) {
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||
&service.CreatedAt, &service.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
|
||||
@@ -314,12 +334,38 @@ func handleCreateService(c *gin.Context) {
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Set default values if not provided
|
||||
// Set default values if not provided; app_settings.default_cpu /
|
||||
// default_memory override the hardcoded fallbacks.
|
||||
if service.CPU == "" {
|
||||
service.CPU = "0.5"
|
||||
service.CPU = firstNonEmpty(settingValue(db.(*database.DB), "default_cpu", "", ""), "0.5")
|
||||
}
|
||||
if service.Memory == "" {
|
||||
service.Memory = "512Mi"
|
||||
service.Memory = firstNonEmpty(settingValue(db.(*database.DB), "default_memory", "", ""), "512Mi")
|
||||
}
|
||||
if req.Builder == "" {
|
||||
service.Builder = "auto"
|
||||
} else {
|
||||
service.Builder = req.Builder
|
||||
}
|
||||
service.CPUReserve = req.CPUReserve
|
||||
service.MemoryReserve = req.MemoryReserve
|
||||
service.StaticBuildCmd = req.StaticBuildCmd
|
||||
service.StaticDir = req.StaticDir
|
||||
if service.Builder == "static" && service.StaticDir == "" {
|
||||
service.StaticDir = "dist"
|
||||
}
|
||||
for label, check := range map[string]func(string) error{
|
||||
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
||||
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
||||
} {
|
||||
v := map[string]string{
|
||||
"cpu": service.CPU, "cpu_reserve": service.CPUReserve,
|
||||
"memory": service.Memory, "memory_reserve": service.MemoryReserve,
|
||||
}[label]
|
||||
if err := check(v); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if service.Replicas < 1 {
|
||||
service.Replicas = 1
|
||||
@@ -351,14 +397,18 @@ func handleCreateService(c *gin.Context) {
|
||||
(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, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28)`,
|
||||
healthcheck_path, restart_policy, volumes,
|
||||
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
||||
created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
|
||||
$27, $28, $29, $30, $31, $32, $33)`,
|
||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
||||
volumesJSON, service.CreatedAt, service.UpdatedAt,
|
||||
volumesJSON, service.Builder, service.CPUReserve, service.MemoryReserve,
|
||||
service.StaticBuildCmd, service.StaticDir, service.CreatedAt, service.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -443,6 +493,9 @@ func handleGetService(c *gin.Context) {
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||
COALESCE(s.static_dir, ''),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
@@ -454,7 +507,9 @@ func handleGetService(c *gin.Context) {
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
||||
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||
&service.CreatedAt, &service.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -520,6 +575,9 @@ func handleUpdateService(c *gin.Context) {
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||
COALESCE(s.static_dir, ''),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
@@ -532,6 +590,9 @@ func handleUpdateService(c *gin.Context) {
|
||||
&existingService.BuildPath, &existingService.CPU, &existingService.Memory,
|
||||
&existingService.Replicas, &existingService.Port, &existingService.Domain,
|
||||
&existingService.HealthCheckPath, &existingService.RestartPolicy,
|
||||
&existingService.Builder, &existingService.CPUReserve,
|
||||
&existingService.MemoryReserve, &existingService.StaticBuildCmd,
|
||||
&existingService.StaticDir,
|
||||
&existingService.CreatedAt, &existingService.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -586,6 +647,37 @@ func handleUpdateService(c *gin.Context) {
|
||||
if req.RestartPolicy != "" {
|
||||
existingService.RestartPolicy = req.RestartPolicy
|
||||
}
|
||||
if req.Builder != "" {
|
||||
existingService.Builder = req.Builder
|
||||
}
|
||||
if req.CPUReserve != nil {
|
||||
existingService.CPUReserve = *req.CPUReserve
|
||||
}
|
||||
if req.MemoryReserve != nil {
|
||||
existingService.MemoryReserve = *req.MemoryReserve
|
||||
}
|
||||
if req.StaticBuildCmd != nil {
|
||||
existingService.StaticBuildCmd = *req.StaticBuildCmd
|
||||
}
|
||||
if req.StaticDir != nil {
|
||||
existingService.StaticDir = *req.StaticDir
|
||||
}
|
||||
if existingService.Builder == "static" && existingService.StaticDir == "" {
|
||||
existingService.StaticDir = "dist"
|
||||
}
|
||||
for label, check := range map[string]func(string) error{
|
||||
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
||||
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
||||
} {
|
||||
v := map[string]string{
|
||||
"cpu": existingService.CPU, "cpu_reserve": existingService.CPUReserve,
|
||||
"memory": existingService.Memory, "memory_reserve": existingService.MemoryReserve,
|
||||
}[label]
|
||||
if err := check(v); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
}
|
||||
// lib/pq sends a nil []byte as an empty bytea literal rather than NULL,
|
||||
// so the COALESCE fallback needs an untyped nil when volumes aren't sent.
|
||||
var volumesArg interface{}
|
||||
@@ -607,7 +699,9 @@ func handleUpdateService(c *gin.Context) {
|
||||
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
|
||||
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
|
||||
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
|
||||
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16
|
||||
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
|
||||
builder = $19, cpu_reserve = $20, memory_reserve = $21,
|
||||
static_build_cmd = $22, static_dir = $23
|
||||
WHERE id = $18`,
|
||||
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
||||
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
||||
@@ -615,6 +709,8 @@ func handleUpdateService(c *gin.Context) {
|
||||
existingService.Replicas, existingService.Port, existingService.Domain,
|
||||
existingService.HealthCheckPath, existingService.RestartPolicy,
|
||||
existingService.UpdatedAt, volumesArg, existingService.ID,
|
||||
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
|
||||
existingService.StaticBuildCmd, existingService.StaticDir,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -218,12 +218,14 @@ func handleUpdateVariables(c *gin.Context) {
|
||||
`SELECT id, project_id, name, COALESCE(image, ''), COALESCE(command, ''),
|
||||
COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(domain, ''),
|
||||
COALESCE(healthcheck_path, ''), COALESCE(restart_policy, 'unless-stopped'),
|
||||
COALESCE(cpu, ''), COALESCE(memory, '')
|
||||
COALESCE(cpu, ''), COALESCE(memory, ''),
|
||||
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''), COALESCE(memory_reserve, '')
|
||||
FROM services WHERE id = $1`,
|
||||
serviceID,
|
||||
).Scan(&svc.ID, &svc.ProjectID, &svc.Name, &svc.Image, &svc.Command,
|
||||
&svc.Replicas, &svc.Port, &svc.Domain, &svc.HealthCheckPath,
|
||||
&svc.RestartPolicy, &svc.CPU, &svc.Memory)
|
||||
&svc.RestartPolicy, &svc.CPU, &svc.Memory,
|
||||
&svc.Builder, &svc.CPUReserve, &svc.MemoryReserve)
|
||||
resp["redeploy_queued"] = svcErr == nil && enqueueServiceRedeploy(c, db.(*database.DB), svc)
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
|
||||
@@ -211,8 +211,12 @@ func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest)
|
||||
// Build Docker image
|
||||
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
||||
|
||||
// Create build context tar
|
||||
buildCtx, err := createBuildContext(req.SourcePath, optimizedDockerfile)
|
||||
// createBuildContext wants the Dockerfile contents, not its path.
|
||||
dockerfileContent, err := os.ReadFile(optimizedDockerfile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read Dockerfile: %w", err)
|
||||
}
|
||||
buildCtx, err := createBuildContext(req.SourcePath, string(dockerfileContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create build context: %w", err)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type BuildManager struct {
|
||||
railpackBuilder *RailpackBuilder
|
||||
nixpacksBuilder *NixpacksBuilder
|
||||
dockerfileBuilder *DockerfileBuilder
|
||||
staticBuilder *StaticBuilder
|
||||
dockerClient *docker.Client
|
||||
cacheManager *CacheManager
|
||||
parallelBuilder *ParallelBuilder
|
||||
@@ -28,6 +29,7 @@ const (
|
||||
BuildTypeRailpack BuildType = "railpack"
|
||||
BuildTypeNixpacks BuildType = "nixpacks"
|
||||
BuildTypeDockerfile BuildType = "dockerfile"
|
||||
BuildTypeStatic BuildType = "static"
|
||||
BuildTypePrebuilt BuildType = "prebuilt"
|
||||
)
|
||||
|
||||
@@ -39,6 +41,7 @@ func NewBuildManager(workDir string, dockerClient *docker.Client) *BuildManager
|
||||
railpackBuilder: NewRailpackBuilder(workDir, dockerClient),
|
||||
nixpacksBuilder: NewNixpacksBuilder(workDir, dockerClient),
|
||||
dockerfileBuilder: NewDockerfileBuilder(workDir, dockerClient),
|
||||
staticBuilder: NewStaticBuilder(dockerClient),
|
||||
dockerClient: dockerClient,
|
||||
cacheManager: cacheManager,
|
||||
parallelBuilder: parallelBuilder,
|
||||
@@ -80,6 +83,8 @@ func (bm *BuildManager) Build(ctx context.Context, req *types.BuildRequest) (*ty
|
||||
return bm.nixpacksBuilder.Build(ctx, req)
|
||||
case BuildTypeDockerfile:
|
||||
return bm.dockerfileBuilder.Build(ctx, req)
|
||||
case BuildTypeStatic:
|
||||
return bm.staticBuilder.Build(ctx, req)
|
||||
case BuildTypePrebuilt:
|
||||
return bm.buildPrebuilt(ctx, req)
|
||||
default:
|
||||
@@ -95,7 +100,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
|
||||
|
||||
// Pull the prebuilt image — must drain the pull stream or the daemon
|
||||
// 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 {
|
||||
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"containr/internal/docker"
|
||||
"containr/internal/types"
|
||||
)
|
||||
|
||||
// StaticBuilder produces an nginx-served image from a repo's built assets.
|
||||
// STATIC_BUILD_CMD runs inside a node build stage; STATIC_DIR names the
|
||||
// output directory copied into the nginx image. Both are overridable via
|
||||
// build args so repos with non-node toolchains can swap the build image too.
|
||||
type StaticBuilder struct {
|
||||
dockerClient *docker.Client
|
||||
}
|
||||
|
||||
func NewStaticBuilder(dockerClient *docker.Client) *StaticBuilder {
|
||||
return &StaticBuilder{dockerClient: dockerClient}
|
||||
}
|
||||
|
||||
const staticDockerfile = `ARG BUILD_IMAGE=node:20-alpine
|
||||
FROM ${BUILD_IMAGE} AS assets
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
ARG STATIC_BUILD_CMD
|
||||
RUN sh -c "${STATIC_BUILD_CMD}"
|
||||
|
||||
FROM nginx:alpine
|
||||
ARG STATIC_DIR=dist
|
||||
COPY --from=assets /app/${STATIC_DIR} /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
`
|
||||
|
||||
func (s *StaticBuilder) Build(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
|
||||
buildCmd := req.BuildCommand
|
||||
if buildCmd == "" {
|
||||
buildCmd = "npm ci && npm run build"
|
||||
}
|
||||
staticDir := req.BuildArgs["STATIC_DIR"]
|
||||
if staticDir == "" {
|
||||
staticDir = "dist"
|
||||
}
|
||||
|
||||
buildArgs := map[string]*string{}
|
||||
for k, v := range req.BuildArgs {
|
||||
v := v
|
||||
buildArgs[k] = &v
|
||||
}
|
||||
for k, v := range req.Environment {
|
||||
v := v
|
||||
buildArgs[k] = &v
|
||||
}
|
||||
buildArgs["STATIC_BUILD_CMD"] = &buildCmd
|
||||
buildArgs["STATIC_DIR"] = &staticDir
|
||||
|
||||
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
||||
buildCtx, err := createBuildContext(req.SourcePath, staticDockerfile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create build context: %w", err)
|
||||
}
|
||||
defer buildCtx.Close()
|
||||
|
||||
if _, err := s.dockerClient.BuildImage(ctx, buildCtx, docker.BuildOptions{
|
||||
Dockerfile: "Dockerfile",
|
||||
Tags: []string{imageName},
|
||||
BuildArgs: buildArgs,
|
||||
NoCache: req.NoCache,
|
||||
Remove: true,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("failed to build static image: %w", err)
|
||||
}
|
||||
|
||||
if req.RegistryURL != "" {
|
||||
full := fmt.Sprintf("%s/%s", req.RegistryURL, imageName)
|
||||
if err := s.dockerClient.PushImage(ctx, imageName, req.RegistryURL); err != nil {
|
||||
return nil, fmt.Errorf("failed to push image: %w", err)
|
||||
}
|
||||
imageName = full
|
||||
}
|
||||
|
||||
imageInfo, err := s.dockerClient.GetImageInfo(ctx, imageName)
|
||||
if err != nil {
|
||||
imageInfo = &docker.ImageInfo{Size: 0}
|
||||
}
|
||||
|
||||
return &types.BuildResponse{
|
||||
ImageName: imageName,
|
||||
ImageTag: req.ImageTag,
|
||||
Size: imageInfo.Size,
|
||||
Digest: imageInfo.Digest,
|
||||
}, nil
|
||||
}
|
||||
@@ -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() {
|
||||
dbCreateCmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
|
||||
dbCreateCmd.Flags().String("version", "", "engine version tag")
|
||||
dbCreateCmd.Flags().String("storage-size", "", "volume size (e.g. 10Gi)")
|
||||
DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbDownloadCmd)
|
||||
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)
|
||||
}
|
||||
@@ -79,7 +79,7 @@ var servicesCreateCmd = &cobra.Command{
|
||||
body := map[string]interface{}{
|
||||
"name": args[1],
|
||||
}
|
||||
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory"} {
|
||||
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
|
||||
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||
key := f
|
||||
// JSON uses snake_case keys matching the API schema.
|
||||
@@ -94,6 +94,14 @@ var servicesCreateCmd = &cobra.Command{
|
||||
key = "restart_policy"
|
||||
case "healthcheck-path":
|
||||
key = "healthcheck_path"
|
||||
case "cpu-reserve":
|
||||
key = "cpu_reserve"
|
||||
case "memory-reserve":
|
||||
key = "memory_reserve"
|
||||
case "static-cmd":
|
||||
key = "static_build_cmd"
|
||||
case "static-dir":
|
||||
key = "static_dir"
|
||||
}
|
||||
body[key] = v
|
||||
}
|
||||
@@ -151,7 +159,7 @@ var servicesUpdateCmd = &cobra.Command{
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory"} {
|
||||
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
|
||||
if cmd.Flags().Changed(f) {
|
||||
key := f
|
||||
switch f {
|
||||
@@ -159,6 +167,14 @@ var servicesUpdateCmd = &cobra.Command{
|
||||
key = "restart_policy"
|
||||
case "healthcheck-path":
|
||||
key = "healthcheck_path"
|
||||
case "cpu-reserve":
|
||||
key = "cpu_reserve"
|
||||
case "memory-reserve":
|
||||
key = "memory_reserve"
|
||||
case "static-cmd":
|
||||
key = "static_build_cmd"
|
||||
case "static-dir":
|
||||
key = "static_dir"
|
||||
}
|
||||
v, _ := cmd.Flags().GetString(f)
|
||||
body[key] = v
|
||||
@@ -210,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
|
||||
// objects. Bind mounts are type=bind, named volumes type=volume.
|
||||
func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} {
|
||||
@@ -406,6 +477,11 @@ func init() {
|
||||
f.String("memory", "", "memory limit (e.g. 512m)")
|
||||
f.Int("port", 0, "container port")
|
||||
f.Int("replicas", 0, "replica count")
|
||||
f.String("builder", "", "build strategy: auto|railpack|nixpacks|dockerfile|static")
|
||||
f.String("cpu-reserve", "", "soft CPU reservation (e.g. 0.25)")
|
||||
f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)")
|
||||
f.String("static-cmd", "", "build command for the static builder")
|
||||
f.String("static-dir", "", "output dir for the static builder (default dist)")
|
||||
|
||||
uf := servicesUpdateCmd.Flags()
|
||||
uf.String("name", "", "service name")
|
||||
@@ -423,6 +499,11 @@ func init() {
|
||||
uf.Bool("clear-volumes", false, "remove all volume mounts")
|
||||
uf.String("maintenance", "", "maintenance mode: on|off")
|
||||
uf.StringArray("basic-auth", nil, "basic-auth credential user:password (repeatable, replaces all)")
|
||||
uf.String("builder", "", "build strategy: auto|railpack|nixpacks|dockerfile|static")
|
||||
uf.String("cpu-reserve", "", "soft CPU reservation (empty clears)")
|
||||
uf.String("memory-reserve", "", "soft memory reservation (empty clears)")
|
||||
uf.String("static-cmd", "", "build command for the static builder (empty clears)")
|
||||
uf.String("static-dir", "", "output dir for the static builder (empty clears)")
|
||||
|
||||
for _, a := range []string{"start", "stop", "restart", "redeploy"} {
|
||||
verb := a
|
||||
@@ -433,7 +514,11 @@ func init() {
|
||||
RunE: serviceAction(verb),
|
||||
})
|
||||
}
|
||||
servicesCloneCmd.Flags().String("name", "", "name for the clone (default <name>-copy)")
|
||||
servicesCloneCmd.Flags().String("project", "", "target project id (default: same project)")
|
||||
servicesCloneCmd.Flags().String("environment", "", "target environment (default: source's)")
|
||||
|
||||
domainsAddCmd.Flags().Bool("default", false, "set as the default domain")
|
||||
servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd)
|
||||
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd)
|
||||
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd)
|
||||
}
|
||||
@@ -50,7 +50,7 @@ var varsSetCmd = &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
secret, _ := cmd.Flags().GetBool("secret")
|
||||
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:] {
|
||||
k, v, found := strings.Cut(pair, "=")
|
||||
if !found || k == "" {
|
||||
@@ -73,7 +73,7 @@ var varsUnsetCmd = &cobra.Command{
|
||||
drop[k] = true
|
||||
}
|
||||
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]
|
||||
for _, v := range vars {
|
||||
if !drop[str(v, "key")] {
|
||||
@@ -85,20 +85,92 @@ var varsUnsetCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
func fetchVars(c *Client, serviceID string) ([]map[string]interface{}, error) {
|
||||
data, err := c.Do("GET", "/services/"+serviceID+"/variables", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unwrapList(data, "variables")
|
||||
// Shared variables — project scope, referenced as ${{shared.KEY}}.
|
||||
|
||||
var varsSharedCmd = &cobra.Command{
|
||||
Use: "shared",
|
||||
Short: "Manage project-level shared variables (${{shared.KEY}})",
|
||||
}
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -115,7 +187,7 @@ func mutateVars(serviceID string, redeploy bool, mutate func([]map[string]interf
|
||||
if redeploy {
|
||||
body["redeploy"] = true
|
||||
}
|
||||
data, err := c.Do("PUT", "/services/"+serviceID+"/variables", body)
|
||||
data, err = c.Do("PUT", path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -125,9 +197,9 @@ func mutateVars(serviceID string, redeploy bool, mutate func([]map[string]interf
|
||||
}
|
||||
obj, _ := unwrapObject(data)
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -147,5 +219,7 @@ func init() {
|
||||
varsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
|
||||
varsSetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
|
||||
varsUnsetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
|
||||
VariablesCmd.AddCommand(varsListCmd, varsSetCmd, varsUnsetCmd)
|
||||
sharedVarsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
|
||||
varsSharedCmd.AddCommand(sharedVarsListCmd, sharedVarsSetCmd, sharedVarsUnsetCmd)
|
||||
VariablesCmd.AddCommand(varsListCmd, varsSetCmd, varsUnsetCmd, varsSharedCmd)
|
||||
}
|
||||
@@ -74,6 +74,7 @@ func init() {
|
||||
rootCmd.AddCommand(commands.ExecCmd)
|
||||
rootCmd.AddCommand(commands.VariablesCmd)
|
||||
rootCmd.AddCommand(commands.VolumesCmd)
|
||||
rootCmd.AddCommand(commands.RegistriesCmd)
|
||||
rootCmd.AddCommand(commands.DatabasesCmd)
|
||||
rootCmd.AddCommand(commands.CronCmd)
|
||||
rootCmd.AddCommand(commands.TemplatesCmd)
|
||||
|
||||
@@ -88,6 +88,52 @@ func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseS
|
||||
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
|
||||
SELECT EXISTS(
|
||||
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
|
||||
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
|
||||
WHERE id = $1
|
||||
`
|
||||
@@ -170,6 +216,13 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
|
||||
&i.ConnectionUrl,
|
||||
&i.BackupSchedule,
|
||||
&i.NextBackupAt,
|
||||
&i.Provider,
|
||||
&i.ExternalHost,
|
||||
&i.ExternalPort,
|
||||
&i.ExternalName,
|
||||
&i.ExternalUsername,
|
||||
&i.ExternalPassword,
|
||||
&i.ExternalSsl,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -177,7 +230,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
|
||||
}
|
||||
|
||||
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
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
@@ -202,6 +255,13 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
|
||||
&i.ConnectionUrl,
|
||||
&i.BackupSchedule,
|
||||
&i.NextBackupAt,
|
||||
&i.Provider,
|
||||
&i.ExternalHost,
|
||||
&i.ExternalPort,
|
||||
&i.ExternalName,
|
||||
&i.ExternalUsername,
|
||||
&i.ExternalPassword,
|
||||
&i.ExternalSsl,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -222,7 +282,7 @@ func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (str
|
||||
}
|
||||
|
||||
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
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
@@ -248,6 +308,13 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic
|
||||
&i.ConnectionUrl,
|
||||
&i.BackupSchedule,
|
||||
&i.NextBackupAt,
|
||||
&i.Provider,
|
||||
&i.ExternalHost,
|
||||
&i.ExternalPort,
|
||||
&i.ExternalName,
|
||||
&i.ExternalUsername,
|
||||
&i.ExternalPassword,
|
||||
&i.ExternalSsl,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -311,7 +378,7 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
|
||||
}
|
||||
|
||||
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
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
@@ -338,6 +405,13 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
|
||||
&i.ConnectionUrl,
|
||||
&i.BackupSchedule,
|
||||
&i.NextBackupAt,
|
||||
&i.Provider,
|
||||
&i.ExternalHost,
|
||||
&i.ExternalPort,
|
||||
&i.ExternalName,
|
||||
&i.ExternalUsername,
|
||||
&i.ExternalPassword,
|
||||
&i.ExternalSsl,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
|
||||
@@ -80,19 +80,26 @@ type DatabaseBackup struct {
|
||||
}
|
||||
|
||||
type DatabaseService struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Plan string `json:"plan"`
|
||||
Region string `json:"region"`
|
||||
ConnectionUrl sql.NullString `json:"connection_url"`
|
||||
BackupSchedule sql.NullString `json:"backup_schedule"`
|
||||
NextBackupAt sql.NullTime `json:"next_backup_at"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Plan string `json:"plan"`
|
||||
Region string `json:"region"`
|
||||
ConnectionUrl sql.NullString `json:"connection_url"`
|
||||
BackupSchedule sql.NullString `json:"backup_schedule"`
|
||||
NextBackupAt sql.NullTime `json:"next_backup_at"`
|
||||
Provider string `json:"provider"`
|
||||
ExternalHost sql.NullString `json:"external_host"`
|
||||
ExternalPort sql.NullInt32 `json:"external_port"`
|
||||
ExternalName sql.NullString `json:"external_name"`
|
||||
ExternalUsername sql.NullString `json:"external_username"`
|
||||
ExternalPassword string `json:"external_password"`
|
||||
ExternalSsl bool `json:"external_ssl"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Deployment struct {
|
||||
@@ -167,6 +174,17 @@ type ProjectMember struct {
|
||||
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 {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -204,6 +222,11 @@ type Service struct {
|
||||
Volumes json.RawMessage `json:"volumes"`
|
||||
MaintenanceMode bool `json:"maintenance_mode"`
|
||||
BasicAuthUsers string `json:"basic_auth_users"`
|
||||
Builder string `json:"builder"`
|
||||
CpuReserve string `json:"cpu_reserve"`
|
||||
MemoryReserve string `json:"memory_reserve"`
|
||||
StaticBuildCmd string `json:"static_build_cmd"`
|
||||
StaticDir string `json:"static_dir"`
|
||||
}
|
||||
|
||||
type ServiceDomain struct {
|
||||
|
||||
@@ -22,6 +22,7 @@ type Querier interface {
|
||||
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
|
||||
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
|
||||
CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error
|
||||
CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error
|
||||
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
|
||||
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
||||
|
||||
@@ -84,10 +84,11 @@ type VolumeMount struct {
|
||||
}
|
||||
|
||||
type ResourceLimits struct {
|
||||
MemoryBytes int64 `json:"memory_bytes,omitempty"`
|
||||
CPUQuota int64 `json:"cpu_quota,omitempty"`
|
||||
CPUPeriod int64 `json:"cpu_period,omitempty"`
|
||||
CPUShares int64 `json:"cpu_shares,omitempty"`
|
||||
MemoryBytes int64 `json:"memory_bytes,omitempty"`
|
||||
MemoryReservation int64 `json:"memory_reservation,omitempty"`
|
||||
CPUQuota int64 `json:"cpu_quota,omitempty"`
|
||||
CPUPeriod int64 `json:"cpu_period,omitempty"`
|
||||
CPUShares int64 `json:"cpu_shares,omitempty"`
|
||||
}
|
||||
|
||||
type HealthCheck struct {
|
||||
@@ -152,10 +153,14 @@ type BuildConfig struct {
|
||||
PrebuiltImage string `json:"prebuilt_image"`
|
||||
BuildCommand string `json:"build_command"`
|
||||
StartCommand string `json:"start_command"`
|
||||
BuildArgs map[string]string `json:"build_args,omitempty"`
|
||||
Environment map[string]string `json:"environment"`
|
||||
Branch string `json:"branch"`
|
||||
Commit string `json:"commit"`
|
||||
NoCache bool `json:"no_cache"`
|
||||
// Pull credentials for private prebuilt images.
|
||||
PullUsername string `json:"pull_username,omitempty"`
|
||||
PullPassword string `json:"pull_password,omitempty"`
|
||||
Branch string `json:"branch"`
|
||||
Commit string `json:"commit"`
|
||||
NoCache bool `json:"no_cache"`
|
||||
}
|
||||
|
||||
type TriggerConfig struct {
|
||||
@@ -166,6 +171,12 @@ type TriggerConfig struct {
|
||||
Timestamp time.Time `json:"timestamp"` // When trigger occurred
|
||||
}
|
||||
|
||||
// DockerClient exposes the engine's docker handle for read-only queries
|
||||
// (capacity checks, image info).
|
||||
func (de *DeploymentEngine) DockerClient() *docker.Client {
|
||||
return de.dockerClient
|
||||
}
|
||||
|
||||
func NewDeploymentEngine(buildManager *build.BuildManager, dockerClient *docker.Client) *DeploymentEngine {
|
||||
return &DeploymentEngine{
|
||||
buildManager: buildManager,
|
||||
@@ -290,7 +301,10 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme
|
||||
ImageTag: deployment.ID,
|
||||
BuildCommand: buildConfig.BuildCommand,
|
||||
StartCommand: buildConfig.StartCommand,
|
||||
BuildArgs: buildConfig.BuildArgs,
|
||||
Environment: buildConfig.Environment,
|
||||
PullUsername: buildConfig.PullUsername,
|
||||
PullPassword: buildConfig.PullPassword,
|
||||
ProjectID: deployment.ProjectID,
|
||||
ServiceID: deployment.ServiceID,
|
||||
DeploymentID: deployment.ID,
|
||||
@@ -330,7 +344,9 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
||||
HealthPath: cfg.HealthPath,
|
||||
RestartPolicy: cfg.RestartPolicy,
|
||||
MemoryBytes: cfg.Resources.MemoryBytes,
|
||||
MemoryReserve: cfg.Resources.MemoryReservation,
|
||||
NanoCPUs: cfg.Resources.CPUQuota,
|
||||
CPUShares: cfg.Resources.CPUShares,
|
||||
Volumes: cfg.VolumeMounts,
|
||||
Domains: cfg.Domains,
|
||||
Maintenance: cfg.Maintenance,
|
||||
|
||||
@@ -47,7 +47,9 @@ type RuntimeSpec struct {
|
||||
HealthPath string // http path probed on Port for container healthcheck
|
||||
RestartPolicy string
|
||||
MemoryBytes int64
|
||||
MemoryReserve int64 // soft reservation; scheduler hint, not a hard cap
|
||||
NanoCPUs int64
|
||||
CPUShares int64 // relative weight (1024 ≈ 1 cpu), not a cap
|
||||
Volumes []VolumeMount // applied to every replica
|
||||
Domains []string // all public hostnames; Domain is the default
|
||||
Maintenance bool // redirect traffic to MaintenanceURL
|
||||
@@ -273,15 +275,17 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
|
||||
}
|
||||
|
||||
cfg := docker.ContainerConfig{
|
||||
Name: fmt.Sprintf("%s%d", serviceNamePrefix(spec.ServiceID), index),
|
||||
Image: spec.Image,
|
||||
Cmd: spec.Command,
|
||||
Env: env,
|
||||
Labels: labels,
|
||||
RestartPolicy: spec.RestartPolicy,
|
||||
Memory: spec.MemoryBytes,
|
||||
NanoCPUs: spec.NanoCPUs,
|
||||
Networks: endpoints,
|
||||
Name: fmt.Sprintf("%s%d", serviceNamePrefix(spec.ServiceID), index),
|
||||
Image: spec.Image,
|
||||
Cmd: spec.Command,
|
||||
Env: env,
|
||||
Labels: labels,
|
||||
RestartPolicy: spec.RestartPolicy,
|
||||
Memory: spec.MemoryBytes,
|
||||
MemoryReservation: spec.MemoryReserve,
|
||||
NanoCPUs: spec.NanoCPUs,
|
||||
CPUShares: spec.CPUShares,
|
||||
Networks: endpoints,
|
||||
}
|
||||
|
||||
for _, v := range spec.Volumes {
|
||||
@@ -526,12 +530,14 @@ func specHash(spec RuntimeSpec) string {
|
||||
Env map[string]string
|
||||
Port int32
|
||||
MemoryBytes, NanoCPUs int64
|
||||
MemoryReserve, CPUShares int64
|
||||
Volumes []VolumeMount
|
||||
Domains []string
|
||||
Maintenance bool
|
||||
BasicAuth string
|
||||
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
|
||||
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs, spec.Volumes,
|
||||
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs,
|
||||
spec.MemoryReserve, spec.CPUShares, spec.Volumes,
|
||||
spec.Domains, spec.Maintenance, spec.BasicAuthUsers})
|
||||
sum := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(sum[:8])
|
||||
|
||||
@@ -156,8 +156,10 @@ func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (s
|
||||
PortBindings: config.PortBindings,
|
||||
Mounts: config.Mounts,
|
||||
Resources: container.Resources{
|
||||
Memory: config.Memory,
|
||||
NanoCPUs: config.NanoCPUs,
|
||||
Memory: config.Memory,
|
||||
MemoryReservation: config.MemoryReservation,
|
||||
NanoCPUs: config.NanoCPUs,
|
||||
CPUShares: config.CPUShares,
|
||||
},
|
||||
NetworkMode: container.NetworkMode(config.NetworkMode),
|
||||
}
|
||||
@@ -291,7 +293,7 @@ func (c *Client) PullImageWait(ctx context.Context, ref string, auth registry.Au
|
||||
|
||||
// BuildImage builds an image from a Dockerfile
|
||||
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
|
||||
return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
|
||||
resp, err := c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
|
||||
Dockerfile: options.Dockerfile,
|
||||
Tags: options.Tags,
|
||||
BuildArgs: options.BuildArgs,
|
||||
@@ -299,6 +301,34 @@ func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options
|
||||
Remove: options.Remove,
|
||||
NoCache: options.NoCache,
|
||||
})
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
// The daemon aborts the build if the response stream isn't consumed,
|
||||
// and reports failures only inside that stream.
|
||||
defer resp.Body.Close()
|
||||
var buildErr string
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
for {
|
||||
var line struct {
|
||||
ErrorDetail struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errorDetail"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := dec.Decode(&line); err != nil {
|
||||
break
|
||||
}
|
||||
if line.ErrorDetail.Message != "" {
|
||||
buildErr = line.ErrorDetail.Message
|
||||
} else if line.Error != "" {
|
||||
buildErr = line.Error
|
||||
}
|
||||
}
|
||||
if buildErr != "" {
|
||||
return resp, fmt.Errorf("docker build failed: %s", buildErr)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// RemoveImage removes an image
|
||||
|
||||
@@ -12,20 +12,22 @@ import (
|
||||
|
||||
// ContainerConfig represents the configuration for creating a container
|
||||
type ContainerConfig struct {
|
||||
Name string
|
||||
Image string
|
||||
Cmd []string
|
||||
Env []string
|
||||
Labels map[string]string
|
||||
RestartPolicy string
|
||||
ExposedPorts nat.PortSet
|
||||
PortBindings nat.PortMap
|
||||
Mounts []mount.Mount
|
||||
Memory int64
|
||||
NanoCPUs int64
|
||||
NetworkMode string
|
||||
Networks map[string]*network.EndpointSettings
|
||||
Healthcheck *container.HealthConfig
|
||||
Name string
|
||||
Image string
|
||||
Cmd []string
|
||||
Env []string
|
||||
Labels map[string]string
|
||||
RestartPolicy string
|
||||
ExposedPorts nat.PortSet
|
||||
PortBindings nat.PortMap
|
||||
Mounts []mount.Mount
|
||||
Memory int64
|
||||
MemoryReservation int64
|
||||
NanoCPUs int64
|
||||
CPUShares int64
|
||||
NetworkMode string
|
||||
Networks map[string]*network.EndpointSettings
|
||||
Healthcheck *container.HealthConfig
|
||||
}
|
||||
|
||||
// LogOptions represents options for retrieving container logs
|
||||
|
||||
@@ -53,15 +53,17 @@ func init() {
|
||||
// Services
|
||||
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}].", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir.", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
|
||||
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_clone", "Clone a service (config, volumes, domains, variables) into the same or another project. Args: id, name, project_id, environment.", "POST", "/services/{id}/clone", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false})
|
||||
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
|
||||
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears.", "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})
|
||||
|
||||
// Domains
|
||||
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []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_check", "DNS preflight for all service domains — returns ok|wrong-target|pending per domain", "GET", "/services/{id}/domains/check", []string{"id"}, nil, false})
|
||||
|
||||
// Registries — private-image pull credentials, owner-scoped.
|
||||
register(spec{"containr_registries_list", "List registry credentials (passwords never returned)", "GET", "/registries", nil, nil, false})
|
||||
register(spec{"containr_registries_create", "Add a registry credential. Args: name (required), host (required — e.g. ghcr.io, docker.io), username, password (stored encrypted).", "POST", "/registries", nil, []string{"name", "host"}, false})
|
||||
register(spec{"containr_registries_update", "Update a registry credential. Args: id, name, host (required), username, password (empty keeps stored).", "PUT", "/registries/{id}", []string{"id"}, []string{"name", "host"}, false})
|
||||
register(spec{"containr_registries_delete", "Remove a registry credential. Requires confirm:true.", "DELETE", "/registries/{id}", []string{"id"}, nil, true})
|
||||
|
||||
// Docker volumes (admin)
|
||||
register(spec{"containr_volumes_list", "List docker volumes on the node with in-use flags (admin)", "GET", "/admin/volumes", nil, nil, false})
|
||||
register(spec{"containr_volumes_delete", "Delete an unused docker volume. Requires confirm:true. (admin)", "DELETE", "/admin/volumes/{name}", []string{"name"}, nil, true})
|
||||
@@ -77,6 +85,8 @@ func init() {
|
||||
// Variables
|
||||
register(spec{"containr_variables_list", "List service environment variables (secrets masked)", "GET", "/services/{id}/variables", []string{"id"}, nil, false})
|
||||
register(spec{"containr_variables_set", "Replace all variables on a service. Args: id, variables = [{key, value, is_secret}], redeploy (queue a redeploy to apply). Fetch the current list first and merge — the server replaces wholesale.", "PUT", "/services/{id}/variables", []string{"id"}, []string{"variables", "redeploy"}, false})
|
||||
register(spec{"containr_shared_variables_list", "List project-level shared variables, referenced from service env as ${{shared.KEY}} (secrets masked). Args: id (project).", "GET", "/projects/{id}/variables", []string{"id"}, nil, false})
|
||||
register(spec{"containr_shared_variables_set", "Replace all shared project variables. Args: id (project), variables = [{key, value, is_secret}]. Services reference them as ${{shared.KEY}} — resolved at deploy/refresh, not live-updated.", "PUT", "/projects/{id}/variables", []string{"id"}, []string{"variables"}, false})
|
||||
|
||||
// Deployments
|
||||
register(spec{"containr_deploy", "Trigger a deployment for a service. Args: id (service), commit_hash, branch, no_cache.", "POST", "/services/{id}/deployments", []string{"id"}, []string{"commit_hash", "branch", "trigger", "no_cache"}, false})
|
||||
@@ -91,6 +101,9 @@ func init() {
|
||||
register(spec{"containr_databases_list", "List managed databases", "GET", "/databases", nil, nil, false})
|
||||
register(spec{"containr_databases_get", "Get a database (connection URL included)", "GET", "/databases/{id}", []string{"id"}, nil, false})
|
||||
register(spec{"containr_databases_create", "Provision a managed database. Args: name, type (postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse).", "POST", "/databases", nil, nil, false})
|
||||
register(spec{"containr_databases_register_external", "Register a database hosted elsewhere — probed first, password stored encrypted. Args: name (required), type (required), host (required), port, database, username, password, ssl.", "POST", "/databases/register-external", nil, []string{"name", "type", "host"}, false})
|
||||
register(spec{"containr_databases_test_connection", "Probe a DB connection ad-hoc. Args: type, host (required), port, database, username, password, ssl.", "POST", "/databases/test-connection", nil, []string{"type", "host"}, false})
|
||||
register(spec{"containr_databases_test_connection_by_id", "Re-probe a registered external database with its stored credentials. Args: id.", "POST", "/databases/{id}/test-connection", []string{"id"}, nil, false})
|
||||
register(spec{"containr_databases_action", "Start/stop/restart a database. Args: id, action.", "POST", "/databases/{id}/action", []string{"id"}, []string{"action"}, false})
|
||||
register(spec{"containr_databases_backup", "Take a manual backup snapshot", "POST", "/databases/{id}/backup", []string{"id"}, nil, false})
|
||||
register(spec{"containr_databases_restore", "Restore a database from a backup — overwrites current data. Requires confirm:true.", "POST", "/databases/{id}/restore", []string{"id"}, []string{"backup_id"}, true})
|
||||
|
||||
@@ -29,6 +29,10 @@ type BuildRequest struct {
|
||||
Branch string `json:"branch"` // Git branch
|
||||
Commit string `json:"commit"` // Git commit SHA
|
||||
NoCache bool `json:"no_cache"` // Disable Docker layer cache
|
||||
|
||||
// Registry credentials for pulling a private prebuilt image.
|
||||
PullUsername string `json:"pull_username,omitempty"`
|
||||
PullPassword string `json:"pull_password,omitempty"`
|
||||
}
|
||||
|
||||
// BuildResponse represents the response from a build operation
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS builder varchar(32) NOT NULL DEFAULT 'auto';
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS cpu_reserve varchar(20) NOT NULL DEFAULT '';
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS memory_reserve varchar(20) NOT NULL DEFAULT '';
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS static_build_cmd varchar(255) NOT NULL DEFAULT '';
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS static_dir varchar(255) NOT NULL DEFAULT '';
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE services DROP COLUMN IF EXISTS static_dir;
|
||||
ALTER TABLE services DROP COLUMN IF EXISTS static_build_cmd;
|
||||
ALTER TABLE services DROP COLUMN IF EXISTS memory_reserve;
|
||||
ALTER TABLE services DROP COLUMN IF EXISTS cpu_reserve;
|
||||
ALTER TABLE services DROP COLUMN IF EXISTS builder;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE IF NOT EXISTS registries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
username VARCHAR(255) NOT NULL DEFAULT '',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (owner_id, host)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_registries_owner ON registries(owner_id);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS registries;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,33 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE database_services
|
||||
ADD COLUMN IF NOT EXISTS provider VARCHAR(20) NOT NULL DEFAULT 'managed',
|
||||
ADD COLUMN IF NOT EXISTS external_host VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS external_port INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS external_name VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS external_username VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS external_password TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS external_ssl BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE database_services
|
||||
DROP CONSTRAINT IF EXISTS database_services_plan_check,
|
||||
ADD CONSTRAINT database_services_plan_check
|
||||
CHECK (plan IN ('hobby', 'starter', 'standard', 'business', 'external'));
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE database_services
|
||||
DROP CONSTRAINT IF EXISTS database_services_plan_check,
|
||||
ADD CONSTRAINT database_services_plan_check
|
||||
CHECK (plan IN ('hobby', 'starter', 'standard', 'business'));
|
||||
|
||||
ALTER TABLE database_services
|
||||
DROP COLUMN IF EXISTS provider,
|
||||
DROP COLUMN IF EXISTS external_host,
|
||||
DROP COLUMN IF EXISTS external_port,
|
||||
DROP COLUMN IF EXISTS external_name,
|
||||
DROP COLUMN IF EXISTS external_username,
|
||||
DROP COLUMN IF EXISTS external_password,
|
||||
DROP COLUMN IF EXISTS external_ssl;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,19 @@
|
||||
-- +goose Up
|
||||
-- Project-level shared variables (Railway-style). Services reference them via
|
||||
-- ${{shared.KEY}} in environment values; secret entries are encrypted at rest
|
||||
-- and masked in API responses exactly like service variables.
|
||||
CREATE TABLE IF NOT EXISTS project_variables (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
|
||||
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
key varchar(255) NOT NULL,
|
||||
value text NOT NULL DEFAULT '',
|
||||
is_secret boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (project_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_variables_project ON project_variables(project_id);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS project_variables;
|
||||
@@ -1,16 +1,16 @@
|
||||
-- name: ListDatabaseServicesByUser :many
|
||||
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
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- 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
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- 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
|
||||
WHERE id = $1;
|
||||
|
||||
@@ -20,7 +20,7 @@ FROM database_services
|
||||
WHERE id = $1;
|
||||
|
||||
-- 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
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
@@ -110,3 +110,9 @@ WHERE backup_schedule IS NOT NULL AND backup_schedule <> ''
|
||||
UPDATE database_services
|
||||
SET next_backup_at = $1
|
||||
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);
|
||||
@@ -61,7 +61,24 @@ CREATE TABLE services (
|
||||
published_port INTEGER NOT NULL DEFAULT 0,
|
||||
volumes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
maintenance_mode BOOLEAN NOT NULL DEFAULT false,
|
||||
basic_auth_users TEXT NOT NULL DEFAULT ''
|
||||
basic_auth_users TEXT NOT NULL DEFAULT '',
|
||||
builder VARCHAR(32) NOT NULL DEFAULT 'auto',
|
||||
cpu_reserve VARCHAR(20) NOT NULL DEFAULT '',
|
||||
memory_reserve VARCHAR(20) NOT NULL DEFAULT '',
|
||||
static_build_cmd VARCHAR(255) NOT NULL DEFAULT '',
|
||||
static_dir VARCHAR(255) NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE registries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
username VARCHAR(255) NOT NULL DEFAULT '',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (owner_id, host)
|
||||
);
|
||||
|
||||
CREATE TABLE service_domains (
|
||||
@@ -122,6 +139,13 @@ CREATE TABLE database_services (
|
||||
connection_url TEXT,
|
||||
backup_schedule VARCHAR(100),
|
||||
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(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -20,12 +20,20 @@ import {
|
||||
createUserToken,
|
||||
listUserTokens,
|
||||
revokeUserToken,
|
||||
listRegistries,
|
||||
createRegistry,
|
||||
deleteRegistry,
|
||||
type Registry,
|
||||
listDatabases,
|
||||
databaseAction,
|
||||
updateDatabaseBackupSchedule,
|
||||
createDatabaseBackup,
|
||||
restoreDatabaseBackup,
|
||||
createManagedDatabase,
|
||||
registerExternalDatabase,
|
||||
testDatabaseConnection,
|
||||
testDatabaseConnectionByID,
|
||||
type ExternalDatabaseInput,
|
||||
listServicesByProject,
|
||||
listServiceVariables,
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -1603,6 +1740,7 @@ export function SettingsPage() {
|
||||
|
||||
{/* Personal Access Tokens */}
|
||||
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
||||
<RegistriesSection />
|
||||
|
||||
{/* Platform Settings - owner only */}
|
||||
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
||||
@@ -1767,7 +1905,11 @@ export function DatabasesPage() {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [bindDb, setBindDb] = useState<DatabaseEntity | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createMode, setCreateMode] = useState<'managed' | 'external'>('managed');
|
||||
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 databasesQuery = useQuery({
|
||||
queryKey: ['databases'],
|
||||
@@ -1795,6 +1937,40 @@ export function DatabasesPage() {
|
||||
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({
|
||||
mutationFn: ({ id, action }: { id: string; action: 'start' | 'stop' | 'restart' }) =>
|
||||
databaseAction(id, action),
|
||||
@@ -1880,9 +2056,16 @@ export function DatabasesPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
{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>
|
||||
</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>
|
||||
<ChevronDown size={15} className={`shrink-0 text-[var(--text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
@@ -1910,6 +2093,25 @@ export function DatabasesPage() {
|
||||
{signedIn ? (
|
||||
<>
|
||||
<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
|
||||
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
|
||||
disabled={running || actionMutation.isPending}
|
||||
@@ -1931,6 +2133,8 @@ export function DatabasesPage() {
|
||||
>
|
||||
<RefreshCw size={11} /> Restart
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setBindDb(bindDb?.id === db.id ? null : db)}
|
||||
disabled={!db.connection_url}
|
||||
@@ -1973,6 +2177,7 @@ export function DatabasesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{db.provider !== 'external' && (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p>
|
||||
@@ -2029,6 +2234,7 @@ export function DatabasesPage() {
|
||||
</p>
|
||||
)}
|
||||
</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="border-b border-[var(--border-subtle)] px-5 py-4">
|
||||
<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 className="space-y-4 px-5 py-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Name</label>
|
||||
<input
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))}
|
||||
value={createMode === 'managed' ? createForm.name : externalForm.name}
|
||||
onChange={(e) => (createMode === 'managed' ? setCreateForm((f) => ({ ...f, name: e.target.value })) : setExternalForm((f) => ({ ...f, name: e.target.value })))}
|
||||
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)]"
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
{createError ? (
|
||||
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-3.5 py-2.5 text-xs text-[var(--error)]">{createError}</div>
|
||||
) : null}
|
||||
@@ -2104,12 +2418,15 @@ export function DatabasesPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || createForm.name.trim().length === 0}
|
||||
onClick={() => (createMode === 'managed' ? createMutation.mutate() : registerExternalMutation.mutate())}
|
||||
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"
|
||||
>
|
||||
{createMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||
Create database
|
||||
{(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||
{createMode === 'managed' ? 'Create database' : 'Register database'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,9 @@ import {
|
||||
removeServiceDomain,
|
||||
setDefaultServiceDomain,
|
||||
checkServiceDomains,
|
||||
cloneService,
|
||||
moveService,
|
||||
listProjects,
|
||||
} from '@/lib/api-client';
|
||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
||||
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 = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
||||
@@ -448,6 +481,9 @@ export function ServiceDetailPage() {
|
||||
const [domainInput, setDomainInput] = useState('');
|
||||
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
|
||||
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
|
||||
const [buildForm, setBuildForm] = useState<{
|
||||
builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string;
|
||||
} | null>(null);
|
||||
|
||||
const domainsQuery = useQuery({
|
||||
queryKey: ['service-domains', serviceId],
|
||||
@@ -1219,8 +1255,9 @@ export function ServiceDetailPage() {
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
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)]">{'${{postgres.POSTGRES_PASSWORD}}'}</code>
|
||||
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> or a project
|
||||
shared variable with{' '}
|
||||
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2390,6 +2427,144 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-[var(--text-primary)]">Build & Resources</h3>
|
||||
{buildForm === null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setBuildForm({
|
||||
builder: service.builder ?? 'auto',
|
||||
cpuReserve: service.cpuReserve ?? '',
|
||||
memoryReserve: service.memoryReserve ?? '',
|
||||
staticCmd: service.staticBuildCmd ?? '',
|
||||
staticDir: service.staticDir ?? '',
|
||||
})
|
||||
}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{buildForm === null ? (
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Builder</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.builder ?? 'auto'}</p>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Reservations</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
||||
{[service.cpuReserve && `cpu ${service.cpuReserve}`, service.memoryReserve && `mem ${service.memoryReserve}`]
|
||||
.filter(Boolean).join(' · ') || 'none'}
|
||||
</p>
|
||||
</div>
|
||||
{service.builder === 'static' && (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3 col-span-2">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Static build</p>
|
||||
<p className="mt-1 text-sm font-medium mono text-[var(--text-primary)]">
|
||||
{service.staticBuildCmd || 'npm ci && npm run build'} → {service.staticDir || 'dist'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Builder</label>
|
||||
<select
|
||||
value={buildForm.builder}
|
||||
onChange={(e) => setBuildForm({ ...buildForm, builder: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
{['auto', 'railpack', 'nixpacks', 'dockerfile', 'static'].map((b) => (
|
||||
<option key={b} value={b}>{b}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">CPU reservation (e.g. 0.25)</label>
|
||||
<input
|
||||
value={buildForm.cpuReserve}
|
||||
onChange={(e) => setBuildForm({ ...buildForm, cpuReserve: e.target.value })}
|
||||
placeholder="0.25"
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Memory reservation (e.g. 128Mi)</label>
|
||||
<input
|
||||
value={buildForm.memoryReserve}
|
||||
onChange={(e) => setBuildForm({ ...buildForm, memoryReserve: e.target.value })}
|
||||
placeholder="128Mi"
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{buildForm.builder === 'static' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Build command</label>
|
||||
<input
|
||||
value={buildForm.staticCmd}
|
||||
onChange={(e) => setBuildForm({ ...buildForm, staticCmd: e.target.value })}
|
||||
placeholder="npm ci && npm run build"
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Output directory</label>
|
||||
<input
|
||||
value={buildForm.staticDir}
|
||||
onChange={(e) => setBuildForm({ ...buildForm, staticDir: e.target.value })}
|
||||
placeholder="dist"
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateServiceMutation.isPending}
|
||||
onClick={() => {
|
||||
updateServiceMutation.mutate(
|
||||
{
|
||||
builder: buildForm.builder as 'auto' | 'railpack' | 'nixpacks' | 'dockerfile' | 'static',
|
||||
cpu_reserve: buildForm.cpuReserve,
|
||||
memory_reserve: buildForm.memoryReserve,
|
||||
static_build_cmd: buildForm.staticCmd,
|
||||
static_dir: buildForm.staticDir,
|
||||
},
|
||||
{ onSuccess: () => setBuildForm(null) },
|
||||
);
|
||||
}}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBuildForm(null)}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<p className="text-[10px] text-[var(--text-tertiary)]">Builder applies to the next source build; reservations on next deploy</p>
|
||||
</div>
|
||||
{updateServiceMutation.isError && (
|
||||
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -2491,6 +2666,135 @@ export function ServiceDetailPage() {
|
||||
</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>
|
||||
|
||||
@@ -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 { canvasStorageKey, clearCanvasMetadata } from '../storage';
|
||||
import { MetricsDashboard } from '../components/MetricsDashboard';
|
||||
import { SharedVariablesSection } from '../components/SharedVariablesSection';
|
||||
import { CommandPalette, StatusBadge, LiveIndicator, useToast } from '@/shared/components';
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -935,6 +936,8 @@ export function ProjectWorkspacePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isDemoMode && signedIn && <SharedVariablesSection projectId={project.id} />}
|
||||
|
||||
<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>
|
||||
<button
|
||||
|
||||
@@ -971,6 +971,276 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2764,6 +3034,154 @@ export interface paths {
|
||||
patch?: 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}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3570,6 +3988,98 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -8050,6 +8560,19 @@ export interface components {
|
||||
maintenance_mode?: boolean;
|
||||
/** @description Basic-auth usernames gating the route (hashes never returned) */
|
||||
basic_auth?: string[];
|
||||
/**
|
||||
* @description Build strategy; auto detects from repo contents
|
||||
* @enum {string}
|
||||
*/
|
||||
builder?: "auto" | "railpack" | "nixpacks" | "dockerfile" | "static";
|
||||
/** @description Soft CPU reservation (fraction, e.g. 0.25 → ~256 CPU shares) */
|
||||
cpu_reserve?: string;
|
||||
/** @description Soft memory reservation (e.g. 128Mi) */
|
||||
memory_reserve?: string;
|
||||
/** @description Build command for the static builder (default "npm ci && npm run build") */
|
||||
static_build_cmd?: string;
|
||||
/** @description Output directory for the static builder (default dist) */
|
||||
static_dir?: string;
|
||||
/** @description Computed public URL (published port or domain) */
|
||||
public_url?: string;
|
||||
/**
|
||||
@@ -8107,6 +8630,19 @@ export interface components {
|
||||
restart_policy?: string;
|
||||
/** @description Volume/bind mounts applied to every replica */
|
||||
volumes?: components["schemas"]["ServiceVolume"][];
|
||||
/**
|
||||
* @description Build strategy (default auto)
|
||||
* @enum {string}
|
||||
*/
|
||||
builder?: "auto" | "railpack" | "nixpacks" | "dockerfile" | "static";
|
||||
/** @description Soft CPU reservation (fraction, e.g. 0.25) */
|
||||
cpu_reserve?: string;
|
||||
/** @description Soft memory reservation (e.g. 128Mi) */
|
||||
memory_reserve?: string;
|
||||
/** @description Build command for the static builder */
|
||||
static_build_cmd?: string;
|
||||
/** @description Output directory for the static builder (default dist) */
|
||||
static_dir?: string;
|
||||
};
|
||||
UpdateServiceRequest: {
|
||||
/** @description Service name */
|
||||
@@ -8151,6 +8687,19 @@ export interface components {
|
||||
maintenance_mode?: boolean;
|
||||
/** @description Replaces all basic-auth credentials; [] clears the gate */
|
||||
basic_auth?: components["schemas"]["BasicAuthCred"][];
|
||||
/**
|
||||
* @description Build strategy
|
||||
* @enum {string}
|
||||
*/
|
||||
builder?: "auto" | "railpack" | "nixpacks" | "dockerfile" | "static";
|
||||
/** @description Soft CPU reservation (empty string clears) */
|
||||
cpu_reserve?: string;
|
||||
/** @description Soft memory reservation (empty string clears) */
|
||||
memory_reserve?: string;
|
||||
/** @description Build command for the static builder (empty string clears) */
|
||||
static_build_cmd?: string;
|
||||
/** @description Output directory for the static builder (empty string clears) */
|
||||
static_dir?: string;
|
||||
};
|
||||
ServiceDomain: {
|
||||
/** Format: uuid */
|
||||
@@ -8182,6 +8731,26 @@ export interface components {
|
||||
/** @description Plaintext on write — stored as bcrypt htpasswd; never returned */
|
||||
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: {
|
||||
/**
|
||||
* @description volume = docker named volume, bind = host path
|
||||
@@ -8611,6 +9180,12 @@ export interface components {
|
||||
region?: string;
|
||||
/** @description Cron expression for automatic backups, empty when unset */
|
||||
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
|
||||
* @description Next scheduled backup run
|
||||
@@ -8646,6 +9221,28 @@ export interface components {
|
||||
/** @description Database region */
|
||||
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: {
|
||||
/** @description Updated database name */
|
||||
name?: string;
|
||||
|
||||
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
||||
"path": "guides/CLI.md",
|
||||
"section": "guides",
|
||||
"title": "Containr CLI",
|
||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add <id> app.example.com --default\ncontainr services domains check <id> # DNS preflight per domain\ncontainr services domains remove <id> <domain-id>\n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update <id> --maintenance on\ncontainr services update <id> --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdiLine truncated
|
||||
"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",
|
||||
|
||||
@@ -58,6 +58,11 @@ export type ServiceEntity = {
|
||||
domains?: ServiceDomain[];
|
||||
maintenanceMode?: boolean;
|
||||
basicAuth?: string[];
|
||||
builder?: 'auto' | 'railpack' | 'nixpacks' | 'dockerfile' | 'static';
|
||||
cpuReserve?: string;
|
||||
memoryReserve?: string;
|
||||
staticBuildCmd?: string;
|
||||
staticDir?: string;
|
||||
publicUrl?: string;
|
||||
};
|
||||
|
||||
@@ -584,6 +589,11 @@ function normalizeService(service: RawService): ServiceEntity | null {
|
||||
domains: service.domains as ServiceDomain[] | undefined,
|
||||
maintenanceMode: service.maintenance_mode,
|
||||
basicAuth: service.basic_auth as string[] | undefined,
|
||||
builder: service.builder as ServiceEntity['builder'],
|
||||
cpuReserve: service.cpu_reserve,
|
||||
memoryReserve: service.memory_reserve,
|
||||
staticBuildCmd: service.static_build_cmd,
|
||||
staticDir: service.static_dir,
|
||||
publicUrl: service.public_url,
|
||||
};
|
||||
}
|
||||
@@ -1175,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 {
|
||||
const state = runtime ?? { desired: 0, status: 'stopped' as const, urls: [], ports: [], containers: [] };
|
||||
// The API emits null (not []) for empty collections — coerce at the boundary.
|
||||
@@ -1238,6 +1310,22 @@ export async function updateServiceVariables(
|
||||
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[]> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (input.resource) {
|
||||
@@ -1661,6 +1749,39 @@ export async function createManagedDatabase(
|
||||
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 CreateGitWebhookInput = components['schemas']['CreateWebhookRequest'];
|
||||
export type GitWebhookEntity = components['schemas']['GitWebhook'];
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
@@ -0,0 +1,40 @@
|
||||
This is an Expo/React Native mobile application — the Containr companion client. Prioritize mobile-first patterns, performance, and cross-platform compatibility.
|
||||
|
||||
## Expo has changed — do not trust your training data
|
||||
|
||||
Expo ships breaking changes every SDK release. APIs you remember are likely renamed, moved, or removed. Before writing any code that touches an Expo, EAS, or React Native API:
|
||||
|
||||
1. Read the major version of the `expo` package in `package.json`.
|
||||
2. Fetch the matching versioned docs: `https://docs.expo.dev/versions/v<major>.0.0/`
|
||||
3. For anything else, fetch https://docs.expo.dev/llms.txt — an index of all Expo docs with corrections to common LLM misconceptions. Follow its links to the specific page you need; never answer from memory.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npx expo install <package> # ALWAYS use instead of npm/yarn/pnpm/bun add — resolves SDK-compatible versions
|
||||
npx expo start # start the dev server
|
||||
npx expo lint # lint
|
||||
npx tsc --noEmit # typecheck
|
||||
npx expo-doctor # diagnose dependency and config issues
|
||||
npx expo install --fix # fix incompatible package versions
|
||||
```
|
||||
|
||||
Run lint and typecheck before declaring any task done.
|
||||
|
||||
## Navigation & Routing
|
||||
|
||||
- Use **Expo Router** for all navigation. Routes live in `src/app/` — every file there is a screen, `_layout.tsx` files define navigators. Keep non-route code (components, hooks, utils) outside `src/app/`.
|
||||
- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`.
|
||||
- Docs: https://docs.expo.dev/router/introduction.md
|
||||
|
||||
## Building with EAS
|
||||
|
||||
Use EAS to build, sign, and submit the app in the cloud (`eas build`, `eas submit`) and to ship over-the-air updates (`eas update`) — no local Xcode or Android Studio required. Run EAS CLI as `npx eas-cli@latest <command>`.
|
||||
Docs: https://docs.expo.dev/eas/index.md
|
||||
|
||||
## Rules
|
||||
|
||||
- If `ios/` and `android/` directories do not exist, they are generated (Continuous Native Generation). Never create or edit them by hand — configure native behavior in `app.json` and config plugins. They are gitignored.
|
||||
- Expo Go only includes its bundled native modules. After adding a library with native code, the app needs a development build: `npx expo run:ios|android` locally, or `eas build --profile development`.
|
||||
- Prefer recommended Expo modules over third-party libraries. Docs: https://docs.expo.dev/versions/latest/index.md
|
||||
- The app speaks only to the Containr REST API (`/api/v1`) with a `cnp_` personal access token — same contract as the CLI and MCP server. Add endpoints there, never to the mobile client alone.
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Containr",
|
||||
"slug": "containr-mobile",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"scheme": "containr",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "ai.containr.mobile"
|
||||
},
|
||||
"android": {
|
||||
"package": "ai.containr.mobile",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#0B0F14",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-secure-store",
|
||||
"expo-font"
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,10 @@
|
||||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require("eslint-config-expo/flat");
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ["dist/*"],
|
||||
}
|
||||
]);
|
||||
Generated
+11420
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "containr-mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"expo": "~57.0.27",
|
||||
"expo-constants": "~57.0.21",
|
||||
"expo-font": "~57.0.4",
|
||||
"expo-linking": "~57.0.12",
|
||||
"expo-router": "~57.0.25",
|
||||
"expo-secure-store": "~57.0.4",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.3",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "~4.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-expo": "~57.0.2",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
image?: string;
|
||||
environment?: string;
|
||||
domain?: string;
|
||||
public_url?: string;
|
||||
port?: number;
|
||||
replicas?: number;
|
||||
cpu?: string;
|
||||
memory?: string;
|
||||
sleep_enabled?: boolean;
|
||||
sleep_idle_minutes?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Deployment {
|
||||
id: string;
|
||||
service_id: string;
|
||||
service_name?: string;
|
||||
project_name?: string;
|
||||
status: string;
|
||||
image_name?: string;
|
||||
created_at?: string;
|
||||
completed_at?: string | null;
|
||||
}
|
||||
|
||||
export interface Database {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
version?: string;
|
||||
plan?: string;
|
||||
connection_url?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface EnvCheck {
|
||||
ok: boolean;
|
||||
unresolved: string[];
|
||||
empty: string[];
|
||||
unreadable: string[];
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
stream: string;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class Api {
|
||||
constructor(
|
||||
private baseUrl: string,
|
||||
private token: string,
|
||||
) {}
|
||||
|
||||
private async req<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`;
|
||||
try {
|
||||
const j = (await res.json()) as { error?: string };
|
||||
if (j.error) msg = j.error;
|
||||
} catch {
|
||||
// non-JSON error body
|
||||
}
|
||||
throw new ApiError(res.status, msg);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
ping() {
|
||||
return fetch(`${this.baseUrl}/health`).then((r) => r.ok);
|
||||
}
|
||||
|
||||
projects() {
|
||||
return this.req<{ projects: Project[] }>('GET', '/projects?limit=100').then(
|
||||
(r) => r.projects ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
projectServices(projectId: string) {
|
||||
return this.req<{ services: Service[] }>(
|
||||
'GET',
|
||||
`/projects/${projectId}/services?limit=200`,
|
||||
).then((r) => r.services ?? []);
|
||||
}
|
||||
|
||||
service(id: string) {
|
||||
return this.req<{ service: Service }>('GET', `/services/${id}`).then((r) => r.service);
|
||||
}
|
||||
|
||||
serviceAction(id: string, action: 'start' | 'stop' | 'restart' | 'redeploy' | 'sleep' | 'wake') {
|
||||
return this.req<{ status?: string; message?: string }>(
|
||||
'POST',
|
||||
`/services/${id}/${action}`,
|
||||
);
|
||||
}
|
||||
|
||||
serviceLogs(id: string, tail = 300) {
|
||||
return this.req<{ logs: LogEntry[]; message?: string }>(
|
||||
'GET',
|
||||
`/services/${id}/logs?tail=${tail}`,
|
||||
).then((r) => r.logs ?? []);
|
||||
}
|
||||
|
||||
envCheck(id: string) {
|
||||
return this.req<EnvCheck>('GET', `/services/${id}/env-check`);
|
||||
}
|
||||
|
||||
serviceDeployments(id: string) {
|
||||
return this.req<{ deployments: Deployment[] }>(
|
||||
'GET',
|
||||
`/services/${id}/deployments?limit=20`,
|
||||
).then((r) => r.deployments ?? []);
|
||||
}
|
||||
|
||||
recentDeployments() {
|
||||
return this.req<{ deployments: Deployment[] }>(
|
||||
'GET',
|
||||
'/deployments?limit=20',
|
||||
).then((r) => r.deployments ?? []);
|
||||
}
|
||||
|
||||
deploymentAction(id: string, action: 'rollback' | 'cancel') {
|
||||
return this.req<unknown>('POST', `/deployments/${id}/${action}`);
|
||||
}
|
||||
|
||||
databases() {
|
||||
return this.req<{ databases: Database[] }>('GET', '/databases').then(
|
||||
(r) => r.databases ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
databaseBackup(id: string) {
|
||||
return this.req<unknown>('POST', `/databases/${id}/backup`);
|
||||
}
|
||||
|
||||
databaseAction(id: string, action: 'start' | 'stop' | 'restart') {
|
||||
return this.req<unknown>('POST', `/databases/${id}/action`, { action });
|
||||
}
|
||||
|
||||
templates() {
|
||||
return this.req<{ templates: Template[] }>('GET', '/templates?limit=100').then(
|
||||
(r) => r.templates ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { colors } from '../../theme';
|
||||
|
||||
export default function TabsLayout() {
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bg },
|
||||
headerTintColor: colors.text,
|
||||
tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border },
|
||||
tabBarActiveTintColor: colors.accent,
|
||||
tabBarInactiveTintColor: colors.textDim,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Overview',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="grid-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="services"
|
||||
options={{
|
||||
title: 'Services',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="cube-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="databases"
|
||||
options={{
|
||||
title: 'Databases',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="server-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="settings-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { Alert, FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useApi } from '../../session';
|
||||
import { colors } from '../../theme';
|
||||
import { relTime } from '../../time';
|
||||
import { ActionButton, Card, Empty, ErrorBox, Loading, StatusBadge } from '../../components';
|
||||
|
||||
export default function Databases() {
|
||||
const api = useApi();
|
||||
const qc = useQueryClient();
|
||||
const dbs = useQuery({ queryKey: ['databases'], queryFn: () => api.databases() });
|
||||
|
||||
const action = useMutation({
|
||||
mutationFn: ({ id, act }: { id: string; act: 'start' | 'stop' | 'restart' }) =>
|
||||
api.databaseAction(id, act),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['databases'] }),
|
||||
onError: (e) => Alert.alert('Action failed', e instanceof Error ? e.message : String(e)),
|
||||
});
|
||||
const backup = useMutation({
|
||||
mutationFn: (id: string) => api.databaseBackup(id),
|
||||
onSuccess: () => Alert.alert('Backup started', 'Snapshot queued for this database.'),
|
||||
onError: (e) => Alert.alert('Backup failed', e instanceof Error ? e.message : String(e)),
|
||||
});
|
||||
|
||||
if (dbs.isLoading) return <Loading />;
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
style={styles.root}
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
data={dbs.data ?? []}
|
||||
keyExtractor={(d) => d.id}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={dbs.isRefetching}
|
||||
onRefresh={() => dbs.refetch()}
|
||||
tintColor={colors.accent}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
dbs.error ? <ErrorBox error={dbs.error} /> : <Empty text="No databases" />
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<Card>
|
||||
<View style={styles.head}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.name}>{item.name}</Text>
|
||||
<Text style={styles.meta}>
|
||||
{item.type}
|
||||
{item.version ? ` ${item.version}` : ''} · created {relTime(item.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
<StatusBadge status={item.status} />
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
{item.status === 'running' ? (
|
||||
<ActionButton
|
||||
label="Backup now"
|
||||
kind="primary"
|
||||
disabled={backup.isPending}
|
||||
onPress={() => backup.mutate(item.id)}
|
||||
/>
|
||||
) : (
|
||||
<ActionButton
|
||||
label="Start"
|
||||
kind="primary"
|
||||
disabled={action.isPending}
|
||||
onPress={() => action.mutate({ id: item.id, act: 'start' })}
|
||||
/>
|
||||
)}
|
||||
{item.status === 'running' ? (
|
||||
<>
|
||||
<ActionButton
|
||||
label="Restart"
|
||||
disabled={action.isPending}
|
||||
onPress={() => action.mutate({ id: item.id, act: 'restart' })}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Stop"
|
||||
kind="danger"
|
||||
disabled={action.isPending}
|
||||
onPress={() => action.mutate({ id: item.id, act: 'stop' })}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: colors.bg },
|
||||
head: { flexDirection: 'row', alignItems: 'center', marginBottom: 10 },
|
||||
name: { color: colors.text, fontSize: 16, fontWeight: '600' },
|
||||
meta: { color: colors.textDim, fontSize: 12, marginTop: 2 },
|
||||
actions: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from '../../session';
|
||||
import { colors } from '../../theme';
|
||||
import { relTime } from '../../time';
|
||||
import { Card, Empty, ErrorBox, Row, SectionTitle, StatusBadge, StatusDot } from '../../components';
|
||||
|
||||
export default function Overview() {
|
||||
const api = useApi();
|
||||
const router = useRouter();
|
||||
|
||||
const projects = useQuery({ queryKey: ['projects'], queryFn: () => api.projects() });
|
||||
const deployments = useQuery({
|
||||
queryKey: ['deployments'],
|
||||
queryFn: () => api.recentDeployments(),
|
||||
});
|
||||
const servicesQueries = useQueries({
|
||||
queries: (projects.data ?? []).map((p) => ({
|
||||
queryKey: ['services', p.id],
|
||||
queryFn: () => api.projectServices(p.id),
|
||||
})),
|
||||
});
|
||||
|
||||
const allServices = servicesQueries.flatMap((q) => q.data ?? []);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const s of allServices) counts[s.status] = (counts[s.status] ?? 0) + 1;
|
||||
|
||||
const refetch = () => {
|
||||
projects.refetch();
|
||||
deployments.refetch();
|
||||
servicesQueries.forEach((q) => q.refetch());
|
||||
};
|
||||
const refreshing = projects.isRefetching || deployments.isRefetching;
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
style={styles.root}
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={refetch} tintColor={colors.accent} />
|
||||
}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{projects.error ? <ErrorBox error={projects.error} /> : null}
|
||||
<View style={styles.statRow}>
|
||||
<Stat label="Running" value={counts.running ?? 0} color={colors.ok} />
|
||||
<Stat label="Sleeping" value={counts.sleeping ?? 0} color={colors.info} />
|
||||
<Stat label="Failed" value={counts.failed ?? 0} color={colors.err} />
|
||||
<Stat label="Stopped" value={counts.stopped ?? 0} color={colors.textDim} />
|
||||
</View>
|
||||
|
||||
<SectionTitle>Projects</SectionTitle>
|
||||
{projects.data?.length === 0 ? <Empty text="No projects yet" /> : null}
|
||||
</>
|
||||
}
|
||||
data={projects.data ?? []}
|
||||
keyExtractor={(p) => p.id}
|
||||
renderItem={({ item }) => {
|
||||
const idx = (projects.data ?? []).findIndex((p) => p.id === item.id);
|
||||
const svcs = servicesQueries[idx]?.data ?? [];
|
||||
return (
|
||||
<Card>
|
||||
<Row
|
||||
title={item.name}
|
||||
subtitle={`${svcs.length} service${svcs.length === 1 ? '' : 's'}`}
|
||||
onPress={() => router.push(`/project/${item.id}?name=${encodeURIComponent(item.name)}`)}
|
||||
right={
|
||||
<View style={styles.dots}>
|
||||
{svcs.slice(0, 6).map((s) => (
|
||||
<StatusDot key={s.id} status={s.status} />
|
||||
))}
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
ListFooterComponent={
|
||||
<>
|
||||
<SectionTitle>Recent deployments</SectionTitle>
|
||||
{deployments.data?.length === 0 ? <Empty text="No deployments" /> : null}
|
||||
{(deployments.data ?? []).slice(0, 10).map((d) => (
|
||||
<Row
|
||||
key={d.id}
|
||||
title={`${d.service_name ?? d.service_id}`}
|
||||
subtitle={`${d.project_name ?? ''} · ${relTime(d.created_at)}`}
|
||||
right={<StatusBadge status={d.status} />}
|
||||
onPress={() => router.push(`/service/${d.service_id}`)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<View style={[styles.stat, { borderColor: color }]}>
|
||||
<Text style={[styles.statValue, { color }]}>{value}</Text>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: colors.bg },
|
||||
statRow: { flexDirection: 'row', gap: 8, marginBottom: 4 },
|
||||
stat: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
statValue: { fontSize: 20, fontWeight: '800' },
|
||||
statLabel: { color: colors.textDim, fontSize: 11, marginTop: 2 },
|
||||
dots: { flexDirection: 'row', gap: 4 },
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { FlatList, RefreshControl, StyleSheet } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from '../../session';
|
||||
import { colors } from '../../theme';
|
||||
import { Empty, ErrorBox, Loading, Row, StatusBadge } from '../../components';
|
||||
|
||||
export default function Services() {
|
||||
const api = useApi();
|
||||
const router = useRouter();
|
||||
const projects = useQuery({ queryKey: ['projects'], queryFn: () => api.projects() });
|
||||
const servicesQueries = useQueries({
|
||||
queries: (projects.data ?? []).map((p) => ({
|
||||
queryKey: ['services', p.id],
|
||||
queryFn: () => api.projectServices(p.id),
|
||||
})),
|
||||
});
|
||||
|
||||
const rows = servicesQueries.flatMap((q, i) =>
|
||||
(q.data ?? []).map((s) => ({ ...s, _project: projects.data?.[i]?.name ?? '' })),
|
||||
);
|
||||
|
||||
if (projects.isLoading) return <Loading />;
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
style={styles.root}
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
data={rows}
|
||||
keyExtractor={(s) => s.id}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={servicesQueries.some((q) => q.isRefetching)}
|
||||
onRefresh={() => servicesQueries.forEach((q) => q.refetch())}
|
||||
tintColor={colors.accent}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
projects.error ? <ErrorBox error={projects.error} /> : <Empty text="No services" />
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<Row
|
||||
title={item.name}
|
||||
subtitle={`${item._project} · ${item.type}${item.domain ? ` · ${item.domain}` : ''}`}
|
||||
right={<StatusBadge status={item.status} />}
|
||||
onPress={() => router.push(`/service/${item.id}`)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: colors.bg } });
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Alert, ScrollView, StyleSheet, Text } from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useSession } from '../../session';
|
||||
import { colors } from '../../theme';
|
||||
import { ActionButton, Card, SectionTitle } from '../../components';
|
||||
|
||||
export default function Settings() {
|
||||
const { serverUrl, signOut } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.root} contentContainerStyle={{ padding: 16 }}>
|
||||
<SectionTitle>Connection</SectionTitle>
|
||||
<Card>
|
||||
<Text style={styles.label}>Server</Text>
|
||||
<Text style={styles.value}>{serverUrl}</Text>
|
||||
</Card>
|
||||
|
||||
<SectionTitle>App</SectionTitle>
|
||||
<Card>
|
||||
<Text style={styles.label}>Version</Text>
|
||||
<Text style={styles.value}>
|
||||
{Constants.expoConfig?.version ?? 'dev'} · Expo SDK{' '}
|
||||
{Constants.expoConfig?.sdkVersion ?? '?'}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<ActionButton
|
||||
label="Disconnect"
|
||||
kind="danger"
|
||||
onPress={() =>
|
||||
Alert.alert('Disconnect', 'Remove the stored server and token?', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Disconnect',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await signOut();
|
||||
router.replace('/login');
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: colors.bg },
|
||||
label: { color: colors.textDim, fontSize: 12, marginBottom: 4 },
|
||||
value: { color: colors.text, fontSize: 15 },
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Stack, useRouter, useSegments } from 'expo-router';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { SessionProvider, useSession } from '../session';
|
||||
import { colors } from '../theme';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 15_000, retry: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
function AuthGate() {
|
||||
const { ready, api } = useSession();
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const inLogin = segments[0] === 'login';
|
||||
if (!api && !inLogin) router.replace('/login');
|
||||
else if (api && inLogin) router.replace('/');
|
||||
}, [ready, api, segments, router]);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bg },
|
||||
headerTintColor: colors.text,
|
||||
contentStyle: { backgroundColor: colors.bg },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="project/[id]" options={{ title: 'Project' }} />
|
||||
<Stack.Screen name="service/[id]" options={{ title: 'Service' }} />
|
||||
<Stack.Screen name="service/logs" options={{ title: 'Logs' }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SessionProvider>
|
||||
<StatusBar style="light" />
|
||||
<AuthGate />
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
export default function Index() {
|
||||
return <Redirect href="/(tabs)" />;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import { useSession, normalizeServerUrl } from '../session';
|
||||
import { colors } from '../theme';
|
||||
|
||||
export default function Login() {
|
||||
const { signIn } = useSession();
|
||||
const [url, setUrl] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const connect = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const normalized = normalizeServerUrl(url);
|
||||
const res = await fetch(`${normalized}/api/v1/projects?limit=1`, {
|
||||
headers: { Authorization: `Bearer ${token.trim()}` },
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new Error('Token rejected. Use a personal access token (cnp_…).');
|
||||
}
|
||||
if (!res.ok) throw new Error(`Server responded ${res.status}`);
|
||||
await signIn(normalized, token);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Connection failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.root}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<Text style={styles.logo}>Containr</Text>
|
||||
<Text style={styles.sub}>Self-hosted platform console</Text>
|
||||
|
||||
<Text style={styles.label}>Server URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="https://containr.example.com"
|
||||
placeholderTextColor={colors.textDim}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Personal access token</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="cnp_…"
|
||||
placeholderTextColor={colors.textDim}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
value={token}
|
||||
onChangeText={setToken}
|
||||
/>
|
||||
|
||||
{error ? <Text style={styles.err}>{error}</Text> : null}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, (!url.trim() || !token.trim() || busy) && { opacity: 0.5 }]}
|
||||
onPress={connect}
|
||||
disabled={!url.trim() || !token.trim() || busy}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator color={colors.text} />
|
||||
) : (
|
||||
<Text style={styles.btnText}>Connect</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={styles.hint}>
|
||||
Create a token in the web UI under Settings → Access Tokens.
|
||||
</Text>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: colors.bg, padding: 24, justifyContent: 'center' },
|
||||
logo: { color: colors.text, fontSize: 34, fontWeight: '800', letterSpacing: -0.5 },
|
||||
sub: { color: colors.textDim, fontSize: 14, marginBottom: 32, marginTop: 4 },
|
||||
label: {
|
||||
color: colors.textDim,
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
marginBottom: 6,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 10,
|
||||
color: colors.text,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 15,
|
||||
marginBottom: 16,
|
||||
},
|
||||
err: { color: colors.err, marginBottom: 12 },
|
||||
btn: {
|
||||
backgroundColor: colors.accent,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||
hint: { color: colors.textDim, fontSize: 12, marginTop: 20, textAlign: 'center' },
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { FlatList, RefreshControl, StyleSheet } from 'react-native';
|
||||
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from '../../session';
|
||||
import { colors } from '../../theme';
|
||||
import { Empty, ErrorBox, Loading, Row, StatusBadge } from '../../components';
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { id, name } = useLocalSearchParams<{ id: string; name?: string }>();
|
||||
const api = useApi();
|
||||
const router = useRouter();
|
||||
const nav = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
if (name) nav.setOptions({ title: name });
|
||||
}, [name, nav]);
|
||||
|
||||
const services = useQuery({
|
||||
queryKey: ['services', id],
|
||||
queryFn: () => api.projectServices(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
if (services.isLoading) return <Loading />;
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
style={styles.root}
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
data={services.data ?? []}
|
||||
keyExtractor={(s) => s.id}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={services.isRefetching}
|
||||
onRefresh={() => services.refetch()}
|
||||
tintColor={colors.accent}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
services.error ? <ErrorBox error={services.error} /> : <Empty text="No services" />
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<Row
|
||||
title={item.name}
|
||||
subtitle={`${item.type} · ${item.environment ?? 'production'}${
|
||||
item.domain ? ` · ${item.domain}` : ''
|
||||
}`}
|
||||
right={<StatusBadge status={item.status} />}
|
||||
onPress={() => router.push(`/service/${item.id}`)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: colors.bg } });
|
||||
@@ -0,0 +1,191 @@
|
||||
import React from 'react';
|
||||
import { Alert, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useApi } from '../../session';
|
||||
import { colors } from '../../theme';
|
||||
import { relTime } from '../../time';
|
||||
import {
|
||||
ActionButton,
|
||||
Card,
|
||||
Empty,
|
||||
ErrorBox,
|
||||
Loading,
|
||||
SectionTitle,
|
||||
StatusBadge,
|
||||
} from '../../components';
|
||||
|
||||
export default function ServiceDetail() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const api = useApi();
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const service = useQuery({
|
||||
queryKey: ['service', id],
|
||||
queryFn: () => api.service(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
const envCheck = useQuery({
|
||||
queryKey: ['env-check', id],
|
||||
queryFn: () => api.envCheck(id!),
|
||||
enabled: !!id,
|
||||
retry: false,
|
||||
});
|
||||
const deployments = useQuery({
|
||||
queryKey: ['service-deployments', id],
|
||||
queryFn: () => api.serviceDeployments(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: ['service', id] });
|
||||
qc.invalidateQueries({ queryKey: ['services'] });
|
||||
qc.invalidateQueries({ queryKey: ['service-deployments', id] });
|
||||
};
|
||||
const onErr = (e: unknown) =>
|
||||
Alert.alert('Action failed', e instanceof Error ? e.message : String(e));
|
||||
|
||||
const action = useMutation({
|
||||
mutationFn: (act: 'start' | 'stop' | 'restart' | 'redeploy' | 'sleep' | 'wake') =>
|
||||
api.serviceAction(id!, act),
|
||||
onSuccess: invalidate,
|
||||
onError: onErr,
|
||||
});
|
||||
const rollback = useMutation({
|
||||
mutationFn: (depId: string) => api.deploymentAction(depId, 'rollback'),
|
||||
onSuccess: invalidate,
|
||||
onError: onErr,
|
||||
});
|
||||
|
||||
const s = service.data;
|
||||
if (service.isLoading) return <Loading />;
|
||||
if (!s) return <ErrorBox error={service.error ?? new Error('Not found')} />;
|
||||
|
||||
const busy = action.isPending;
|
||||
const canStart = s.status === 'stopped' || s.status === 'failed';
|
||||
const canStop = s.status === 'running';
|
||||
const canWake = s.status === 'sleeping';
|
||||
const lastDeploy = deployments.data?.[0];
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.root}
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={service.isRefetching}
|
||||
onRefresh={() => service.refetch()}
|
||||
tintColor={colors.accent}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View style={styles.head}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.name}>{s.name}</Text>
|
||||
<Text style={styles.meta}>
|
||||
{s.type} · {s.environment ?? 'production'}
|
||||
{s.image ? `\n${s.image}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
<StatusBadge status={s.status} />
|
||||
</View>
|
||||
|
||||
{envCheck.data && !envCheck.data.ok ? (
|
||||
<Card style={{ borderColor: colors.warn }}>
|
||||
<Text style={styles.warnTitle}>Environment issues</Text>
|
||||
{envCheck.data.unresolved.map((v) => (
|
||||
<Text key={v} style={styles.warnItem}>
|
||||
Unresolved reference: {v}
|
||||
</Text>
|
||||
))}
|
||||
{envCheck.data.empty.map((v) => (
|
||||
<Text key={v} style={styles.warnItem}>
|
||||
Empty value: {v}
|
||||
</Text>
|
||||
))}
|
||||
{envCheck.data.unreadable.map((v) => (
|
||||
<Text key={v} style={styles.warnItem}>
|
||||
Unreadable secret (rotate): {v}
|
||||
</Text>
|
||||
))}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<SectionTitle>Actions</SectionTitle>
|
||||
<View style={styles.actions}>
|
||||
{canStart ? (
|
||||
<ActionButton label="Start" kind="primary" disabled={busy} onPress={() => action.mutate('start')} />
|
||||
) : null}
|
||||
{canStop ? (
|
||||
<ActionButton label="Stop" kind="danger" disabled={busy} onPress={() => action.mutate('stop')} />
|
||||
) : null}
|
||||
{canWake ? (
|
||||
<ActionButton label="Wake" kind="primary" disabled={busy} onPress={() => action.mutate('wake')} />
|
||||
) : null}
|
||||
{s.status === 'running' ? (
|
||||
<>
|
||||
<ActionButton label="Restart" disabled={busy} onPress={() => action.mutate('restart')} />
|
||||
<ActionButton label="Redeploy" disabled={busy} onPress={() => action.mutate('redeploy')} />
|
||||
{s.sleep_enabled ? (
|
||||
<ActionButton label="Sleep" disabled={busy} onPress={() => action.mutate('sleep')} />
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<ActionButton
|
||||
label="View logs"
|
||||
onPress={() => router.push(`/service/logs?id=${s.id}&name=${encodeURIComponent(s.name)}`)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{s.sleep_enabled ? (
|
||||
<Text style={styles.hint}>
|
||||
Sleep enabled — scales to zero after {s.sleep_idle_minutes}m idle, wakes on traffic.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<SectionTitle>Deployments</SectionTitle>
|
||||
{deployments.data?.length === 0 ? <Empty text="No deployments" /> : null}
|
||||
{(deployments.data ?? []).slice(0, 8).map((d) => (
|
||||
<Card key={d.id}>
|
||||
<View style={styles.depRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.depTitle}>{d.image_name ?? d.id.slice(0, 8)}</Text>
|
||||
<Text style={styles.meta}>{relTime(d.created_at)}</Text>
|
||||
</View>
|
||||
<StatusBadge status={d.status} />
|
||||
</View>
|
||||
{d.id !== lastDeploy?.id && d.status === 'success' ? (
|
||||
<ActionButton
|
||||
label="Rollback to this"
|
||||
disabled={rollback.isPending}
|
||||
onPress={() =>
|
||||
Alert.alert('Rollback', 'Roll back to this deployment?', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Rollback', onPress: () => rollback.mutate(d.id) },
|
||||
])
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: colors.bg },
|
||||
head: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
|
||||
name: { color: colors.text, fontSize: 24, fontWeight: '800' },
|
||||
meta: { color: colors.textDim, fontSize: 12, marginTop: 4 },
|
||||
warnTitle: { color: colors.warn, fontWeight: '700', marginBottom: 6 },
|
||||
warnItem: { color: colors.text, fontSize: 13, marginTop: 2 },
|
||||
actions: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||
hint: { color: colors.textDim, fontSize: 12, marginTop: 4 },
|
||||
depRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
|
||||
depTitle: { color: colors.text, fontSize: 14, fontWeight: '600' },
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { FlatList, RefreshControl, StyleSheet, Text } from 'react-native';
|
||||
import { useLocalSearchParams, useNavigation } from 'expo-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from '../../session';
|
||||
import { colors } from '../../theme';
|
||||
import { Empty, ErrorBox, Loading } from '../../components';
|
||||
|
||||
export default function ServiceLogs() {
|
||||
const { id, name } = useLocalSearchParams<{ id: string; name?: string }>();
|
||||
const api = useApi();
|
||||
const nav = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
if (name) nav.setOptions({ title: `${name} logs` });
|
||||
}, [name, nav]);
|
||||
|
||||
const logs = useQuery({
|
||||
queryKey: ['logs', id],
|
||||
queryFn: () => api.serviceLogs(id!, 300),
|
||||
enabled: !!id,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
if (logs.isLoading) return <Loading />;
|
||||
const entries = logs.data ?? [];
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
style={styles.root}
|
||||
contentContainerStyle={{ padding: 12 }}
|
||||
data={[...entries].reverse()}
|
||||
inverted
|
||||
keyExtractor={(_, i) => String(i)}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={logs.isRefetching}
|
||||
onRefresh={() => logs.refetch()}
|
||||
tintColor={colors.accent}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
logs.error ? (
|
||||
<ErrorBox error={logs.error} />
|
||||
) : (
|
||||
<Empty text="No log output yet" />
|
||||
)
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<Text
|
||||
style={[
|
||||
styles.line,
|
||||
item.stream === 'stderr' && { color: colors.err },
|
||||
]}
|
||||
>
|
||||
{item.message}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: colors.bg },
|
||||
line: {
|
||||
color: colors.text,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
paddingVertical: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { colors, statusColor } from './theme';
|
||||
|
||||
export function StatusDot({ status }: { status: string }) {
|
||||
return (
|
||||
<View
|
||||
style={[styles.dot, { backgroundColor: statusColor[status] ?? colors.textDim }]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const c = statusColor[status] ?? colors.textDim;
|
||||
return (
|
||||
<View style={[styles.badge, { borderColor: c }]}>
|
||||
<View style={[styles.dot, { backgroundColor: c }]} />
|
||||
<Text style={[styles.badgeText, { color: c }]}>{status}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ children, style }: { children: React.ReactNode; style?: object }) {
|
||||
return <View style={[styles.card, style]}>{children}</View>;
|
||||
}
|
||||
|
||||
export function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return <Text style={styles.section}>{children}</Text>;
|
||||
}
|
||||
|
||||
export function Empty({ text }: { text: string }) {
|
||||
return <Text style={styles.empty}>{text}</Text>;
|
||||
}
|
||||
|
||||
export function ErrorBox({ error }: { error: unknown }) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return <Text style={styles.err}>{msg}</Text>;
|
||||
}
|
||||
|
||||
export function ActionButton({
|
||||
label,
|
||||
onPress,
|
||||
kind = 'default',
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
kind?: 'default' | 'primary' | 'danger';
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const bg =
|
||||
kind === 'primary' ? colors.accent : kind === 'danger' ? colors.err : colors.surfaceAlt;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, { backgroundColor: bg, opacity: disabled ? 0.4 : 1 }]}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text style={styles.btnText}>{label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
export function Row({
|
||||
title,
|
||||
subtitle,
|
||||
right,
|
||||
onPress,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
right?: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<TouchableOpacity style={styles.row} onPress={onPress} disabled={!onPress}>
|
||||
<View style={{ flex: 1, marginRight: 8 }}>
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text style={styles.rowSub} numberOfLines={1}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{right}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
export function Loading() {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.accent} size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
dot: { width: 8, height: 8, borderRadius: 4 },
|
||||
badge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
badgeText: { fontSize: 11, fontWeight: '600', textTransform: 'uppercase' },
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
},
|
||||
section: {
|
||||
color: colors.textDim,
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
marginTop: 14,
|
||||
marginBottom: 8,
|
||||
},
|
||||
empty: { color: colors.textDim, fontSize: 14, paddingVertical: 12 },
|
||||
err: { color: colors.err, fontSize: 13, paddingVertical: 8 },
|
||||
btn: {
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
alignItems: 'center',
|
||||
marginRight: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
btnText: { color: colors.text, fontWeight: '600', fontSize: 13 },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
rowTitle: { color: colors.text, fontSize: 15, fontWeight: '500' },
|
||||
rowSub: { color: colors.textDim, fontSize: 12, marginTop: 2 },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { Api } from './api';
|
||||
|
||||
const KEY_URL = 'containr.server_url';
|
||||
const KEY_TOKEN = 'containr.token';
|
||||
|
||||
interface Session {
|
||||
ready: boolean;
|
||||
api: Api | null;
|
||||
serverUrl: string | null;
|
||||
signIn: (serverUrl: string, token: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const Ctx = createContext<Session>({
|
||||
ready: false,
|
||||
api: null,
|
||||
serverUrl: null,
|
||||
signIn: async () => {},
|
||||
signOut: async () => {},
|
||||
});
|
||||
|
||||
export function normalizeServerUrl(raw: string): string {
|
||||
let u = raw.trim();
|
||||
if (!/^https?:\/\//i.test(u)) u = `https://${u}`;
|
||||
return u.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [serverUrl, setServerUrl] = useState<string | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [u, t] = await Promise.all([
|
||||
SecureStore.getItemAsync(KEY_URL),
|
||||
SecureStore.getItemAsync(KEY_TOKEN),
|
||||
]);
|
||||
setServerUrl(u);
|
||||
setToken(t);
|
||||
} finally {
|
||||
setReady(true);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const value = useMemo<Session>(
|
||||
() => ({
|
||||
ready,
|
||||
serverUrl,
|
||||
api: serverUrl && token ? new Api(serverUrl, token) : null,
|
||||
signIn: async (url, tok) => {
|
||||
const normalized = normalizeServerUrl(url);
|
||||
await SecureStore.setItemAsync(KEY_URL, normalized);
|
||||
await SecureStore.setItemAsync(KEY_TOKEN, tok.trim());
|
||||
setServerUrl(normalized);
|
||||
setToken(tok.trim());
|
||||
},
|
||||
signOut: async () => {
|
||||
await SecureStore.deleteItemAsync(KEY_URL);
|
||||
await SecureStore.deleteItemAsync(KEY_TOKEN);
|
||||
setServerUrl(null);
|
||||
setToken(null);
|
||||
},
|
||||
}),
|
||||
[ready, serverUrl, token],
|
||||
);
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useSession() {
|
||||
return useContext(Ctx);
|
||||
}
|
||||
|
||||
export function useApi(): Api {
|
||||
const { api } = useSession();
|
||||
if (!api) throw new Error('not signed in');
|
||||
return api;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export const colors = {
|
||||
bg: '#0B0F14',
|
||||
surface: '#131A22',
|
||||
surfaceAlt: '#1A232E',
|
||||
border: '#26313D',
|
||||
text: '#E8EEF4',
|
||||
textDim: '#8A97A5',
|
||||
accent: '#4F8FF7',
|
||||
accentDim: '#2A4A7F',
|
||||
ok: '#3FB68B',
|
||||
warn: '#E0A93E',
|
||||
err: '#E05C5C',
|
||||
info: '#4FB7D9',
|
||||
};
|
||||
|
||||
export const statusColor: Record<string, string> = {
|
||||
running: colors.ok,
|
||||
deployed: colors.ok,
|
||||
success: colors.ok,
|
||||
building: colors.warn,
|
||||
deploying: colors.warn,
|
||||
pending: colors.warn,
|
||||
waking: colors.warn,
|
||||
sleeping: colors.info,
|
||||
stopped: colors.textDim,
|
||||
queued: colors.textDim,
|
||||
failed: colors.err,
|
||||
error: colors.err,
|
||||
cancelled: colors.textDim,
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
export function relTime(iso?: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return '—';
|
||||
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return `${d}d ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
@@ -667,6 +667,167 @@ paths:
|
||||
'200':
|
||||
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:
|
||||
get:
|
||||
tags:
|
||||
@@ -1889,6 +2050,80 @@ paths:
|
||||
schema:
|
||||
$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}:
|
||||
get:
|
||||
tags:
|
||||
@@ -2388,6 +2623,78 @@ paths:
|
||||
schema:
|
||||
$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/github-app/install-url:
|
||||
get:
|
||||
@@ -5173,6 +5480,22 @@ components:
|
||||
items:
|
||||
type: string
|
||||
description: Basic-auth usernames gating the route (hashes never returned)
|
||||
builder:
|
||||
type: string
|
||||
enum: [auto, railpack, nixpacks, dockerfile, static]
|
||||
description: Build strategy; auto detects from repo contents
|
||||
cpu_reserve:
|
||||
type: string
|
||||
description: Soft CPU reservation (fraction, e.g. 0.25 → ~256 CPU shares)
|
||||
memory_reserve:
|
||||
type: string
|
||||
description: Soft memory reservation (e.g. 128Mi)
|
||||
static_build_cmd:
|
||||
type: string
|
||||
description: Build command for the static builder (default "npm ci && npm run build")
|
||||
static_dir:
|
||||
type: string
|
||||
description: Output directory for the static builder (default dist)
|
||||
public_url:
|
||||
type: string
|
||||
description: Computed public URL (published port or domain)
|
||||
@@ -5248,6 +5571,22 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/ServiceVolume'
|
||||
description: Volume/bind mounts applied to every replica
|
||||
builder:
|
||||
type: string
|
||||
enum: [auto, railpack, nixpacks, dockerfile, static]
|
||||
description: Build strategy (default auto)
|
||||
cpu_reserve:
|
||||
type: string
|
||||
description: Soft CPU reservation (fraction, e.g. 0.25)
|
||||
memory_reserve:
|
||||
type: string
|
||||
description: Soft memory reservation (e.g. 128Mi)
|
||||
static_build_cmd:
|
||||
type: string
|
||||
description: Build command for the static builder
|
||||
static_dir:
|
||||
type: string
|
||||
description: Output directory for the static builder (default dist)
|
||||
|
||||
UpdateServiceRequest:
|
||||
type: object
|
||||
@@ -5312,6 +5651,22 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/BasicAuthCred'
|
||||
description: Replaces all basic-auth credentials; [] clears the gate
|
||||
builder:
|
||||
type: string
|
||||
enum: [auto, railpack, nixpacks, dockerfile, static]
|
||||
description: Build strategy
|
||||
cpu_reserve:
|
||||
type: string
|
||||
description: Soft CPU reservation (empty string clears)
|
||||
memory_reserve:
|
||||
type: string
|
||||
description: Soft memory reservation (empty string clears)
|
||||
static_build_cmd:
|
||||
type: string
|
||||
description: Build command for the static builder (empty string clears)
|
||||
static_dir:
|
||||
type: string
|
||||
description: Output directory for the static builder (empty string clears)
|
||||
|
||||
ServiceDomain:
|
||||
type: object
|
||||
@@ -5365,6 +5720,44 @@ components:
|
||||
type: string
|
||||
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:
|
||||
type: object
|
||||
required:
|
||||
@@ -5994,6 +6387,12 @@ components:
|
||||
backup_schedule:
|
||||
type: string
|
||||
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:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -6036,6 +6435,49 @@ components:
|
||||
type: string
|
||||
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:
|
||||
type: object
|
||||
properties:
|
||||
@@ -7684,5 +8126,7 @@ tags:
|
||||
description: Vulnerability scans and compliance reporting
|
||||
- name: Cron
|
||||
description: Scheduled jobs
|
||||
- name: Registries
|
||||
description: Private-image pull credentials
|
||||
- name: Gateway
|
||||
description: API gateway — upstream services, keys, analytics
|
||||
@@ -38,14 +38,19 @@ auth login|logout|status token auth and profiles
|
||||
tokens list|create|revoke personal access tokens
|
||||
projects list|get|create|delete
|
||||
services list|get|create|update|delete|start|stop|restart|redeploy
|
||||
services clone|move duplicate config or re-parent a service
|
||||
services domains list|add|remove|default|check multi-domain + DNS preflight
|
||||
registries list|add|update|remove private-image pull credentials
|
||||
volumes list|delete docker volume inventory + cleanup (admin)
|
||||
deploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)
|
||||
deployments list|get|logs|rollback|cancel
|
||||
logs <service-id> [--follow] runtime logs (SSE follow)
|
||||
exec <service-id> -- <cmd> one-off container command
|
||||
variables list|set|unset env vars (secrets masked; --redeploy applies them)
|
||||
variables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}
|
||||
databases list|get|create|delete|action|backup|restore|download-backup
|
||||
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
|
||||
templates list|get|create|delete|deploy
|
||||
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
|
||||
@@ -115,6 +120,58 @@ Maintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`
|
||||
taken offline with empty responses. Basic-auth credentials are stored
|
||||
bcrypt-hashed (htpasswd) and gate every request.
|
||||
|
||||
## Build strategy and resources
|
||||
|
||||
Source-built services pick a builder per service:
|
||||
|
||||
```bash
|
||||
containr services update <id> --builder railpack # auto|railpack|nixpacks|dockerfile|static
|
||||
containr services update <id> --builder static --static-cmd "npm run build" --static-dir dist
|
||||
```
|
||||
|
||||
`auto` detects from repo contents (Dockerfile → railpack → nixpacks).
|
||||
`static` builds assets in a node stage and serves them from nginx —
|
||||
`--static-cmd` runs inside the build stage (default
|
||||
`npm ci && npm run build`), `--static-dir` is copied into the nginx image
|
||||
(default `dist`, exposed on port 80).
|
||||
|
||||
Resource reservations complement the hard `cpu`/`memory` limits:
|
||||
|
||||
```bash
|
||||
containr services update <id> --cpu-reserve 0.25 --memory-reserve 128Mi
|
||||
```
|
||||
|
||||
`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/
|
||||
`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty
|
||||
string clears. `app_settings.default_cpu`/`default_memory` change the
|
||||
instance-level defaults used at service creation;
|
||||
`app_settings.capacity_policy=block` fails deployments whose requested
|
||||
memory exceeds node capacity (default `warn` logs only).
|
||||
|
||||
## Clone, move, registries
|
||||
|
||||
`services clone` duplicates config, volumes, domains, access gates,
|
||||
builder settings, and variables — ciphertext secret values are copied
|
||||
verbatim (no decrypt/re-encrypt). The clone starts stopped:
|
||||
|
||||
```bash
|
||||
containr services clone <id> --name staging-api --project <other-project-id> --environment preview
|
||||
containr services move <id> <project-id> # name conflicts → 409
|
||||
```
|
||||
|
||||
Note: cloned domains are verbatim copies — the same hostname attached to
|
||||
two services makes Traefik routing ambiguous. Re-domain or undeploy one.
|
||||
|
||||
`registries` stores private-image pull credentials, owner-scoped and
|
||||
encrypted at rest. Matched by image host on every image-sourced deploy:
|
||||
|
||||
```bash
|
||||
containr registries add ghcr ghcr.io --username <user> --password <pat>
|
||||
containr registries add hub docker.io --username <user> --password <pw> # Docker Hub
|
||||
containr registries update <id> --username <new-user> # empty --password keeps stored
|
||||
containr registries remove <id>
|
||||
```
|
||||
|
||||
## MCP server
|
||||
|
||||
`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools
|
||||
|
||||
@@ -16,13 +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.
|
||||
- **Pagination**: `?limit=` + `?cursor=` (pass `pagination.next_cursor` verbatim; legacy `?page=` also works). Invalid cursors fall back to page 1.
|
||||
- **Deployments**: serialized per service — a deploy on a busy service returns `status: queued` and runs when the active one finishes. `POST /deployments/{id}/cancel` drops queued (`200 cancelled`) or aborts running (`202 cancelling`) work. `no_cache: true` on create disables the Docker layer cache. `PUT /services/{id}/variables` accepts `redeploy: true` to apply env changes to running containers.
|
||||
- **Variables**: service env values support `${{service.KEY}}` cross-service refs (builtins HOST/PORT) and `${{shared.KEY}}` project shared variables (`GET|PUT /projects/{id}/variables`, CLI `variables shared …`). Resolved at deploy/reconcile, not live-updated.
|
||||
- **Volumes**: service mounts via `PUT /services/{id}` `volumes: [{type: volume|bind, source, target, read_only}]` — replaces the whole list (`[]` clears), applied on next deploy. `GET /admin/volumes` inventories node volumes with `in_use` flags; `DELETE /admin/volumes/{name}` removes unused ones (admin).
|
||||
- **Domains**: multiple hostnames per service via `GET|POST /services/{id}/domains`, `DELETE /services/{id}/domains/{domain_id}`, `POST …/{domain_id}/default` (mirrors `services.domain`). `GET /services/{id}/domains/check` returns `ok|wrong-target|pending` per domain vs `app_settings.public_ip`/`PUBLIC_IP`. `PUT` `maintenance_mode` redirects all traffic to `GET /maintenance` (needs `base_url`/`BASE_URL` setting); `basic_auth: [{username,password}]` stores bcrypt htpasswd and gates the route — responses return usernames only.
|
||||
- **Builders & resources**: service field `builder` = `auto|railpack|nixpacks|dockerfile|static` (auto detects; static builds assets with `static_build_cmd` and serves `static_dir` via nginx:80). `cpu`/`memory` are hard caps; `cpu_reserve`/`memory_reserve` are soft reservations (CPU shares / MemoryReservation) — empty string clears. `app_settings.default_cpu`/`default_memory` set instance defaults; `app_settings.capacity_policy=block` rejects deploys exceeding node memory (default `warn`).
|
||||
- **Clone & move**: `POST /services/{id}/clone` `{name?, project_id?, environment?}` copies config, volumes, domains, access gates, and variables (secret ciphertext verbatim; clone starts stopped). `POST /services/{id}/move` `{project_id}` re-parents a service — both require ownership of source and target; name conflicts → `CONFLICT`. Cloned domains are verbatim copies: the same hostname on two services makes routing ambiguous.
|
||||
- **Registries**: `GET|POST /registries`, `PUT|DELETE /registries/{id}` — owner-scoped private-image pull credentials, encrypted at rest, never returned (`has_password` signals presence). `host` matched against the image reference on image deploys (`docker.io` for Docker Hub; scheme/case normalized). Empty `password` on update keeps the stored credential.
|
||||
- **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.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
+10
-3
@@ -21,18 +21,25 @@ for platform in $PLATFORMS; do
|
||||
GOARCH=${platform#*/}
|
||||
|
||||
OUTPUT_NAME="containr-${GOOS}-${GOARCH}"
|
||||
MCP_NAME="containr-mcp-${GOOS}-${GOARCH}"
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
OUTPUT_NAME="${OUTPUT_NAME}.exe"
|
||||
MCP_NAME="${MCP_NAME}.exe"
|
||||
fi
|
||||
|
||||
echo "📦 Building for ${platform}..."
|
||||
|
||||
echo "📦 Building CLI for ${platform}..."
|
||||
GOOS=$GOOS GOARCH=$GOARCH go build \
|
||||
-ldflags "$LDFLAGS" \
|
||||
-o "bin/${OUTPUT_NAME}" \
|
||||
./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
|
||||
|
||||
echo "🎉 CLI build complete!"
|
||||
|
||||
Reference in new issue
Block a user