Compare commits

...
39 Commits
Author SHA1 Message Date
Tomáš Dvořák b22ac401c8 Merge pull request #21 from Dvorinka/feat/service-domains
C2: multi-domain, DNS preflight, maintenance + basic-auth gates
2026-10-09 00:48:49 +02:00
Tomáš Dvořák ea96929435 Merge pull request #20 from Dvorinka/feat/service-volumes
C1: service volume mounts + admin volume inventory
2026-10-09 00:48:43 +02:00
Tomáš Dvořák 971773f80c Merge pull request #19 from Dvorinka/feat/deploy-queue
feat(deploy): pipeline maturity — queue, cancel, no-cache, secrets at rest
2026-10-09 00:48:37 +02:00
Tomáš Dvořák 7e99e41f8d Merge pull request #18 from Dvorinka/feat/agent-docs
docs: AGENTS.md + llms.txt
2026-10-09 00:48:32 +02:00
Tomáš Dvořák 6c5ff7be41 Merge pull request #17 from Dvorinka/feat/api-ergonomics
feat(api): error codes, idempotency keys, cursor pagination
2026-10-09 00:48:27 +02:00
Tomáš Dvořák 7b93506e2d Merge pull request #16 from Dvorinka/feat/mcp-server
feat(mcp): stdio MCP server exposing the full API as tools
2026-10-09 00:48:22 +02:00
Tomáš Dvořák f34fba165c Merge pull request #15 from Dvorinka/feat/cli-v2
feat(cli): v2 — full API coverage, profiles, --json/--yes, containr up
2026-10-09 00:48:16 +02:00
Tomáš Dvořák 6949c01464 Merge pull request #14 from Dvorinka/feat/personal-access-tokens
feat: personal access tokens with scoped bearer auth
2026-10-09 00:41:09 +02:00
Tomas Dvorak f22ae22375 Merge branch 'feat/service-volumes' into feat/service-domains 2026-10-09 00:40:13 +02:00
Tomas DvorakandDevin a4b6ea4bd2 fix(ui): volumes save — fill type/read_only defaults for generated type
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 00:40:12 +02:00
Tomas DvorakandDevin e995c98d05 feat(services): multi-domain, DNS check, maintenance + basic-auth gates (C2)
service_domains table holds the full hostname set; services.domain stays
the derived default and is synced on every domain write. Runtime spec
carries all domains into a multi-Host Traefik rule plus access gates.

- GET|POST /services/:id/domains, DELETE + POST /:domain_id/default
- GET /services/:id/domains/check — DNS preflight vs public_ip/base_url
  (ok | wrong-target | pending), stamps last_checked_at
- maintenance_mode → Traefik redirectregex to /api/v1/maintenance
  (base_url/BASE_URL setting; falls back to noop@internal offline)
- basic_auth [{username,password}] → bcrypt htpasswd on the label;
  responses return usernames only
- First domain auto-defaults; domain="" via PUT clears all domains
- CLI: services domains list|add|remove|default|check, update
  --maintenance/--basic-auth; MCP: 5 domain tools + update args
- Settings UI: Domains card (CRUD + DNS check badges) + Access card
- Fixes latent C1 bug: PUT without volumes 500'd — nil []byte sent as
  bytea, not NULL; COALESCE needs untyped nil + ::jsonb cast

Verified live: domain CRUD + default sync, pending/wrong-target checks,
bcrypt htpasswd and both middlewares on the deployed container's labels.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 16:51:03 +02:00
Tomas DvorakandDevin 2f8f595d6e feat(services): volume mounts + admin volume inventory (C1)
services.volumes JSONB [{type: volume|bind, source, target, read_only}]
mapped into every replica's container config at deploy and reconcile
time, hashed into the runtime spec so mount changes trigger
reconciliation. Mounted via existing docker.HostConfig.Mounts.

- RuntimeSpec.Volumes + specHash + mount.Mount mapping in createReplica
- Create/update service accepts volumes; PUT replaces the whole list
- Replica-count reconcile now goes through the deploy queue — the last
  raw `go` reconcile left that could race a build
- GET /admin/volumes inventories node volumes with in_use flags;
  DELETE /admin/volumes/:name removes unused ones (409 on in-use)
- CLI: services update --volume/--bind/--clear-volumes, volumes
  list|delete; MCP: services_update + volumes_list/delete
- Settings UI: Volumes section with mount editor
- Migration normalizes the nullable baseline column to NOT NULL [].

Verified live: deploy applies mounts to the container, docker
auto-creates named volumes, in-use deletion is refused.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 16:38:46 +02:00
Tomas DvorakandDevin 747b714a02 feat(deploy): webhook queue+nocache, restart sweep, secrets at rest
- Webhook-triggered deploys now go through the per-service deploy queue
  and default to no_cache builds (dflow convention — prevents stale
  layer surprises on push).
- Boot sweep marks deployments interrupted by restart as failed instead
  of leaving phantom queued/building rows.
- is_secret environment variables are AES-GCM-encrypted at rest
  (enc:v1: prefix); key from SECRETS_KEY or JWT_SECRET fallback.
  Existing plaintext secrets encrypt on boot; deploy/exec paths decrypt
  transparently. Masked-echo writes preserve stored ciphertext.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 16:27:10 +02:00
Tomas DvorakandDevin 7839dc48a3 feat(deploy): per-service deploy queue, cancellable deployments, no-cache builds
- internal/deployqueue: serialized FIFO per service — concurrent deploys
  of one service no longer race (the container-name-conflict failure
  mode); different services still run in parallel.
- Deploys and rollbacks enqueue through the queue; a busy service
  returns status 'queued' and promotes when the active job finishes.
- POST /deployments/:id/cancel — queued jobs drop immediately
  (200 cancelled); active jobs get ctx cancelled so build/reconcile
  abort (202 cancelling, settles 'cancelled'); finished deployments
  return 409 NOT_CANCELLABLE.
- Redeploy and variables save now route through the same queue so they
  can't overlap a build; PUT /services/:id/variables accepts
  redeploy:true to apply env changes to running containers.
- no_cache on deployment create plumbs BuildRequest -> BuildOptions for
  nixpacks, dockerfile and railpack builders.
- CLI: deployments cancel, deploy --no-cache, variables set/unset
  --redeploy. MCP: containr_deployments_cancel; deploy/variables_set
  document no_cache/redeploy. UI: Cancel button + queued/cancelled
  badges on the deployments table. OpenAPI + types regenerated.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 16:23:28 +02:00
Tomas DvorakandDevin 84d58cf808 docs: AGENTS.md + llms.txt — agent-facing surface documentation
Repo-level AGENTS.md for coding agents (layout, conventions, verify
commands) and llms.txt describing the operator surface: PAT scopes,
CLI/MCP setup, error codes, idempotency, cursor pagination.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 15:40:04 +02:00
Tomas DvorakandDevin 18f78f00a3 feat(api): error codes, idempotency keys, cursor pagination
- Stable machine-readable error codes alongside the error message
  (UNAUTHENTICATED, INVALID_TOKEN, ADMIN_REQUIRED, SCOPE_INSUFFICIENT,
  IDEMPOTENCY_IN_PROGRESS); CLI renders "CODE: message".
- Idempotency middleware on authed/admin groups: POST requests with an
  Idempotency-Key header replay the first response for 24h (Redis-backed,
  caller-scoped); concurrent duplicates get 409. X-Idempotent-Replay
  header marks replays.
- Shared pagination helpers: ?cursor= (opaque, preferred) alongside
  legacy ?page= on projects and deployments lists; pagination meta
  includes next_cursor; invalid cursors fail soft to page 1.
- CLI --idempotency-key flag for safe scripted retries.
- OpenAPI updated: error envelope, cursor params, idempotency contract.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 15:38:36 +02:00
Tomas Dvorak 485e41a817 feat(mcp): stdio MCP server exposing the full API as tools
Add containr-mcp so MCP-compatible agents (Claude, Cursor, Devin)
operate the platform directly — ROADMAP2 phase A3. Reuses the CLI's
thin client and PAT auth; zero duplicated resource logic.

- ~60 containr_* tools across projects, services, deployments, logs,
  exec, variables, databases, cron, templates, nodes, scaling, ha,
  security, gateway, admin, notifications, tokens
- declarative tool specs → {param} path substitution, GET extras become
  query params, remaining args form the request body
- destructive tools require confirm:true and return isError without it
- results are pretty JSON; API error envelopes surface as isError
- configured via CONTAINR_API_URL + CONTAINR_TOKEN env; official
  modelcontextprotocol/go-sdk over StdioTransport

Verified over stdio: initialize + tools/list, auth_status returns the
token owner's identity, projects list, destructive refusal, token
creation through MCP.

Authored By: TDvorak <[email protected]>
2026-10-08 15:25:34 +02:00
Tomas Dvorak e51b20b2d4 feat(cli): v2 — full API coverage, profiles, --json/--yes, containr up
Rewrite the CLI as a thin client over the authenticated API so agents
and scripts reach every resource the UI does (ROADMAP2 phase A2).

- shared Client with bearer auth, error-envelope decoding, and stable
  exit codes: 0 ok / 1 error / 2 auth / 3 not-found / 4 validation /
  5 unreachable
- named profiles in ~/.containr.yaml (current_profile + profiles.*);
  resolution order --flag > env > profile > localhost default;
  CONTAINR_TOKEN / CONTAINR_API_URL / CONTAINR_PROFILE env support
- --json prints raw API responses; --yes skips confirmations;
  destructive commands refuse non-interactively without it
- auth login verifies the token against /user/profile before saving,
  interactive prompt via term.ReadPassword
- commands for every resource: tokens, projects, services, deploy,
  deployments (logs/rollback), logs --follow (SSE), exec, variables
  (set/unset merge with secret masking preserved), databases
  (backup/restore/download), cron, templates, nodes + agent tokens,
  scaling, ha, security, gateway, admin, notifications
- containr up: git remote + branch → create-or-reuse service → deploy

Verified live: login/status/tokens/projects/services, up creating a
service + deployment, non-TTY confirmation refusal, scope-capped admin
rejection (exit 2), revoke.

Authored By: TDvorak <[email protected]>
2026-10-08 15:19:31 +02:00
Tomas Dvorak 17e429b86b docs: ROADMAP2 — dflow successor plan and phased build
Deep analysis of dflow (~43 queue processors, ~30 collections, ~180
server actions) mapped against Containr, sectioned into phases A–G.
Phase A (agent spine) is in progress — this commit adds PATs.

Authored By: TDvorak <[email protected]>
2026-10-08 14:31:39 +02:00
Tomas Dvorak e881747388 feat: personal access tokens with scoped bearer auth
Add cnp_ personal access tokens so the CLI, MCP server, and external
agents authenticate without a browser session — the agent-first spine
from ROADMAP2 phase A. Modeled on agent_auth_tokens: raw token shown
once, only SHA-256 hash stored.

- user_tokens table (goose migration + sqlc), name/prefix/scope/expiry,
  owner-scoped revoke, last_used_at stamping
- middleware: cnp_ bearer resolution, read scope gates mutations with
  403 SCOPE_INSUFFICIENT, non-admin scopes cap is_admin even for admin
  owners — a token never outranks its scope or its owner
- GET/POST/DELETE /api/v1/user/tokens + OpenAPI + generated types
- Settings UI: create with scope/expiry pickers, one-time token display
  with copy, list with last-used/expiry, confirm-revoke
- docs: OPERATIONS.md section, README feature line

Verified end-to-end against live Postgres: create/list/revoke, read
scope blocks PUT (403), write PAT on admin owner blocked from admin
routes (403), admin scope works, revoked token returns 401.

Authored By: TDvorak <[email protected]>
2026-10-08 14:31:29 +02:00
Tomáš Dvořák f9ae5b962c chore: add FUNDING.yml — GitHub Sponsors button
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
2026-10-04 19:50:34 +02:00
Tomáš DvořákandDevin 15b5b0e6e7 fix: HA checks resolve service targets over project network; persist checks (#13)
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
- Health checks for local services dialed 127.0.0.1 from inside the backend
  container — the host's published port is unreachable there. Service-bound
  checks now resolve the service's DNS name on its project network (backend
  joins on demand), falling back to the service's stored port. A resolver
  failure surfaces as TARGET_UNRESOLVED instead of a fake loopback probe.
- Checks lived only in memory: a backend restart silently deleted every
  configured health check. ha_health_checks table + write-through on
  add/update/tick and hydrate-on-boot, matching the scaling-policy pattern.
- sqlc schema.sql gained the runtime columns added by later migrations —
  the stale snapshot already caused one silently-dropped-column bug.
- OpenAPI: runtime response now documents ports and health fields; types
  regenerated.
- Buttons no longer wrap ("+ new project" rendered on two lines).

Verified live: service check probes whoami-web:80 via DNS → healthy;
check survives a backend restart and rehydrates.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-04 09:37:47 +02:00
Tomáš DvořákandDevin 32b52d121f fix: e2e QA hardening — honest runtime state, stable public ports, read-only anon UI (#12)
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
* fix: e2e QA hardening — honest runtime URLs, health, and read-only anon UI

Backend:
- managed DB provisioning: attach DB containers to the platform network and
  probe the container IP, fixing the 127.0.0.1 reachability failure inside
  the backend container; log provisioning failures
- stop fabricating connection URLs when provisioning produced none
- template deploys now carry port + health_check into service runtime fields
- service health probed over the project network (backend joins it) instead
  of an in-container wget that fails on shell-less images
- runtime/public/connection URLs rewrite loopback to the request Host so
  links are usable for LAN/remote clients

Frontend:
- service Networking panel shows real runtime URLs/ports/health instead of
  hardcoded containr.dev + 8080->443
- anonymous views hide all mutation controls (new project, add service,
  group, context menu)
- project cards: remove decorative svcN labels; badge says DOWN only when
  zero services run, IDLE when empty, DEGRADED for partial
- mobile projects header no longer clips (fluid search width)
- unknown routes render a not-found page instead of silently redirecting

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: telemetry scope label, chunk split, remove stale vite artifacts

- /system/host now reports scope=host|docker-desktop-vm and the daemon OS;
  Projects dashboard labels cards VM.* when the daemon is a Desktop VM so
  memory/core numbers aren't mistaken for physical host values
- manualChunks: split @xyflow/react and recharts out of the main bundle;
  removes the >1MB chunk warning
- drop tracked vite.config.js/.d.ts build artifacts that shadowed the real
  vite.config.ts

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: persist service host port; demo-data scope for telemetry

Public URLs were ephemeral: every restart/redeploy rebound a random host
port, so endpoint links rotted. Persist published_port on the service and
prefer it for replica 0 on create. If the port is taken, fall back to
ephemeral and repersist the new assignment. Post-start the binding is
verified — Docker Desktop accepts a conflicting publish silently, which
previously left services running with no endpoint at all.

demo-data.ts gains the required scope field for HostMonitoring (CI fix).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-04 09:01:28 +02:00
Tomáš Dvořák d968eb2315 Merge pull request #11 from Dvorinka/feat/app-demo-polish
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
feat(app): unified demo mode + public-demo build for Vercel
2026-09-23 19:07:14 +02:00
Tomas Dvorak 38e0bc1802 merge: resolve main into feat/app-demo-polish 2026-09-23 19:06:42 +02:00
Tomáš Dvořák fe08f8d299 Merge pull request #10 from Dvorinka/feat/fullscreen-canvas
feat(canvas): fullscreen Railway-style workspace canvas
2026-09-23 19:02:55 +02:00
Tomáš Dvořák ad797a49c9 Merge pull request #9 from Dvorinka/feat/vercel-landing
feat: landing page + public template catalog
2026-09-23 19:02:47 +02:00
Tomas DvorakandDevin 6661357963 feat(canvas): shared 26px grid + collision-free node placement
Nodes snapped to the dot-grid lattice on drop; occupied spots resolve
to the nearest free lattice cell instead of stacking. Same protection
inside groups (sibling-aware), on inspector assign, and on ungroup.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:59:14 +02:00
Tomas DvorakandDevin 7fc6ed1215 feat(canvas): full service cockpit on left-click + real grouping
The inspector is now a proper service panel: address with copy, group
assignment (Ungrouped / any group / "+ New group" wrapping the node),
masked env vars with a deep link into the service's Variables tab, a
config grid deep-linking to Config, inferred connections, and the
lifecycle actions. ServiceDetailPage honours ?section= for the deep
links.

Grouping is first-class: assign via the inspector or drag nodes onto a
frame; members slot beneath existing ones and the frame grows only as
needed instead of clamping or ballooning. Fixed a drag bug where moving
a member inside its own group ejected it.

Context menus everywhere: right-click a node (existing actions), a
group frame (Rename via inline edit, Ungroup keeps children in place),
or empty pane (Add service, Add group here, Tidy layout, Fit view).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:50:38 +02:00
Tomas DvorakandDevin d9bfd8a2fd feat(canvas): fullscreen workspace outside the app shell
Move /projects/:id and its service detail route out of PlatformShell —
the project view now owns the entire viewport like Railway's canvas:
no sidebar, no global nav, just the slim project bar over the graph.
Service detail inherits the same chrome-free context with a breadcrumb
back to the canvas, and back/delete navigation preserves ?demo=1.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:20:05 +02:00
Tomas DvorakandDevin a23fa2a0cc fix(site): blinking title cursor in hero mockup, matching the app
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:12:59 +02:00
Tomas DvorakandDevin 1425618c92 feat(canvas): brand tech icons on services, matching site preview
Resolve a service's tech (postgres, redis, go, node, nginx, kafka,
~50 slugs) from its image/name and render the Simple Icons logo in
the tinted icon tile — canvas nodes, inspector, service detail
header, and database rows — with graceful fallback to type icons.
Demo services get resolvable images, ports, and domains; a Redis
Cache service joins Core Platform so the demo canvas mirrors the
landing preview (5 services, web/api/worker → api → postgres/redis).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:12:15 +02:00
Tomas DvorakandDevin 894192bf9b feat(site): drop live demo, add Railway-style canvas preview
Remove all links to the deleted containr-app deployment and replace
the static hero screenshot with a CSS/HTML product mockup: a project
canvas with five icon-branded service nodes, connected edges (one
animated deploying edge), env/zoom/minimap pills, status dots,
domains, and image tags. Mobile gets a two-column layout with its
own edge paths.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 17:56:43 +02:00
Tomas DvorakandDevin e825eb0d7c feat(site): wire landing to deployed app demo
- All CTAs (nav, hero, install note, gallery, catalog, footer) link to
  https://containr-app.vercel.app - the app frontend deployed as a
  public demo (VITE_PUBLIC_DEMO build).
- templates.html: per-template "Open in demo app" deep links via
  ?template=<id>; dead default API removed so the official snapshot
  renders instantly until an instance is configured with ?api=.
- Hero canvas screenshot updated to the fullscreen canvas build.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 17:41:43 +02:00
Tomas DvorakandDevin 5b0a8e0759 feat(app): unified demo mode + public-demo build for Vercel
- Central demo-mode helper (lib/demo-mode.ts): ?demo=1 query flag plus
  VITE_PUBLIC_DEMO build flag that makes every route demo for the
  public Vercel showcase.
- Single demo fixture source (lib/demo-data.ts): projects, services,
  builds, databases, cron, HA, security, host monitoring, templates.
  Fixes cross-page ID drift that broke project navigation.
- Demo support on Projects, HA, Security, Usage, Templates, Landing,
  Service detail; DemoRestricted gate on People/Settings/Audit/Admin.
- Demo-aware PlatformShell: nav filtering, env:demo, demo account menu,
  ?demo=1 preserved through internal links.
- TemplatesPage: selection moved into ?template= URL param for deep
  links; template fixtures shared via demo-data.
- requestJson returns {} for unparseable 200 bodies (SPA fallback) so
  envelope unwraps degrade instead of dereferencing null.
- Recharts ResponsiveContainer initialDimension kills -1 warnings.
- vercel.json: vite framework + SPA rewrites for direct deep links.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 17:38:02 +02:00
Tomas DvorakandDevin e57f0470eb feat(canvas): fullscreen Railway-style workspace canvas
Rebuild the project canvas as a full-viewport graph instead of a boxed
66vh panel. Slim workspace top bar now holds the view tabs, project meta
and actions; the canvas fills all remaining space.

- Floating glass controls: env filter + group pill (top-left), zoom out /
  live zoom % / zoom in / fit / tidy pill (bottom-left), dark MiniMap
  (bottom-right), hint chip, demo badge.
- Inspector panel: selecting a node opens a right-side glass panel with
  status, image, address, inferred connections incl. variable reasons,
  and deploy/restart/stop/delete actions. Esc or pane click closes.
- Auto-layout: dependency-depth layered layout (consumers left,
  datastores right) for first-visit placement and the Tidy action.
  Unpositioned services park right of existing bounds, not at (0,0).
- Nodes: fixed 240px width (was 210 vs 220-280 CSS mismatch), per-type
  icons/colors incl. cron, spinner icon while deploying, hover lift.
- Edges: lime bezier strokes, animated dashes while either endpoint is
  deploying, hover glow updated from stale purple to accent.
- Groups: off-brand crimson replaced with accent tokens, double-click
  title to rename inline.
- Keyboard: F fit, 0 reset zoom, +/- zoom, Esc deselect. Marquee select
  on Shift-drag. Double-click zoom disabled, dead connection drags off.
- Mobile: icon-only tabs, minimap/hint hidden under md.
- Retake docs/screenshots/canvas.png + site copy (1600x900 demo).

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 17:00:50 +02:00
Tomas DvorakandDevin 117b469fae feat: public template catalog + Vercel git integration
Backend: service_templates.is_public lets owners publish user templates to
the anonymous catalog (GET /api/v1/templates), mirroring Railway's
marketplace publish flow. Migration, sqlc queries, visibility rules, and
openapi.yaml updated; types regenerated.

App: template editor gains a "List in the public catalog" checkbox;
Public badge on catalog cards and detail view.

Site: new templates.html catalog page - search, category filters, Official
vs Community badges, detail modal with variables and copyable import JSON.
Fetches the live catalog from a reachable instance (?api= or localStorage
override) and falls back to templates.json, generated from SeedTemplates
via cmd/dumptemplates. Shared styles extracted to style.css.

Vercel: project connected to GitHub repo with rootDirectory=site;
.vercelignore limits CLI uploads to site/ so `vercel deploy --prod` from
repo root matches git builds.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 16:40:58 +02:00
Tomas DvorakandDevin 2b38406a63 feat(site): standalone landing page for Vercel
Static single-file page in site/ matching the app theme (dark #0c0d0f,
lime #b4e34a, self-hosted Inter + JetBrains Mono). Real product
screenshots, install one-liner with copy button, template marquee,
tabbed gallery. No build step; deployed via vercel deploy --prod.

Live: https://containr-lyart.vercel.app

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 15:33:23 +02:00
Tomáš Dvořák 84b102a2c6 Merge pull request #8 from Dvorinka/ci/auto-release
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
feat(ci): release pipeline on merge to main
2026-09-23 12:08:34 +02:00
134 changed files with 13913 additions and 1493 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
github: Dvorinka
+1
View File
@@ -123,3 +123,4 @@ bin
ref/ ref/
.playwright-mcp/ .playwright-mcp/
canvas-*.png canvas-*.png
.vercel
+2
View File
@@ -0,0 +1,2 @@
/*
!/site
+37
View File
@@ -0,0 +1,37 @@
# AGENTS.md — Containr
Containr is a self-hosted Docker PaaS (Railway-style). Backend: Go + Gin + PostgreSQL (sqlc) + Redis-compatible cache (Dragonfly) + Docker API. Frontend: React + Vite + TypeScript + React Query. API contract: `docs/api/openapi.yaml` (generated types: `app/frontend/src/generated/api-types.ts`).
This file is for **coding agents working on the repo**. For operating Containr as a deployed product, see `llms.txt`.
## Layout
- `app/backend` — Go monolith. Entrypoints: `cmd/server` (API), `cmd/cli` (`containr` CLI), `cmd/mcp` (`containr-mcp` MCP server).
- `app/backend/internal/api` — Gin handlers + route groups (`routes.go`: public / `read` OptionalAuth / `authed` / `admin`).
- `app/backend/internal/middleware` — `Auth` (JWT + Better Auth session + PAT), `RequireAdmin`, `Idempotency`.
- `app/backend/internal/cli/commands` — all CLI commands; shared `Client`, `Output`, `confirm` helpers live here.
- `app/backend/internal/mcpserver` — declarative MCP tool registry mirroring the API.
- `app/backend/sqlc` — `schema.sql` + `queries/*.sql`; generate via `sqlc generate` into `internal/database/sqlcdb`.
- `app/backend/migrations_goose` — goose migrations (`YYYYMMDDHHMMSS_name.sql`).
- `app/frontend` — React app. Pages under `src/features/`, API calls via `src/lib/api-client.ts`.
## Build / test / verify
```sh
cd app/backend && go build ./... && go vet ./... && go test ./...
cd app/frontend && npx tsc --noEmit && npm run lint && npm run build
cd app/frontend && npm run generate:api # after any openapi.yaml change
```
Dev stack: Postgres on `:15432` (`containr-postgres-1`), Dragonfly on `:16379` (`containr-dragonfly-1`). Server needs `DATABASE_URL`, `REDIS_URL`, `JWT_SECRET`, `PORT=8080`.
## Conventions that matter
- New endpoints go in the right route group; mutations on `authed`/`admin` get idempotency for free.
- Deploy work goes through `deploy_queue` (`internal/deployqueue`) — per-service FIFO; enqueue jobs instead of `go`-ing deployment/reconcile goroutines so they never race a build.
- New list endpoints use `pageWindow`/`paginationMeta` (`internal/api/pagination.go`) — cursor + page, `next_cursor` in the envelope.
- New errors use stable codes via `respondError(c, status, code, msg)` in `internal/api/errors.go`.
- New CLI command → also register an MCP tool in `internal/mcpserver/server.go` and document in OpenAPI. All three surfaces ship together.
- Destructive CLI commands must gate on `--yes`/`confirm` via `commands.confirm`; destructive MCP tools require `confirm: true`.
- sqlc: edit `schema.sql` + `queries/*.sql`, run `sqlc generate`, add the goose migration.
- Don't commit secrets. PATs (`cnp_…`) and agent tokens (`cagt_…`) are sha256-hashed at rest; raw values are shown once.
+2 -1
View File
@@ -56,7 +56,8 @@ dedicated console.
- **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete - **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry - **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
- **HA & scaling** — failover policies, node agents, autoscaling rules - **HA & scaling** — failover policies, node agents, autoscaling rules
- **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vulnerability scans - **Auth & security** — Better Auth sessions (email/password self-hosted), personal access tokens with `read`/`write`/`admin` scopes for CLI and agents, audit logs, vulnerability scans
- **CLI** — `containr` covers the full API: projects, services, deployments, logs, exec, variables, databases, cron, templates, nodes, scaling, HA, security, gateway, admin — with `--json`, `--yes`, named profiles, and `containr up` for current-directory deploys
- **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline - **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel - **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
+396
View File
@@ -0,0 +1,396 @@
# Containr — Roadmap 2: The dflow Convergence
Last verified: **2026-10-08** — deep pass over the full dflow codebase
(`~/Desktop/PROG+HTML/dflow`, community fork of dflow-sh/dflow, frozen for
preservation) against Containr `main` (`15b5b0e`).
Goal: make Containr the superior successor — absorb everything worth having
from dflow, then go past it and past Railway. Same power for humans and
agents: the UI is the friendly face, the CLI and MCP are the machine face,
all three sit on the same API. **Branding stays Containr/Vertice** —
white-labeling is an admin option, not an identity change.
---
## 1. What dflow actually is (verified)
Next.js 15 + PayloadCMS 3 + MongoDB + Redis/BullMQ. Runtime model: every
host is a remote server driven **over SSH** with Dokku auto-installed as the
per-app container manager. Async work = 43 BullMQ queue processors
(`src/queues/**`), live logs via Redis pub/sub → SSE endpoint, persisted to
deployment records. ~30 collections, ~180 server actions, 11 dashboard
routes per service (general, deployments, logs, environment, domains,
volumes, proxy, scaling, backups, settings).
### Full capability matrix — dflow → Containr
Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable (dokku/SSH-specific)
#### Deployment pipeline
| dflow capability | Containr status | Action |
|---|---|---|
| Queued deployments, serialized per server, cancellable while queued | ❌ goroutine fire-and-forget; `CancelDeployment` exists in engine but is **never routed** | Add deploy queue + cancel route |
| Deployment states `queued → building → success/failed`, live SSE logs + persisted record | 🟡 states exist (`building→running`); WS streams builds; no queued state | Extend status + stream |
| Redeploy cache modes (`ps:rebuild` vs fresh pull); webhook pushes force `no-cache` | ❌ always rebuilds | Add `cache` flag on deploy/redeploy |
| Cancel queued deployment from UI | ❌ (engine fn unrouted) | Route it |
| Per-service job ordering | ❌ concurrent deploys of one service can race | Serialize per service |
#### Service configuration
| dflow capability | Containr status | Action |
|---|---|---|
| Multi-domain per service + default flag + cert type | ❌ single `services.domain` | `service_domains` table |
| Cert types: letsencrypt / none / Cloudflare Origin / **CF-for-SaaS custom hostnames** | 🟡 Traefik ACME only | LE default; CF custom hostnames deferred (only needed if hosting tenant domains) |
| DNS preflight check (`checkDNSConfigAction`) | ❌ | `GET /services/:id/domains/check` |
| Volumes per service + dangling-volume browser/attach/delete | ❌ engine supports `VolumeMounts`; no API/UI | `services.volumes` jsonb + Volumes tab |
| Resource **limits and reservations**, per-server defaults | 🟡 `cpu_limit`/`memory_limit` int columns (unclear units) | explicit string units + reserves + instance defaults |
| Pre-deploy capacity check (dokku binary interceptor) | ❌ | Node capacity check pre-deploy/scale |
| Maintenance mode toggle | ❌ | Traefik middleware label |
| Basic-auth gate per service (`toggleHttpAuth`) | ❌ | Traefik basic-auth middleware |
| Raw proxy config override (`setServiceNginxConfig`) | ❌ | Allowlisted `traefik_labels` jsonb |
| Service clone (full config copy) | ❌ | `POST /services/:id/clone` |
| Move service between projects (`SwitchServiceProjectDialog`) | ❌ | `PUT /services/:id {project_id}` |
| Env var save → hot-apply + optional restart (`noRestart`) | ❌ rows rewritten; containers keep stale env until manual redeploy | Apply→redeploy flow |
| Static-site builder; Heroku buildpacks | 🟡 railpack/nixpacks/dockerfile auto-detect, no user override | `services.builder` enum + static builder |
| Docker registry accounts (private registries) | ❌ | `registry_credentials` per service/instance |
#### Databases
| dflow capability | Containr status | Action |
|---|---|---|
| Managed DBs (pg/mysql/mariadb/mongo/redis/clickhouse via dokku plugins) | ✅ plus dragonfly | — |
| **External DB registration** (Neon/Atlas/RDS conn details + test) | ❌ | `provider=external` + test endpoint |
| Internal backups + restore (same-type enforced) | ✅ | — |
| Scheduled backups | ✅ cron field | — |
| **External S3 backups** (auth/schedule/unschedule ops) | ❌ | `backup_targets` + shipper |
| **Cross-server DB migration** (export → sftp hop → import) | ❌ | `POST /databases/:id/migrate` |
| Public DB port expose/unexpose | ❌ loopback only | opt-in TCP expose |
| DB → service env linking (`dokku link`) | ✅ Bind action injects `DATABASE_URL` | — |
#### Servers / infrastructure
| dflow capability | Containr status | Action |
|---|---|---|
| Attach server = paste IP + SSH key; Dokku auto-installs | 🟡 agents exist, manual install | **SSH bootstrap**: one SSH session installs agent, never needed again |
| Tailscale/Headscale, NetBird, ZeroTier enroll + mesh connect | ❌ | Agent mesh-IP detection first; auth-key enroll optional |
| AWS EC2 + Hetzner provisioning w/ cloud-init | ❌ | IaC (ROADMAP §2.2): Terraform Proxmox/AWS + Hetzner module |
| Security groups (AWS SG rules, sync) | ❌ | Under IaC provider modules |
| Server-level global/wildcard domain (`updateServerDomain`) | ❌ | `nodes.default_domain` |
| Scheduled server cleanup (dokku cleanup → docker prune) | ❌ | Agent `prune` command + schedule |
| Queue flush/inspect per server (`flushServerQueues`, `getServerQueues`) | ➖ no queue yet | Ops page shows jobs once queue exists |
| Arbitrary SSH command on server (`executeCommand`) | ➖ exec is container-scoped | Node terminal via agent (PTY) |
| Server onboarding wizard, reset, cleanup-on-delete | 🟡 token issue/revoke only | Onboarding flow + `nodes add` UX |
| Ansible playbooks (stored, runnable per server, exec history) | ➖ | **Defer** — security surface; SSH bootstrap covers provisioning |
| Per-node resource dashboards | 🟡 heartbeat telemetry exists | Node detail page |
#### Platform / org
| dflow capability | Containr status | Action |
|---|---|---|
| Multi-tenant organisations + custom RBAC (per-resource CRUD, create-limits, read-scope) | 🟡 `is_admin` + `project_members` | Defer full RBAC; design PAT scopes role-compatible |
| Team invite links/emails | 🟡 manual user create only | `POST /invites` → link join |
| User API keys → full REST API | ❌ **the big gap** — session/JWT only | PATs (Phase A) |
| Outbound webhooks (collection events, HMAC, custom headers) | ❌ | `webhooks` table + delivery log |
| Banners (global + per-tenant, dismissible) | ❌ | `banners` table + top bar |
| White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` — **Containr remains the default brand** |
| Activity feed (icon/severity/category/metadata, 90-day TTL job) | 🟡 `audit_logs` coarser | Enrich + project feed UI |
| Admin metrics dashboard (users/servers/queued/failed counts) | 🟡 `/admin` exists | Extend |
| **Impersonate user** | ❌ | Admin support tool |
| In-app docs (content-collections markdown) | ✅ `/docs` | — |
| Dynamic client-side filter framework (535-line `filter.utils`) | 🟡 audit page filters only | Reuse pattern for tables |
| Ops "bubble" (queue monitor, terminal, sync) | ❌ | Operations page |
#### Agent surface
| dflow capability | Containr status | Action |
|---|---|---|
| Payload REST/GraphQL auto-API + per-user API keys | ❌ | PATs + OpenAPI (better: typed, documented) |
| CLI | ❌ | none in dflow — **Containr CLI is a genuine leap past it** |
| MCP | ❌ | none — same leap |
| Railway migrator script | ❌ | `containr migrate railway` |
| GitHub App **manifest flow** (instance self-provisions the App) | 🟡 env-configured App | `POST /git/github-app/manifest` flow |
| fork-sync webhook deploy trigger | ❌ | ~20 lines on receiver |
| Azure DevOps provider | ❌ | Optional (dflow does token-clone only) |
---
## 2. The spine: agent-first platform
*Agent and user on the same level.* Concretely:
- Every operation reachable by a **personal access token**, not just
sessions.
- Every operation in the **CLI** with `--json`, stable exit codes, `--yes`.
- Every operation in an **MCP server** (stdio first) so Devin/Claude Code/
Cursor/Codex drive Containr natively.
- `openapi.yaml` stays the single source of truth; UI, CLI, MCP are thin
clients.
### Phase A — Agent surface (do this FIRST; everything after inherits it)
- [ ] **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:
- `auth login [--token] [--url] [--profile]` (multi-instance profiles).
- Resources: `projects services deploy logs exec variables databases
cron templates nodes scaling ha security gateway notifications
webhooks domains volumes backups admin`.
- `containr up` / `deploy` — cwd→service create-or-update→watch
(the `railway up` equivalent; single most important command).
- `containr link` (bind cwd→service), `containr open`, `containr run`,
`containr shell` (PTY once exec upgrade lands), `completion`.
- Universal `--json`, `-o yaml`, `--watch`, `--yes`; exit codes
0/1/2/3/4 (ok/err/not-found/unauthorized/conflict).
- GoReleaser → GH releases + `install.sh`; `version` command.
- Conventions from docsync/envdiff/cix/repolint.
- [ ] **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;
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` +
`llms-full.txt`, MCP tool list generated from OpenAPI where possible.
**Gate**: an agent holding only a PAT registers a project, deploys from a
git repo, streams logs, sets vars, rolls back — zero UI. Same via CLI
for a human.
---
## 3. Phase B — Deployment pipeline maturity
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.
## 4. Phase C — Service configuration parity
- [ ] **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
compares vs edge IP → `ok|wrong-target|pending`.
- [ ] **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;
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).
## 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.
- [ ] **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.
- [ ] **Backup import** — upload archive → new restore point (pairs with
migrators).
## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates)
- [ ] **Template = ordered service graph** — `services[]` entries with
`{name, type, source, build, ports, volumes, variables, depends_on}`;
deploy order from dependency refs.
- [ ] **Expressions** — `${{service.KEY}}` (already the runtime ref
syntax — reuse), `{{secret(len,"charset")}}`, `{{random:int}}`,
`{{VAR:-default}}`; `POST /templates/:id/plan` returns the resolved
graph without deploying (agents love this).
- [ ] **Compose import** — `POST /templates/import/compose` (compose-go
parser → graph), `POST /templates/import/github` (compose file from
repo path). Implements `docs/superpowers/specs/2026-04-14-*`.
- [ ] **Graph deploy pipeline** — creates project + services in
dependency order, resolves vars, triggers builds; defined
partial-failure policy.
- [ ] **Visual template builder** — canvas mode: drop git/image/db
nodes, wire refs, set order, save/publish. Reuses existing canvas
primitives (dflow's `templates/compose` is exactly this).
- [ ] **Catalog growth** — convert `templates/*.md` (20 app guides) into
real graph templates; screenshots + badges per the old spec.
**Gate**: paste n8n compose → 3-service project with generated secrets
and wired refs, one click or one CLI call.
## 7. Phase F — Nodes & infrastructure
- [ ] **SSH bootstrap install** — `containr nodes add --ssh user@host` +
UI flow: one SSH session runs agent install (binary + systemd +
enroll token), SSH closed forever. dflow onboarding UX, agent-model
security.
- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/
zt0 IPs at register; backend stores + displays. Optional
`TAILSCALE_AUTH_KEY` enroll-at-provision later.
- [ ] **Node detail page** — placed services, telemetry history, agent
version + self-upgrade, **drain/cordon**, remove.
- [ ] **Node housekeeping** — agent `prune` command (docker system
prune, bounded) + optional schedule (`setServerAutoCleanup` analog —
disk pressure is the #1 small-VPS killer).
- [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists):
pin/spread/affinity, prefer-lowest-usage; deploy engine routes to
`services.node_id` via agent commands (agent already runs containers).
- [ ] **Build-on-node** — agents currently `docker run` prebuilt images
only; extend agent to pull registry images (build stays central) —
private registry creds flow through Phase C.
- [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/
OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module
(dflow's second provider), cloud-init → auto-enroll.
- [ ] **Global/wildcard domain per node** (`nodes.default_domain`) —
auto-domains for services on that node.
## 8. Phase G — Platform polish
- [ ] **Outbound webhooks** — `webhooks(url, secret, events[], headers,
enabled)` + `webhook_deliveries` log; HMAC `X-Containr-Signature`;
events: `deployment.* service.* database.* security.* agent.*`.
- [ ] **Activity feed** — enrich `audit_logs` with `severity category
icon label metadata`; per-project timeline + global feed; TTL job.
- [ ] **Banners** — `banners(title, body, level, scope, active)`; top
bar in UI + surfaced to CLI on `auth status`.
- [ ] **White-label** — `app_settings.branding`: product name, logos,
favicon, accent override, docs/support links. `GET /api/v1/branding`
public + cached. **Containr/Vertice remains default;** this is for
self-hosters who rebrand their instance.
- [ ] **Operations page** — unified view of running/queued/failed jobs
(deploys, builds, backups, cron, scans, agent commands) — dflow's
bubble panel as a real page; flush/stuck-job tools once queue exists.
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`);
`nodes/:id/terminal` via agent (dflow's wetty without the extra
container).
- [ ] **Team invites** — `POST /invites` → signed link → join flow;
replaces manual-only user creation.
- [ ] **Impersonate user** — admin support tool, audit-logged.
- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest`
returns manifest + redirect; instance self-provisions its App
(dflow pattern — removes the env-setup barrier).
- [ ] **fork-sync trigger** — accept repo-sync events on webhook
receiver.
- [ ] **Dynamic filter framework** — port dflow's `filter.utils`
pattern (typed filter configs → URL state → client filtering) for
audit logs, deployments, notifications tables.
- [ ] **Notification producers round-out** — backups, agent offline,
upgrade available, security findings.
- [ ] **Custom roles/RBAC** — defer until demanded; PAT scopes designed
forward-compatible now.
## 9. Phase H — Migration & ecosystem
- [ ] **Railway migrator** — `containr migrate railway --token --project
--dry-run`: Railway GraphQL → services/vars/domains → recreate via
PAT. Landing-page pitch: "off Railway in one command."
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST
export or Mongo dump: servers→nodes, projects, services(+vars/
domains/volumes), databases, templates. Onboards dflow refugees.
- [ ] **`containr import compose`** — Phase-E parser straight to a
project (no template intermediate).
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
fetch/publish; bundled catalog works offline (dflow's self-contained
principle — nothing phones home).
## 10. Carried-over debt (ROADMAP.md items still open)
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit →
`gateway/proxy.go` → metrics. Management API exists; proxy unwired.
- [ ] **Preview environments runtime** — real build+deploy+subdomain+TTL
(bookkeeping only now). Pairs with clone + multi-domain.
- [ ] **Environments** — env switcher, per-env vars/domains, promote.
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`.
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
webhook table tests, Playwright e2e golden path.
- [ ] **First-run wizard** — admin → GitHub App (manifest flow from
Phase G makes this self-serve) → Tunnel → done.
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release.
## 11. Deliberately not imported from dflow
- Dokku-over-SSH runtime — our Docker API + agent model is strictly
better.
- Per-server-named BullMQ queues — our queue stays a single ordered
table.
- Payload/Mongo/Next.js stack, multi-tenant plugin machinery.
- Ansible playbook runner — arbitrary code-on-node is a security
surface; SSH bootstrap + agent commands cover the real needs.
Revisit only with a concrete demand.
- `isAdmin`-only collection access — our owner/admin/public model is
richer already.
- Netdata install flow — our agent telemetry + optional Beszel covers
monitoring; don't ship a third dashboard stack.
## 12. Recommended order
```
A (agent surface) ──► B (deploy pipeline) ──► C/D (service+data depth)
│ │
▼ ▼
CLI/MCP dogfood every feature E (templates v2)
│
F (nodes/infra) ──► H (migrators)
│
G (polish) + debt
```
Why this order: PATs+CLI+MCP first = every later feature ships
user-and-agent-visible at once. The queue (B) is prerequisite for
the ops surface and correct concurrency. Templates v2 needs volumes,
multi-domain, clone. Migrators last — they're launch marketing, not
foundation.
## 13. Design rules
- **API is the contract.** UI, CLI, MCP are thin clients over
`openapi.yaml`. A feature isn't done until all three reach it.
- **Everything scriptable.** JSON in/out, `--json`/`--yes`, uniform
pagination, name-or-id addressing.
- **Same powers, same position.** Nothing user-facing that isn't
API-facing; agent-visible state (tokens, jobs) visible to users too.
- **dflow is a donor, not a dependency.** Port semantics, not code.
- **Containr branding is the default**; white-label is instance config.
- **Self-hosted first** — no required external service, no phone-home.
+1 -4
View File
@@ -15,8 +15,5 @@ var (
func main() { func main() {
cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime)) cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime))
if err := cli.Run(); err != nil { os.Exit(cli.Execute())
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
} }
+24
View File
@@ -0,0 +1,24 @@
// dumptemplates prints the seeded official template catalog as JSON in the same
// shape as GET /api/v1/templates. The landing site (site/templates.json) uses it
// as the offline fallback catalog: `go run ./cmd/dumptemplates > ../../site/templates.json`.
package main
import (
"containr/internal/api"
"encoding/json"
"fmt"
"os"
)
func main() {
out := struct {
Templates []api.ServiceTemplate `json:"templates"`
}{Templates: api.SeedTemplates()}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(out); err != nil {
fmt.Fprintln(os.Stderr, "encode:", err)
os.Exit(1)
}
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"containr/internal/mcpserver"
)
// Injected at build time via -ldflags.
var Version = "dev"
// containr-mcp exposes the whole Containr API as MCP tools over stdio.
// Configure it in an MCP client with:
//
// command: containr-mcp
// env: CONTAINR_API_URL, CONTAINR_TOKEN (a cnp_ personal access token)
func main() {
apiURL := os.Getenv("CONTAINR_API_URL")
if apiURL == "" {
apiURL = "http://localhost:8080/api/v1"
}
apiURL = strings.TrimSuffix(apiURL, "/")
if !strings.HasSuffix(apiURL, "/api/v1") {
apiURL += "/api/v1"
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := mcpserver.Run(ctx, apiURL, os.Getenv("CONTAINR_TOKEN"), Version); err != nil {
fmt.Fprintf(os.Stderr, "containr-mcp: %v\n", err)
os.Exit(1)
}
}
+9 -2
View File
@@ -8,11 +8,12 @@ require (
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/go-playground/validator/v10 v10.30.1 github.com/go-playground/validator/v10 v10.30.1
github.com/go-redis/redis/v8 v8.11.5 github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/miekg/dns v1.1.72 github.com/miekg/dns v1.1.72
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/pressly/goose/v3 v3.24.1 github.com/pressly/goose/v3 v3.24.1
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/rs/cors v1.10.1 github.com/rs/cors v1.10.1
@@ -21,6 +22,7 @@ require (
github.com/sqlc-dev/pqtype v0.3.0 github.com/sqlc-dev/pqtype v0.3.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
golang.org/x/term v0.46.0
) )
require ( require (
@@ -45,6 +47,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect
@@ -63,6 +66,8 @@ require (
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect
@@ -71,6 +76,7 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel v1.41.0 // indirect
@@ -84,10 +90,11 @@ require (
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/mod v0.41.0 // indirect golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.59.0 // indirect golang.org/x/net v0.59.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.23.0 // indirect golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.11.0 // indirect golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.49.0 // indirect golang.org/x/tools v0.49.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
+18 -4
View File
@@ -65,11 +65,13 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -105,6 +107,8 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -143,6 +147,10 @@ github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
@@ -178,6 +186,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
@@ -213,16 +223,20 @@ golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
+70 -32
View File
@@ -23,6 +23,7 @@ import (
dockercontainer "github.com/docker/docker/api/types/container" dockercontainer "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry" "github.com/docker/docker/api/types/registry"
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -196,7 +197,7 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
} }
db.Settings = h.generateMockSettings() db.Settings = h.generateMockSettings()
db.ConnectionURL = h.resolveConnectionURL(db) db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c))
} else { } else {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
db.ConnectionURL = "" db.ConnectionURL = ""
@@ -235,7 +236,7 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
} }
db.Settings = h.generateMockSettings() db.Settings = h.generateMockSettings()
db.ConnectionURL = h.resolveConnectionURL(db) db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c))
} else { } else {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
db.ConnectionURL = "" db.ConnectionURL = ""
@@ -942,33 +943,47 @@ 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).
func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string { func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string {
if strings.TrimSpace(db.ConnectionURL) != "" { return strings.TrimSpace(db.ConnectionURL)
return db.ConnectionURL
}
return h.generateConnectionURL(db)
} }
func (h *DatabaseHandler) generateConnectionURL(db DatabaseService) string { // requestHostname returns the host the client used to reach the API, without
safeName := sanitizeDBIdentifier(db.Name, "app") // the port. Empty when it cannot be determined.
switch db.Type { func requestHostname(c *gin.Context) string {
case "postgresql": host := strings.TrimSpace(c.Request.Host)
return fmt.Sprintf("postgresql://user:password@%s.containr.local:5432/%s", safeName, safeName) if host == "" {
case "redis":
return fmt.Sprintf("redis://%s.containr.local:6379", safeName)
case "dragonfly":
return fmt.Sprintf("redis://%s.containr.local:6379", safeName)
case "mysql":
return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", safeName, safeName)
case "mariadb":
return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", safeName, safeName)
case "mongodb":
return fmt.Sprintf("mongodb://user:password@%s.containr.local:27017/%s", safeName, safeName)
case "clickhouse":
return fmt.Sprintf("http://%s.containr.local:8123", safeName)
default:
return "" return ""
} }
if h, _, err := net.SplitHostPort(host); err == nil {
return strings.Trim(h, "[]")
}
return strings.Trim(host, "[]")
}
// rewriteLoopbackURL swaps loopback/unspecified hosts in rawURL for the host
// the caller actually used, so URLs handed to remote browsers are usable.
func rewriteLoopbackURL(rawURL, host string) string {
if rawURL == "" || host == "" {
return rawURL
}
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
switch strings.ToLower(u.Hostname()) {
case "localhost", "127.0.0.1", "0.0.0.0", "::1", "":
default:
return rawURL
}
port := u.Port()
if port == "" {
u.Host = host
} else {
u.Host = net.JoinHostPort(host, port)
}
return u.String()
} }
func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService { func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService {
@@ -1223,13 +1238,13 @@ func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseNam
defer cancel() defer cancel()
if h.dockerClient == nil { if h.dockerClient == nil {
fallbackURL := h.generateConnectionURL(DatabaseService{Name: databaseName, Type: dbType}) _ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{})
_ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{String: fallbackURL, Valid: fallbackURL != ""})
return return
} }
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables) connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables)
if err != nil { if err != nil {
log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err)
_ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{}) _ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{})
return return
} }
@@ -1337,7 +1352,7 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
if err != nil { if err != nil {
return "", err return "", err
} }
if err := waitForRuntimePortReadiness(ctx, hostPort); err != nil { if err := h.waitForDatabaseReadiness(ctx, containerID, hostPort, plan.Port.Port()); err != nil {
return "", err return "", err
} }
@@ -1371,12 +1386,24 @@ func (h *DatabaseHandler) resolvePublishedHostPort(ctx context.Context, containe
return "", fmt.Errorf("published host port not available") return "", fmt.Errorf("published host port not available")
} }
func waitForRuntimePortReadiness(ctx context.Context, hostPort string) error { // waitForDatabaseReadiness probes the database's TCP port until it accepts a
addr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort)) // connection. The published port lives on host loopback, which a containerized
deadline := time.Now().Add(30 * time.Second) // backend cannot reach; in that case the database is additionally attached to
// the backend's own network and probed by container IP.
func (h *DatabaseHandler) waitForDatabaseReadiness(ctx context.Context, containerID, hostPort, containerPort string) error {
probeAddr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort))
if backendNet := h.dockerClient.OwnNetworkName(ctx); backendNet != "" {
if err := h.dockerClient.ConnectNetwork(ctx, backendNet, containerID, network.EndpointSettings{}); err != nil {
log.Printf("containr: attach database %s to %s failed: %v", containerID, backendNet, err)
}
if ip := h.containerIPOnNetwork(ctx, containerID, backendNet); ip != "" {
probeAddr = net.JoinHostPort(ip, containerPort)
}
}
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 1500*time.Millisecond) conn, err := net.DialTimeout("tcp", probeAddr, 1500*time.Millisecond)
if err == nil { if err == nil {
_ = conn.Close() _ = conn.Close()
return nil return nil
@@ -1389,7 +1416,18 @@ func waitForRuntimePortReadiness(ctx context.Context, hostPort string) error {
} }
} }
return fmt.Errorf("database runtime did not become reachable on %s", addr) return fmt.Errorf("database runtime did not become reachable on %s", probeAddr)
}
func (h *DatabaseHandler) containerIPOnNetwork(ctx context.Context, containerID, networkName string) string {
inspect, err := h.dockerClient.GetContainer(ctx, containerID)
if err != nil || inspect.NetworkSettings == nil {
return ""
}
if ep, ok := inspect.NetworkSettings.Networks[networkName]; ok && ep != nil {
return strings.TrimSpace(ep.IPAddress)
}
return ""
} }
func (h *DatabaseHandler) ensureImage(ctx context.Context, imageRef string) error { func (h *DatabaseHandler) ensureImage(ctx context.Context, imageRef string) error {
+39 -7
View File
@@ -51,15 +51,16 @@ func TestDatabaseConnectionURLAndDefaultVersion(t *testing.T) {
} }
for _, tc := range cases { for _, tc := range cases {
url := handler.generateConnectionURL(DatabaseService{ plan, err := buildDatabaseRuntimePlan(tc.dbType, "example", nil)
Type: tc.dbType, if err != nil {
Name: "example", t.Fatalf("buildDatabaseRuntimePlan(%q) failed: %v", tc.dbType, err)
})
if url == "" {
t.Fatalf("generateConnectionURL returned empty for type %q", tc.dbType)
} }
url := plan.ConnectionURL("15432")
if len(tc.urlContains) > 0 && !strings.HasPrefix(url, tc.urlContains) { if len(tc.urlContains) > 0 && !strings.HasPrefix(url, tc.urlContains) {
t.Fatalf("generateConnectionURL(%q) = %q, expected prefix %q", tc.dbType, url, tc.urlContains) t.Fatalf("ConnectionURL(%q) = %q, expected prefix %q", tc.dbType, url, tc.urlContains)
}
if !strings.Contains(url, ":15432") {
t.Fatalf("ConnectionURL(%q) = %q, expected published port", tc.dbType, url)
} }
gotVersion := handler.getDefaultVersion(tc.dbType) gotVersion := handler.getDefaultVersion(tc.dbType)
@@ -69,6 +70,17 @@ func TestDatabaseConnectionURLAndDefaultVersion(t *testing.T) {
} }
} }
func TestResolveConnectionURLNeverFabricates(t *testing.T) {
handler := &DatabaseHandler{}
if got := handler.resolveConnectionURL(DatabaseService{Type: "postgresql", Name: "example"}); got != "" {
t.Fatalf("resolveConnectionURL with no stored URL = %q, want empty", got)
}
if got := handler.resolveConnectionURL(DatabaseService{ConnectionURL: "postgresql://u:p@localhost:15432/db"}); got != "postgresql://u:p@localhost:15432/db" {
t.Fatalf("resolveConnectionURL = %q, want stored URL", got)
}
}
func TestBuildDatabaseRuntimePlanSupportsAllTypes(t *testing.T) { func TestBuildDatabaseRuntimePlanSupportsAllTypes(t *testing.T) {
cases := []struct { cases := []struct {
dbType string dbType string
@@ -199,3 +211,23 @@ func TestHumanReadableBytes(t *testing.T) {
} }
} }
} }
func TestRewriteLoopbackURL(t *testing.T) {
tests := []struct {
name, raw, host, want string
}{
{"service url", "http://localhost:35981", "10.0.0.182", "http://10.0.0.182:35981"},
{"db url keeps creds", "postgresql://u:p@localhost:35432/db?sslmode=disable", "10.0.0.182", "postgresql://u:[email protected]:35432/db?sslmode=disable"},
{"ipv4 loopback", "redis://:[email protected]:36379/0", "host.lan", "redis://:[email protected]:36379/0"},
{"unspecified", "http://0.0.0.0:9000/x", "10.0.0.182", "http://10.0.0.182:9000/x"},
{"external host untouched", "https://example.com:443/x", "10.0.0.182", "https://example.com:443/x"},
{"empty raw", "", "10.0.0.182", ""},
{"empty host", "http://localhost:35981", "", "http://localhost:35981"},
{"no port", "http://localhost/path", "10.0.0.182", "http://10.0.0.182/path"},
}
for _, tt := range tests {
if got := rewriteLoopbackURL(tt.raw, tt.host); got != tt.want {
t.Errorf("%s: rewriteLoopbackURL(%q,%q) = %q, want %q", tt.name, tt.raw, tt.host, got, tt.want)
}
}
}
+164 -25
View File
@@ -3,10 +3,11 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue"
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -35,6 +36,7 @@ type CreateDeploymentRequest struct {
Branch string `json:"branch"` Branch string `json:"branch"`
Trigger string `json:"trigger"` Trigger string `json:"trigger"`
EnvVars map[string]string `json:"env_vars"` EnvVars map[string]string `json:"env_vars"`
NoCache bool `json:"no_cache"`
} }
type DeploymentResponse struct { type DeploymentResponse struct {
@@ -74,14 +76,21 @@ func handleGetDeployments(c *gin.Context) {
return return
} }
offset, limit := pageWindow(c, 50, 100)
var total int
_ = db.(*database.DB).QueryRow(
`SELECT COUNT(*) FROM deployments WHERE service_id = $1`, serviceID,
).Scan(&total)
rows, err := db.(*database.DB).Query( rows, err := db.(*database.DB).Query(
`SELECT id, service_id, commit_hash, status, image_name, image_tag, `SELECT id, service_id, commit_hash, status, image_name, image_tag,
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
FROM deployments FROM deployments
WHERE service_id = $1 WHERE service_id = $1
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 50`, LIMIT $2 OFFSET $3`,
serviceID, serviceID, limit, offset,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
@@ -104,7 +113,10 @@ func handleGetDeployments(c *gin.Context) {
deployments = append(deployments, d) deployments = append(deployments, d)
} }
c.JSON(http.StatusOK, gin.H{"deployments": deployments}) c.JSON(http.StatusOK, gin.H{
"deployments": deployments,
"pagination": paginationMeta(offset, limit, total),
})
} }
// RecentDeployment is a deployment row joined with its service and project names. // RecentDeployment is a deployment row joined with its service and project names.
@@ -132,11 +144,19 @@ func handleGetRecentDeployments(c *gin.Context) {
userID := optionalUserUUID(c) userID := optionalUserUUID(c)
isAdmin := contextIsAdmin(c) isAdmin := contextIsAdmin(c)
limit := 10 offset, limit := pageWindow(c, 10, 50)
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 { var total int
limit = n if err := db.(*database.DB).QueryRow(
} `SELECT COUNT(*)
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`,
userID, isAdmin,
).Scan(&total); err != nil {
total = 0
} }
rows, err := db.(*database.DB).Query( rows, err := db.(*database.DB).Query(
@@ -148,8 +168,8 @@ func handleGetRecentDeployments(c *gin.Context) {
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1) OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
ORDER BY d.created_at DESC ORDER BY d.created_at DESC
LIMIT $3`, LIMIT $3 OFFSET $4`,
userID, isAdmin, limit, userID, isAdmin, limit, offset,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
@@ -168,7 +188,10 @@ func handleGetRecentDeployments(c *gin.Context) {
deployments = append(deployments, d) deployments = append(deployments, d)
} }
c.JSON(http.StatusOK, gin.H{"deployments": deployments}) c.JSON(http.StatusOK, gin.H{
"deployments": deployments,
"pagination": paginationMeta(offset, limit, total),
})
} }
func handleCreateDeployment(c *gin.Context) { func handleCreateDeployment(c *gin.Context) {
@@ -289,7 +312,23 @@ func handleCreateDeployment(c *gin.Context) {
} }
engineInstance := engine.(*deployment.DeploymentEngine) engineInstance := engine.(*deployment.DeploymentEngine)
go runDeploymentAndSync(context.Background(), db.(*database.DB), engineInstance, &d, service, req, userID.(string)) pos := getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
DeploymentID: d.ID,
Run: func(jctx context.Context) {
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, service, req, userID.(string))
},
})
if pos > 0 {
d.Status = "queued"
_, _ = db.(*database.DB).Exec(
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), d.ID,
)
_, _ = db.(*database.DB).Exec(
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
)
}
} }
c.JSON(http.StatusCreated, DeploymentResponse{ c.JSON(http.StatusCreated, DeploymentResponse{
@@ -356,6 +395,18 @@ func runDeploymentAndSyncWithImage(
replicas = 1 replicas = 1
} }
var publishedPort int32
// Best effort: reuse the host port from the last live deployment so public
// URLs survive redeploys. Column exists post-migration; older DBs get
// ephemeral ports.
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
maintenanceMode, basicAuthUsers := serviceAccess(db, service.ID)
maintURL := ""
if maintenanceMode {
maintURL = maintenanceURL(db)
}
deployReq := &deployment.DeploymentRequest{ deployReq := &deployment.DeploymentRequest{
ProjectID: service.ProjectID.String(), ProjectID: service.ProjectID.String(),
ServiceID: service.ID.String(), ServiceID: service.ID.String(),
@@ -367,13 +418,19 @@ func runDeploymentAndSyncWithImage(
Environment: env, Environment: env,
Replicas: replicas, Replicas: replicas,
PublicPort: int32(service.Port), PublicPort: int32(service.Port),
PublishedPort: publishedPort,
Domain: service.Domain, Domain: service.Domain,
HealthPath: service.HealthCheckPath, HealthPath: service.HealthCheckPath,
RestartPolicy: service.RestartPolicy, RestartPolicy: service.RestartPolicy,
VolumeMounts: loadServiceVolumes(db, service.ID),
Domains: serviceDomainNames(db, service.ID, service.Domain),
Resources: deployment.ResourceLimits{ Resources: deployment.ResourceLimits{
MemoryBytes: parseMemoryLimit(service.Memory), MemoryBytes: parseMemoryLimit(service.Memory),
CPUQuota: parseCPULimit(service.CPU), CPUQuota: parseCPULimit(service.CPU),
}, },
Maintenance: maintenanceMode,
MaintenanceURL: maintURL,
BasicAuthUsers: basicAuthUsers,
}, },
Trigger: deployment.TriggerConfig{ Trigger: deployment.TriggerConfig{
Type: req.Trigger, Type: req.Trigger,
@@ -400,6 +457,7 @@ func runDeploymentAndSyncWithImage(
SourcePath: sourcePath, SourcePath: sourcePath,
Branch: req.Branch, Branch: req.Branch,
Commit: req.CommitHash, Commit: req.CommitHash,
NoCache: req.NoCache,
} }
} }
@@ -427,16 +485,19 @@ func runDeploymentAndSyncWithImage(
select { select {
case <-ctx.Done(): case <-ctx.Done():
failedAt := time.Now() failedAt := time.Now()
timeoutErr := "Deployment timed out before completion" finalStatus, finalErr := "failed", "Deployment timed out before completion"
if errors.Is(ctx.Err(), context.Canceled) {
finalStatus, finalErr = "cancelled", "Deployment cancelled"
}
_, _ = db.Exec( _, _ = db.Exec(
`UPDATE deployments `UPDATE deployments
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 SET status = $1, error = $2, completed_at = $3, updated_at = $3
WHERE id = $3`, WHERE id = $4`,
timeoutErr, failedAt, dbDeployment.ID, finalStatus, finalErr, failedAt, dbDeployment.ID,
) )
_, _ = db.Exec( _, _ = db.Exec(
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, `UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`,
failedAt, service.ID, finalStatus, failedAt, service.ID,
) )
return return
case <-syncTicker.C: case <-syncTicker.C:
@@ -503,13 +564,86 @@ func runDeploymentAndSyncWithImage(
} }
func mapEngineStatusToDBStatus(status string) string { func mapEngineStatusToDBStatus(status string) string {
switch status { if status == "running" {
case "running":
return "deployed" return "deployed"
case "cancelled": }
return "failed"
default:
return status return status
}
// getDeployQueue returns the per-service deploy queue. SetupRoutes always
// sets it; the fallback keeps handler tests that skip SetupRoutes working.
func getDeployQueue(c *gin.Context) *deployqueue.Queue {
if v, exists := c.Get("deploy_queue"); exists {
if q, ok := v.(*deployqueue.Queue); ok && q != nil {
return q
}
}
return deployqueue.New()
}
// handleCancelDeployment cancels a queued or in-flight deployment. Queued
// jobs are dropped and marked cancelled immediately; active jobs get their
// context cancelled so the build/reconcile aborts and the sync loop writes
// the terminal 'cancelled' status.
func handleCancelDeployment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available", "code": "DEPENDENCY_UNAVAILABLE"})
return
}
deploymentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID", "code": "VALIDATION"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated", "code": "UNAUTHENTICATED"})
return
}
var status, owner string
var serviceID uuid.UUID
err = db.(*database.DB).QueryRow(
`SELECT d.status, d.service_id, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(&status, &serviceID, &owner)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found", "code": "NOT_FOUND"})
return
}
if owner != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied", "code": "FORBIDDEN"})
return
}
switch status {
case "deployed", "failed", "cancelled":
c.JSON(http.StatusConflict, gin.H{"error": "Deployment already finished", "code": "NOT_CANCELLABLE"})
return
}
switch getDeployQueue(c).Cancel(serviceID, deploymentID) {
case deployqueue.WasQueued:
now := time.Now()
_, _ = db.(*database.DB).Exec(
`UPDATE deployments SET status = 'cancelled', error = 'Deployment cancelled', completed_at = $1, updated_at = $1 WHERE id = $2`,
now, deploymentID,
)
_, _ = db.(*database.DB).Exec(
`UPDATE services SET status = 'cancelled', updated_at = $1 WHERE id = $2`,
now, serviceID,
)
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
case deployqueue.WasActive:
c.JSON(http.StatusAccepted, gin.H{"status": "cancelling"})
default:
c.JSON(http.StatusConflict, gin.H{"error": "Deployment is not running on this node", "code": "NOT_CANCELLABLE"})
} }
} }
@@ -708,7 +842,12 @@ func handleRollbackDeployment(c *gin.Context) {
Trigger: "rollback", Trigger: "rollback",
} }
// Reuse the stored image directly — no rebuild. // Reuse the stored image directly — no rebuild.
go runDeploymentAndSyncWithImage(context.Background(), db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage) getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
DeploymentID: rollback.ID,
Run: func(jctx context.Context) {
runDeploymentAndSyncWithImage(jctx, db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
},
})
} }
c.JSON(http.StatusCreated, gin.H{ c.JSON(http.StatusCreated, gin.H{
+375
View File
@@ -0,0 +1,375 @@
package api
import (
"context"
"database/sql"
"net"
"net/http"
"regexp"
"strings"
"time"
"containr/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// ServiceDomain is one hostname routed to a service. services.domain stays
// the derived default; this table is the source of truth for the full set.
type ServiceDomain struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Domain string `json:"domain"`
IsDefault bool `json:"is_default"`
CertType string `json:"cert_type"`
CertStatus string `json:"cert_status"`
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// BasicAuthCred is a plaintext credential the server encodes to htpasswd.
type BasicAuthCred struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
var hostnameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`)
func validHostname(d string) bool {
return len(d) <= 253 && hostnameRE.MatchString(d)
}
func loadServiceDomains(db *database.DB, serviceID uuid.UUID) []ServiceDomain {
rows, err := db.Query(
`SELECT id, service_id, domain, is_default, cert_type, cert_status, last_checked_at, created_at
FROM service_domains WHERE service_id = $1 ORDER BY is_default DESC, created_at ASC`, serviceID)
if err != nil {
return nil
}
defer rows.Close()
out := []ServiceDomain{}
for rows.Next() {
var d ServiceDomain
if err := rows.Scan(&d.ID, &d.ServiceID, &d.Domain, &d.IsDefault, &d.CertType, &d.CertStatus, &d.LastCheckedAt, &d.CreatedAt); err == nil {
out = append(out, d)
}
}
return out
}
func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) []string {
domains := loadServiceDomains(db, serviceID)
out := make([]string, 0, len(domains))
for _, d := range domains {
out = append(out, d.Domain)
}
if len(out) == 0 && fallback != "" {
out = []string{fallback}
}
return out
}
// syncDefaultDomain writes the is_default row back to services.domain so
// legacy reads (CLI tables, public_url) keep working.
func syncDefaultDomain(db *database.DB, serviceID uuid.UUID) {
var domain string
err := db.QueryRow(
`SELECT domain FROM service_domains WHERE service_id = $1 AND is_default`, serviceID).Scan(&domain)
if err == sql.ErrNoRows {
err = db.QueryRow(
`SELECT domain FROM service_domains WHERE service_id = $1 ORDER BY created_at ASC LIMIT 1`, serviceID).Scan(&domain)
}
if err == sql.ErrNoRows {
domain = ""
}
_, _ = db.Exec(`UPDATE services SET domain = $1 WHERE id = $2`, domain, serviceID)
}
// serviceAccess loads the maintenance/basic-auth columns for the runtime spec.
func serviceAccess(db *database.DB, serviceID uuid.UUID) (maintenance bool, basicAuth string) {
_ = db.QueryRow(`SELECT maintenance_mode, basic_auth_users FROM services WHERE id = $1`, serviceID).
Scan(&maintenance, &basicAuth)
return maintenance, basicAuth
}
// maintenanceURL is the redirect target for maintenance mode — the public
// base URL of this API plus the maintenance page path.
func maintenanceURL(db *database.DB) string {
base := strings.TrimRight(settingValue(db, "base_url", "BASE_URL", ""), "/")
if base == "" {
return ""
}
return base + "/api/v1/maintenance"
}
// encodeBasicAuth turns plaintext creds into Traefik's htpasswd format
// (user:bcrypt hash, comma-separated). Bcrypt is what Traefik expects.
func encodeBasicAuth(creds []BasicAuthCred) (string, error) {
pairs := make([]string, 0, len(creds))
for _, cred := range creds {
if cred.Username == "" || cred.Password == "" {
continue
}
hash, err := bcrypt.GenerateFromPassword([]byte(cred.Password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
pairs = append(pairs, cred.Username+":"+string(hash))
}
return strings.Join(pairs, ","), nil
}
// basicAuthUsernames returns just the usernames for API responses — never
// the hashes.
func basicAuthUsernames(encoded string) []string {
var out []string
for _, pair := range strings.Split(encoded, ",") {
if u, _, ok := strings.Cut(pair, ":"); ok && u != "" {
out = append(out, u)
}
}
return out
}
// serviceWriteAccess mirrors the service update handler's access check.
func serviceWriteAccess(c *gin.Context, db *database.DB, serviceID uuid.UUID) bool {
userID, _ := c.Get("user_id")
var ok bool
err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool))`,
serviceID, userID, contextIsAdmin(c)).Scan(&ok)
return err == nil && ok
}
func handleListServiceDomains(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
c.JSON(http.StatusOK, gin.H{"domains": loadServiceDomains(db.(*database.DB), serviceID)})
}
func handleAddServiceDomain(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
return
}
var req struct {
Domain string `json:"domain" binding:"required"`
IsDefault bool `json:"is_default"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
domain := strings.ToLower(strings.TrimSpace(req.Domain))
if !validHostname(domain) {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid hostname")
return
}
// First domain on a service becomes the default automatically.
var count int
_ = db.(*database.DB).QueryRow(`SELECT COUNT(*) FROM service_domains WHERE service_id = $1`, serviceID).Scan(&count)
makeDefault := req.IsDefault || count == 0
tx, err := db.(*database.DB).Begin()
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
defer tx.Rollback()
if makeDefault {
if _, err := tx.Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
}
var inserted ServiceDomain
err = tx.QueryRow(
`INSERT INTO service_domains (service_id, domain, is_default)
VALUES ($1, $2, $3)
RETURNING id, service_id, domain, is_default, cert_type, cert_status, created_at`,
serviceID, domain, makeDefault).
Scan(&inserted.ID, &inserted.ServiceID, &inserted.Domain, &inserted.IsDefault, &inserted.CertType, &inserted.CertStatus, &inserted.CreatedAt)
if err != nil {
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
respondError(c, http.StatusConflict, "CONFLICT", "Domain already attached to this service")
return
}
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
if err := tx.Commit(); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusCreated, gin.H{"domain": inserted})
}
func handleDeleteServiceDomain(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
return
}
domainID, err := uuid.Parse(c.Param("domain_id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
return
}
res, err := db.(*database.DB).Exec(
`DELETE FROM service_domains WHERE id = $1 AND service_id = $2`, domainID, serviceID)
affected, _ := res.RowsAffected()
if err != nil || affected == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusOK, gin.H{"deleted": domainID})
}
func handleSetDefaultDomain(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
return
}
domainID, err := uuid.Parse(c.Param("domain_id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
return
}
res, err := db.(*database.DB).Exec(
`UPDATE service_domains SET is_default = (id = $1) WHERE service_id = $2`,
domainID, serviceID)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to set default domain")
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusOK, gin.H{"domains": loadServiceDomains(db.(*database.DB), serviceID)})
}
// handleCheckServiceDomains resolves each domain's DNS and compares it to
// the configured expected target (app_settings.public_ip / PUBLIC_IP, or the
// base_url host). Statuses: ok | wrong-target | pending.
func handleCheckServiceDomains(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
dbh := db.(*database.DB)
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
expectedIP := settingValue(dbh, "public_ip", "PUBLIC_IP", "")
expectedHost := ""
if base := settingValue(dbh, "base_url", "BASE_URL", ""); base != "" {
if u := strings.TrimPrefix(strings.TrimPrefix(base, "https://"), "http://"); u != "" {
expectedHost, _, _ = strings.Cut(u, "/")
expectedHost, _, _ = strings.Cut(expectedHost, ":")
}
}
type result struct {
Domain string `json:"domain"`
Status string `json:"status"` // ok | wrong-target | pending
Resolved []string `json:"resolved"`
}
results := []result{}
resolver := net.DefaultResolver
for _, d := range loadServiceDomains(dbh, serviceID) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 4*time.Second)
addrs, _ := resolver.LookupHost(ctx, d.Domain)
cname, _ := resolver.LookupCNAME(ctx, d.Domain)
cancel()
r := result{Domain: d.Domain, Resolved: addrs}
cname = strings.TrimSuffix(cname, ".")
switch {
case len(addrs) == 0:
r.Status = "pending"
case expectedIP != "":
r.Status = "wrong-target"
for _, a := range addrs {
if a == expectedIP {
r.Status = "ok"
}
}
case expectedHost != "":
r.Status = "ok" // resolves; CNAME match is advisory only
if cname != "" && cname != d.Domain && cname != expectedHost {
r.Status = "wrong-target"
}
default:
r.Status = "ok"
}
results = append(results, r)
_, _ = dbh.Exec(`UPDATE service_domains SET last_checked_at = NOW() WHERE id = $1`, d.ID)
}
c.JSON(http.StatusOK, gin.H{"domains": results})
}
// handleMaintenancePage serves the maintenance-mode landing page. Public:
// browsers hit it via the Traefik redirect before any auth.
func handleMaintenancePage(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Maintenance</title><style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0b0e14;color:#e6e8ee;font-family:system-ui,sans-serif}
.card{text-align:center;padding:2rem}.title{font-size:1.4rem;font-weight:600;margin-bottom:.5rem}
.sub{color:#9aa3b2;font-size:.95rem}.brand{margin-top:2rem;color:#5b6472;font-size:.75rem;letter-spacing:.12em;text-transform:uppercase}
</style></head><body><div class="card">
<div class="title">This service is under maintenance</div>
<div class="sub">Please check back shortly.</div>
<div class="brand">Containr</div>
</div></body></html>`))
}
+29
View File
@@ -0,0 +1,29 @@
package api
import (
"github.com/gin-gonic/gin"
)
// Stable machine-readable error codes for agent clients. The "error" field
// stays human-readable; "code" is the contract the CLI/MCP rely on.
const (
CodeUnauthorized = "UNAUTHENTICATED"
CodeInvalidToken = "INVALID_TOKEN"
CodeForbidden = "FORBIDDEN"
CodeAdminRequired = "ADMIN_REQUIRED"
CodeScopeInsufficient = "SCOPE_INSUFFICIENT"
CodeNotFound = "NOT_FOUND"
CodeValidation = "VALIDATION"
CodeConflict = "CONFLICT"
CodeInternal = "INTERNAL"
CodeUnavailable = "DEPENDENCY_UNAVAILABLE"
CodeIdempotencyRunning = "IDEMPOTENCY_IN_PROGRESS"
)
// respondError emits the standard error envelope. Existing handlers return
// bare {"error": msg} — this adds the stable code for new and
// agent-facing paths.
func respondError(c *gin.Context, status int, code, message string) {
c.JSON(status, gin.H{"error": message, "code": code})
c.Abort()
}
+18 -4
View File
@@ -14,6 +14,7 @@ import (
"containr/internal/database" "containr/internal/database"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -178,11 +179,24 @@ func handleGitWebhookPush(c *gin.Context) {
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`, `UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID, time.Now(), service.ID,
) )
go runDeploymentAndSync( // Webhook pushes default to a clean build — stale layers are the
context.Background(), db, engine, &d, service, // classic silent-rollback failure (dflow convention).
CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook"}, webhookReq := CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook", NoCache: true}
repoUserID, if pos := getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
DeploymentID: d.ID,
Run: func(jctx context.Context) {
runDeploymentAndSync(jctx, db, engine, &d, service, webhookReq, repoUserID)
},
}); pos > 0 {
_, _ = db.Exec(
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), d.ID,
) )
_, _ = db.Exec(
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
}
enqueued++ enqueued++
} }
+52
View File
@@ -0,0 +1,52 @@
package api
import (
"encoding/base64"
"fmt"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
// pageWindow parses pagination from either ?page=&limit= (legacy) or
// ?cursor=&limit= (agent-friendly). The cursor is an opaque base64 offset —
// cheap to implement over the existing offset queries while the public
// contract stays cursor-shaped for clients.
func pageWindow(c *gin.Context, defaultLimit, maxLimit int) (offset, limit int) {
limit = defaultLimit
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= maxLimit {
limit = n
}
}
if cur := c.Query("cursor"); cur != "" {
if raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(cur, "o_")); err == nil {
if n, err := strconv.Atoi(string(raw)); err == nil && n >= 0 {
return n, limit
}
}
return 0, limit // invalid cursor → first page, fail soft
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
return (page - 1) * limit, limit
}
// paginationMeta renders the standard envelope: legacy page fields plus
// next_cursor when another page exists.
func paginationMeta(offset, limit, total int) gin.H {
page := offset/limit + 1
meta := gin.H{
"page": page,
"limit": limit,
"total": total,
"pages": (total + limit - 1) / limit,
}
if offset+limit < total {
meta["next_cursor"] = "o_" + base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprint(offset+limit)))
}
return meta
}
@@ -0,0 +1,55 @@
package api
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func ctxWithQuery(q string) *gin.Context {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/x?"+q, nil)
return c
}
func TestPageWindowDefaults(t *testing.T) {
offset, limit := pageWindow(ctxWithQuery(""), 10, 100)
if offset != 0 || limit != 10 {
t.Fatalf("expected 0/10, got %d/%d", offset, limit)
}
}
func TestPageWindowLegacyPage(t *testing.T) {
offset, limit := pageWindow(ctxWithQuery("page=3&limit=20"), 10, 100)
if offset != 40 || limit != 20 {
t.Fatalf("expected 40/20, got %d/%d", offset, limit)
}
}
func TestPageWindowCursor(t *testing.T) {
// cursor for offset 50 produced by paginationMeta
meta := paginationMeta(0, 50, 200)
cursor := meta["next_cursor"].(string)
offset, limit := pageWindow(ctxWithQuery("cursor="+cursor+"&limit=50"), 10, 100)
if offset != 50 || limit != 50 {
t.Fatalf("expected 50/50, got %d/%d", offset, limit)
}
}
func TestPageWindowInvalidCursorFailsSoft(t *testing.T) {
offset, _ := pageWindow(ctxWithQuery("cursor=garbage"), 10, 100)
if offset != 0 {
t.Fatalf("expected offset 0 on invalid cursor, got %d", offset)
}
}
func TestPaginationMetaNextCursorOnlyWhenMore(t *testing.T) {
if _, ok := paginationMeta(0, 10, 10)["next_cursor"]; ok {
t.Fatal("no next page should omit next_cursor")
}
if _, ok := paginationMeta(0, 10, 25)["next_cursor"]; !ok {
t.Fatal("expected next_cursor when more pages exist")
}
}
+2 -18
View File
@@ -8,7 +8,6 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -55,18 +54,9 @@ func handleGetProjects(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB) queries := sqlcdb.New(db.DB)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) offset, limit := pageWindow(c, 10, 100)
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
search := strings.TrimSpace(c.DefaultQuery("search", "")) search := strings.TrimSpace(c.DefaultQuery("search", ""))
if page < 1 {
page = 1
}
if limit > 100 || limit < 1 {
limit = 10
}
offset := (page - 1) * limit
searchParam := sql.NullString{} searchParam := sql.NullString{}
if search != "" { if search != "" {
searchParam = sql.NullString{String: search, Valid: true} searchParam = sql.NullString{String: search, Valid: true}
@@ -99,15 +89,9 @@ func handleGetProjects(c *gin.Context) {
return return
} }
totalCount := int(total)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"projects": projects, "projects": projects,
"pagination": gin.H{ "pagination": paginationMeta(offset, limit, int(total)),
"page": page,
"limit": limit,
"total": totalCount,
"pages": (totalCount + limit - 1) / limit,
},
}) })
} }
+86
View File
@@ -4,17 +4,20 @@ import (
"context" "context"
"log" "log"
"net/http" "net/http"
"os"
"time" "time"
"containr/internal/build" "containr/internal/build"
"containr/internal/config" "containr/internal/config"
"containr/internal/database" "containr/internal/database"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/docker" "containr/internal/docker"
"containr/internal/ha" "containr/internal/ha"
"containr/internal/metrics" "containr/internal/metrics"
"containr/internal/middleware" "containr/internal/middleware"
"containr/internal/scaling" "containr/internal/scaling"
"containr/internal/secrets"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -57,6 +60,33 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
} }
} }
haManager := ha.NewHighAvailabilityManager(scheduler, metricsCollector) haManager := ha.NewHighAvailabilityManager(scheduler, metricsCollector)
if db != nil && db.DB != nil {
haManager.WithPersistence(db.DB)
if err := haManager.LoadHealthChecks(context.Background()); err != nil {
log.Printf("Failed to restore health checks: %v", err)
}
}
if dockerClient != nil && db != nil {
// Service-bound checks probe the service DNS name on the project
// network — the backend container joins it on demand. The old
// 127.0.0.1 fallback stays for node-bound checks.
docker := dockerClient
haManager.WithServiceResolver(func(ctx context.Context, serviceID string) (string, int, error) {
var name, projectID string
var port int
err := db.QueryRowContext(ctx,
`SELECT name, project_id::text, COALESCE(port, 0) FROM services WHERE id = $1`,
serviceID,
).Scan(&name, &projectID, &port)
if err != nil {
return "", 0, err
}
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(projectID)); err != nil {
return "", 0, err
}
return name, port, nil
})
}
haAPIManager := NewHAManager(haManager) haAPIManager := NewHAManager(haManager)
// Initialize scaling handler // Initialize scaling handler
@@ -71,6 +101,45 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Initialize database handler // Initialize database handler
databaseHandler := NewDatabaseHandler(db.DB, dockerClient) databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
// Per-service serialized deploy queue — one active deployment per
// service, the rest wait as 'queued'.
deployQueue := deployqueue.New()
// Encrypt-at-rest for secret env vars. SECRETS_KEY wins, JWT_SECRET is
// the fallback so existing installs need no new config.
secrets.Configure(os.Getenv("SECRETS_KEY"), cfg.JWTSecret)
// Boot sweep: in-memory engine state dies on restart, so anything that
// looked in-flight is actually orphaned — mark it failed rather than
// leaving phantom 'building'/'queued' rows.
_, _ = db.Exec(
`UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW()
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back')`,
)
// Boot sweep: encrypt any plaintext secret values left over from before
// encryption-at-rest landed. Transparent and idempotent.
if encRows, err := db.Query(`SELECT id, value FROM environment_variables WHERE is_secret = TRUE`); err == nil {
type encRow struct {
id string
value string
}
var toEncrypt []encRow
for encRows.Next() {
var r encRow
if err := encRows.Scan(&r.id, &r.value); err == nil && !secrets.IsEncrypted(r.value) {
toEncrypt = append(toEncrypt, r)
}
}
encRows.Close()
for _, r := range toEncrypt {
_, _ = db.Exec(`UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2`, secrets.Encrypt(r.value), r.id)
}
if len(toEncrypt) > 0 {
log.Printf("secrets: encrypted %d plaintext secret variable(s)", len(toEncrypt))
}
}
// Initialize security handler // Initialize security handler
securityHandler := NewSecurityHandler(db, cfg.JWTSecret) securityHandler := NewSecurityHandler(db, cfg.JWTSecret)
@@ -88,6 +157,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
if deploymentEngine != nil { if deploymentEngine != nil {
c.Set("deployment_engine", deploymentEngine) c.Set("deployment_engine", deploymentEngine)
} }
c.Set("deploy_queue", deployQueue)
c.Set("scheduler", scheduler) c.Set("scheduler", scheduler)
c.Set("metrics_collector", metricsCollector) c.Set("metrics_collector", metricsCollector)
c.Set("auto_scaler", autoScaler) c.Set("auto_scaler", autoScaler)
@@ -178,6 +248,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.GET("/auth/bootstrap", handleAuthBootstrap) public.GET("/auth/bootstrap", handleAuthBootstrap)
public.POST("/auth/login", handleLogin) public.POST("/auth/login", handleLogin)
public.POST("/auth/register", handleRegister) public.POST("/auth/register", handleRegister)
public.GET("/maintenance", handleMaintenancePage)
} }
// Read routes — public browsing. OptionalAuth resolves a session when // Read routes — public browsing. OptionalAuth resolves a session when
@@ -198,6 +269,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
read.GET("/services/:id/metrics", handleGetServiceMetrics) read.GET("/services/:id/metrics", handleGetServiceMetrics)
read.GET("/services/:id/runtime", handleGetServiceRuntime) read.GET("/services/:id/runtime", handleGetServiceRuntime)
read.GET("/services/:id/deployments", handleGetDeployments) read.GET("/services/:id/deployments", handleGetDeployments)
read.GET("/services/:id/domains", handleListServiceDomains)
read.GET("/services/:id/domains/check", handleCheckServiceDomains)
read.GET("/deployments", handleGetRecentDeployments) read.GET("/deployments", handleGetRecentDeployments)
read.GET("/deployments/:id", handleGetDeployment) read.GET("/deployments/:id", handleGetDeployment)
@@ -229,10 +302,16 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// enforce ownership; platform admins additionally pass every check. // enforce ownership; platform admins additionally pass every check.
authed := v1.Group("/") authed := v1.Group("/")
authed.Use(middleware.Auth(cfg.JWTSecret)) authed.Use(middleware.Auth(cfg.JWTSecret))
authed.Use(middleware.Idempotency())
{ {
authed.GET("/user/profile", handleGetProfile) authed.GET("/user/profile", handleGetProfile)
authed.PUT("/user/profile", handleUpdateProfile) authed.PUT("/user/profile", handleUpdateProfile)
// Personal access tokens for CLI/MCP/agent auth.
authed.GET("/user/tokens", handleListUserTokens)
authed.POST("/user/tokens", handleCreateUserToken)
authed.DELETE("/user/tokens/:id", handleDeleteUserToken)
authed.GET("/notifications", handleListNotifications) authed.GET("/notifications", handleListNotifications)
authed.POST("/notifications/:id/read", handleMarkNotificationRead) authed.POST("/notifications/:id/read", handleMarkNotificationRead)
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead) authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
@@ -250,12 +329,16 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.PUT("/services/:id", handleUpdateService) authed.PUT("/services/:id", handleUpdateService)
authed.DELETE("/services/:id", handleDeleteService) authed.DELETE("/services/:id", handleDeleteService)
authed.POST("/services/:id/domains", handleAddServiceDomain)
authed.DELETE("/services/:id/domains/:domain_id", handleDeleteServiceDomain)
authed.POST("/services/:id/domains/:domain_id/default", handleSetDefaultDomain)
authed.POST("/services/:id/start", handleServiceStart) authed.POST("/services/:id/start", handleServiceStart)
authed.POST("/services/:id/stop", handleServiceStop) authed.POST("/services/:id/stop", handleServiceStop)
authed.POST("/services/:id/restart", handleServiceRestart) authed.POST("/services/:id/restart", handleServiceRestart)
authed.POST("/services/:id/redeploy", handleServiceRedeploy) authed.POST("/services/:id/redeploy", handleServiceRedeploy)
authed.POST("/services/:id/deployments", handleCreateDeployment) authed.POST("/services/:id/deployments", handleCreateDeployment)
authed.POST("/deployments/:id/rollback", handleRollbackDeployment) authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
authed.POST("/deployments/:id/cancel", handleCancelDeployment)
// Environment variables — secrets, owner/admin only. // Environment variables — secrets, owner/admin only.
authed.GET("/services/:id/variables", handleGetVariables) authed.GET("/services/:id/variables", handleGetVariables)
@@ -325,6 +408,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Admin routes — platform-wide controls and cross-user management. // Admin routes — platform-wide controls and cross-user management.
admin := v1.Group("/") admin := v1.Group("/")
admin.Use(middleware.Auth(cfg.JWTSecret)) admin.Use(middleware.Auth(cfg.JWTSecret))
admin.Use(middleware.Idempotency())
admin.Use(middleware.RequireAdmin()) admin.Use(middleware.RequireAdmin())
{ {
admin.GET("/admin/overview", handleAdminOverview) admin.GET("/admin/overview", handleAdminOverview)
@@ -345,6 +429,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
scalingHandler.RegisterAdminRoutes(admin) scalingHandler.RegisterAdminRoutes(admin)
agentHandler.SetupAdminRoutes(admin) agentHandler.SetupAdminRoutes(admin)
admin.GET("/admin/volumes", handleAdminListVolumes)
admin.DELETE("/admin/volumes/:name", handleAdminDeleteVolume)
admin.GET("/audit-logs", handleGetAuditLogs) admin.GET("/audit-logs", handleGetAuditLogs)
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs) admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
+114 -5
View File
@@ -11,6 +11,8 @@ import (
"containr/internal/database" "containr/internal/database"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/secrets"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -36,6 +38,10 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
HealthPath: service.HealthCheckPath, HealthPath: service.HealthCheckPath,
RestartPolicy: service.RestartPolicy, RestartPolicy: service.RestartPolicy,
} }
// Best effort: reuse the host port from the last live deployment so the
// public URL survives restarts. Column exists post-migration; older DBs
// silently get ephemeral ports.
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&spec.PublishedPort)
if cmd := strings.TrimSpace(service.Command); cmd != "" { if cmd := strings.TrimSpace(service.Command); cmd != "" {
spec.Command = strings.Fields(cmd) spec.Command = strings.Fields(cmd)
} }
@@ -47,6 +53,12 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
} }
spec.NanoCPUs = parseCPULimit(service.CPU) spec.NanoCPUs = parseCPULimit(service.CPU)
spec.MemoryBytes = parseMemoryLimit(service.Memory) spec.MemoryBytes = parseMemoryLimit(service.Memory)
spec.Volumes = loadServiceVolumes(db, service.ID)
spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
if spec.Maintenance {
spec.MaintenanceURL = maintenanceURL(db)
}
if spec.Port > 0 { if spec.Port > 0 {
env["PORT"] = strconv.Itoa(service.Port) env["PORT"] = strconv.Itoa(service.Port)
} }
@@ -74,7 +86,7 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
if err := rows.Scan(&k, &v); err != nil { if err := rows.Scan(&k, &v); err != nil {
return nil, err return nil, err
} }
env[k] = v env[k] = secrets.Decrypt(v)
} }
var refServices []struct{ ID, Name string } var refServices []struct{ ID, Name string }
@@ -122,7 +134,7 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
if err != nil { if err != nil {
return "", false return "", false
} }
return value, true return secrets.Decrypt(value), true
} }
for k, v := range env { for k, v := range env {
@@ -283,6 +295,13 @@ func handleGetServiceRuntime(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
engine.ProbeServiceHealth(c.Request.Context(), state, service.ProjectID.String(), service.Name, service.Port, service.HealthCheckPath)
persistPublishedPort(db, service.ID, state.Ports)
if host := requestHostname(c); host != "" {
for i, u := range state.URLs {
state.URLs[i] = rewriteLoopbackURL(u, host)
}
}
c.JSON(http.StatusOK, gin.H{"runtime": state}) c.JSON(http.StatusOK, gin.H{"runtime": state})
} }
@@ -302,6 +321,7 @@ func reconcileNow(c *gin.Context, engine *deployment.DeploymentEngine, db *datab
status := "stopped" status := "stopped"
if state != nil { if state != nil {
status = state.Status status = state.Status
persistPublishedPort(db, service.ID, state.Ports)
} }
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID) _, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
c.JSON(http.StatusOK, gin.H{"runtime": state}) c.JSON(http.StatusOK, gin.H{"runtime": state})
@@ -369,6 +389,7 @@ func handleServiceRestart(c *gin.Context) {
// handleServiceRedeploy re-applies the current spec: pulls the image for // handleServiceRedeploy re-applies the current spec: pulls the image for
// image-sourced services and reconciles containers (env, ports, replicas). // image-sourced services and reconciles containers (env, ports, replicas).
// Runs through the per-service deploy queue so it never overlaps a build.
func handleServiceRedeploy(c *gin.Context) { func handleServiceRedeploy(c *gin.Context) {
engine, db, ok := getRuntimeEngine(c) engine, db, ok := getRuntimeEngine(c)
if !ok { if !ok {
@@ -394,7 +415,90 @@ func handleServiceRedeploy(c *gin.Context) {
} }
} }
service.Image = image service.Image = image
reconcileNow(c, engine, db, service)
finished := make(chan struct{})
var recErr error
var state *deployment.RuntimeState
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{Run: func(jctx context.Context) {
defer close(finished)
state, recErr = reconcileServiceJob(jctx, db, engine, service)
}})
select {
case <-finished:
if recErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": recErr.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"runtime": state})
case <-c.Request.Context().Done():
c.JSON(http.StatusAccepted, gin.H{"status": "queued"})
}
}
// reconcileServiceJob is the shared queue-job body for redeploy paths:
// build the spec from current DB state, reconcile containers, persist the
// resulting status and published port.
func reconcileServiceJob(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, service Service) (*deployment.RuntimeState, error) {
spec, err := serviceRuntimeSpec(db, service)
if err != nil {
return nil, err
}
state, err := engine.ReconcileService(ctx, spec)
if err != nil {
return nil, err
}
if state != nil {
persistPublishedPort(db, service.ID, state.Ports)
status := "stopped"
if state.Status != "" {
status = state.Status
}
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
}
return state, nil
}
// enqueueServiceRedeploy resolves a deployable image (service.Image, else
// the last successful deployment's tag) and queues a reconcile. Returns
// false when no image is available or the engine is missing.
func enqueueServiceRedeploy(c *gin.Context, db *database.DB, service Service) bool {
engineValue, exists := c.Get("deployment_engine")
if !exists || engineValue == nil {
return false
}
engine, ok := engineValue.(*deployment.DeploymentEngine)
if !ok {
return false
}
image := service.Image
if image == "" {
if err := db.QueryRow(
`SELECT image_name || ':' || image_tag FROM deployments
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
ORDER BY created_at DESC LIMIT 1`,
service.ID,
).Scan(&image); err != nil {
return false
}
}
service.Image = image
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{Run: func(jctx context.Context) {
_, _ = reconcileServiceJob(jctx, db, engine, service)
}})
return true
}
// persistPublishedPort records the host port Docker actually bound so later
// redeploys can reuse it. Idempotent — only writes when the value changed.
func persistPublishedPort(db *database.DB, serviceID uuid.UUID, ports []uint16) {
if len(ports) == 0 {
return
}
_, _ = db.Exec(
`UPDATE services SET published_port = $1 WHERE id = $2 AND published_port <> $1`,
int(ports[0]), serviceID,
)
} }
// liveServiceStatus reconciles the stored status with real container state. // liveServiceStatus reconciles the stored status with real container state.
@@ -415,10 +519,11 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
service.Status = state.Status service.Status = state.Status
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID) _, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID)
} }
persistPublishedPort(db, service.ID, state.Ports)
if service.Domain != "" { if service.Domain != "" {
service.PublicURL = "https://" + service.Domain service.PublicURL = "https://" + service.Domain
} else if len(state.URLs) > 0 { } else if len(state.URLs) > 0 {
service.PublicURL = state.URLs[0] service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c))
} }
} }
@@ -475,9 +580,13 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
if _, err := engine.ReconcileService(ctx, spec); err != nil { state, err := engine.ReconcileService(ctx, spec)
if err != nil {
return err return err
} }
if state != nil {
persistPublishedPort(db, service.ID, state.Ports)
}
_, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID) _, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID)
return nil return nil
} }
+171 -12
View File
@@ -3,8 +3,12 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue"
"context" "context"
"encoding/json"
"fmt"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -33,10 +37,65 @@ type Service struct {
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
RestartPolicy string `json:"restart_policy" db:"restart_policy"` RestartPolicy string `json:"restart_policy" db:"restart_policy"`
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB
Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
MaintenanceMode bool `json:"maintenance_mode" db:"-"`
BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
// ServiceVolume is a volume/bind mount applied to every replica.
type ServiceVolume struct {
Type string `json:"type"` // volume | bind (empty = volume)
Source string `json:"source"` // volume name or host path
Target string `json:"target"` // container path (absolute)
ReadOnly bool `json:"read_only"`
}
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades
// to no mounts rather than failing the request.
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
var raw []byte
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
return nil
}
var stored []ServiceVolume
if err := json.Unmarshal(raw, &stored); err != nil {
return nil
}
out := make([]deployment.VolumeMount, 0, len(stored))
for _, v := range stored {
out = append(out, deployment.VolumeMount{
Type: v.Type,
Source: v.Source,
Destination: v.Target,
ReadOnly: v.ReadOnly,
})
}
return out
}
// validateServiceVolumes enforces the mount contract — bad paths fail
// fast at write time instead of surfacing as docker create errors later.
func validateServiceVolumes(vols []ServiceVolume) error {
for i, v := range vols {
if v.Type == "" {
v.Type = "volume"
}
if v.Type != "volume" && v.Type != "bind" {
return fmt.Errorf("volumes[%d].type must be volume or bind", i)
}
if strings.TrimSpace(v.Source) == "" {
return fmt.Errorf("volumes[%d].source is required", i)
}
if !strings.HasPrefix(v.Target, "/") {
return fmt.Errorf("volumes[%d].target must be an absolute container path", i)
}
}
return nil
}
// CreateServiceRequest represents a request to create a service // CreateServiceRequest represents a request to create a service
type CreateServiceRequest struct { type CreateServiceRequest struct {
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
@@ -55,6 +114,9 @@ type CreateServiceRequest struct {
Domain string `json:"domain"` Domain string `json:"domain"`
HealthCheckPath string `json:"healthcheck_path"` HealthCheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"` RestartPolicy string `json:"restart_policy"`
Volumes []ServiceVolume `json:"volumes"`
MaintenanceMode bool `json:"maintenance_mode"`
BasicAuth []BasicAuthCred `json:"basic_auth"`
} }
// UpdateServiceRequest represents a request to update a service // UpdateServiceRequest represents a request to update a service
@@ -74,6 +136,9 @@ type UpdateServiceRequest struct {
Domain *string `json:"domain"` Domain *string `json:"domain"`
HealthCheckPath *string `json:"healthcheck_path"` HealthCheckPath *string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"` RestartPolicy string `json:"restart_policy"`
Volumes *[]ServiceVolume `json:"volumes"`
MaintenanceMode *bool `json:"maintenance_mode"`
BasicAuth *[]BasicAuthCred `json:"basic_auth"`
} }
// handleGetServices retrieves all services for a project // handleGetServices retrieves all services for a project
@@ -262,6 +327,15 @@ func handleCreateService(c *gin.Context) {
if service.RestartPolicy == "" { if service.RestartPolicy == "" {
service.RestartPolicy = "unless-stopped" service.RestartPolicy = "unless-stopped"
} }
if err := validateServiceVolumes(req.Volumes); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
service.Volumes = req.Volumes
volumesJSON, _ := json.Marshal(req.Volumes)
if len(req.Volumes) == 0 {
volumesJSON = []byte("[]")
}
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment) environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
if err != nil { if err != nil {
@@ -277,14 +351,14 @@ func handleCreateService(c *gin.Context) {
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name, (id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
build_command, start_command, type, status, image, command, environment, build_command, start_command, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain, git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, created_at, updated_at) healthcheck_path, restart_policy, volumes, 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)`, 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)`,
service.ID, service.ProjectID, service.Name, environmentID, service.Type, service.ID, service.ProjectID, service.Name, environmentID, service.Type,
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image, sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
"", service.Command, service.Type, service.Status, service.Image, service.Command, "", service.Command, service.Type, service.Status, service.Image, service.Command,
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory, service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy, service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
service.CreatedAt, service.UpdatedAt, volumesJSON, service.CreatedAt, service.UpdatedAt,
) )
if err != nil { if err != nil {
@@ -292,6 +366,26 @@ func handleCreateService(c *gin.Context) {
return return
} }
// Domains live in service_domains; services.domain is the derived default.
if req.Domain != "" && validHostname(strings.ToLower(req.Domain)) {
_, _ = db.(*database.DB).Exec(
`INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true)
ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`,
service.ID, strings.ToLower(req.Domain))
}
if req.MaintenanceMode || len(req.BasicAuth) > 0 {
basicAuthUsers := ""
if encoded, encErr := encodeBasicAuth(req.BasicAuth); encErr == nil {
basicAuthUsers = encoded
}
_, _ = db.(*database.DB).Exec(
`UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`,
req.MaintenanceMode, basicAuthUsers, service.ID)
service.MaintenanceMode = req.MaintenanceMode
service.BasicAuth = basicAuthUsernames(basicAuthUsers)
}
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
c.JSON(http.StatusCreated, gin.H{"service": service}) c.JSON(http.StatusCreated, gin.H{"service": service})
} }
@@ -369,6 +463,15 @@ func handleGetService(c *gin.Context) {
} }
liveServiceStatus(c, db.(*database.DB), &service) liveServiceStatus(c, db.(*database.DB), &service)
for _, v := range loadServiceVolumes(db.(*database.DB), service.ID) {
service.Volumes = append(service.Volumes, ServiceVolume{
Type: v.Type, Source: v.Source, Target: v.Destination, ReadOnly: v.ReadOnly,
})
}
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
service.MaintenanceMode = maintenance
service.BasicAuth = basicAuthUsernames(basicAuth)
c.JSON(http.StatusOK, gin.H{"service": service}) c.JSON(http.StatusOK, gin.H{"service": service})
} }
@@ -483,6 +586,18 @@ func handleUpdateService(c *gin.Context) {
if req.RestartPolicy != "" { if req.RestartPolicy != "" {
existingService.RestartPolicy = req.RestartPolicy existingService.RestartPolicy = req.RestartPolicy
} }
// lib/pq sends a nil []byte as an empty bytea literal rather than NULL,
// so the COALESCE fallback needs an untyped nil when volumes aren't sent.
var volumesArg interface{}
if req.Volumes != nil {
if err := validateServiceVolumes(*req.Volumes); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
existingService.Volumes = *req.Volumes
volumesJSON, _ := json.Marshal(*req.Volumes)
volumesArg = volumesJSON
}
existingService.UpdatedAt = time.Now() existingService.UpdatedAt = time.Now()
@@ -492,14 +607,14 @@ func handleUpdateService(c *gin.Context) {
SET name = $1, type = $2, image = $3, command = $4, environment = $5, SET name = $1, type = $2, image = $3, command = $4, environment = $5,
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10, git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
replicas = $11, port = $12, domain = $13, healthcheck_path = $14, replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
restart_policy = $15, updated_at = $16 restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16
WHERE id = $17`, WHERE id = $18`,
existingService.Name, existingService.Type, existingService.Image, existingService.Command, existingService.Name, existingService.Type, existingService.Image, existingService.Command,
existingService.Environment, existingService.GitRepo, existingService.GitBranch, existingService.Environment, existingService.GitRepo, existingService.GitBranch,
existingService.BuildPath, existingService.CPU, existingService.Memory, existingService.BuildPath, existingService.CPU, existingService.Memory,
existingService.Replicas, existingService.Port, existingService.Domain, existingService.Replicas, existingService.Port, existingService.Domain,
existingService.HealthCheckPath, existingService.RestartPolicy, existingService.HealthCheckPath, existingService.RestartPolicy,
existingService.UpdatedAt, existingService.ID, existingService.UpdatedAt, volumesArg, existingService.ID,
) )
if err != nil { if err != nil {
@@ -507,21 +622,65 @@ func handleUpdateService(c *gin.Context) {
return return
} }
// Domain writes route through service_domains — services.domain is the
// derived default and gets rewritten by syncDefaultDomain.
if req.Domain != nil {
domain := strings.ToLower(strings.TrimSpace(*req.Domain))
if domain == "" {
_, _ = db.(*database.DB).Exec(`DELETE FROM service_domains WHERE service_id = $1`, serviceID)
} else if validHostname(domain) {
_, _ = db.(*database.DB).Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID)
_, _ = db.(*database.DB).Exec(
`INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true)
ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`,
serviceID, domain)
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid hostname", "code": "VALIDATION"})
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
_ = db.(*database.DB).QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&existingService.Domain)
}
if req.MaintenanceMode != nil || req.BasicAuth != nil {
maintenance, basicAuth := serviceAccess(db.(*database.DB), serviceID)
if req.MaintenanceMode != nil {
maintenance = *req.MaintenanceMode
}
if req.BasicAuth != nil {
encoded, encErr := encodeBasicAuth(*req.BasicAuth)
if encErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encode basic auth", "code": "INTERNAL"})
return
}
basicAuth = encoded
}
_, _ = db.(*database.DB).Exec(
`UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`,
maintenance, basicAuth, serviceID)
existingService.MaintenanceMode = maintenance
}
existingService.Domains = loadServiceDomains(db.(*database.DB), serviceID)
_, storedAuth := serviceAccess(db.(*database.DB), serviceID)
existingService.BasicAuth = basicAuthUsernames(storedAuth)
// A replica change applies immediately when the service has live // A replica change applies immediately when the service has live
// containers; other spec fields take effect on the next deploy. // containers; other spec fields take effect on the next deploy.
// Runs through the deploy queue so it can't overlap a build.
if req.Replicas != nil && existingService.Status == "running" { if req.Replicas != nil && existingService.Status == "running" {
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil { if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
engine := engineValue.(*deployment.DeploymentEngine) engine := engineValue.(*deployment.DeploymentEngine)
serviceCopy := existingService serviceCopy := existingService
go func() { dbCopy := db.(*database.DB)
spec, err := serviceRuntimeSpec(db.(*database.DB), serviceCopy) getDeployQueue(c).Enqueue(serviceCopy.ID, deployqueue.Job{Run: func(jctx context.Context) {
spec, err := serviceRuntimeSpec(dbCopy, serviceCopy)
if err != nil { if err != nil {
return return
} }
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) if state, err := engine.ReconcileService(jctx, spec); err == nil && state != nil {
defer cancel() persistPublishedPort(dbCopy, serviceCopy.ID, state.Ports)
_, _ = engine.ReconcileService(ctx, spec) }
}() }})
} }
} }
+8
View File
@@ -40,6 +40,9 @@ type hostMonitoringResponse struct {
UptimeSeconds int64 `json:"uptime_seconds"` UptimeSeconds int64 `json:"uptime_seconds"`
DockerAvailable bool `json:"docker_available"` DockerAvailable bool `json:"docker_available"`
Docker map[string]any `json:"docker,omitempty"` Docker map[string]any `json:"docker,omitempty"`
// Scope names what the telemetry actually measures: the physical host for
// native dockerd, or the Docker Desktop VM when the daemon runs in one.
Scope string `json:"scope"`
CollectedAt string `json:"collected_at"` CollectedAt string `json:"collected_at"`
} }
@@ -159,6 +162,7 @@ func handleGetHostMonitoring(c *gin.Context) {
Storage: storage, Storage: storage,
Load: load, Load: load,
UptimeSeconds: uptime, UptimeSeconds: uptime,
Scope: "host",
CollectedAt: time.Now().UTC().Format(time.RFC3339), CollectedAt: time.Now().UTC().Format(time.RFC3339),
} }
@@ -173,6 +177,10 @@ func handleGetHostMonitoring(c *gin.Context) {
"images": info.Images, "images": info.Images,
"driver": info.Driver, "driver": info.Driver,
"server": info.ServerVersion, "server": info.ServerVersion,
"os": info.OperatingSystem,
}
if strings.Contains(strings.ToLower(info.OperatingSystem), "docker desktop") {
response.Scope = "docker-desktop-vm"
} }
} }
} }
+21 -5
View File
@@ -28,6 +28,7 @@ type ServiceTemplate struct {
Variables string `json:"variables" db:"variables"` Variables string `json:"variables" db:"variables"`
IsOfficial bool `json:"is_official" db:"is_official"` IsOfficial bool `json:"is_official" db:"is_official"`
OwnerID string `json:"owner_id,omitempty" db:"owner_id"` OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
IsPublic bool `json:"is_public" db:"is_public"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
@@ -335,6 +336,16 @@ func handleCreateFromTemplate(c *gin.Context) {
return return
} }
// sqlc's schema predates the runtime columns; port and healthcheck_path are
// written via raw SQL like every other service mutation path.
if _, err = tx.ExecContext(ctx,
`UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3`,
config.Port, strings.TrimSpace(config.HealthCheck), serviceID,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
return
}
for key, value := range envVars { for key, value := range envVars {
if strings.TrimSpace(key) == "" { if strings.TrimSpace(key) == "" {
continue continue
@@ -383,6 +394,7 @@ type templateWriteRequest struct {
Logo string `json:"logo"` Logo string `json:"logo"`
Config json.RawMessage `json:"config" binding:"required"` Config json.RawMessage `json:"config" binding:"required"`
Variables json.RawMessage `json:"variables"` Variables json.RawMessage `json:"variables"`
IsPublic bool `json:"is_public"`
} }
var templateCategories = map[string]bool{ var templateCategories = map[string]bool{
@@ -483,6 +495,7 @@ func handleCreateTemplate(c *gin.Context) {
Config: req.Config, Config: req.Config,
Variables: templateVariablesParam(req.Variables), Variables: templateVariablesParam(req.Variables),
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true}, OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
IsPublic: req.IsPublic,
}); err != nil { }); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"})
return return
@@ -529,6 +542,7 @@ func handleUpdateTemplate(c *gin.Context) {
Logo: nullableText(req.Logo), Logo: nullableText(req.Logo),
Config: req.Config, Config: req.Config,
Variables: templateVariablesParam(req.Variables), Variables: templateVariablesParam(req.Variables),
IsPublic: req.IsPublic,
ID: templateID, ID: templateID,
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true}, OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
}) })
@@ -727,7 +741,7 @@ func SeedTemplates() []ServiceTemplate {
Name: "Background Worker", Name: "Background Worker",
Description: "Background job processing service", Description: "Background job processing service",
Category: "worker", Category: "worker",
Logo: "https://cdn.simpleicons.org/terminal", Logo: "https://cdn.simpleicons.org/gnubash",
Config: `{"type":"worker","runtime":"node","build_command":"npm install","start_command":"npm run worker"}`, Config: `{"type":"worker","runtime":"node","build_command":"npm install","start_command":"npm run worker"}`,
Variables: `[{"key":"WORKER_CONCURRENCY","label":"Concurrency","default":"4","required":false,"secret":false}]`, Variables: `[{"key":"WORKER_CONCURRENCY","label":"Concurrency","default":"4","required":false,"secret":false}]`,
IsOfficial: true, IsOfficial: true,
@@ -737,7 +751,7 @@ func SeedTemplates() []ServiceTemplate {
Name: "Cron Job", Name: "Cron Job",
Description: "Scheduled task runner", Description: "Scheduled task runner",
Category: "cron", Category: "cron",
Logo: "https://cdn.simpleicons.org/clock", Logo: "https://cdn.simpleicons.org/clockify",
Config: `{"type":"cron","runtime":"node","build_command":"npm install","start_command":"npm run cron"}`, Config: `{"type":"cron","runtime":"node","build_command":"npm install","start_command":"npm run cron"}`,
Variables: `[{"key":"CRON_SCHEDULE","label":"Schedule","default":"0 * * * *","required":true,"secret":false}]`, Variables: `[{"key":"CRON_SCHEDULE","label":"Schedule","default":"0 * * * *","required":true,"secret":false}]`,
IsOfficial: true, IsOfficial: true,
@@ -1013,10 +1027,11 @@ func defaultTemplateResources(serviceType string) (cpu, memory string) {
} }
} }
// templateVisibleTo reports whether the caller may see a template: official // templateVisibleTo reports whether the caller may see a template: official and
// templates are public, user templates are owner/admin only. // published (is_public) templates are public, other user templates are
// owner/admin only.
func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool { func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool {
if t.IsOfficial { if t.IsOfficial || t.IsPublic {
return true return true
} }
if contextIsAdmin(c) { if contextIsAdmin(c) {
@@ -1047,6 +1062,7 @@ func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
Variables: variables, Variables: variables,
IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool, IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool,
OwnerID: ownerID, OwnerID: ownerID,
IsPublic: row.IsPublic,
CreatedAt: templateNullTime(row.CreatedAt), CreatedAt: templateNullTime(row.CreatedAt),
UpdatedAt: templateNullTime(row.UpdatedAt), UpdatedAt: templateNullTime(row.UpdatedAt),
} }
+194
View File
@@ -0,0 +1,194 @@
package api
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"net/http"
"strings"
"time"
"containr/internal/database"
sqlcdb "containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Personal access tokens let the CLI, MCP server, and third-party agents
// authenticate without a browser session. The raw token (cnp_…) is
// returned exactly once at creation; only its sha256 hash persists.
var userTokenScopes = map[string]bool{"read": true, "write": true, "admin": true}
func generateUserToken() (token string, prefix string, hash string, err error) {
buf := make([]byte, 24)
if _, err = rand.Read(buf); err != nil {
return "", "", "", err
}
token = "cnp_" + hex.EncodeToString(buf)
prefix = token[:12] // cnp_ + 8 hex chars, enough to identify in a list
sum := sha256.Sum256([]byte(token))
return token, prefix, hex.EncodeToString(sum[:]), nil
}
func userTokenJSON(t sqlcdb.UserToken) gin.H {
row := gin.H{
"id": t.ID.String(),
"name": t.Name,
"key_prefix": t.KeyPrefix,
"scope": t.Scope,
}
if t.CreatedAt.Valid {
row["created_at"] = t.CreatedAt.Time
}
if t.LastUsedAt.Valid {
row["last_used_at"] = t.LastUsedAt.Time
}
if t.ExpiresAt.Valid {
row["expires_at"] = t.ExpiresAt.Time
}
return row
}
func handleCreateUserToken(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
var req struct {
Name string `json:"name"`
Scope string `json:"scope"`
ExpiresInDays int `json:"expires_in_days"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"})
return
}
if req.Scope == "" {
req.Scope = "write"
}
if !userTokenScopes[req.Scope] {
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be one of: read, write, admin", "code": "VALIDATION"})
return
}
// A token can never outrank its owner: non-admin users cannot mint
// admin-scope tokens even for themselves.
if req.Scope == "admin" && !c.GetBool("is_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "admin scope requires a platform admin account", "code": "FORBIDDEN"})
return
}
if req.ExpiresInDays < 0 || req.ExpiresInDays > 3650 {
c.JSON(http.StatusBadRequest, gin.H{"error": "expires_in_days must be between 0 (never) and 3650", "code": "VALIDATION"})
return
}
token, prefix, hash, err := generateUserToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token", "code": "INTERNAL"})
return
}
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
return
}
expiresAt := sql.NullTime{}
if req.ExpiresInDays > 0 {
expiresAt = sql.NullTime{Time: time.Now().Add(time.Duration(req.ExpiresInDays) * 24 * time.Hour), Valid: true}
}
row, err := queries.CreateUserToken(c.Request.Context(), sqlcdb.CreateUserTokenParams{
UserID: ownerUUID,
Name: req.Name,
KeyPrefix: prefix,
TokenHash: hash,
Scope: req.Scope,
ExpiresAt: expiresAt,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token", "code": "INTERNAL"})
return
}
LogAudit(userID, "user_token", row.ID.String(), "create", map[string]interface{}{"name": req.Name, "scope": req.Scope})
payload := userTokenJSON(row)
payload["token"] = token
c.JSON(http.StatusCreated, payload)
}
func handleListUserTokens(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
return
}
rows, err := queries.ListUserTokens(c.Request.Context(), ownerUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens", "code": "INTERNAL"})
return
}
out := make([]gin.H, 0, len(rows))
for _, t := range rows {
out = append(out, userTokenJSON(t))
}
c.JSON(http.StatusOK, gin.H{"tokens": out})
}
func handleDeleteUserToken(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
return
}
tokenUUID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID", "code": "VALIDATION"})
return
}
affected, err := queries.RevokeUserToken(c.Request.Context(), sqlcdb.RevokeUserTokenParams{
ID: tokenUUID,
UserID: ownerUUID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token", "code": "INTERNAL"})
return
}
if affected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found", "code": "NOT_FOUND"})
return
}
LogAudit(userID, "user_token", tokenUUID.String(), "revoke", nil)
c.JSON(http.StatusOK, gin.H{"message": "Token revoked"})
}
+23 -1
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/secrets"
"net/http" "net/http"
"time" "time"
@@ -25,6 +26,7 @@ type EnvironmentVariable struct {
type UpdateVariablesRequest struct { type UpdateVariablesRequest struct {
Variables []VariableInput `json:"variables" binding:"required"` Variables []VariableInput `json:"variables" binding:"required"`
Redeploy bool `json:"redeploy"`
} }
type VariableInput struct { type VariableInput struct {
@@ -159,6 +161,9 @@ func handleUpdateVariables(c *gin.Context) {
value = stored value = stored
} }
} }
if v.IsSecret {
value = secrets.Encrypt(value)
}
varID := uuid.New() varID := uuid.New()
_, err = tx.Exec( _, err = tx.Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at) `INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
@@ -204,5 +209,22 @@ func handleUpdateVariables(c *gin.Context) {
variables = append(variables, v) variables = append(variables, v)
} }
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Environment variables updated successfully"}) resp := gin.H{"variables": variables, "message": "Environment variables updated successfully"}
if req.Redeploy {
// Load the service spec inputs and queue a reconcile behind any
// in-flight deployment — new env lands in the recreated containers.
var svc Service
svcErr := db.(*database.DB).QueryRow(
`SELECT id, project_id, name, COALESCE(image, ''), COALESCE(command, ''),
COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(domain, ''),
COALESCE(healthcheck_path, ''), COALESCE(restart_policy, 'unless-stopped'),
COALESCE(cpu, ''), COALESCE(memory, '')
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)
resp["redeploy_queued"] = svcErr == nil && enqueueServiceRedeploy(c, db.(*database.DB), svc)
}
c.JSON(http.StatusOK, resp)
} }
+105
View File
@@ -0,0 +1,105 @@
package api
import (
"net/http"
"time"
"containr/internal/docker"
"github.com/gin-gonic/gin"
)
// VolumeInfo is one docker volume plus whether any container mounts it.
type VolumeInfo struct {
Name string `json:"name"`
Driver string `json:"driver"`
Mountpoint string `json:"mountpoint"`
Scope string `json:"scope"`
InUse bool `json:"in_use"`
UsedBy []string `json:"used_by,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
func dockerClientOr500(c *gin.Context) (*docker.Client, bool) {
v, exists := c.Get("docker_client")
if !exists || v == nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Docker client not available")
return nil, false
}
return v.(*docker.Client), true
}
// handleAdminListVolumes returns the node's docker volume inventory with
// in-use flags. Admin-only: mountpoints reveal host filesystem layout.
func handleAdminListVolumes(c *gin.Context) {
client, ok := dockerClientOr500(c)
if !ok {
return
}
ctx := c.Request.Context()
vols, err := client.ListVolumes(ctx)
if err != nil {
respondError(c, http.StatusBadGateway, "DEPENDENCY_UNAVAILABLE", "Failed to list volumes: "+err.Error())
return
}
containers, err := client.ListContainers(ctx, true)
if err != nil {
respondError(c, http.StatusBadGateway, "DEPENDENCY_UNAVAILABLE", "Failed to list containers: "+err.Error())
return
}
inUse := map[string][]string{}
for _, cont := range containers {
for _, m := range cont.Mounts {
if m.Type == "volume" {
name := cont.ID[:12]
if len(cont.Names) > 0 {
name = cont.Names[0]
}
inUse[m.Name] = append(inUse[m.Name], name)
}
}
}
out := make([]VolumeInfo, 0, len(vols.Volumes))
for _, v := range vols.Volumes {
if v == nil {
continue
}
info := VolumeInfo{
Name: v.Name,
Driver: v.Driver,
Mountpoint: v.Mountpoint,
Scope: v.Scope,
Labels: v.Labels,
UsedBy: inUse[v.Name],
}
info.InUse = len(info.UsedBy) > 0
if t, err := time.Parse(time.RFC3339Nano, v.CreatedAt); err == nil {
info.CreatedAt = t
}
out = append(out, info)
}
c.JSON(http.StatusOK, gin.H{"volumes": out})
}
// handleAdminDeleteVolume removes a docker volume. Docker refuses removal
// of in-use volumes — that error is surfaced as a conflict.
func handleAdminDeleteVolume(c *gin.Context) {
client, ok := dockerClientOr500(c)
if !ok {
return
}
name := c.Param("name")
if name == "" {
respondError(c, http.StatusBadRequest, "VALIDATION", "Volume name is required")
return
}
if err := client.RemoveVolume(c.Request.Context(), name, false); err != nil {
respondError(c, http.StatusConflict, "CONFLICT", "Failed to remove volume: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"deleted": name})
}
+1
View File
@@ -222,6 +222,7 @@ func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest)
Dockerfile: "Dockerfile", Dockerfile: "Dockerfile",
Tags: []string{imageName}, Tags: []string{imageName},
BuildArgs: buildArgs, BuildArgs: buildArgs,
NoCache: req.NoCache,
Remove: true, Remove: true,
} }
+1
View File
@@ -498,6 +498,7 @@ func (n *NixpacksBuilder) Build(ctx context.Context, req *types.BuildRequest) (*
BuildArgs: map[string]*string{ BuildArgs: map[string]*string{
"BUILDKIT_INLINE_CACHE": strPtr("1"), "BUILDKIT_INLINE_CACHE": strPtr("1"),
}, },
NoCache: req.NoCache,
Remove: true, Remove: true,
} }
+1
View File
@@ -156,6 +156,7 @@ func (rb *RailpackBuilder) Build(ctx context.Context, req *types.BuildRequest) (
Tags: []string{imageName}, Tags: []string{imageName},
BuildArgs: buildArgs, BuildArgs: buildArgs,
Labels: req.Labels, Labels: req.Labels,
NoCache: req.NoCache,
Remove: true, Remove: true,
} }
+361
View File
@@ -0,0 +1,361 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// AdminCmd groups platform-admin operations.
var AdminCmd = &cobra.Command{
Use: "admin",
Short: "Platform administration (admin scope required)",
}
var adminOverviewCmd = &cobra.Command{
Use: "overview",
Short: "Platform overview",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/admin/overview", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var adminUsersCmd = &cobra.Command{
Use: "users",
Short: "List all users",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/admin/users", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "users")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, u := range items {
rows = append(rows, []string{
str(u, "id"), str(u, "email"), str(u, "name"), str(u, "is_admin"), relTime(u, "created_at"),
})
}
printRows(data, []string{"ID", "EMAIL", "NAME", "ADMIN", "CREATED"}, rows)
return nil
},
}
var adminSettingsCmd = &cobra.Command{
Use: "settings",
Short: "Show platform settings",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/settings", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var adminAuditCmd = &cobra.Command{
Use: "audit-logs",
Short: "List audit log entries",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/audit-logs", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// GatewayCmd manages the API gateway (admin).
var GatewayCmd = &cobra.Command{
Use: "gateway",
Short: "Manage the API gateway (admin)",
}
var gatewayServicesCmd = &cobra.Command{
Use: "services",
Short: "List gateway services",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/gateway/services", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var gatewayKeysCmd = &cobra.Command{
Use: "keys",
Short: "List gateway API keys",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/gateway/keys", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var gatewayAnalyticsCmd = &cobra.Command{
Use: "analytics",
Short: "Gateway traffic analytics",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
kind, _ := cmd.Flags().GetString("type")
data, err := c.Do("GET", "/gateway/analytics/"+kind, nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// HACmd manages high availability.
var HACmd = &cobra.Command{
Use: "ha",
Short: "High availability controls",
}
var haStatusCmd = &cobra.Command{
Use: "status",
Short: "Show HA status",
RunE: simpleGet("/ha/status"),
}
var haAlertsCmd = &cobra.Command{
Use: "alerts",
Short: "List active HA alerts",
RunE: simpleGet("/ha/alerts/active"),
}
var haHealthCmd = &cobra.Command{
Use: "health",
Short: "List health check results",
RunE: simpleGet("/ha/health/results"),
}
var haPoliciesCmd = &cobra.Command{
Use: "policies",
Short: "List failover policies",
RunE: simpleGet("/ha/failover/policies"),
}
var haEnableCmd = &cobra.Command{
Use: "enable",
Short: "Enable the HA manager",
RunE: simplePost("/ha/enable"),
}
var haDisableCmd = &cobra.Command{
Use: "disable",
Short: "Disable the HA manager",
RunE: func(cmd *cobra.Command, args []string) error {
if !Confirm("Disable HA? Services will no longer be failed over.") {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
return simplePost("/ha/disable")(cmd, args)
},
}
var haFailoverCmd = &cobra.Command{
Use: "failover",
Short: "Trigger a manual failover",
RunE: func(cmd *cobra.Command, args []string) error {
if !Confirm("Trigger a platform failover now? Services may be rescheduled.") {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
return simplePost("/ha/failover")(cmd, args)
},
}
// ScalingCmd manages autoscaling.
var ScalingCmd = &cobra.Command{
Use: "scaling",
Short: "Autoscaling controls",
}
var scalingStatusCmd = &cobra.Command{
Use: "status",
Short: "Show autoscaler status",
RunE: simpleGet("/scaling/status"),
}
var scalingPoliciesCmd = &cobra.Command{
Use: "policies",
Short: "List scaling policies",
RunE: simpleGet("/scaling/policies"),
}
var scalingServicesCmd = &cobra.Command{
Use: "services",
Short: "Show per-service scaling state",
RunE: simpleGet("/scaling/services"),
}
var scalingScaleCmd = &cobra.Command{
Use: "scale <service-id> <replicas>",
Short: "Manually scale a service",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
var replicas int
fmt.Sscanf(args[1], "%d", &replicas)
data, err := c.Do("POST", "/scaling/services/"+args[0]+"/scale", map[string]int{"replicas": replicas})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// SecurityCmd manages security scans and findings.
var SecurityCmd = &cobra.Command{
Use: "security",
Short: "Security scans and vulnerabilities",
}
var securityScanCmd = &cobra.Command{
Use: "scan <project-id>",
Short: "Run a security scan",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
scanType, _ := cmd.Flags().GetString("type")
serviceID, _ := cmd.Flags().GetString("service")
body := map[string]interface{}{"project_id": args[0], "scan_type": scanType}
if serviceID != "" {
body["service_id"] = serviceID
}
data, err := c.Do("POST", "/security/scans", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var securityVulnsCmd = &cobra.Command{
Use: "vulnerabilities <project-id>",
Short: "List a project's vulnerabilities",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/projects/"+args[0]+"/vulnerabilities", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var securityMetricsCmd = &cobra.Command{
Use: "metrics <project-id>",
Short: "Show a project's security metrics",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/projects/"+args[0]+"/security/metrics", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// simpleGet/simplePost cover the many read/trigger endpoints whose full
// JSON response is the useful output.
func simpleGet(path string) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", path, nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
}
}
func simplePost(path string) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", path, map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
}
}
func init() {
gatewayAnalyticsCmd.Flags().String("type", "traffic", "traffic|ops")
securityScanCmd.Flags().String("type", "configuration", "dependency|configuration|comprehensive")
securityScanCmd.Flags().String("service", "", "limit scan to one service")
AdminCmd.AddCommand(adminOverviewCmd, adminUsersCmd, adminSettingsCmd, adminAuditCmd)
GatewayCmd.AddCommand(gatewayServicesCmd, gatewayKeysCmd, gatewayAnalyticsCmd)
HACmd.AddCommand(haStatusCmd, haAlertsCmd, haHealthCmd, haPoliciesCmd, haEnableCmd, haDisableCmd, haFailoverCmd)
ScalingCmd.AddCommand(scalingStatusCmd, scalingPoliciesCmd, scalingServicesCmd, scalingScaleCmd)
SecurityCmd.AddCommand(securityScanCmd, securityVulnsCmd, securityMetricsCmd)
}
+94 -83
View File
@@ -2,139 +2,150 @@ package commands
import ( import (
"fmt" "fmt"
"net/http" "os"
"strings"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "golang.org/x/term"
) )
// AuthCmd represents the auth command // AuthCmd groups authentication commands.
var AuthCmd = &cobra.Command{ var AuthCmd = &cobra.Command{
Use: "auth", Use: "auth",
Short: "Authenticate with Containr API", Short: "Authenticate with a Containr instance",
Long: `Manage authentication with the Containr API. Long: `Login with a personal access token (cnp_...) created under
You can login, logout, and check your current authentication status.`, Settings → Personal Access Tokens, check status, or switch profiles.`,
} }
// loginCmd represents the login command
var loginCmd = &cobra.Command{ var loginCmd = &cobra.Command{
Use: "login [token]", Use: "login [token]",
Short: "Login to Containr", Short: "Store a personal access token",
Long: `Login to Containr using your API token. Long: `Login with a cnp_ token. Reads interactively when no argument is
You can get your token from the Containr web interface.`, given. The token is verified against the API before it is stored.`,
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
RunE: runLogin, RunE: runLogin,
} }
// logoutCmd represents the logout command
var logoutCmd = &cobra.Command{ var logoutCmd = &cobra.Command{
Use: "logout", Use: "logout",
Short: "Logout from Containr", Short: "Remove the stored token",
Long: `Remove stored authentication credentials.`,
RunE: runLogout, RunE: runLogout,
} }
// statusCmd represents the status command
var statusCmd = &cobra.Command{ var statusCmd = &cobra.Command{
Use: "status", Use: "status",
Short: "Check authentication status", Short: "Show the active profile and verify the token",
Long: `Check if you are currently authenticated with Containr.`,
RunE: runStatus, RunE: runStatus,
} }
func init() { func init() {
AuthCmd.AddCommand(loginCmd) loginCmd.Flags().String("url", "", "API URL to store in the profile (e.g. https://containr.example.com)")
AuthCmd.AddCommand(logoutCmd) loginCmd.Flags().String("name", "", "profile name to save under (default: active profile)")
AuthCmd.AddCommand(statusCmd) AuthCmd.AddCommand(loginCmd, logoutCmd, statusCmd)
}
type profileResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
IsAdmin bool `json:"is_admin"`
} }
func runLogin(cmd *cobra.Command, args []string) error { func runLogin(cmd *cobra.Command, args []string) error {
var token string var token string
if len(args) > 0 { if len(args) > 0 {
token = args[0] token = args[0]
} else { } else if isTerminal() {
// Prompt for token fmt.Fprint(os.Stderr, "Token: ")
fmt.Print("Enter your Containr API token: ") raw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Scanln(&token) fmt.Fprintln(os.Stderr)
if err != nil {
return err
}
token = string(raw)
} }
if token == "" { if token == "" {
return fmt.Errorf("token is required") return &APIError{Message: "token is required — pass it as an argument or pipe it via stdin", ExitCode: ExitValidation}
} }
// Store token in config // Verify before saving so a mistyped token fails loudly.
viper.Set("token", token) urlFlag, _ := cmd.Flags().GetString("url")
if err := viper.WriteConfig(); err != nil { probe := &Client{BaseURL: ResolveAPIURL(), Token: token}
return fmt.Errorf("failed to save token: %w", err) if urlFlag != "" {
probe.BaseURL = normalizeBase(urlFlag)
}
var profile profileResponse
if err := probe.DoJSON("GET", "/user/profile", nil, &profile); err != nil {
return fmt.Errorf("token verification failed: %w", err)
} }
fmt.Println("✓ Successfully logged in to Containr") name, _ := cmd.Flags().GetString("name")
if name == "" {
name = ActiveProfile()
}
if err := SaveProfile(name, probe.BaseURL, token); err != nil {
return fmt.Errorf("failed to save profile: %w", err)
}
if JSONMode() {
return PrintJSON(map[string]interface{}{
"profile": name, "api_url": probe.BaseURL,
"user": profile,
})
}
fmt.Printf("Logged in as %s (%s) — profile %q → %s\n", profile.Name, profile.Email, name, probe.BaseURL)
return nil return nil
} }
func runLogout(cmd *cobra.Command, args []string) error { func runLogout(cmd *cobra.Command, args []string) error {
// Remove token from config name := ActiveProfile()
viper.Set("token", "") if err := ClearProfileToken(name); err != nil {
if err := viper.WriteConfig(); err != nil { return fmt.Errorf("failed to update config: %w", err)
return fmt.Errorf("failed to remove token: %w", err) }
if !JSONMode() {
fmt.Printf("Removed token from profile %q\n", name)
} else {
return PrintJSON(map[string]string{"status": "ok", "profile": name})
} }
fmt.Println("✓ Successfully logged out from Containr")
return nil return nil
} }
func runStatus(cmd *cobra.Command, args []string) error { func runStatus(cmd *cobra.Command, args []string) error {
token := viper.GetString("token") client := NewClient()
if token == "" { profile := ActiveProfile()
fmt.Println("❌ Not authenticated")
fmt.Println("Run 'containr auth login <token>' to authenticate") if JSONMode() {
out := map[string]interface{}{
"profile": profile,
"api_url": client.BaseURL,
}
if client.Token != "" {
var p profileResponse
if err := client.DoJSON("GET", "/user/profile", nil, &p); err == nil {
out["user"] = p
out["authenticated"] = true
} else {
out["authenticated"] = false
}
} else {
out["authenticated"] = false
}
return PrintJSON(out)
}
fmt.Printf("Profile: %s\n", profile)
fmt.Printf("API URL: %s\n", client.BaseURL)
if client.Token == "" {
fmt.Println("Token: not set — run `containr auth login <token>`")
return nil return nil
} }
var p profileResponse
fmt.Println("✓ Authenticated with Containr") if err := client.DoJSON("GET", "/user/profile", nil, &p); err != nil {
apiURL := buildAPIURL("/user/profile") fmt.Printf("Token: present but rejected (%v)\n", err)
fmt.Printf("API URL: %s\n", strings.TrimSuffix(buildAPIURL(""), "/"))
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return fmt.Errorf("failed to create verification request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("⚠ Token verification unavailable: %v\n", err)
return nil return nil
} }
defer resp.Body.Close() fmt.Printf("Identity: %s <%s>\n", p.Name, p.Email)
if p.IsAdmin {
switch resp.StatusCode { fmt.Println("Role: platform admin")
case http.StatusOK:
fmt.Println("✓ Token verified against API")
case http.StatusUnauthorized, http.StatusForbidden:
fmt.Println("❌ Token rejected by API (expired/invalid)")
default:
fmt.Printf("⚠ Token verification returned unexpected status: %s\n", resp.Status)
} }
return nil return nil
} }
func buildAPIURL(endpoint string) string {
baseURL := viper.GetString("api-url")
if baseURL == "" {
baseURL = "http://localhost:8080/api/v1"
}
baseURL = strings.TrimSuffix(baseURL, "/")
if endpoint == "" {
return baseURL
}
if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
return baseURL + endpoint
}
+41 -5
View File
@@ -6,16 +6,52 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
) )
func TestBuildAPIURLDefaultsAndTrimming(t *testing.T) { func TestResolveAPIURLDefaultsAndTrimming(t *testing.T) {
viper.Set("api-url", "") viper.Set("api-url", "")
got := buildAPIURL("/user/profile") viper.Set("current_profile", "")
if got != "http://localhost:8080/api/v1/user/profile" { viper.Set("profiles.default.api_url", "")
if got := ResolveAPIURL(); got != "http://localhost:8080/api/v1" {
t.Fatalf("unexpected default api url: %s", got) t.Fatalf("unexpected default api url: %s", got)
} }
viper.Set("api-url", "https://api.example.com/api/v1/") viper.Set("api-url", "https://api.example.com/api/v1/")
got = buildAPIURL("user/profile") if got := ResolveAPIURL(); got != "https://api.example.com/api/v1" {
if got != "https://api.example.com/api/v1/user/profile" {
t.Fatalf("unexpected trimmed api url: %s", got) t.Fatalf("unexpected trimmed api url: %s", got)
} }
// Bare hosts gain the /api/v1 suffix.
viper.Set("api-url", "https://containr.example.com")
if got := ResolveAPIURL(); got != "https://containr.example.com/api/v1" {
t.Fatalf("unexpected suffixed api url: %s", got)
}
viper.Set("api-url", "")
}
func TestResolveTokenProfileAndEnv(t *testing.T) {
viper.Set("token", "")
viper.Set("current_profile", "staging")
viper.Set("profiles.staging.token", "cnp_staging")
if got := ResolveToken(); got != "cnp_staging" {
t.Fatalf("expected profile token, got %s", got)
}
t.Setenv("CONTAINR_TOKEN", "cnp_env")
if got := ResolveToken(); got != "cnp_env" {
t.Fatalf("env should win over profile, got %s", got)
}
viper.Set("token", "cnp_flag")
if got := ResolveToken(); got != "cnp_flag" {
t.Fatalf("flag should win over env, got %s", got)
}
}
func TestExitCodeMapping(t *testing.T) {
if got := exitCodeFor(401); got != ExitAuth {
t.Fatalf("401 → %d, want %d", got, ExitAuth)
}
if got := exitCodeFor(404); got != ExitNotFound {
t.Fatalf("404 → %d, want %d", got, ExitNotFound)
}
if got := exitCodeFor(400); got != ExitValidation {
t.Fatalf("400 → %d, want %d", got, ExitValidation)
}
} }
+169
View File
@@ -0,0 +1,169 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/spf13/viper"
)
// Exit codes — stable for scripting and agent use.
const (
ExitOK = 0
ExitError = 1
ExitAuth = 2
ExitNotFound = 3
ExitValidation = 4
ExitUnavailable = 5
)
// APIError is a non-2xx response from the Containr API. Code carries the
// stable exit code so `main` can map errors to exit statuses.
type APIError struct {
Status int
Message string
ExitCode int
}
func (e *APIError) Error() string {
if e.Message != "" {
return e.Message
}
return fmt.Sprintf("request failed with status %d", e.Status)
}
func exitCodeFor(status int) int {
switch status {
case http.StatusUnauthorized, http.StatusForbidden:
return ExitAuth
case http.StatusNotFound:
return ExitNotFound
case http.StatusBadRequest, http.StatusConflict, http.StatusUnprocessableEntity:
return ExitValidation
default:
return ExitError
}
}
// Client is a thin authenticated HTTP client for the Containr API. All CLI
// commands go through it — no command builds its own request.
type Client struct {
BaseURL string
Token string
HTTPClient *http.Client
}
// NewClient resolves the active profile (flag > env > current_profile) and
// returns a client ready to call the API. Call RequireAuth() for commands
// that need a token.
func NewClient() *Client {
return &Client{
BaseURL: ResolveAPIURL(),
Token: ResolveToken(),
HTTPClient: &http.Client{Timeout: 60 * time.Second},
}
}
// RequireAuth returns an error suitable for the CLI when no token is set.
func (c *Client) RequireAuth() error {
if c.Token == "" {
return &APIError{
Status: 0,
Message: "not authenticated — run `containr auth login <token>` or set CONTAINR_TOKEN",
ExitCode: ExitAuth,
}
}
return nil
}
// Do performs an API request. body may be nil, a []byte, or any value that
// marshals to JSON. Returns the raw response body.
func (c *Client) Do(method, path string, body interface{}) ([]byte, error) {
var reader io.Reader
if body != nil {
var data []byte
switch v := body.(type) {
case []byte:
data = v
default:
var err error
data, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to encode request: %w", err)
}
}
reader = bytes.NewReader(data)
}
url := c.BaseURL + path
req, err := http.NewRequest(method, url, reader)
if err != nil {
return nil, err
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if k := viper.GetString("idempotency-key"); k != "" && method == http.MethodPost {
req.Header.Set("Idempotency-Key", k)
}
req.Header.Set("Accept", "application/json")
hc := c.HTTPClient
if hc == nil {
hc = &http.Client{Timeout: 60 * time.Second}
}
resp, err := hc.Do(req)
if err != nil {
return nil, &APIError{
Status: 0,
Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err),
ExitCode: ExitUnavailable,
}
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode >= 400 {
msg := strings.TrimSpace(string(data))
var envelope struct {
Error string `json:"error"`
Code string `json:"code"`
}
if json.Unmarshal(data, &envelope) == nil && envelope.Error != "" {
msg = envelope.Error
if envelope.Code != "" {
msg = envelope.Code + ": " + msg
}
}
return nil, &APIError{
Status: resp.StatusCode,
Message: msg,
ExitCode: exitCodeFor(resp.StatusCode),
}
}
return data, nil
}
// DoJSON performs a request and unmarshals the response into out.
func (c *Client) DoJSON(method, path string, body interface{}, out interface{}) error {
data, err := c.Do(method, path, body)
if err != nil {
return err
}
if out == nil || len(data) == 0 {
return nil
}
return json.Unmarshal(data, out)
}
+106
View File
@@ -0,0 +1,106 @@
package commands
import (
"fmt"
"os"
"strings"
"github.com/spf13/viper"
)
// ActiveProfile resolves which profile applies: --profile flag >
// CONTAINR_PROFILE env > current_profile in config > "default".
func ActiveProfile() string {
if p := viper.GetString("profile"); p != "" {
return p
}
if p := os.Getenv("CONTAINR_PROFILE"); p != "" {
return p
}
if p := viper.GetString("current_profile"); p != "" {
return p
}
return "default"
}
// ResolveAPIURL returns the effective API base URL:
// --api-url flag > CONTAINR_API_URL env > profile.api_url > localhost default.
func ResolveAPIURL() string {
if u := viper.GetString("api-url"); u != "" {
return normalizeBase(u)
}
if u := os.Getenv("CONTAINR_API_URL"); u != "" {
return normalizeBase(u)
}
if u := viper.GetString("profiles." + ActiveProfile() + ".api_url"); u != "" {
return normalizeBase(u)
}
return "http://localhost:8080/api/v1"
}
// ResolveToken returns the effective auth token:
// --token flag > CONTAINR_TOKEN env > profile.token.
func ResolveToken() string {
if t := viper.GetString("token"); t != "" {
return t
}
if t := os.Getenv("CONTAINR_TOKEN"); t != "" {
return t
}
return viper.GetString("profiles." + ActiveProfile() + ".token")
}
func normalizeBase(u string) string {
u = strings.TrimSuffix(strings.TrimSpace(u), "/")
// Accept the bare host too — append the API prefix when missing.
if u != "" && !strings.HasSuffix(u, "/api/v1") {
u += "/api/v1"
}
return u
}
// SaveProfile persists api_url/token under a named profile and makes it
// current. Creates the config file if none exists.
func SaveProfile(name, apiURL, token string) error {
base := "profiles." + name
if apiURL != "" {
viper.Set(base+".api_url", apiURL)
}
if token != "" {
viper.Set(base+".token", token)
}
viper.Set("current_profile", name)
return writeConfig()
}
// ClearProfileToken removes only the token from the active profile.
func ClearProfileToken(name string) error {
viper.Set("profiles."+name+".token", "")
return writeConfig()
}
// ListProfiles returns profile name → api_url for `auth status --all`.
func ListProfiles() map[string]string {
out := map[string]string{}
profiles := viper.GetStringMap("profiles")
for name := range profiles {
if u := viper.GetString("profiles." + name + ".api_url"); u != "" {
out[name] = u
} else {
out[name] = "(no url)"
}
}
return out
}
func writeConfig() error {
if err := viper.WriteConfig(); err != nil {
// No config file yet — create one.
home, herr := os.UserHomeDir()
if herr != nil {
return fmt.Errorf("cannot locate home directory: %w", herr)
}
return viper.WriteConfigAs(home + "/.containr.yaml")
}
return nil
}
+169
View File
@@ -0,0 +1,169 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// CronCmd manages scheduled jobs.
var CronCmd = &cobra.Command{
Use: "cron",
Aliases: []string{"cron-jobs"},
Short: "Manage cron jobs",
}
var cronListCmd = &cobra.Command{
Use: "list",
Short: "List cron jobs",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/cron-jobs", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "cron_jobs", "jobs")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, j := range items {
rows = append(rows, []string{
str(j, "id"), str(j, "name"), str(j, "schedule"), str(j, "service_id"),
str(j, "enabled", "is_active"), relTime(j, "last_run_at"),
})
}
printRows(data, []string{"ID", "NAME", "SCHEDULE", "SERVICE", "ENABLED", "LAST RUN"}, rows)
return nil
},
}
var cronGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a cron job",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/cron-jobs/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var cronCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a cron job (5-field expression)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
for _, f := range []string{"name", "schedule", "command", "service-id", "timezone"} {
v, _ := cmd.Flags().GetString(f)
if v != "" {
key := f
if f == "service-id" {
key = "service_id"
}
body[key] = v
}
}
if body["service_id"] == nil || body["schedule"] == nil || body["command"] == nil {
return &APIError{Message: "--service-id, --schedule, and --command are required", ExitCode: ExitValidation}
}
data, err := c.Do("POST", "/cron-jobs", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var cronDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a cron job",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete cron job %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/cron-jobs/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted cron job %s\n", args[0])
return nil
},
}
var cronTriggerCmd = &cobra.Command{
Use: "trigger <id>",
Short: "Run a cron job now",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/cron-jobs/"+args[0]+"/trigger", map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var cronExecutionsCmd = &cobra.Command{
Use: "executions <id>",
Short: "List a cron job's execution history",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/cron-jobs/"+args[0]+"/executions", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "executions")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, e := range items {
rows = append(rows, []string{
str(e, "id"), str(e, "status", "exit_code"), str(e, "started_at"), str(e, "output", "stdout"),
})
}
printRows(data, []string{"ID", "STATUS", "STARTED", "OUTPUT"}, rows)
return nil
},
}
func init() {
cronCreateCmd.Flags().String("name", "", "job name")
cronCreateCmd.Flags().String("service-id", "", "target service id (required)")
cronCreateCmd.Flags().String("schedule", "", "5-field cron expression (required)")
cronCreateCmd.Flags().String("command", "", "command run inside the container (required)")
cronCreateCmd.Flags().String("timezone", "", "IANA timezone (default UTC)")
CronCmd.AddCommand(cronListCmd, cronGetCmd, cronCreateCmd, cronDeleteCmd, cronTriggerCmd, cronExecutionsCmd)
}
@@ -0,0 +1,204 @@
package commands
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
// DatabasesCmd manages managed databases.
var DatabasesCmd = &cobra.Command{
Use: "databases",
Aliases: []string{"database", "db"},
Short: "Manage databases",
}
var dbListCmd = &cobra.Command{
Use: "list",
Short: "List managed databases",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/databases", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "databases")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, d := range items {
rows = append(rows, []string{
str(d, "id"), str(d, "name"), str(d, "type", "engine"), str(d, "status"),
})
}
printRows(data, []string{"ID", "NAME", "TYPE", "STATUS"}, rows)
return nil
},
}
var dbGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a database (connection URL included)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/databases/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbCreateCmd = &cobra.Command{
Use: "create <name>",
Short: "Provision a managed database",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
dbType, _ := cmd.Flags().GetString("type")
body := map[string]interface{}{"name": args[0], "type": dbType}
for _, f := range []string{"version", "storage-size"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := f
if f == "storage-size" {
key = "storage_size"
}
body[key] = v
}
}
data, err := c.Do("POST", "/databases", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a database and its data",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete database %s and all its data?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/databases/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted database %s\n", args[0])
return nil
},
}
var dbActionCmd = &cobra.Command{
Use: "action <id> <start|stop|restart>",
Short: "Start, stop, or restart a database",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/databases/"+args[0]+"/action", map[string]string{"action": args[1]})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbBackupCmd = &cobra.Command{
Use: "backup <id>",
Short: "Take a manual backup snapshot",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/databases/"+args[0]+"/backup", map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbRestoreCmd = &cobra.Command{
Use: "restore <id> <backup-id>",
Short: "Restore a database from a backup",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Restore database %s from backup %s? Current data will be overwritten.", args[0], args[1])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
data, err := c.Do("POST", "/databases/"+args[0]+"/restore", map[string]string{"backup_id": args[1]})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbDownloadCmd = &cobra.Command{
Use: "download-backup <id> <backup-id> [file]",
Short: "Download a backup archive",
Args: cobra.RangeArgs(2, 3),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", fmt.Sprintf("/databases/%s/backups/%s/download", args[0], args[1]), nil)
if err != nil {
return err
}
out := args[1] + ".tar.gz"
if len(args) > 2 {
out = args[2]
}
if err := os.WriteFile(out, data, 0o600); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"file": out})
}
fmt.Printf("Wrote %s (%d bytes)\n", out, len(data))
return nil
},
}
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)
}
+162
View File
@@ -0,0 +1,162 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// DeployCmd triggers a deployment for a service.
var DeployCmd = &cobra.Command{
Use: "deploy <service-id>",
Short: "Trigger a deployment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{"trigger": "cli"}
if nc, _ := cmd.Flags().GetBool("no-cache"); nc {
body["no_cache"] = true
}
for _, f := range []string{"commit-hash", "branch"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := "commit_hash"
if f == "branch" {
key = "branch"
}
body[key] = v
}
}
data, err := c.Do("POST", "/services/"+args[0]+"/deployments", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
d, _ := unwrapObject(data, "deployment")
fmt.Printf("Deployment %s started for service %s (status: %s)\n",
str(d, "id"), args[0], str(d, "status"))
fmt.Println("Follow with: containr deployments logs " + str(d, "id"))
return nil
},
}
// DeploymentsCmd manages deployment history.
var DeploymentsCmd = &cobra.Command{
Use: "deployments",
Aliases: []string{"deployment"},
Short: "Inspect deployments",
}
var deploymentsListCmd = &cobra.Command{
Use: "list [service-id]",
Short: "List deployments (all recent, or for one service)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
path := "/deployments"
if len(args) > 0 {
path = "/services/" + args[0] + "/deployments"
}
data, err := c.Do("GET", path, nil)
if err != nil {
return err
}
items, err := unwrapList(data, "deployments")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, d := range items {
rows = append(rows, []string{
str(d, "id"), str(d, "service_id"), str(d, "status"), str(d, "trigger"), relTime(d, "created_at", "started_at"),
})
}
printRows(data, []string{"ID", "SERVICE", "STATUS", "TRIGGER", "STARTED"}, rows)
return nil
},
}
var deploymentsGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a deployment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/deployments/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var deploymentsLogsCmd = &cobra.Command{
Use: "logs <id>",
Short: "Stream a deployment's build/deploy logs",
Args: cobra.ExactArgs(1),
RunE: runDeploymentLogs,
}
var deploymentsCancelCmd = &cobra.Command{
Use: "cancel <id>",
Short: "Cancel a queued or running deployment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/deployments/"+args[0]+"/cancel", map[string]interface{}{})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
obj, _ := unwrapObject(data)
fmt.Printf("Deployment %s: %s\n", args[0], str(obj, "status"))
return nil
},
}
var deploymentsRollbackCmd = &cobra.Command{
Use: "rollback <id>",
Short: "Roll back to a deployment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/deployments/"+args[0]+"/rollback", map[string]interface{}{})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Rolled back to deployment %s\n", args[0])
return nil
},
}
func init() {
DeployCmd.Flags().String("commit-hash", "", "commit to deploy")
DeployCmd.Flags().String("branch", "", "branch to deploy")
DeployCmd.Flags().Bool("no-cache", false, "build without Docker layer cache")
DeploymentsCmd.AddCommand(deploymentsListCmd, deploymentsGetCmd, deploymentsLogsCmd, deploymentsRollbackCmd, deploymentsCancelCmd)
}
@@ -0,0 +1,113 @@
package commands
import (
"encoding/json"
"fmt"
"time"
)
// client returns an authenticated API client or an ExitAuth error.
func client() (*Client, error) {
c := NewClient()
if err := c.RequireAuth(); err != nil {
return nil, err
}
return c, nil
}
// unwrapList accepts both top-level arrays and {"<key>": [...]} envelopes,
// which the Containr API uses inconsistently between endpoints.
func unwrapList(data []byte, keys ...string) ([]map[string]interface{}, error) {
var arr []map[string]interface{}
if err := json.Unmarshal(data, &arr); err == nil {
return arr, nil
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(data, &obj); err != nil {
return nil, fmt.Errorf("unexpected response shape: %w", err)
}
for _, k := range keys {
if raw, ok := obj[k]; ok {
if err := json.Unmarshal(raw, &arr); err != nil {
return nil, fmt.Errorf("cannot decode %q: %w", k, err)
}
return arr, nil
}
}
return []map[string]interface{}{}, nil
}
func unwrapObject(data []byte, keys ...string) (map[string]interface{}, error) {
var obj map[string]interface{}
if err := json.Unmarshal(data, &obj); err != nil {
return nil, fmt.Errorf("unexpected response shape: %w", err)
}
for _, k := range keys {
if inner, ok := obj[k].(map[string]interface{}); ok {
return inner, nil
}
}
return obj, nil
}
// str extracts a display string from a response map.
func str(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
if v, ok := m[k]; ok && v != nil {
switch t := v.(type) {
case string:
return t
case float64:
if t == float64(int64(t)) {
return fmt.Sprintf("%d", int64(t))
}
return fmt.Sprintf("%v", t)
case bool:
return fmt.Sprintf("%t", t)
}
}
}
return "-"
}
func shortID(m map[string]interface{}) string {
id := str(m, "id")
if len(id) > 8 {
return id[:8]
}
return id
}
func relTime(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
s, ok := m[k].(string)
if !ok || s == "" {
continue
}
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return s
}
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
return "-"
}
// printRows renders a table, or raw JSON for the full response in --json mode.
func printRows(raw []byte, headers []string, rows [][]string) {
if JSONMode() {
PrintRaw(raw)
return
}
PrintTable(headers, rows)
}
+171
View File
@@ -0,0 +1,171 @@
package commands
import (
"bufio"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"github.com/spf13/cobra"
)
// LogsCmd prints service runtime logs.
var LogsCmd = &cobra.Command{
Use: "logs <service-id>",
Short: "Print or follow service logs",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
follow, _ := cmd.Flags().GetBool("follow")
tail, _ := cmd.Flags().GetInt("tail")
path := fmt.Sprintf("/services/%s/logs?tail=%d", args[0], tail)
if follow {
return streamSSE(c, path+"&follow=true")
}
data, err := c.Do("GET", path, nil)
if err != nil {
return err
}
return printLogEntries(data)
},
}
// ExecCmd runs a one-off command inside a service container.
var ExecCmd = &cobra.Command{
Use: "exec <service-id> -- <command>",
Short: "Run a one-off command in the service container (30s ceiling)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
command := strings.Join(args[1:], " ")
if command == "" {
return &APIError{Message: "no command given — usage: containr exec <id> -- <command>", ExitCode: ExitValidation}
}
data, err := c.Do("POST", "/services/"+args[0]+"/exec", map[string]string{"command": command})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
resp, _ := unwrapObject(data)
fmt.Print(str(resp, "output", "stdout"))
if code := str(resp, "exit_code", "exitCode"); code != "-" && code != "0" {
return &APIError{Message: fmt.Sprintf("command exited with %s", code), ExitCode: ExitError}
}
return nil
},
}
type logEntry struct {
Timestamp string `json:"timestamp"`
Message string `json:"message"`
Stream string `json:"stream"`
}
// printLogEntries decodes the {"logs":[...]} envelope both log endpoints use.
func printLogEntries(data []byte) error {
var payload struct {
Logs []logEntry `json:"logs"`
Message string `json:"message"`
}
if err := json.Unmarshal(data, &payload); err != nil {
PrintRaw(data)
return nil
}
if JSONMode() {
PrintRaw(data)
return nil
}
for _, e := range payload.Logs {
fmt.Println(e.Message)
}
if len(payload.Logs) == 0 && payload.Message != "" {
fmt.Fprintln(os.Stderr, payload.Message)
}
return nil
}
// runDeploymentLogs prints persisted build+runtime logs for a deployment.
func runDeploymentLogs(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
logType, _ := cmd.Flags().GetString("type")
data, err := c.Do("GET", "/deployments/"+args[0]+"/logs?type="+logType, nil)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
var payload struct {
BuildLog string `json:"build_log"`
RuntimeLog string `json:"runtime_log"`
}
if json.Unmarshal(data, &payload) == nil && (payload.BuildLog != "" || payload.RuntimeLog != "") {
if payload.BuildLog != "" {
fmt.Print(payload.BuildLog)
}
if payload.RuntimeLog != "" {
fmt.Print(payload.RuntimeLog)
}
return nil
}
return printLogEntries(data)
}
// streamSSE consumes a text/event-stream response and prints each
// `data:` line's message field until the server closes the stream.
func streamSSE(c *Client, path string) error {
req, err := http.NewRequest("GET", c.BaseURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Accept", "text/event-stream")
hc := c.HTTPClient
if hc == nil {
hc = http.DefaultClient
}
resp, err := hc.Do(req)
if err != nil {
return &APIError{Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err), ExitCode: ExitUnavailable}
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return &APIError{Status: resp.StatusCode, ExitCode: exitCodeFor(resp.StatusCode)}
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64<<10), 4<<20)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
var e logEntry
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &e); err == nil {
fmt.Println(e.Message)
} else {
fmt.Println(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
return scanner.Err()
}
func init() {
LogsCmd.Flags().BoolP("follow", "f", false, "stream new log lines (SSE)")
LogsCmd.Flags().Int("tail", 100, "number of lines to return")
deploymentsLogsCmd.Flags().String("type", "all", "log type: all|build|runtime")
}
+150
View File
@@ -0,0 +1,150 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// NodesCmd manages node agents (admin).
var NodesCmd = &cobra.Command{
Use: "nodes",
Aliases: []string{"node", "agents"},
Short: "Manage node agents (admin)",
}
var nodesListCmd = &cobra.Command{
Use: "list",
Short: "List registered node agents",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/agents", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "agents")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, a := range items {
rows = append(rows, []string{
str(a, "id"), str(a, "name", "hostname"), str(a, "status"), str(a, "version"), relTime(a, "last_heartbeat_at"),
})
}
printRows(data, []string{"ID", "NAME", "STATUS", "VERSION", "HEARTBEAT"}, rows)
return nil
},
}
var nodesGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a node agent",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/agents/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodesDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Remove a node agent",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Remove node agent %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/agents/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Removed agent %s\n", args[0])
return nil
},
}
// Agent tokens (cagt_) onboard new nodes.
var nodeTokensCmd = &cobra.Command{
Use: "tokens",
Short: "Manage agent onboarding tokens",
}
var nodeTokensIssueCmd = &cobra.Command{
Use: "issue [label]",
Short: "Issue an onboarding token (shown once)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
if len(args) > 0 {
body["label"] = args[0]
}
data, err := c.Do("POST", "/agent-tokens", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodeTokensListCmd = &cobra.Command{
Use: "list",
Short: "List onboarding tokens",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/agent-tokens", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodeTokensRevokeCmd = &cobra.Command{
Use: "revoke <id>",
Short: "Revoke an onboarding token",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("DELETE", "/agent-tokens/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() {
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesDeleteCmd, nodeTokensCmd)
}
@@ -0,0 +1,65 @@
package commands
import (
"github.com/spf13/cobra"
)
// NotificationsCmd lists and manages notifications.
var NotificationsCmd = &cobra.Command{
Use: "notifications",
Aliases: []string{"notifs"},
Short: "Manage notifications",
}
var notifListCmd = &cobra.Command{
Use: "list",
Short: "List notifications",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/notifications", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "notifications")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, n := range items {
rows = append(rows, []string{
str(n, "id"), str(n, "type"), str(n, "title"), str(n, "read"), relTime(n, "created_at"),
})
}
printRows(data, []string{"ID", "TYPE", "TITLE", "READ", "AGE"}, rows)
return nil
},
}
var notifReadCmd = &cobra.Command{
Use: "read <id>|all",
Short: "Mark a notification (or all) as read",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
path := "/notifications/" + args[0] + "/read"
if args[0] == "all" {
path = "/notifications/read-all"
}
data, err := c.Do("POST", path, map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() {
NotificationsCmd.AddCommand(notifListCmd, notifReadCmd)
}
@@ -0,0 +1,95 @@
package commands
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/viper"
)
// JSONMode reports whether --json was passed. In JSON mode every command
// prints the raw API response and nothing else — safe for agents and pipes.
func JSONMode() bool {
return viper.GetBool("json")
}
// PrintResult renders data: pretty JSON in --json mode, otherwise the
// default JSON rendering is used unless a command prints its own table
// first (commands that render a table should check JSONMode() themselves).
func PrintJSON(v interface{}) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}
// PrintRaw prints raw response bytes as pretty JSON when they decode as
// JSON, else verbatim.
func PrintRaw(data []byte) {
var v interface{}
if json.Unmarshal(data, &v) == nil {
out, _ := json.MarshalIndent(v, "", " ")
fmt.Println(string(out))
return
}
fmt.Println(string(data))
}
// PrintTable renders rows under headers with tabwriter alignment.
// Skipped entirely in JSON mode.
func PrintTable(headers []string, rows [][]string) {
if JSONMode() {
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, strings.Join(headers, "\t"))
for _, row := range rows {
fmt.Fprintln(w, strings.Join(row, "\t"))
}
w.Flush()
}
// Confirm asks the user to confirm a destructive action. Returns true
// immediately when --yes/--force was passed or stdin is not a TTY and
// --yes was given. In non-interactive contexts without --yes it refuses.
func Confirm(prompt string) bool {
if viper.GetBool("yes") {
return true
}
if !isTerminal() {
return false
}
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
reader := bufio.NewReader(os.Stdin)
answer, err := reader.ReadString('\n')
if err != nil {
return false
}
answer = strings.ToLower(strings.TrimSpace(answer))
return answer == "y" || answer == "yes"
}
func isTerminal() bool {
info, err := os.Stdin.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
// ExitCode inspects an error and returns the process exit code.
func ExitCode(err error) int {
if err == nil {
return ExitOK
}
if apiErr, ok := err.(*APIError); ok {
return apiErr.ExitCode
}
return ExitError
}
+102 -198
View File
@@ -1,219 +1,123 @@
package commands package commands
import ( import (
"bytes"
"containr/internal/pkg/utils"
"encoding/json"
"fmt" "fmt"
"io"
"net/http"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper"
) )
// Project represents a Containr project // ProjectsCmd manages projects.
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ProjectsCmd represents the projects command
var ProjectsCmd = &cobra.Command{ var ProjectsCmd = &cobra.Command{
Use: "projects", Use: "projects",
Aliases: []string{"project"},
Short: "Manage projects", Short: "Manage projects",
Long: `Manage your Containr projects.
You can list, create, update, and delete projects.`,
} }
// listProjectsCmd represents the list command var projectsListCmd = &cobra.Command{
var listProjectsCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List all projects", Short: "List projects",
Long: `List all your Containr projects.`, RunE: func(cmd *cobra.Command, args []string) error {
RunE: runListProjects, c, err := client()
} if err != nil {
return err
// createProjectCmd represents the create command
var createProjectCmd = &cobra.Command{
Use: "create [name]",
Short: "Create a new project",
Long: `Create a new Containr project.
Provide a name and optional description.`,
Args: cobra.ExactArgs(1),
RunE: runCreateProject,
}
// deleteProjectCmd represents the delete command
var deleteProjectCmd = &cobra.Command{
Use: "delete [project-id]",
Short: "Delete a project",
Long: `Delete a Containr project by ID.`,
Args: cobra.ExactArgs(1),
RunE: runDeleteProject,
}
var projectDescription string
// getAPIURL constructs the full API URL for a given endpoint
func getAPIURL(endpoint string) string {
baseURL := viper.GetString("api-url")
if baseURL == "" {
baseURL = "http://localhost:8080/api/v1" // Default for development
} }
data, err := c.Do("GET", "/projects", nil)
// Ensure baseURL doesn't end with / and endpoint starts with / if err != nil {
baseURL = strings.TrimSuffix(baseURL, "/") return err
if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
} }
items, err := unwrapList(data, "projects")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, p := range items {
rows = append(rows, []string{
str(p, "id"), str(p, "name"), str(p, "description"), relTime(p, "created_at"),
})
}
printRows(data, []string{"ID", "NAME", "DESCRIPTION", "CREATED"}, rows)
return nil
},
}
return baseURL + endpoint var projectsGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/projects/"+args[0], nil)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
p, _ := unwrapObject(data, "project")
fmt.Printf("ID: %s\n", str(p, "id"))
fmt.Printf("Name: %s\n", str(p, "name"))
fmt.Printf("Description: %s\n", str(p, "description"))
fmt.Printf("Created: %s\n", str(p, "created_at"))
return nil
},
}
var projectsCreateCmd = &cobra.Command{
Use: "create <name>",
Short: "Create a project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
desc, _ := cmd.Flags().GetString("description")
body := map[string]interface{}{"name": args[0]}
if desc != "" {
body["description"] = desc
}
data, err := c.Do("POST", "/projects", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
p, _ := unwrapObject(data, "project")
fmt.Printf("Created project %s (%s)\n", str(p, "name"), str(p, "id"))
return nil
},
}
var projectsDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a project and all its services",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete project %s and all its services?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/projects/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted project %s\n", args[0])
return nil
},
} }
func init() { func init() {
ProjectsCmd.AddCommand(listProjectsCmd) projectsCreateCmd.Flags().StringP("description", "d", "", "project description")
ProjectsCmd.AddCommand(createProjectCmd) ProjectsCmd.AddCommand(projectsListCmd, projectsGetCmd, projectsCreateCmd, projectsDeleteCmd)
ProjectsCmd.AddCommand(deleteProjectCmd)
// Add flags
createProjectCmd.Flags().StringVarP(&projectDescription, "description", "d", "", "Project description")
}
func runListProjects(cmd *cobra.Command, args []string) error {
apiURL := getAPIURL("/projects")
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
var projects []Project
if err := json.Unmarshal(body, &projects); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if len(projects) == 0 {
fmt.Println("No projects found")
return nil
}
fmt.Println("Your Projects:")
fmt.Println()
for _, project := range projects {
fmt.Printf("📦 %s (%s)\n", project.Name, project.ID)
if project.Description != "" {
fmt.Printf(" %s\n", project.Description)
}
fmt.Printf(" Created: %s\n", utils.FormatTime(project.CreatedAt))
fmt.Println()
}
return nil
}
func runCreateProject(cmd *cobra.Command, args []string) error {
name := args[0]
projectData := map[string]interface{}{
"name": name,
}
if projectDescription != "" {
projectData["description"] = projectDescription
}
jsonData, err := json.Marshal(projectData)
if err != nil {
return fmt.Errorf("failed to marshal project data: %w", err)
}
apiURL := getAPIURL("/projects")
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
var project Project
if err := json.Unmarshal(body, &project); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
fmt.Printf("✓ Project '%s' created successfully!\n", project.Name)
fmt.Printf("ID: %s\n", project.ID)
return nil
}
func runDeleteProject(cmd *cobra.Command, args []string) error {
projectID := args[0]
apiURL := getAPIURL("/projects/" + projectID)
req, err := http.NewRequest("DELETE", apiURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
}
fmt.Printf("✓ Project '%s' deleted successfully!\n", projectID)
return nil
} }
@@ -0,0 +1,439 @@
package commands
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
// ServicesCmd manages services.
var ServicesCmd = &cobra.Command{
Use: "services",
Aliases: []string{"service", "svc"},
Short: "Manage services",
}
var servicesListCmd = &cobra.Command{
Use: "list [project-id]",
Short: "List services in a project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/projects/"+args[0]+"/services", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "services")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, s := range items {
rows = append(rows, []string{
str(s, "id"), str(s, "name"), str(s, "type"), str(s, "status"), str(s, "environment"),
})
}
printRows(data, []string{"ID", "NAME", "TYPE", "STATUS", "ENV"}, rows)
return nil
},
}
var servicesGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a service",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0], nil)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
s, _ := unwrapObject(data, "service")
for _, k := range []string{"id", "name", "type", "status", "environment", "image", "git_repo", "git_branch", "domain", "port"} {
fmt.Printf("%-14s %s\n", k+":", str(s, k))
}
return nil
},
}
var servicesCreateCmd = &cobra.Command{
Use: "create <project-id> <name>",
Short: "Create a service (image- or git-based)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{
"name": args[1],
}
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := f
// JSON uses snake_case keys matching the API schema.
switch f {
case "git-repo":
key = "git_repo"
case "git-branch":
key = "git_branch"
case "build-path":
key = "build_path"
case "restart-policy":
key = "restart_policy"
case "healthcheck-path":
key = "healthcheck_path"
}
body[key] = v
}
}
if v, _ := cmd.Flags().GetInt("port"); v > 0 {
body["port"] = v
}
if v, _ := cmd.Flags().GetInt("replicas"); v > 0 {
body["replicas"] = v
}
data, err := c.Do("POST", "/projects/"+args[0]+"/services", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
s, _ := unwrapObject(data, "service")
fmt.Printf("Created service %s (%s)\n", str(s, "name"), str(s, "id"))
return nil
},
}
var servicesDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a service",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete service %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/services/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted service %s\n", args[0])
return nil
},
}
var servicesUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a service (volumes, replicas, domain, ...)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory"} {
if cmd.Flags().Changed(f) {
key := f
switch f {
case "restart-policy":
key = "restart_policy"
case "healthcheck-path":
key = "healthcheck_path"
}
v, _ := cmd.Flags().GetString(f)
body[key] = v
}
}
if cmd.Flags().Changed("replicas") {
v, _ := cmd.Flags().GetInt("replicas")
body["replicas"] = v
}
if cmd.Flags().Changed("port") {
v, _ := cmd.Flags().GetInt("port")
body["port"] = v
}
if cmd.Flags().Changed("volume") || cmd.Flags().Changed("bind") || cmd.Flags().Changed("clear-volumes") {
volumes := []map[string]interface{}{}
for _, spec := range volumeFlagValues(cmd, "volume", "bind") {
volumes = append(volumes, spec)
}
body["volumes"] = volumes
}
if cmd.Flags().Changed("maintenance") {
v, _ := cmd.Flags().GetString("maintenance")
body["maintenance_mode"] = v == "on" || v == "true"
}
if cmd.Flags().Changed("basic-auth") {
creds := []map[string]interface{}{}
pairs, _ := cmd.Flags().GetStringArray("basic-auth")
for _, p := range pairs {
user, pass, ok := strings.Cut(p, ":")
if ok {
creds = append(creds, map[string]interface{}{"username": user, "password": pass})
}
}
body["basic_auth"] = creds
}
if len(body) == 0 {
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
}
data, err := c.Do("PUT", "/services/"+args[0], body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Updated service %s\n", args[0])
return nil
},
}
// volumeFlagValues parses repeated "src:target[:ro]" flags into API mount
// objects. Bind mounts are type=bind, named volumes type=volume.
func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} {
var out []map[string]interface{}
for _, f := range flags {
vals, _ := cmd.Flags().GetStringArray(f)
for _, v := range vals {
parts := strings.SplitN(v, ":", 3)
if len(parts) < 2 {
continue
}
m := map[string]interface{}{
"type": f,
"source": parts[0],
"target": parts[1],
}
if len(parts) == 3 && parts[2] == "ro" {
m["read_only"] = true
}
out = append(out, m)
}
}
return out
}
var servicesDomainsCmd = &cobra.Command{
Use: "domains",
Short: "Manage service domains",
}
var domainsListCmd = &cobra.Command{
Use: "list <service-id>",
Short: "List domains attached to a service",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0]+"/domains", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "domains")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, d := range items {
def := ""
if b, ok := d["is_default"].(bool); ok && b {
def = "*"
}
rows = append(rows, []string{str(d, "id"), str(d, "domain"), def, str(d, "cert_status")})
}
printRows(data, []string{"ID", "DOMAIN", "DEFAULT", "CERT"}, rows)
return nil
},
}
var domainsAddCmd = &cobra.Command{
Use: "add <service-id> <domain>",
Short: "Attach a domain to a service",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
def, _ := cmd.Flags().GetBool("default")
data, err := c.Do("POST", "/services/"+args[0]+"/domains", map[string]interface{}{
"domain": args[1], "is_default": def,
})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Added domain %s\n", args[1])
return nil
},
}
var domainsRemoveCmd = &cobra.Command{
Use: "remove <service-id> <domain-id>",
Short: "Detach a domain",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if _, err := c.Do("DELETE", fmt.Sprintf("/services/%s/domains/%s", args[0], args[1]), nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[1]})
}
fmt.Printf("Removed domain %s\n", args[1])
return nil
},
}
var domainsDefaultCmd = &cobra.Command{
Use: "default <service-id> <domain-id>",
Short: "Set the default domain",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", fmt.Sprintf("/services/%s/domains/%s/default", args[0], args[1]), map[string]interface{}{})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Default domain updated\n")
return nil
},
}
var domainsCheckCmd = &cobra.Command{
Use: "check <service-id>",
Short: "DNS preflight for all service domains",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0]+"/domains/check", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "domains")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, d := range items {
resolved, _ := d["resolved"].([]interface{})
addrs := make([]string, 0, len(resolved))
for _, a := range resolved {
if s, ok := a.(string); ok {
addrs = append(addrs, s)
}
}
rows = append(rows, []string{str(d, "domain"), str(d, "status"), strings.Join(addrs, ", ")})
}
printRows(data, []string{"DOMAIN", "STATUS", "RESOLVED"}, rows)
return nil
},
}
// serviceAction returns a RunE for the simple POST action endpoints.
func serviceAction(verb string) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", fmt.Sprintf("/services/%s/%s", args[0], verb), map[string]interface{}{})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("%s: %s\n", verb, args[0])
return nil
}
}
func init() {
f := servicesCreateCmd.Flags()
f.String("type", "web", "service type: web|worker|database|cron")
f.String("image", "", "container image (image-based service)")
f.String("git-repo", "", "git repository URL (build-from-source service)")
f.String("git-branch", "main", "git branch")
f.String("build-path", "", "build context subdirectory")
f.String("command", "", "container start command override")
f.String("environment", "production", "production|preview|development")
f.String("domain", "", "public domain")
f.String("restart-policy", "unless-stopped", "docker restart policy")
f.String("healthcheck-path", "", "HTTP health check path")
f.String("cpu", "", "CPU limit (e.g. 0.5)")
f.String("memory", "", "memory limit (e.g. 512m)")
f.Int("port", 0, "container port")
f.Int("replicas", 0, "replica count")
uf := servicesUpdateCmd.Flags()
uf.String("name", "", "service name")
uf.String("image", "", "container image")
uf.String("command", "", "container start command")
uf.String("domain", "", "public domain (empty string clears)")
uf.String("restart-policy", "", "docker restart policy")
uf.String("healthcheck-path", "", "HTTP health check path")
uf.String("cpu", "", "CPU limit (e.g. 0.5)")
uf.String("memory", "", "memory limit (e.g. 512m)")
uf.Int("replicas", 0, "replica count")
uf.Int("port", 0, "container port")
uf.StringArray("volume", nil, "named volume mount name:path[:ro] (repeatable, replaces all mounts)")
uf.StringArray("bind", nil, "bind mount src:path[:ro] (repeatable)")
uf.Bool("clear-volumes", false, "remove all volume mounts")
uf.String("maintenance", "", "maintenance mode: on|off")
uf.StringArray("basic-auth", nil, "basic-auth credential user:password (repeatable, replaces all)")
for _, a := range []string{"start", "stop", "restart", "redeploy"} {
verb := a
ServicesCmd.AddCommand(&cobra.Command{
Use: verb + " <id>",
Short: verb + " a service",
Args: cobra.ExactArgs(1),
RunE: serviceAction(verb),
})
}
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)
}
@@ -0,0 +1,127 @@
package commands
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
// TemplatesCmd manages the template catalog.
var TemplatesCmd = &cobra.Command{
Use: "templates",
Aliases: []string{"template"},
Short: "Browse and deploy templates",
}
var templatesListCmd = &cobra.Command{
Use: "list",
Short: "List templates",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/templates", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "templates")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, t := range items {
rows = append(rows, []string{
str(t, "id"), str(t, "name"), str(t, "category"), str(t, "description"),
})
}
printRows(data, []string{"ID", "NAME", "CATEGORY", "DESCRIPTION"}, rows)
return nil
},
}
var templatesGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show a template definition",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/templates/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesCreateCmd = &cobra.Command{
Use: "create <file>",
Short: "Create a user template from a JSON file",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
raw, err := os.ReadFile(args[0])
if err != nil {
return fmt.Errorf("cannot read %s: %w", args[0], err)
}
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/templates", raw)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a user template",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete template %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/templates/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted template %s\n", args[0])
return nil
},
}
var templatesDeployCmd = &cobra.Command{
Use: "deploy <template-id> <project-id>",
Short: "Deploy a template into a project",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", map[string]string{"project_id": args[1]})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() {
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd)
}
+101
View File
@@ -0,0 +1,101 @@
package commands
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
// TokensCmd manages personal access tokens (cnp_...).
var TokensCmd = &cobra.Command{
Use: "tokens",
Short: "Manage personal access tokens",
Long: `Create, list, and revoke the cnp_ tokens used by this CLI,
the MCP server, and external agents.`,
}
var tokensListCmd = &cobra.Command{
Use: "list",
Short: "List your tokens (metadata only)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/user/tokens", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "tokens")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, t := range items {
rows = append(rows, []string{
str(t, "id"), str(t, "name"), str(t, "key_prefix"), str(t, "scope"),
relTime(t, "last_used_at"), str(t, "expires_at"),
})
}
printRows(data, []string{"ID", "NAME", "PREFIX", "SCOPE", "LAST USED", "EXPIRES"}, rows)
return nil
},
}
var tokensCreateCmd = &cobra.Command{
Use: "create <name>",
Short: "Create a token (raw value printed once)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
scope, _ := cmd.Flags().GetString("scope")
days, _ := cmd.Flags().GetInt("expires-in-days")
data, err := c.Do("POST", "/user/tokens", map[string]interface{}{
"name": args[0], "scope": scope, "expires_in_days": days,
})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
t, _ := unwrapObject(data)
fmt.Println(str(t, "token"))
fmt.Fprintln(os.Stderr, "Store it now — it is shown only once. Scope: "+str(t, "scope"))
return nil
},
}
var tokensRevokeCmd = &cobra.Command{
Use: "revoke <id>",
Short: "Revoke a token",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Revoke token %s? Clients using it lose access immediately.", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/user/tokens/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "revoked", "id": args[0]})
}
fmt.Printf("Revoked token %s\n", args[0])
return nil
},
}
func init() {
tokensCreateCmd.Flags().String("scope", "write", "read|write|admin (admin requires an admin account)")
tokensCreateCmd.Flags().Int("expires-in-days", 0, "0 = never expires, max 3650")
TokensCmd.AddCommand(tokensListCmd, tokensCreateCmd, tokensRevokeCmd)
}
+145
View File
@@ -0,0 +1,145 @@
package commands
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// UpCmd deploys the current directory: resolves a project, creates (or
// reuses) a service named after the directory, and triggers a deployment.
// The git remote URL and current branch become the build source.
var UpCmd = &cobra.Command{
Use: "up",
Short: "Deploy the current directory (create service + deploy)",
Long: `Deploy the current directory to Containr.
The git remote and current branch are used as the build source. If no
service named after this directory exists in the project, one is
created; an existing service is redeployed.`,
RunE: runUp,
}
func runUp(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
projectID, _ := cmd.Flags().GetString("project")
if projectID == "" {
projectID = viper.GetString("profiles." + ActiveProfile() + ".project_id")
}
if projectID == "" {
return &APIError{
Message: "no project selected — pass --project <id> or set project_id on the active profile",
ExitCode: ExitValidation,
}
}
cwd, err := os.Getwd()
if err != nil {
return err
}
name, _ := cmd.Flags().GetString("name")
if name == "" {
name = filepath.Base(cwd)
}
repo := gitOutput(cwd, "config", "--get", "remote.origin.url")
branch := gitOutput(cwd, "rev-parse", "--abbrev-ref", "HEAD")
commit := gitOutput(cwd, "rev-parse", "HEAD")
image, _ := cmd.Flags().GetString("image")
if repo == "" && image == "" {
return &APIError{
Message: "no git remote and no --image — either init git with an origin remote or pass --image",
ExitCode: ExitValidation,
}
}
// Reuse an existing same-named service when present.
svcID, _ := cmd.Flags().GetString("service")
if svcID == "" {
if data, err := c.Do("GET", "/projects/"+projectID+"/services", nil); err == nil {
if items, err := unwrapList(data, "services"); err == nil {
for _, s := range items {
if str(s, "name") == name {
svcID = str(s, "id")
break
}
}
}
}
}
if svcID == "" {
body := map[string]interface{}{
"name": name,
"type": "web",
"environment": "production",
}
if repo != "" {
body["git_repo"] = repo
if branch != "" {
body["git_branch"] = branch
}
}
if image != "" {
body["image"] = image
}
data, err := c.Do("POST", "/projects/"+projectID+"/services", body)
if err != nil {
return err
}
s, _ := unwrapObject(data, "service")
svcID = str(s, "id")
if svcID == "" || svcID == "-" {
PrintRaw(data)
return &APIError{Message: "service created but id missing from response", ExitCode: ExitError}
}
if !JSONMode() {
fmt.Fprintf(os.Stderr, "Created service %s (%s)\n", name, svcID)
}
}
deployBody := map[string]interface{}{"trigger": "cli-up"}
if commit != "" {
deployBody["commit_hash"] = commit
}
if branch != "" {
deployBody["branch"] = branch
}
data, err := c.Do("POST", "/services/"+svcID+"/deployments", deployBody)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
d, _ := unwrapObject(data, "deployment")
fmt.Printf("Deploying %s → service %s (deployment %s)\n", name, svcID, str(d, "id"))
return nil
}
func gitOutput(dir string, args ...string) string {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func init() {
UpCmd.Flags().String("project", "", "target project id (or set project_id on the profile)")
UpCmd.Flags().String("name", "", "service name (default: directory name)")
UpCmd.Flags().String("service", "", "existing service id to redeploy")
UpCmd.Flags().String("image", "", "deploy this image instead of building from git")
}
@@ -0,0 +1,151 @@
package commands
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
// VariablesCmd manages service environment variables.
var VariablesCmd = &cobra.Command{
Use: "variables",
Aliases: []string{"vars", "env"},
Short: "Manage service environment variables",
}
var varsListCmd = &cobra.Command{
Use: "list <service-id>",
Short: "List variables (secrets shown masked)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0]+"/variables", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "variables")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, v := range items {
rows = append(rows, []string{str(v, "key"), str(v, "value"), str(v, "is_secret")})
}
printRows(data, []string{"KEY", "VALUE", "SECRET"}, rows)
return nil
},
}
// Variables are replaced wholesale server-side — set/unset fetch the
// current list, mutate it, and PUT it back. Masked secret placeholders are
// preserved by the server.
var varsSetCmd = &cobra.Command{
Use: "set <service-id> KEY=VALUE [KEY=VALUE...]",
Short: "Set one or more variables",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
secret, _ := cmd.Flags().GetBool("secret")
redeploy, _ := cmd.Flags().GetBool("redeploy")
return mutateVars(args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
for _, pair := range args[1:] {
k, v, found := strings.Cut(pair, "=")
if !found || k == "" {
continue
}
vars = upsertVar(vars, k, v, secret)
}
return vars
})
},
}
var varsUnsetCmd = &cobra.Command{
Use: "unset <service-id> KEY [KEY...]",
Short: "Remove variables",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
drop := map[string]bool{}
for _, k := range args[1:] {
drop[k] = true
}
redeploy, _ := cmd.Flags().GetBool("redeploy")
return mutateVars(args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
out := vars[:0]
for _, v := range vars {
if !drop[str(v, "key")] {
out = append(out, v)
}
}
return out
})
},
}
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")
}
func mutateVars(serviceID 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)
if err != nil {
return err
}
vars = mutate(vars)
payload := make([]map[string]interface{}, 0, len(vars))
for _, v := range vars {
payload = append(payload, map[string]interface{}{
"key": str(v, "key"),
"value": str(v, "value"),
"is_secret": v["is_secret"] == true,
})
}
body := map[string]interface{}{"variables": payload}
if redeploy {
body["redeploy"] = true
}
data, err := c.Do("PUT", "/services/"+serviceID+"/variables", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
obj, _ := unwrapObject(data)
if obj["redeploy_queued"] == true {
fmt.Printf("Variables updated on %s — redeploy queued\n", serviceID)
} else {
fmt.Printf("Variables updated on %s — redeploy to apply to running containers\n", serviceID)
}
return nil
}
func upsertVar(vars []map[string]interface{}, key, value string, secret bool) []map[string]interface{} {
for _, v := range vars {
if str(v, "key") == key {
v["value"] = value
v["is_secret"] = secret
return vars
}
}
return append(vars, map[string]interface{}{"key": key, "value": value, "is_secret": secret})
}
func init() {
varsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
varsSetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
varsUnsetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
VariablesCmd.AddCommand(varsListCmd, varsSetCmd, varsUnsetCmd)
}
@@ -0,0 +1,71 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// VolumesCmd manages docker volumes on the node (admin).
var VolumesCmd = &cobra.Command{
Use: "volumes",
Aliases: []string{"volume"},
Short: "Manage docker volumes (admin)",
}
var volumesListCmd = &cobra.Command{
Use: "list",
Short: "List docker volumes with in-use flags",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/admin/volumes", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "volumes")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, v := range items {
inUse := "no"
if b, ok := v["in_use"].(bool); ok && b {
inUse = "yes"
}
rows = append(rows, []string{str(v, "name"), str(v, "driver"), inUse})
}
printRows(data, []string{"NAME", "DRIVER", "IN USE"}, rows)
return nil
},
}
var volumesDeleteCmd = &cobra.Command{
Use: "delete <name>",
Short: "Delete an unused docker volume",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete volume %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/admin/volumes/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "name": args[0]})
}
fmt.Printf("Deleted volume %s\n", args[0])
return nil
},
}
func init() {
VolumesCmd.AddCommand(volumesListCmd, volumesDeleteCmd)
}
+59 -36
View File
@@ -12,23 +12,35 @@ import (
var cfgFile string var cfgFile string
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "containr", Use: "containr",
Short: "Containr CLI - Manage your self-hosted PaaS", Short: "Containr CLI - manage your self-hosted PaaS",
Long: `Containr CLI is a command-line interface for managing your Containr platform. Long: `Containr CLI controls every part of a Containr instance: projects,
You can manage projects, services, deployments, databases, and more from your terminal.`, services, deployments, databases, cron jobs, templates, nodes, scaling,
Version: "1.0.0", HA, security, and the API gateway.
Authenticate once with a personal access token (Settings → Personal
Access Tokens in the web UI):
containr auth login cnp_...
Every command supports --json for machine-readable output, and
destructive actions accept --yes to skip confirmation.`,
Version: "2.0.0",
SilenceUsage: true,
SilenceErrors: true,
} }
// Execute adds all child commands to the root command and sets flags appropriately. // Execute runs the root command and returns the process exit code.
// This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() int {
func Execute() { if err := rootCmd.Execute(); err != nil {
cobra.CheckErr(rootCmd.Execute()) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return commands.ExitCode(err)
}
return commands.ExitOK
} }
// SetVersion overrides the version reported by `containr --version`. // SetVersion overrides the version reported by `containr --version`.
// Called by main with the value injected via -ldflags at build time.
func SetVersion(v string) { func SetVersion(v string) {
rootCmd.Version = v rootCmd.Version = v
} }
@@ -36,46 +48,57 @@ func SetVersion(v string) {
func init() { func init() {
cobra.OnInitialize(initConfig) cobra.OnInitialize(initConfig)
// Global flags pf := rootCmd.PersistentFlags()
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.containr.yaml)") pf.StringVar(&cfgFile, "config", "", "config file (default $HOME/.containr.yaml)")
rootCmd.PersistentFlags().String("api-url", "", "Containr API URL (default is https://api.containr.dev)") pf.String("api-url", "", "Containr API URL (overrides profile and CONTAINR_API_URL)")
rootCmd.PersistentFlags().String("token", "", "Authentication token") pf.String("token", "", "auth token (overrides profile and CONTAINR_TOKEN)")
pf.StringP("profile", "p", "", "config profile to use (default current_profile)")
pf.Bool("json", false, "machine-readable JSON output")
pf.BoolP("yes", "y", false, "skip confirmation prompts (required for destructive ops in scripts)")
pf.String("idempotency-key", "", "Idempotency-Key header for POST mutations (safe retries)")
// Bind flags to viper viper.BindPFlag("api-url", pf.Lookup("api-url"))
viper.BindPFlag("api-url", rootCmd.PersistentFlags().Lookup("api-url")) viper.BindPFlag("token", pf.Lookup("token"))
viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token")) viper.BindPFlag("profile", pf.Lookup("profile"))
viper.BindPFlag("json", pf.Lookup("json"))
viper.BindPFlag("yes", pf.Lookup("yes"))
viper.BindPFlag("idempotency-key", pf.Lookup("idempotency-key"))
// Add command groups
rootCmd.AddCommand(commands.AuthCmd) rootCmd.AddCommand(commands.AuthCmd)
rootCmd.AddCommand(commands.TokensCmd)
rootCmd.AddCommand(commands.ProjectsCmd) rootCmd.AddCommand(commands.ProjectsCmd)
rootCmd.AddCommand(commands.ServicesCmd)
rootCmd.AddCommand(commands.DeployCmd)
rootCmd.AddCommand(commands.DeploymentsCmd)
rootCmd.AddCommand(commands.LogsCmd)
rootCmd.AddCommand(commands.ExecCmd)
rootCmd.AddCommand(commands.VariablesCmd)
rootCmd.AddCommand(commands.VolumesCmd)
rootCmd.AddCommand(commands.DatabasesCmd)
rootCmd.AddCommand(commands.CronCmd)
rootCmd.AddCommand(commands.TemplatesCmd)
rootCmd.AddCommand(commands.NodesCmd)
rootCmd.AddCommand(commands.ScalingCmd)
rootCmd.AddCommand(commands.HACmd)
rootCmd.AddCommand(commands.SecurityCmd)
rootCmd.AddCommand(commands.GatewayCmd)
rootCmd.AddCommand(commands.AdminCmd)
rootCmd.AddCommand(commands.NotificationsCmd)
rootCmd.AddCommand(commands.UpCmd)
} }
// initConfig reads in config file and ENV variables if set.
func initConfig() { func initConfig() {
if cfgFile != "" { if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile) viper.SetConfigFile(cfgFile)
} else { } else {
// Find home directory.
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
cobra.CheckErr(err) cobra.CheckErr(err)
// Search config in home directory with name ".containr" (without extension).
viper.AddConfigPath(home) viper.AddConfigPath(home)
viper.SetConfigType("yaml") viper.SetConfigType("yaml")
viper.SetConfigName(".containr") viper.SetConfigName(".containr")
} }
viper.AutomaticEnv()
viper.AutomaticEnv() // read in environment variables that match // CONTAINR_TOKEN / CONTAINR_API_URL are read explicitly in config.go;
// AutomaticEnv covers them only when bound, so this is belt-and-braces.
// If a config file is found, read it in. viper.ReadInConfig()
if err := viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}
// Run executes the CLI
func Run() error {
rootCmd.Execute()
return nil
} }
-25
View File
@@ -1,25 +0,0 @@
package cli
import (
"strings"
"github.com/spf13/viper"
)
// getAPIURL constructs the full API URL for a given endpoint
func getAPIURL(endpoint string) string {
baseURL := viper.GetString("api-url")
if baseURL == "" {
baseURL = "http://localhost:8080/api/v1" // Default for development
}
// Ensure baseURL doesn't end with / and endpoint starts with /
if strings.HasSuffix(baseURL, "/") {
baseURL = baseURL[:len(baseURL)-1]
}
if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
return baseURL + endpoint
}
@@ -195,6 +195,26 @@ type Service struct {
BuildPath sql.NullString `json:"build_path"` BuildPath sql.NullString `json:"build_path"`
Cpu sql.NullString `json:"cpu"` Cpu sql.NullString `json:"cpu"`
Memory sql.NullString `json:"memory"` Memory sql.NullString `json:"memory"`
Replicas int32 `json:"replicas"`
Port int32 `json:"port"`
Domain string `json:"domain"`
HealthcheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
PublishedPort int32 `json:"published_port"`
Volumes json.RawMessage `json:"volumes"`
MaintenanceMode bool `json:"maintenance_mode"`
BasicAuthUsers string `json:"basic_auth_users"`
}
type ServiceDomain struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Domain string `json:"domain"`
IsDefault bool `json:"is_default"`
CertType string `json:"cert_type"`
CertStatus string `json:"cert_status"`
LastCheckedAt sql.NullTime `json:"last_checked_at"`
CreatedAt time.Time `json:"created_at"`
} }
type ServiceTemplate struct { type ServiceTemplate struct {
@@ -207,6 +227,20 @@ type ServiceTemplate struct {
Variables pqtype.NullRawMessage `json:"variables"` Variables pqtype.NullRawMessage `json:"variables"`
IsOfficial sql.NullBool `json:"is_official"` IsOfficial sql.NullBool `json:"is_official"`
OwnerID uuid.NullUUID `json:"owner_id"` OwnerID uuid.NullUUID `json:"owner_id"`
IsPublic bool `json:"is_public"`
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type UserToken struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
TokenHash string `json:"token_hash"`
Scope string `json:"scope"`
ExpiresAt sql.NullTime `json:"expires_at"`
LastUsedAt sql.NullTime `json:"last_used_at"`
RevokedAt sql.NullTime `json:"revoked_at"`
CreatedAt sql.NullTime `json:"created_at"`
}
@@ -26,6 +26,7 @@ type Querier interface {
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error DeleteAgent(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
@@ -46,6 +47,7 @@ type Querier interface {
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error) GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
@@ -66,9 +68,11 @@ type Querier interface {
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
@@ -77,6 +81,7 @@ type Querier interface {
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error) SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
TouchAgentAuthToken(ctx context.Context, tokenHash string) error TouchAgentAuthToken(ctx context.Context, tokenHash string) error
TouchUserToken(ctx context.Context, id uuid.UUID) error
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
@@ -125,7 +125,7 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi
} }
const createUserTemplate = `-- name: CreateUserTemplate :exec const createUserTemplate = `-- name: CreateUserTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id) INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id, is_public)
VALUES ( VALUES (
$1, $1,
$2, $2,
@@ -135,7 +135,8 @@ VALUES (
$6, $6,
$7, $7,
false, false,
$8 $8,
$9
) )
` `
@@ -148,6 +149,7 @@ type CreateUserTemplateParams struct {
Config json.RawMessage `json:"config"` Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"` Variables pqtype.NullRawMessage `json:"variables"`
OwnerID uuid.NullUUID `json:"owner_id"` OwnerID uuid.NullUUID `json:"owner_id"`
IsPublic bool `json:"is_public"`
} }
func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error { func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error {
@@ -160,6 +162,7 @@ func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplate
arg.Config, arg.Config,
arg.Variables, arg.Variables,
arg.OwnerID, arg.OwnerID,
arg.IsPublic,
) )
return err return err
} }
@@ -198,7 +201,7 @@ func (q *Queries) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUI
} }
const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates FROM service_templates
WHERE id = $1 WHERE id = $1
` `
@@ -216,6 +219,7 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic
&i.Variables, &i.Variables,
&i.IsOfficial, &i.IsOfficial,
&i.OwnerID, &i.OwnerID,
&i.IsPublic,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -223,10 +227,10 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic
} }
const listServiceTemplatesByCategoryForUser = `-- name: ListServiceTemplatesByCategoryForUser :many const listServiceTemplatesByCategoryForUser = `-- name: ListServiceTemplatesByCategoryForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates FROM service_templates
WHERE category = $1 WHERE category = $1
AND (is_official = true OR owner_id = $2) AND (is_official = true OR is_public = true OR owner_id = $2)
ORDER BY is_official DESC, name ASC ORDER BY is_official DESC, name ASC
` `
@@ -254,6 +258,7 @@ func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg
&i.Variables, &i.Variables,
&i.IsOfficial, &i.IsOfficial,
&i.OwnerID, &i.OwnerID,
&i.IsPublic,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@@ -271,9 +276,9 @@ func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg
} }
const listServiceTemplatesForUser = `-- name: ListServiceTemplatesForUser :many const listServiceTemplatesForUser = `-- name: ListServiceTemplatesForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates FROM service_templates
WHERE is_official = true OR owner_id = $1 WHERE is_official = true OR is_public = true OR owner_id = $1
ORDER BY is_official DESC, name ASC ORDER BY is_official DESC, name ASC
` `
@@ -296,6 +301,7 @@ func (q *Queries) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.
&i.Variables, &i.Variables,
&i.IsOfficial, &i.IsOfficial,
&i.OwnerID, &i.OwnerID,
&i.IsPublic,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@@ -320,9 +326,10 @@ SET name = $1,
logo = $4, logo = $4,
config = $5, config = $5,
variables = $6, variables = $6,
is_public = $7,
updated_at = NOW() updated_at = NOW()
WHERE id = $7 WHERE id = $8
AND owner_id = $8 AND owner_id = $9
AND is_official = false AND is_official = false
` `
@@ -333,6 +340,7 @@ type UpdateUserTemplateParams struct {
Logo sql.NullString `json:"logo"` Logo sql.NullString `json:"logo"`
Config json.RawMessage `json:"config"` Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"` Variables pqtype.NullRawMessage `json:"variables"`
IsPublic bool `json:"is_public"`
ID string `json:"id"` ID string `json:"id"`
OwnerID uuid.NullUUID `json:"owner_id"` OwnerID uuid.NullUUID `json:"owner_id"`
} }
@@ -345,6 +353,7 @@ func (q *Queries) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplate
arg.Logo, arg.Logo,
arg.Config, arg.Config,
arg.Variables, arg.Variables,
arg.IsPublic,
arg.ID, arg.ID,
arg.OwnerID, arg.OwnerID,
) )
@@ -0,0 +1,147 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: user_tokens.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const createUserToken = `-- name: CreateUserToken :one
INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at
`
type CreateUserTokenParams struct {
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
TokenHash string `json:"token_hash"`
Scope string `json:"scope"`
ExpiresAt sql.NullTime `json:"expires_at"`
}
func (q *Queries) CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error) {
row := q.db.QueryRowContext(ctx, createUserToken,
arg.UserID,
arg.Name,
arg.KeyPrefix,
arg.TokenHash,
arg.Scope,
arg.ExpiresAt,
)
var i UserToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyPrefix,
&i.TokenHash,
&i.Scope,
&i.ExpiresAt,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const getUserTokenByHash = `-- name: GetUserTokenByHash :one
SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens
WHERE token_hash = $1
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
LIMIT 1
`
func (q *Queries) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) {
row := q.db.QueryRowContext(ctx, getUserTokenByHash, tokenHash)
var i UserToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyPrefix,
&i.TokenHash,
&i.Scope,
&i.ExpiresAt,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const listUserTokens = `-- name: ListUserTokens :many
SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens
WHERE user_id = $1 AND revoked_at IS NULL
ORDER BY created_at DESC
`
func (q *Queries) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) {
rows, err := q.db.QueryContext(ctx, listUserTokens, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []UserToken{}
for rows.Next() {
var i UserToken
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyPrefix,
&i.TokenHash,
&i.Scope,
&i.ExpiresAt,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const revokeUserToken = `-- name: RevokeUserToken :execrows
UPDATE user_tokens SET revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL
`
type RevokeUserTokenParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) {
result, err := q.db.ExecContext(ctx, revokeUserToken, arg.ID, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const touchUserToken = `-- name: TouchUserToken :exec
UPDATE user_tokens SET last_used_at = NOW()
WHERE id = $1 AND revoked_at IS NULL
`
func (q *Queries) TouchUserToken(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, touchUserToken, id)
return err
}
+16 -2
View File
@@ -57,8 +57,16 @@ type ServiceConfig struct {
PublicPort int32 `json:"public_port,omitempty"` PublicPort int32 `json:"public_port,omitempty"`
// Domain is a public hostname routed to PublicPort via Traefik. // Domain is a public hostname routed to PublicPort via Traefik.
Domain string `json:"domain,omitempty"` Domain string `json:"domain,omitempty"`
// Domains is the full hostname set (includes Domain when set).
Domains []string `json:"domains,omitempty"`
Maintenance bool `json:"maintenance,omitempty"`
MaintenanceURL string `json:"maintenance_url,omitempty"`
BasicAuthUsers string `json:"basic_auth_users,omitempty"`
// HealthPath is an HTTP path probed on PublicPort for container health. // HealthPath is an HTTP path probed on PublicPort for container health.
HealthPath string `json:"health_path,omitempty"` HealthPath string `json:"health_path,omitempty"`
// PublishedPort is the host port bound by the previous deployment; replica 0
// reuses it so public URLs survive restarts. 0 = pick ephemeral.
PublishedPort int32 `json:"published_port,omitempty"`
} }
type PortMapping struct { type PortMapping struct {
@@ -147,6 +155,7 @@ type BuildConfig struct {
Environment map[string]string `json:"environment"` Environment map[string]string `json:"environment"`
Branch string `json:"branch"` Branch string `json:"branch"`
Commit string `json:"commit"` Commit string `json:"commit"`
NoCache bool `json:"no_cache"`
} }
type TriggerConfig struct { type TriggerConfig struct {
@@ -288,6 +297,7 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme
TriggeredBy: "deployment_engine", TriggeredBy: "deployment_engine",
Branch: buildConfig.Branch, Branch: buildConfig.Branch,
Commit: buildConfig.Commit, Commit: buildConfig.Commit,
NoCache: buildConfig.NoCache,
} }
response, err := de.buildManager.Build(ctx, buildReq) response, err := de.buildManager.Build(ctx, buildReq)
@@ -315,11 +325,17 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
Env: cfg.Environment, Env: cfg.Environment,
Replicas: cfg.Replicas, Replicas: cfg.Replicas,
Port: cfg.PublicPort, Port: cfg.PublicPort,
PublishedPort: cfg.PublishedPort,
Domain: cfg.Domain, Domain: cfg.Domain,
HealthPath: cfg.HealthPath, HealthPath: cfg.HealthPath,
RestartPolicy: cfg.RestartPolicy, RestartPolicy: cfg.RestartPolicy,
MemoryBytes: cfg.Resources.MemoryBytes, MemoryBytes: cfg.Resources.MemoryBytes,
NanoCPUs: cfg.Resources.CPUQuota, NanoCPUs: cfg.Resources.CPUQuota,
Volumes: cfg.VolumeMounts,
Domains: cfg.Domains,
Maintenance: cfg.Maintenance,
MaintenanceURL: cfg.MaintenanceURL,
BasicAuthUsers: cfg.BasicAuthUsers,
} }
for _, pm := range cfg.PortMappings { for _, pm := range cfg.PortMappings {
if spec.Port == 0 { if spec.Port == 0 {
@@ -330,8 +346,6 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
spec.RestartPolicy = "unless-stopped" spec.RestartPolicy = "unless-stopped"
} }
// Volume mounts aren't reconciled replicas-aware yet; still applied on the
// container config of every replica via spec extension later if needed.
state, err := de.ReconcileService(ctx, spec) state, err := de.ReconcileService(ctx, spec)
if err != nil { if err != nil {
return err return err
+177 -21
View File
@@ -5,8 +5,11 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@@ -14,6 +17,7 @@ import (
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
) )
@@ -38,11 +42,17 @@ type RuntimeSpec struct {
Env map[string]string // already resolved Env map[string]string // already resolved
Replicas int Replicas int
Port int32 // container port to expose publicly; 0 = none Port int32 // container port to expose publicly; 0 = none
PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral
Domain string // public hostname routed via Traefik; empty = none Domain string // public hostname routed via Traefik; empty = none
HealthPath string // http path probed on Port for container healthcheck HealthPath string // http path probed on Port for container healthcheck
RestartPolicy string RestartPolicy string
MemoryBytes int64 MemoryBytes int64
NanoCPUs int64 NanoCPUs int64
Volumes []VolumeMount // applied to every replica
Domains []string // all public hostnames; Domain is the default
Maintenance bool // redirect traffic to MaintenanceURL
MaintenanceURL string // absolute URL; empty = serve empty response
BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
} }
// RuntimeContainer describes one live replica. // RuntimeContainer describes one live replica.
@@ -58,10 +68,14 @@ type RuntimeState struct {
Desired int `json:"desired"` Desired int `json:"desired"`
Containers []RuntimeContainer `json:"containers"` Containers []RuntimeContainer `json:"containers"`
URLs []string `json:"urls"` URLs []string `json:"urls"`
Ports []uint16 `json:"ports"`
Health string `json:"health,omitempty"` // healthy, unhealthy, unreachable
Status string `json:"status"` // running, degraded, stopped Status string `json:"status"` // running, degraded, stopped
} }
func projectNetworkName(projectID string) string { // ProjectNetworkName is the deterministic docker network every service in a
// project joins — containr-proj-<first 12 hex chars of the project uuid>.
func ProjectNetworkName(projectID string) string {
short := strings.ReplaceAll(projectID, "-", "") short := strings.ReplaceAll(projectID, "-", "")
if len(short) > 12 { if len(short) > 12 {
short = short[:12] short = short[:12]
@@ -76,7 +90,7 @@ func serviceNamePrefix(serviceID string) string {
// EnsureProjectNetwork creates the per-project bridge network if missing. // EnsureProjectNetwork creates the per-project bridge network if missing.
// Containers on it resolve each other by service name (private networking). // Containers on it resolve each other by service name (private networking).
func (de *DeploymentEngine) EnsureProjectNetwork(ctx context.Context, projectID string) (string, error) { func (de *DeploymentEngine) EnsureProjectNetwork(ctx context.Context, projectID string) (string, error) {
name := projectNetworkName(projectID) name := ProjectNetworkName(projectID)
networks, err := de.dockerClient.ListNetworks(ctx) networks, err := de.dockerClient.ListNetworks(ctx)
if err != nil { if err != nil {
@@ -217,12 +231,45 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
projectNet: {Aliases: []string{spec.Name}}, projectNet: {Aliases: []string{spec.Name}},
} }
if edgeNet != "" { if edgeNet != "" {
router := "svc-" + spec.ServiceID[:8]
domains := spec.Domains
if len(domains) == 0 && spec.Domain != "" {
domains = []string{spec.Domain}
}
sort.Strings(domains)
rules := make([]string, 0, len(domains))
for _, d := range domains {
rules = append(rules, "Host(`"+d+"`)")
}
endpoints[edgeNet] = &network.EndpointSettings{} endpoints[edgeNet] = &network.EndpointSettings{}
labels["traefik.enable"] = "true" labels["traefik.enable"] = "true"
labels["traefik.docker.network"] = edgeNet labels["traefik.docker.network"] = edgeNet
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".rule"] = "Host(`" + spec.Domain + "`)" labels["traefik.http.routers."+router+".rule"] = strings.Join(rules, " || ")
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".entrypoints"] = "web" labels["traefik.http.routers."+router+".entrypoints"] = "web"
labels["traefik.http.services.svc-"+spec.ServiceID[:8]+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port) labels["traefik.http.services."+router+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
var middlewares []string
if spec.Maintenance {
if spec.MaintenanceURL != "" {
mw := router + "-maint"
labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
middlewares = append(middlewares, mw)
} else {
// No redirect target configured — take the site offline with
// empty responses instead of leaking traffic to the app.
labels["traefik.http.routers."+router+".service"] = "noop@internal"
}
}
if spec.BasicAuthUsers != "" {
mw := router + "-auth"
labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
middlewares = append(middlewares, mw)
}
if len(middlewares) > 0 {
labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
}
} }
cfg := docker.ContainerConfig{ cfg := docker.ContainerConfig{
@@ -237,31 +284,83 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
Networks: endpoints, Networks: endpoints,
} }
if spec.Port > 0 { for _, v := range spec.Volumes {
port := nat.Port(fmt.Sprintf("%d/tcp", spec.Port)) mountType := mount.TypeVolume
cfg.ExposedPorts = nat.PortSet{port: struct{}{}} if v.Type == "bind" {
// Always publish an ephemeral host port for direct access; a configured mountType = mount.TypeBind
// domain is additionally routed via Traefik when the edge network exists.
cfg.PortBindings = nat.PortMap{port: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: ""}}}
if spec.HealthPath != "" {
cfg.Healthcheck = &container.HealthConfig{
Test: []string{"CMD-SHELL", fmt.Sprintf("wget -q -O /dev/null http://127.0.0.1:%d%s || exit 1", spec.Port, spec.HealthPath)},
Interval: 30 * time.Second,
Timeout: 5 * time.Second,
Retries: 3,
StartPeriod: 10 * time.Second,
}
} }
cfg.Mounts = append(cfg.Mounts, mount.Mount{
Type: mountType,
Source: v.Source,
Target: v.Destination,
ReadOnly: v.ReadOnly,
})
} }
var wantPort nat.Port
if spec.Port > 0 {
wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
cfg.ExposedPorts = nat.PortSet{wantPort: struct{}{}}
}
// tryBind creates and starts a container publishing wantPort on hostPort
// ("" = ephemeral). A requested port is verified after start: Docker
// Desktop accepts a conflicting binding at create/start but publishes
// nothing, which would leave the service running with no endpoint.
tryBind := func(hostPort string) (string, error) {
if spec.Port > 0 {
cfg.PortBindings = nat.PortMap{wantPort: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: hostPort}}}
}
id, err := de.dockerClient.CreateContainer(ctx, cfg) id, err := de.dockerClient.CreateContainer(ctx, cfg)
if err != nil { if err != nil {
return "", err return "", err
} }
if err := de.dockerClient.StartContainer(ctx, id); err != nil { if err := de.dockerClient.StartContainer(ctx, id); err != nil {
_ = de.dockerClient.RemoveContainer(ctx, id, true)
return "", fmt.Errorf("start: %w", err) return "", fmt.Errorf("start: %w", err)
} }
if hostPort != "" {
info, err := de.dockerClient.GetContainer(ctx, id)
if err != nil {
_ = de.dockerClient.RemoveContainer(ctx, id, true)
return "", fmt.Errorf("inspect: %w", err)
}
if info.NetworkSettings == nil || len(info.NetworkSettings.Ports[wantPort]) == 0 {
_ = de.dockerClient.RemoveContainer(ctx, id, true)
return "", errPortNotBound
}
}
return id, nil return id, nil
}
preferred := ""
if index == 0 && spec.PublishedPort > 0 {
preferred = strconv.Itoa(int(spec.PublishedPort))
}
id, err := tryBind(preferred)
if err != nil && preferred != "" && (isPortConflict(err) || errors.Is(err, errPortNotBound)) {
// Stored port got taken since last deploy — fall back to ephemeral; the
// new assignment is persisted on the next runtime read.
id, err = tryBind("")
}
if err != nil {
return "", err
}
return id, nil
}
// errPortNotBound marks a container that started but failed to publish the
// requested host port (seen on Docker Desktop, which reports success while
// vpnkit drops a conflicting binding).
var errPortNotBound = errors.New("requested host port not bound")
// isPortConflict reports whether a container create failed because the
// requested host port was already bound.
func isPortConflict(err error) bool {
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "port is already allocated") ||
strings.Contains(msg, "address already in use") ||
strings.Contains(msg, "cannot assign requested address")
} }
// RuntimeState reads live container state for a service. // RuntimeState reads live container state for a service.
@@ -282,6 +381,7 @@ func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string,
for _, p := range c.Ports { for _, p := range c.Ports {
if p.PublicPort != 0 { if p.PublicPort != 0 {
state.URLs = append(state.URLs, fmt.Sprintf("http://localhost:%d", p.PublicPort)) state.URLs = append(state.URLs, fmt.Sprintf("http://localhost:%d", p.PublicPort))
state.Ports = append(state.Ports, p.PublicPort)
} }
} }
} }
@@ -299,6 +399,57 @@ func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string,
return state, nil return state, nil
} }
// ProbeServiceHealth performs an HTTP GET on healthPath and records the
// outcome on state.Health. When the backend runs containerized it first
// attaches to the service's project network and probes the service by DNS
// name — this works for every image (no shell or wget required inside the
// container) and checks the service itself, not the published edge. A
// host-run backend falls back to probing the published host port.
func (de *DeploymentEngine) ProbeServiceHealth(ctx context.Context, state *RuntimeState, projectID, serviceName string, port int, healthPath string) {
if state == nil {
return
}
state.Health = ""
if healthPath == "" || port <= 0 {
return
}
if !strings.HasPrefix(healthPath, "/") {
healthPath = "/" + healthPath
}
probeURL := ""
if networkName := ProjectNetworkName(projectID); networkName != "" {
if err := de.dockerClient.ConnectSelfToNetwork(ctx, networkName); err == nil {
probeURL = fmt.Sprintf("http://%s:%d%s", serviceName, port, healthPath)
}
}
if probeURL == "" && len(state.Ports) > 0 {
probeURL = fmt.Sprintf("http://%s:%d%s", de.dockerClient.HostProbeHost(ctx), state.Ports[0], healthPath)
}
if probeURL == "" {
return
}
reqCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, probeURL, nil)
if err != nil {
state.Health = "unreachable"
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
state.Health = "unreachable"
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
state.Health = "healthy"
return
}
state.Health = "unhealthy"
}
// StopService stops (but keeps) all replicas of a service. // StopService stops (but keeps) all replicas of a service.
func (de *DeploymentEngine) StopService(ctx context.Context, serviceID string) error { func (de *DeploymentEngine) StopService(ctx context.Context, serviceID string) error {
containers, err := de.ListServiceContainers(ctx, serviceID) containers, err := de.ListServiceContainers(ctx, serviceID)
@@ -359,7 +510,7 @@ func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, project
// RemoveProjectNetwork removes the per-project bridge network if present. // RemoveProjectNetwork removes the per-project bridge network if present.
func (de *DeploymentEngine) RemoveProjectNetwork(ctx context.Context, projectID string) error { func (de *DeploymentEngine) RemoveProjectNetwork(ctx context.Context, projectID string) error {
return de.dockerClient.RemoveNetwork(ctx, projectNetworkName(projectID)) return de.dockerClient.RemoveNetwork(ctx, ProjectNetworkName(projectID))
} }
func replicaIndex(c container.Summary) int { func replicaIndex(c container.Summary) int {
@@ -375,8 +526,13 @@ func specHash(spec RuntimeSpec) string {
Env map[string]string Env map[string]string
Port int32 Port int32
MemoryBytes, NanoCPUs int64 MemoryBytes, NanoCPUs int64
Volumes []VolumeMount
Domains []string
Maintenance bool
BasicAuth string
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy, }{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs}) spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs, spec.Volumes,
spec.Domains, spec.Maintenance, spec.BasicAuthUsers})
sum := sha256.Sum256(payload) sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:8]) return hex.EncodeToString(sum[:8])
} }
+146
View File
@@ -0,0 +1,146 @@
package deployqueue
import (
"context"
"sync"
"github.com/google/uuid"
)
// Job is a unit of serialized deployment work. DeploymentID is the
// deployments row it mirrors (uuid.Nil for runtime ops with no row).
// Run is invoked with a cancellable context — cancelled by Cancel for
// active jobs, or the job is dropped before starting when queued.
type Job struct {
DeploymentID uuid.UUID
Run func(ctx context.Context)
}
// Queue serializes deploy work per service: one active job, the rest FIFO.
// A build for service A never races a redeploy or second build of A, while
// services B, C proceed in parallel. Not a global worker pool — per-service
// serialization is what prevents container thrash.
type Queue struct {
mu sync.Mutex
states map[uuid.UUID]*serviceState
cancels map[uuid.UUID]context.CancelFunc
}
type serviceState struct {
running bool
pending []Job
}
func New() *Queue {
return &Queue{
states: make(map[uuid.UUID]*serviceState),
cancels: make(map[uuid.UUID]context.CancelFunc),
}
}
// Enqueue schedules a job. Returns the position: 0 means it starts
// immediately, >0 means that many jobs run first.
func (q *Queue) Enqueue(serviceID uuid.UUID, job Job) int {
q.mu.Lock()
st := q.states[serviceID]
if st == nil {
st = &serviceState{}
q.states[serviceID] = st
}
if st.running {
st.pending = append(st.pending, job)
pos := len(st.pending)
q.mu.Unlock()
return pos
}
st.running = true
q.mu.Unlock()
q.start(serviceID, job)
return 0
}
func (q *Queue) start(serviceID uuid.UUID, job Job) {
ctx, cancel := context.WithCancel(context.Background())
if job.DeploymentID != uuid.Nil {
q.mu.Lock()
q.cancels[job.DeploymentID] = cancel
q.mu.Unlock()
}
go func() {
defer func() {
if job.DeploymentID != uuid.Nil {
q.mu.Lock()
delete(q.cancels, job.DeploymentID)
q.mu.Unlock()
}
cancel()
q.promote(serviceID)
}()
job.Run(ctx)
}()
}
// promote marks the service free and starts the next queued job, if any.
func (q *Queue) promote(serviceID uuid.UUID) {
q.mu.Lock()
st := q.states[serviceID]
if st == nil || len(st.pending) == 0 {
if st != nil {
st.running = false
}
q.mu.Unlock()
return
}
next := st.pending[0]
st.pending = st.pending[1:]
q.mu.Unlock()
q.start(serviceID, next)
}
// CancelState distinguishes what Cancel found.
type CancelState int
const (
NotFound CancelState = iota
WasQueued // removed before it started — caller marks DB cancelled
WasActive // ctx cancelled — the running job marks DB itself
)
// Cancel cancels a job by its deployment row id.
func (q *Queue) Cancel(serviceID, deploymentID uuid.UUID) CancelState {
q.mu.Lock()
if st := q.states[serviceID]; st != nil {
for i, j := range st.pending {
if j.DeploymentID == deploymentID {
st.pending = append(st.pending[:i], st.pending[i+1:]...)
q.mu.Unlock()
return WasQueued
}
}
}
cancel, ok := q.cancels[deploymentID]
q.mu.Unlock()
if ok {
cancel()
return WasActive
}
return NotFound
}
// Do runs fn through the queue and waits for completion — for synchronous
// endpoints (redeploy) that still must not overlap an in-flight deployment.
// ctx bounds the wait, not the job; on timeout the job still runs to
// completion to avoid orphaned reconcile state.
func (q *Queue) Do(ctx context.Context, serviceID uuid.UUID, fn func(ctx context.Context)) {
done := make(chan struct{})
q.Enqueue(serviceID, Job{Run: func(jctx context.Context) {
defer close(done)
fn(jctx)
}})
select {
case <-done:
case <-ctx.Done():
}
}
@@ -0,0 +1,142 @@
package deployqueue
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
)
func TestSerializesPerService(t *testing.T) {
q := New()
svc := uuid.New()
var active, maxActive int32
finish := make(chan struct{})
for i := 0; i < 3; i++ {
q.Enqueue(svc, Job{Run: func(ctx context.Context) {
if n := atomic.AddInt32(&active, 1); n > atomic.LoadInt32(&maxActive) {
atomic.StoreInt32(&maxActive, n)
}
<-finish // hold until released
atomic.AddInt32(&active, -1)
}})
}
deadline := time.After(2 * time.Second)
for atomic.LoadInt32(&active) < 1 {
select {
case <-deadline:
t.Fatal("first job never started")
default:
time.Sleep(time.Millisecond)
}
}
close(finish)
deadline = time.After(2 * time.Second)
for atomic.LoadInt32(&active) > 0 {
select {
case <-deadline:
t.Fatal("jobs did not drain")
default:
time.Sleep(time.Millisecond)
}
}
if got := atomic.LoadInt32(&maxActive); got != 1 {
t.Fatalf("expected max concurrency 1, got %d", got)
}
}
func TestDifferentServicesRunInParallel(t *testing.T) {
q := New()
started := make(chan struct{}, 2)
release := make(chan struct{})
for i := 0; i < 2; i++ {
q.Enqueue(uuid.New(), Job{Run: func(ctx context.Context) {
started <- struct{}{}
<-release
}})
}
for i := 0; i < 2; i++ {
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("parallel services did not both start")
}
}
close(release)
}
func TestCancelQueuedRemovesJob(t *testing.T) {
q := New()
svc := uuid.New()
release := make(chan struct{})
q.Enqueue(svc, Job{DeploymentID: uuid.New(), Run: func(ctx context.Context) { <-release }})
queuedID := uuid.New()
ran := make(chan struct{})
q.Enqueue(svc, Job{DeploymentID: queuedID, Run: func(ctx context.Context) { close(ran) }})
if got := q.Cancel(svc, queuedID); got != WasQueued {
t.Fatalf("expected WasQueued, got %v", got)
}
close(release)
select {
case <-ran:
t.Fatal("cancelled queued job still ran")
case <-time.After(200 * time.Millisecond):
}
}
func TestCancelActiveCancelsContext(t *testing.T) {
q := New()
svc := uuid.New()
id := uuid.New()
ctxDone := make(chan struct{})
q.Enqueue(svc, Job{DeploymentID: id, Run: func(ctx context.Context) {
<-ctx.Done()
close(ctxDone)
}})
deadline := time.After(2 * time.Second)
for {
q.mu.Lock()
_, ok := q.cancels[id]
q.mu.Unlock()
if ok {
break
}
select {
case <-deadline:
t.Fatal("job never registered")
default:
time.Sleep(time.Millisecond)
}
}
if got := q.Cancel(svc, id); got != WasActive {
t.Fatalf("expected WasActive, got %v", got)
}
select {
case <-ctxDone:
case <-time.After(2 * time.Second):
t.Fatal("active job context not cancelled")
}
}
func TestEnqueuePosition(t *testing.T) {
q := New()
svc := uuid.New()
release := make(chan struct{})
if pos := q.Enqueue(svc, Job{Run: func(ctx context.Context) { <-release }}); pos != 0 {
t.Fatalf("first job position = %d, want 0", pos)
}
if pos := q.Enqueue(svc, Job{Run: func(ctx context.Context) {}}); pos != 1 {
t.Fatalf("second job position = %d, want 1", pos)
}
if pos := q.Enqueue(svc, Job{Run: func(ctx context.Context) {}}); pos != 2 {
t.Fatalf("third job position = %d, want 2", pos)
}
close(release)
}
+74
View File
@@ -6,6 +6,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"os"
"sort"
"strings"
"time" "time"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
@@ -24,6 +27,7 @@ import (
// Client wraps the Docker client with additional functionality // Client wraps the Docker client with additional functionality
type Client struct { type Client struct {
cli *client.Client cli *client.Client
probeHost string
} }
// NewClient creates a new Docker client // NewClient creates a new Docker client
@@ -45,6 +49,75 @@ func NewClient() (*Client, error) {
return &Client{cli: cli}, nil return &Client{cli: cli}, nil
} }
// HostProbeHost returns an address through which host-published container
// ports are reachable. When this process runs inside a container, host
// loopback is unreachable; the bridge gateway IP routes to ports published
// on 0.0.0.0. Result is cached for the process lifetime.
func (c *Client) HostProbeHost(ctx context.Context) string {
if c.probeHost != "" {
return c.probeHost
}
host := "127.0.0.1"
if self, err := c.self(ctx); err == nil && self.NetworkSettings != nil {
for _, n := range self.NetworkSettings.Networks {
if n != nil && n.Gateway != "" {
host = n.Gateway
break
}
}
}
c.probeHost = host
return host
}
// OwnNetworkName returns the name of the platform network this process's
// container is attached to, or "" when not running containerized. Per-project
// and default Docker networks are never selected; among remaining networks
// the lexicographically first wins so the choice is deterministic.
func (c *Client) OwnNetworkName(ctx context.Context) string {
self, err := c.self(ctx)
if err != nil || self.NetworkSettings == nil {
return ""
}
names := make([]string, 0, len(self.NetworkSettings.Networks))
for name := range self.NetworkSettings.Networks {
switch {
case name == "bridge" || name == "host" || name == "none":
case strings.HasPrefix(name, "containr-proj-"):
default:
names = append(names, name)
}
}
sort.Strings(names)
if len(names) == 0 {
return ""
}
return names[0]
}
// self inspects the container this process runs in (hostname == container ID).
func (c *Client) self(ctx context.Context) (types.ContainerJSON, error) {
hostname, err := os.Hostname()
if err != nil || hostname == "" {
return types.ContainerJSON{}, fmt.Errorf("hostname unavailable")
}
return c.cli.ContainerInspect(ctx, hostname)
}
// ConnectSelfToNetwork attaches this process's container to the given network.
// No-op when already attached or when not running containerized.
func (c *Client) ConnectSelfToNetwork(ctx context.Context, networkName string) error {
hostname, err := os.Hostname()
if err != nil || hostname == "" {
return fmt.Errorf("hostname unavailable")
}
err = c.cli.NetworkConnect(ctx, networkName, hostname, &network.EndpointSettings{})
if err != nil && strings.Contains(strings.ToLower(err.Error()), "already exists") {
return nil
}
return err
}
// ListContainers returns all containers // ListContainers returns all containers
func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Container, error) { func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Container, error) {
return c.cli.ContainerList(ctx, container.ListOptions{ return c.cli.ContainerList(ctx, container.ListOptions{
@@ -224,6 +297,7 @@ func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options
BuildArgs: options.BuildArgs, BuildArgs: options.BuildArgs,
Labels: options.Labels, Labels: options.Labels,
Remove: options.Remove, Remove: options.Remove,
NoCache: options.NoCache,
}) })
} }
+1
View File
@@ -44,6 +44,7 @@ type BuildOptions struct {
BuildArgs map[string]*string BuildArgs map[string]*string
Labels map[string]string Labels map[string]string
Remove bool Remove bool
NoCache bool
} }
// NetworkConfig represents the configuration for creating a network // NetworkConfig represents the configuration for creating a network
+133 -11
View File
@@ -3,6 +3,7 @@ package ha
import ( import (
"bytes" "bytes"
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@@ -80,6 +81,8 @@ type HealthChecker struct {
results map[string]*HealthCheckResult results map[string]*HealthCheckResult
mu sync.RWMutex mu sync.RWMutex
checkInterval time.Duration checkInterval time.Duration
serviceResolver func(ctx context.Context, serviceID string) (host string, port int, err error)
db *sql.DB
} }
// HealthCheck represents a health check configuration // HealthCheck represents a health check configuration
@@ -596,7 +599,11 @@ func (hc *HealthChecker) performHealthChecks(ctx context.Context) error {
result := hc.performHealthCheck(ctx, check) result := hc.performHealthCheck(ctx, check)
hc.mu.Lock() hc.mu.Lock()
hc.results[check.ID] = result hc.results[check.ID] = result
check.Status = result.Status
check.LastCheck = result.Timestamp
hc.mu.Unlock() hc.mu.Unlock()
// Persist last-known state so a restart resumes with honest history.
hc.persistCheck(check)
} }
return nil return nil
@@ -617,12 +624,20 @@ func (hc *HealthChecker) performHealthCheck(ctx context.Context, check *HealthCh
checkCtx, cancel := context.WithTimeout(ctx, timeout) checkCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
var err error host, port, err := hc.resolveTarget(ctx, check)
if err != nil {
result.Status = HealthStatusUnhealthy
result.Message = err.Error()
result.ErrorCode = "TARGET_UNRESOLVED"
result.Latency = time.Since(start)
return result
}
switch check.Type { switch check.Type {
case HealthCheckTypeHTTP: case HealthCheckTypeHTTP:
err = hc.performHTTPCheck(checkCtx, check) err = hc.performHTTPCheck(checkCtx, check, host, port)
case HealthCheckTypeTCP: case HealthCheckTypeTCP:
err = hc.performTCPCheck(checkCtx, check) err = hc.performTCPCheck(checkCtx, check, host, port)
case HealthCheckTypeCommand: case HealthCheckTypeCommand:
err = hc.performCommandCheck(checkCtx, check) err = hc.performCommandCheck(checkCtx, check)
default: default:
@@ -642,13 +657,11 @@ func (hc *HealthChecker) performHealthCheck(ctx context.Context, check *HealthCh
return result return result
} }
func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthCheck) error { func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthCheck, host string, port int) error {
host := hc.resolveHealthCheckHost(check)
protocol := strings.ToLower(strings.TrimSpace(check.Config.Protocol)) protocol := strings.ToLower(strings.TrimSpace(check.Config.Protocol))
if protocol == "" { if protocol == "" {
protocol = "http" protocol = "http"
} }
port := check.Config.Port
if port <= 0 { if port <= 0 {
if protocol == "https" { if protocol == "https" {
port = 443 port = 443
@@ -684,9 +697,7 @@ func (hc *HealthChecker) performHTTPCheck(ctx context.Context, check *HealthChec
return nil return nil
} }
func (hc *HealthChecker) performTCPCheck(ctx context.Context, check *HealthCheck) error { func (hc *HealthChecker) performTCPCheck(ctx context.Context, check *HealthCheck, host string, port int) error {
host := hc.resolveHealthCheckHost(check)
port := check.Config.Port
if port <= 0 { if port <= 0 {
port = 80 port = 80
} }
@@ -723,6 +734,27 @@ func (hc *HealthChecker) performCommandCheck(ctx context.Context, check *HealthC
return nil return nil
} }
// WithServiceResolver lets the API layer plug in service-aware targeting: a
// ServiceID-bound check resolves to the service's DNS name on its project
// network instead of the backend container's loopback, where dialing
// 127.0.0.1:<host port> can never succeed.
func (m *HighAvailabilityManager) WithServiceResolver(fn func(ctx context.Context, serviceID string) (host string, port int, err error)) *HighAvailabilityManager {
m.healthChecker.serviceResolver = fn
return m
}
// WithPersistence attaches Postgres so configured checks survive restarts.
// Call LoadHealthChecks once during startup to hydrate state.
func (m *HighAvailabilityManager) WithPersistence(db *sql.DB) *HighAvailabilityManager {
m.healthChecker.db = db
return m
}
// LoadHealthChecks hydrates persisted health checks during startup.
func (m *HighAvailabilityManager) LoadHealthChecks(ctx context.Context) error {
return m.healthChecker.LoadHealthChecks(ctx)
}
func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string { func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string {
if strings.TrimSpace(check.NodeID) != "" && hc.scheduler != nil { if strings.TrimSpace(check.NodeID) != "" && hc.scheduler != nil {
nodes := hc.scheduler.GetNodes() nodes := hc.scheduler.GetNodes()
@@ -739,19 +771,109 @@ func (hc *HealthChecker) resolveHealthCheckHost(check *HealthCheck) string {
return "127.0.0.1" return "127.0.0.1"
} }
// resolveTarget picks the dial target for a check: service-bound checks go to
// the service DNS name on the project network; node-bound and free-standing
// checks keep the old host behavior. A service-bound resolver failure is
// surfaced as an error — falling back to loopback would only produce a
// misleading "service down" result.
func (hc *HealthChecker) resolveTarget(ctx context.Context, check *HealthCheck) (string, int, error) {
port := check.Config.Port
if strings.TrimSpace(check.NodeID) == "" && check.ServiceID != "" && hc.serviceResolver != nil {
resolveCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
h, p, err := hc.serviceResolver(resolveCtx, check.ServiceID)
if err != nil {
return "", 0, fmt.Errorf("resolve service target: %w", err)
}
if h == "" {
return "", 0, fmt.Errorf("resolve service target: service %s not found", check.ServiceID)
}
if port <= 0 {
port = p
}
return h, port, nil
}
return hc.resolveHealthCheckHost(check), port, nil
}
// AddHealthCheck adds a new health check // AddHealthCheck adds a new health check
func (hc *HealthChecker) AddHealthCheck(check *HealthCheck) { func (hc *HealthChecker) AddHealthCheck(check *HealthCheck) {
hc.mu.Lock() hc.mu.Lock()
defer hc.mu.Unlock()
hc.checks[check.ID] = check hc.checks[check.ID] = check
hc.mu.Unlock()
hc.persistCheck(check)
} }
// RemoveHealthCheck removes a health check // RemoveHealthCheck removes a health check
func (hc *HealthChecker) RemoveHealthCheck(checkID string) { func (hc *HealthChecker) RemoveHealthCheck(checkID string) {
hc.mu.Lock() hc.mu.Lock()
defer hc.mu.Unlock()
delete(hc.checks, checkID) delete(hc.checks, checkID)
delete(hc.results, checkID) delete(hc.results, checkID)
hc.mu.Unlock()
if hc.db != nil {
_, _ = hc.db.ExecContext(context.Background(), `DELETE FROM ha_health_checks WHERE id = $1`, checkID)
}
}
// persistCheck upserts one check. Best effort — a write failure never blocks
// the in-memory path, matching the scaling-policy persistence style.
func (hc *HealthChecker) persistCheck(check *HealthCheck) {
if hc.db == nil {
return
}
cfg, _ := json.Marshal(check.Config)
var lastCheck interface{}
if !check.LastCheck.IsZero() {
lastCheck = check.LastCheck
}
_, _ = hc.db.ExecContext(context.Background(),
`INSERT INTO ha_health_checks (id, service_id, node_id, type, config, status, last_check, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
ON CONFLICT (id) DO UPDATE
SET service_id = EXCLUDED.service_id, node_id = EXCLUDED.node_id,
type = EXCLUDED.type, config = EXCLUDED.config,
status = EXCLUDED.status, last_check = EXCLUDED.last_check,
updated_at = now()`,
check.ID, check.ServiceID, check.NodeID, string(check.Type), cfg, string(check.Status), lastCheck,
)
}
// LoadHealthChecks hydrates checks persisted across restarts.
func (hc *HealthChecker) LoadHealthChecks(ctx context.Context) error {
if hc.db == nil {
return nil
}
rows, err := hc.db.QueryContext(ctx,
`SELECT id, service_id, node_id, type, config, status, last_check FROM ha_health_checks`)
if err != nil {
return err
}
defer rows.Close()
loaded := 0
for rows.Next() {
var check HealthCheck
var cfg []byte
var status, checkType string
var lastCheck sql.NullTime
if err := rows.Scan(&check.ID, &check.ServiceID, &check.NodeID, &checkType, &cfg, &status, &lastCheck); err != nil {
continue
}
check.Type = HealthCheckType(checkType)
check.Status = HealthStatus(status)
if lastCheck.Valid {
check.LastCheck = lastCheck.Time
}
_ = json.Unmarshal(cfg, &check.Config)
hc.mu.Lock()
hc.checks[check.ID] = &check
hc.mu.Unlock()
loaded++
}
if loaded > 0 {
log.Printf("Restored %d health checks", loaded)
}
return rows.Err()
} }
// GetHealthCheck returns one configured health check. // GetHealthCheck returns one configured health check.
+291
View File
@@ -0,0 +1,291 @@
// Package mcpserver exposes the Containr API as MCP tools over stdio.
// It reuses the CLI's thin client — same PAT auth, same endpoints —
// so agents reach everything the UI and CLI do.
package mcpserver
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"containr/internal/cli/commands"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// spec describes one MCP tool as an HTTP call. {placeholder} segments in
// Path are filled from arguments; remaining string/number/bool args become
// the JSON body for POST/PUT/PATCH.
type spec struct {
name string
description string
method string
path string
// params are the arguments that fill path placeholders (required).
params []string
// bodyKeys restrict which args go into the request body; when empty all
// non-param args are sent.
bodyKeys []string
// destructive tools refuse unless args["confirm"] == true.
destructive bool
}
var tools []spec
func register(s spec) { tools = append(tools, s) }
func init() {
register(spec{"containr_auth_status", "Show which profile/token is active and the authenticated identity", "GET", "/user/profile", nil, nil, false})
// Personal access tokens
register(spec{"containr_tokens_list", "List personal access tokens (metadata only, never values)", "GET", "/user/tokens", nil, nil, false})
register(spec{"containr_tokens_create", "Create a personal access token. Args: name (required), scope (read|write|admin, default write), expires_in_days (0=never). Returns the raw token once.", "POST", "/user/tokens", nil, []string{"name", "scope", "expires_in_days"}, false})
register(spec{"containr_tokens_revoke", "Revoke a personal access token by id. Requires confirm:true.", "DELETE", "/user/tokens/{id}", []string{"id"}, nil, true})
// Projects
register(spec{"containr_projects_list", "List projects", "GET", "/projects", nil, nil, false})
register(spec{"containr_projects_get", "Get a project by id", "GET", "/projects/{id}", []string{"id"}, nil, false})
register(spec{"containr_projects_create", "Create a project. Args: name (required), description.", "POST", "/projects", nil, []string{"name", "description"}, false})
register(spec{"containr_projects_delete", "Delete a project and all its services. Requires confirm:true.", "DELETE", "/projects/{id}", []string{"id"}, nil, true})
// Services
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}].", "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_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})
// Domains
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
register(spec{"containr_domains_add", "Attach a domain to a service. Args: id, domain (required), is_default.", "POST", "/services/{id}/domains", []string{"id"}, []string{"domain", "is_default"}, false})
register(spec{"containr_domains_remove", "Detach a domain. Args: id (service), domain_id.", "DELETE", "/services/{id}/domains/{domain_id}", []string{"id", "domain_id"}, nil, false})
register(spec{"containr_domains_default", "Set the default domain. Args: id (service), domain_id.", "POST", "/services/{id}/domains/{domain_id}/default", []string{"id", "domain_id"}, nil, false})
register(spec{"containr_domains_check", "DNS preflight for all service domains — returns ok|wrong-target|pending per domain", "GET", "/services/{id}/domains/check", []string{"id"}, nil, false})
// Docker volumes (admin)
register(spec{"containr_volumes_list", "List docker volumes on the node with in-use flags (admin)", "GET", "/admin/volumes", nil, nil, false})
register(spec{"containr_volumes_delete", "Delete an unused docker volume. Requires confirm:true. (admin)", "DELETE", "/admin/volumes/{name}", []string{"name"}, nil, true})
// Variables
register(spec{"containr_variables_list", "List service environment variables (secrets masked)", "GET", "/services/{id}/variables", []string{"id"}, nil, false})
register(spec{"containr_variables_set", "Replace all variables on a service. Args: id, variables = [{key, value, is_secret}], redeploy (queue a redeploy to apply). Fetch the current list first and merge — the server replaces wholesale.", "PUT", "/services/{id}/variables", []string{"id"}, []string{"variables", "redeploy"}, false})
// Deployments
register(spec{"containr_deploy", "Trigger a deployment for a service. Args: id (service), commit_hash, branch, no_cache.", "POST", "/services/{id}/deployments", []string{"id"}, []string{"commit_hash", "branch", "trigger", "no_cache"}, false})
register(spec{"containr_deployments_list", "List recent deployments across services", "GET", "/deployments", nil, nil, false})
register(spec{"containr_services_deployments", "List deployments for one service", "GET", "/services/{id}/deployments", []string{"id"}, nil, false})
register(spec{"containr_deployments_get", "Get a deployment", "GET", "/deployments/{id}", []string{"id"}, nil, false})
register(spec{"containr_deployments_logs", "Get persisted build/runtime logs for a deployment. Args: id, type (all|build|runtime).", "GET", "/deployments/{id}/logs", []string{"id"}, nil, false})
register(spec{"containr_deployments_rollback", "Roll back to a deployment. Requires confirm:true.", "POST", "/deployments/{id}/rollback", []string{"id"}, nil, true})
register(spec{"containr_deployments_cancel", "Cancel a queued or running deployment", "POST", "/deployments/{id}/cancel", []string{"id"}, nil, false})
// Databases
register(spec{"containr_databases_list", "List managed databases", "GET", "/databases", nil, nil, false})
register(spec{"containr_databases_get", "Get a database (connection URL included)", "GET", "/databases/{id}", []string{"id"}, nil, false})
register(spec{"containr_databases_create", "Provision a managed database. Args: name, type (postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse).", "POST", "/databases", nil, nil, false})
register(spec{"containr_databases_action", "Start/stop/restart a database. Args: id, action.", "POST", "/databases/{id}/action", []string{"id"}, []string{"action"}, false})
register(spec{"containr_databases_backup", "Take a manual backup snapshot", "POST", "/databases/{id}/backup", []string{"id"}, nil, false})
register(spec{"containr_databases_restore", "Restore a database from a backup — overwrites current data. Requires confirm:true.", "POST", "/databases/{id}/restore", []string{"id"}, []string{"backup_id"}, true})
register(spec{"containr_databases_delete", "Delete a database and its data. Requires confirm:true.", "DELETE", "/databases/{id}", []string{"id"}, nil, true})
// Cron
register(spec{"containr_cron_list", "List cron jobs", "GET", "/cron-jobs", nil, nil, false})
register(spec{"containr_cron_create", "Create a cron job. Args: service_id, schedule (5-field), command, name, timezone.", "POST", "/cron-jobs", nil, nil, false})
register(spec{"containr_cron_trigger", "Run a cron job now", "POST", "/cron-jobs/{id}/trigger", []string{"id"}, nil, false})
register(spec{"containr_cron_executions", "List a cron job's execution history", "GET", "/cron-jobs/{id}/executions", []string{"id"}, nil, false})
register(spec{"containr_cron_delete", "Delete a cron job. Requires confirm:true.", "DELETE", "/cron-jobs/{id}", []string{"id"}, nil, true})
// Templates
register(spec{"containr_templates_list", "List templates", "GET", "/templates", nil, nil, false})
register(spec{"containr_templates_get", "Get a template definition", "GET", "/templates/{id}", []string{"id"}, nil, false})
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
// Notifications
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
// Nodes / agent tokens (admin)
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false})
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
// Scaling / HA / security
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
register(spec{"containr_scaling_policies", "List scaling policies", "GET", "/scaling/policies", nil, nil, false})
register(spec{"containr_scaling_scale", "Manually scale a service. Args: service_id, replicas. (admin)", "POST", "/scaling/services/{service_id}/scale", []string{"service_id"}, []string{"replicas"}, false})
register(spec{"containr_ha_status", "HA manager status", "GET", "/ha/status", nil, nil, false})
register(spec{"containr_ha_alerts", "Active HA alerts", "GET", "/ha/alerts/active", nil, nil, false})
register(spec{"containr_ha_failover", "Trigger a manual failover — reschedules services. Requires confirm:true. (admin)", "POST", "/ha/failover", nil, nil, true})
register(spec{"containr_security_scan", "Run a security scan. Args: project_id (required), scan_type, service_id.", "POST", "/security/scans", nil, nil, false})
register(spec{"containr_security_vulnerabilities", "List a project's vulnerabilities", "GET", "/projects/{project_id}/vulnerabilities", []string{"project_id"}, nil, false})
// Admin
register(spec{"containr_admin_overview", "Platform overview (admin)", "GET", "/admin/overview", nil, nil, false})
register(spec{"containr_admin_users", "List all users (admin)", "GET", "/admin/users", nil, nil, false})
register(spec{"containr_admin_audit_logs", "List audit log entries (admin)", "GET", "/audit-logs", nil, nil, false})
register(spec{"containr_gateway_services", "List gateway services (admin)", "GET", "/gateway/services", nil, nil, false})
register(spec{"containr_gateway_keys", "List gateway API keys (admin)", "GET", "/gateway/keys", nil, nil, false})
}
// Run starts the stdio MCP server. apiURL/token come from env or config —
// the caller resolves them before calling.
func Run(ctx context.Context, apiURL, token, version string) error {
if token == "" {
return fmt.Errorf("CONTAINR_TOKEN is required (create a cnp_ token under Settings → Personal Access Tokens)")
}
client := &commands.Client{BaseURL: apiURL, Token: token}
server := mcp.NewServer(&mcp.Implementation{
Name: "containr",
Version: version,
}, nil)
for _, s := range tools {
s := s
server.AddTool(&mcp.Tool{
Name: s.name,
Description: s.description,
InputSchema: schemaFor(s),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return call(ctx, client, s, req)
})
}
return server.Run(ctx, &mcp.StdioTransport{})
}
// schemaFor builds a JSON Schema for the tool's arguments: path params +
// body keys + confirm for destructive tools. Properties accept any JSON
// type — the API validates values.
func schemaFor(s spec) map[string]interface{} {
props := map[string]interface{}{}
var required []string
for _, p := range s.params {
props[p] = map[string]interface{}{"type": "string", "description": "path parameter"}
required = append(required, p)
}
bodySrc := s.bodyKeys
if len(bodySrc) == 0 && s.method != "GET" && s.method != "DELETE" {
// Unrestricted body: advertise nothing, accept anything.
}
for _, k := range bodySrc {
props[k] = map[string]interface{}{"description": "request body field"}
}
if s.destructive {
props["confirm"] = map[string]interface{}{"type": "boolean", "description": "must be true — this operation is destructive"}
required = append(required, "confirm")
}
schema := map[string]interface{}{
"type": "object",
"properties": props,
"additionalProperties": true,
}
if len(required) > 0 {
schema["required"] = required
}
return schema
}
func call(ctx context.Context, c *commands.Client, s spec, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args map[string]interface{}
if len(req.Params.Arguments) > 0 {
if err := json.Unmarshal(req.Params.Arguments, &args); err != nil {
return errorResult("invalid arguments: " + err.Error()), nil
}
}
if args == nil {
args = map[string]interface{}{}
}
if s.destructive && args["confirm"] != true {
return errorResult("destructive operation — pass confirm:true to proceed"), nil
}
path := s.path
for _, p := range s.params {
v, ok := args[p]
if !ok || fmt.Sprint(v) == "" {
return errorResult(fmt.Sprintf("missing required argument %q", p)), nil
}
path = strings.ReplaceAll(path, "{"+p+"}", fmt.Sprint(v))
}
var body interface{}
var query []string
for k, v := range args {
if k == "confirm" {
continue
}
inParams := false
for _, p := range s.params {
if k == p {
inParams = true
break
}
}
if inParams {
continue
}
if s.method == "GET" || s.method == "DELETE" {
// Non-path args on reads become query parameters.
query = append(query, url.QueryEscape(k)+"="+url.QueryEscape(fmt.Sprint(v)))
continue
}
if len(s.bodyKeys) > 0 {
allowed := false
for _, b := range s.bodyKeys {
if k == b {
allowed = true
break
}
}
if !allowed {
continue
}
}
if body == nil {
body = map[string]interface{}{}
}
body.(map[string]interface{})[k] = v
}
if len(query) > 0 {
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
path += sep + strings.Join(query, "&")
}
data, err := c.Do(s.method, path, body)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(data), nil
}
func textResult(data []byte) *mcp.CallToolResult {
var v interface{}
if json.Unmarshal(data, &v) != nil {
v = string(data)
}
out, _ := json.MarshalIndent(v, "", " ")
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: string(out)}},
}
}
func errorResult(msg string) *mcp.CallToolResult {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{&mcp.TextContent{Text: msg}},
}
}
@@ -0,0 +1,132 @@
package middleware
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"time"
"containr/internal/database"
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
)
// Idempotency replays POST responses for a repeated Idempotency-Key header
// so agents can safely retry mutations. The first request wins; concurrent
// duplicates get 409 IDEMPOTENCY_IN_PROGRESS; completed responses replay
// verbatim for 24h. Keys are scoped to the authenticated identity.
func Idempotency() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method != http.MethodPost {
c.Next()
return
}
key := c.GetHeader("Idempotency-Key")
if key == "" || len(key) > 128 {
c.Next()
return
}
dbValue, exists := c.Get("redis")
if !exists {
c.Next()
return
}
rdb, ok := dbValue.(*database.Redis)
if !ok || rdb == nil || rdb.Client == nil {
c.Next()
return
}
// Scope by caller identity so a stolen key can't replay across users.
actor := c.GetString("user_id")
if actor == "" {
actor = c.GetHeader("Authorization")
}
sum := sha256.Sum256([]byte(actor + ":" + c.Request.URL.Path + ":" + key))
redisKey := "idem:" + hex.EncodeToString(sum[:16])
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
stored, err := rdb.Client.Get(ctx, redisKey).Result()
switch {
case err == nil:
if stored == "processing" {
c.JSON(http.StatusConflict, gin.H{
"error": "a request with this idempotency key is still in progress",
"code": "IDEMPOTENCY_IN_PROGRESS",
})
c.Abort()
return
}
var rec idemRecord
if json.Unmarshal([]byte(stored), &rec) == nil {
c.Header("X-Idempotent-Replay", "true")
c.Data(rec.Status, rec.ContentType, rec.Body)
c.Abort()
return
}
case err != redis.Nil:
// Redis hiccup — degrade to pass-through rather than failing the request.
c.Next()
return
}
// Claim the key for the duration of the request.
if set, serr := rdb.Client.SetNX(ctx, redisKey, "processing", 2*time.Minute).Result(); serr != nil || !set {
c.JSON(http.StatusConflict, gin.H{
"error": "a request with this idempotency key is still in progress",
"code": "IDEMPOTENCY_IN_PROGRESS",
})
c.Abort()
return
}
recorder := &responseRecorder{ResponseWriter: c.Writer, status: http.StatusOK, body: &bytes.Buffer{}}
c.Writer = recorder
c.Next()
// Cache terminal responses only — 5xx is retryable and must not be replayed.
if recorder.status < 500 && recorder.body.Len() <= 1<<20 {
rec := idemRecord{
Status: recorder.status,
ContentType: recorder.Header().Get("Content-Type"),
Body: recorder.body.Bytes(),
}
if rec.ContentType == "" {
rec.ContentType = "application/json; charset=utf-8"
}
if raw, merr := json.Marshal(rec); merr == nil {
_ = rdb.Client.Set(context.Background(), redisKey, raw, 24*time.Hour).Err()
}
} else {
_ = rdb.Client.Del(context.Background(), redisKey).Err()
}
}
}
type idemRecord struct {
Status int `json:"s"`
ContentType string `json:"ct"`
Body []byte `json:"b"`
}
type responseRecorder struct {
gin.ResponseWriter
status int
body *bytes.Buffer
}
func (r *responseRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *responseRecorder) Write(b []byte) (int, error) {
r.body.Write(b)
return r.ResponseWriter.Write(b)
}
@@ -0,0 +1,60 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestIdempotencyPassThroughWithoutKey(t *testing.T) {
gin.SetMode(gin.TestMode)
called := 0
router := gin.New()
router.Use(Idempotency())
router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) })
req := httptest.NewRequest(http.MethodPost, "/x", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if called != 1 || rec.Code != 200 {
t.Fatalf("expected pass-through, called=%d code=%d", called, rec.Code)
}
}
func TestIdempotencyPassThroughWithoutRedis(t *testing.T) {
gin.SetMode(gin.TestMode)
called := 0
router := gin.New()
router.Use(Idempotency())
router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) })
// Key present but no redis in context — must not block the request.
req := httptest.NewRequest(http.MethodPost, "/x", nil)
req.Header.Set("Idempotency-Key", "abc-123")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if called != 1 || rec.Code != 200 {
t.Fatalf("expected pass-through without redis, called=%d code=%d", called, rec.Code)
}
}
func TestIdempotencySkipsNonPOST(t *testing.T) {
gin.SetMode(gin.TestMode)
called := 0
router := gin.New()
router.Use(Idempotency())
router.GET("/x", func(c *gin.Context) { called++; c.String(200, "ok") })
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Idempotency-Key", "abc")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if called != 1 {
t.Fatalf("GET should bypass idempotency, called=%d", called)
}
}
+80 -6
View File
@@ -2,7 +2,10 @@ package middleware
import ( import (
"containr/internal/database" "containr/internal/database"
sqlcdb "containr/internal/database/sqlcdb"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -75,6 +78,14 @@ func RequestID() gin.HandlerFunc {
func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) { func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) {
tokenString, tokenErr, hasToken := extractJWTToken(c) tokenString, tokenErr, hasToken := extractJWTToken(c)
if tokenString != "" { if tokenString != "" {
// Personal access tokens (cnp_…) are not JWTs — they resolve
// against the user_tokens table by sha256 hash.
if strings.HasPrefix(tokenString, patPrefix) {
if id, mail, verified := resolvePersonalAccessToken(c, tokenString); verified {
return id, mail, "", hasToken, true
}
return "", "", "Invalid token", hasToken, false
}
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid { if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
userIDClaim, exists := claims["user_id"] userIDClaim, exists := claims["user_id"]
if exists { if exists {
@@ -101,12 +112,67 @@ func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *bette
return "", "", tokenErr, hasToken, false return "", "", tokenErr, hasToken, false
} }
const patPrefix = "cnp_"
// resolvePersonalAccessToken authenticates a `cnp_` bearer token against the
// user_tokens table (sha256 hash match, not revoked, not expired). On success
// it stamps last_used_at, records the token scope on the context, and
// returns the owner's id and email.
func resolvePersonalAccessToken(c *gin.Context, token string) (string, string, bool) {
dbValue, exists := c.Get("db")
if !exists {
return "", "", false
}
db, ok := dbValue.(*database.DB)
if !ok || db == nil || db.DB == nil {
return "", "", false
}
sum := sha256.Sum256([]byte(token))
queries := sqlcdb.New(db.DB)
row, err := queries.GetUserTokenByHash(c.Request.Context(), hex.EncodeToString(sum[:]))
if err != nil {
return "", "", false
}
var email string
if err := db.QueryRowContext(c.Request.Context(),
`SELECT email FROM users WHERE id = $1`, row.UserID).Scan(&email); err != nil {
return "", "", false
}
_ = queries.TouchUserToken(c.Request.Context(), row.ID)
c.Set("pat_scope", row.Scope)
return row.UserID.String(), email, true
}
// patScopeAllows reports whether a PAT-authenticated request may proceed.
// read scope permits only safe methods; write and admin are unrestricted at
// this layer (admin routes still require the admin scope AND a platform
// admin account — enforced via is_admin in setAuthenticatedContext).
func patScopeAllows(c *gin.Context) bool {
if c.GetString("pat_scope") != "read" {
return true
}
switch c.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
return false
}
// setAuthenticatedContext stores the resolved identity plus the admin flag on // setAuthenticatedContext stores the resolved identity plus the admin flag on
// the gin context so handlers and RequireAdmin can rely on it. // the gin context so handlers and RequireAdmin can rely on it.
func setAuthenticatedContext(c *gin.Context, userID, email string) { func setAuthenticatedContext(c *gin.Context, userID, email string) {
c.Set("user_id", userID) c.Set("user_id", userID)
c.Set("email", email) c.Set("email", email)
c.Set("is_admin", loadIsAdmin(c, userID)) isAdmin := loadIsAdmin(c, userID)
// A PAT can never outrank its scope: non-admin scopes cap the token at
// non-admin even when the owning user is a platform admin.
if scope := c.GetString("pat_scope"); scope != "" && scope != "admin" {
isAdmin = false
}
c.Set("is_admin", isAdmin)
} }
func loadIsAdmin(c *gin.Context, userID string) bool { func loadIsAdmin(c *gin.Context, userID string) bool {
@@ -133,20 +199,28 @@ func Auth(jwtSecret string) gin.HandlerFunc {
userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier) userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
if ok { if ok {
setAuthenticatedContext(c, userID, email) setAuthenticatedContext(c, userID, email)
if !patScopeAllows(c) {
c.JSON(http.StatusForbidden, gin.H{
"error": "Token scope is read-only",
"code": "SCOPE_INSUFFICIENT",
})
c.Abort()
return
}
c.Next() c.Next()
return return
} }
if tokenErr != "" { if tokenErr != "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr}) c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr, "code": "INVALID_TOKEN"})
c.Abort() c.Abort()
return return
} }
if hasToken { if hasToken {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token", "code": "INVALID_TOKEN"})
} else { } else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"})
} }
c.Abort() c.Abort()
} }
@@ -171,12 +245,12 @@ func OptionalAuth(jwtSecret string) gin.HandlerFunc {
func RequireAdmin() gin.HandlerFunc { func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if _, exists := c.Get("user_id"); !exists { if _, exists := c.Get("user_id"); !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"})
c.Abort() c.Abort()
return return
} }
if !c.GetBool("is_admin") { if !c.GetBool("is_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required", "code": "ADMIN_REQUIRED"})
c.Abort() c.Abort()
return return
} }
@@ -131,6 +131,61 @@ func TestAuthStoresStringUserIDForValidClaims(t *testing.T) {
} }
} }
func TestAuthRejectsPATWithoutDatabase(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(Auth("secret"))
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
// A cnp_-prefixed token takes the PAT path, not JWT parsing — without a
// db handle it must fail closed with 401.
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer cnp_"+strings.Repeat("ab", 24))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
}
}
func TestPATScopeAllows(t *testing.T) {
gin.SetMode(gin.TestMode)
cases := []struct {
name string
scope string
method string
want bool
}{
{"read allows GET", "read", http.MethodGet, true},
{"read allows HEAD", "read", http.MethodHead, true},
{"read allows OPTIONS", "read", http.MethodOptions, true},
{"read blocks POST", "read", http.MethodPost, false},
{"read blocks DELETE", "read", http.MethodDelete, false},
{"write allows POST", "write", http.MethodPost, true},
{"write allows DELETE", "write", http.MethodDelete, true},
{"admin allows PATCH", "admin", http.MethodPatch, true},
{"no scope (session/JWT) unrestricted", "", http.MethodPost, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(tc.method, "/x", nil)
if tc.scope != "" {
c.Set("pat_scope", tc.scope)
}
if got := patScopeAllows(c); got != tc.want {
t.Fatalf("patScopeAllows(scope=%q, method=%s) = %v, want %v", tc.scope, tc.method, got, tc.want)
}
})
}
}
func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string { func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string {
t.Helper() t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+98
View File
@@ -0,0 +1,98 @@
package secrets
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"log"
"strings"
"sync"
)
// Prefix marks AES-GCM-encrypted values in environment_variables.value.
// Plaintext rows keep working — Decrypt returns them unchanged, so the
// rollout is transparent encrypt-on-write / decrypt-on-read.
const Prefix = "enc:v1:"
var (
keyOnce sync.Once
key []byte
enabled bool
)
// Configure derives the AES-256 key. SECRETS_KEY wins; JWT_SECRET is the
// fallback so existing installs need no new config. With neither, encrypt
// degrades to plaintext with a single warning.
func Configure(secretsKey, jwtSecret string) {
keyOnce.Do(func() {
seed := secretsKey
if seed == "" {
seed = jwtSecret
}
if seed == "" {
log.Printf("secrets: no SECRETS_KEY or JWT_SECRET — secret variables stay plaintext")
return
}
sum := sha256.Sum256([]byte("containr-env-secrets:" + seed))
key = sum[:]
enabled = true
})
}
func IsEncrypted(value string) bool {
return strings.HasPrefix(value, Prefix)
}
// Encrypt returns value unchanged if disabled or already encrypted.
func Encrypt(value string) string {
if !enabled || IsEncrypted(value) {
return value
}
block, err := aes.NewCipher(key)
if err != nil {
return value
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return value
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return value
}
sealed := gcm.Seal(nonce, nonce, []byte(value), nil)
return Prefix + base64.StdEncoding.EncodeToString(sealed)
}
// Decrypt returns plaintext for enc:v1 values, the input otherwise.
// Corrupt ciphertext fails soft to the stored value rather than erroring
// the deploy path — an unreadable secret is no worse than a plaintext one.
func Decrypt(value string) string {
if !enabled || !IsEncrypted(value) {
return value
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, Prefix))
if err != nil {
return value
}
block, err := aes.NewCipher(key)
if err != nil {
return value
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return value
}
if len(raw) < gcm.NonceSize() {
return value
}
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
if err != nil {
log.Printf("secrets: decrypt failed (%v) — check SECRETS_KEY/JWT_SECRET rotation", fmt.Errorf("%w", err))
return value
}
return string(plain)
}
@@ -0,0 +1,63 @@
package secrets
import (
"sync"
"testing"
)
// resetForTest restores the package state so Configure can be re-run.
func resetForTest() {
keyOnce = sync.Once{}
key = nil
enabled = false
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
resetForTest()
Configure("test-secrets-key", "")
enc := Encrypt("super-secret-value")
if !IsEncrypted(enc) {
t.Fatalf("expected encrypted prefix, got %q", enc)
}
if enc == "super-secret-value" {
t.Fatal("value stored as plaintext")
}
if got := Decrypt(enc); got != "super-secret-value" {
t.Fatalf("decrypt = %q", got)
}
}
func TestPlaintextPassThrough(t *testing.T) {
resetForTest()
Configure("test-secrets-key", "")
if got := Decrypt("legacy-plaintext"); got != "legacy-plaintext" {
t.Fatalf("legacy plaintext changed: %q", got)
}
}
func TestEncryptIsIdempotent(t *testing.T) {
resetForTest()
Configure("test-secrets-key", "")
enc := Encrypt("v")
if got := Encrypt(enc); got != enc {
t.Fatal("double-encrypt changed the value")
}
}
func TestDecryptFailsSoftOnCorruption(t *testing.T) {
resetForTest()
Configure("test-secrets-key", "")
corrupt := Prefix + "not-valid-base64!!!"
if got := Decrypt(corrupt); got != corrupt {
t.Fatal("corrupt ciphertext should pass through unchanged")
}
}
func TestDisabledWithoutKey(t *testing.T) {
resetForTest()
Configure("", "")
if got := Encrypt("v"); got != "v" {
t.Fatal("encrypt should pass through when no key configured")
}
}
+1
View File
@@ -28,6 +28,7 @@ type BuildRequest struct {
TriggeredBy string `json:"triggered_by"` // Who triggered the build TriggeredBy string `json:"triggered_by"` // Who triggered the build
Branch string `json:"branch"` // Git branch Branch string `json:"branch"` // Git branch
Commit string `json:"commit"` // Git commit SHA Commit string `json:"commit"` // Git commit SHA
NoCache bool `json:"no_cache"` // Disable Docker layer cache
} }
// BuildResponse represents the response from a build operation // BuildResponse represents the response from a build operation
@@ -0,0 +1,11 @@
-- +goose Up
-- Railway-style template publishing: owners can mark a user template public so
-- it appears in the anonymous catalog (GET /api/v1/templates) and on the
-- landing page. Official templates stay official; is_public only matters for
-- user templates.
ALTER TABLE public.service_templates ADD COLUMN IF NOT EXISTS is_public boolean NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS idx_service_templates_public ON public.service_templates(is_public) WHERE is_public;
-- +goose Down
DROP INDEX IF EXISTS public.idx_service_templates_public;
ALTER TABLE public.service_templates DROP COLUMN IF EXISTS is_public;
@@ -0,0 +1,8 @@
-- +goose Up
-- Stable public host port for service replica 0. 0 means "not yet assigned";
-- the runtime writes the ephemeral port it picked back here so restarts and
-- redeploys keep the same public endpoint instead of churning.
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS published_port integer NOT NULL DEFAULT 0;
-- +goose Down
ALTER TABLE public.services DROP COLUMN IF EXISTS published_port;
@@ -0,0 +1,18 @@
-- +goose Up
-- Persisted HA health checks. Previously checks lived only in memory, so a
-- backend restart silently deleted every configured check while services kept
-- running unmonitored.
CREATE TABLE IF NOT EXISTS ha_health_checks (
id uuid PRIMARY KEY,
service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE,
node_id varchar(255) NOT NULL DEFAULT '',
type varchar(20) NOT NULL,
config jsonb NOT NULL DEFAULT '{}'::jsonb,
status varchar(20) NOT NULL DEFAULT 'unknown',
last_check timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE IF EXISTS ha_health_checks;
@@ -0,0 +1,25 @@
-- +goose Up
-- Personal access tokens for CLI/MCP/agent authentication.
-- Only sha256 hashes are stored — raw tokens (cnp_…) are shown once at
-- creation and never persisted. Scope gates what the token may do:
-- read = GET/HEAD only, write = all non-admin routes, admin = everything
-- (admin still requires the owning user to be a platform admin).
CREATE TABLE IF NOT EXISTS public.user_tokens (
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
name character varying(255) NOT NULL,
key_prefix character varying(16) NOT NULL,
token_hash text NOT NULL UNIQUE,
scope character varying(20) NOT NULL DEFAULT 'write'
CHECK (scope IN ('read', 'write', 'admin')),
expires_at timestamp with time zone,
last_used_at timestamp with time zone,
revoked_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_tokens_user_id ON public.user_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_user_tokens_token_hash ON public.user_tokens(token_hash);
-- +goose Down
DROP TABLE IF EXISTS public.user_tokens;
@@ -0,0 +1,13 @@
-- +goose Up
-- Volume mounts on services: [{type: "volume"|"bind", source, target,
-- read_only}]. Applied to every replica's container config at deploy and
-- reconcile time. The baseline schema declared the column nullable with
-- no default — this normalizes it.
ALTER TABLE services ADD COLUMN IF NOT EXISTS volumes JSONB DEFAULT '[]'::jsonb;
UPDATE services SET volumes = '[]'::jsonb WHERE volumes IS NULL;
ALTER TABLE services ALTER COLUMN volumes SET DEFAULT '[]'::jsonb;
ALTER TABLE services ALTER COLUMN volumes SET NOT NULL;
-- +goose Down
ALTER TABLE services ALTER COLUMN volumes DROP NOT NULL;
ALTER TABLE services ALTER COLUMN volumes DROP DEFAULT;
@@ -0,0 +1,30 @@
-- +goose Up
-- Multi-domain support. services.domain stays as the derived default for
-- compatibility; service_domains is the source of truth for the full
-- hostname set. Backfills existing services.domain rows as defaults.
CREATE TABLE IF NOT EXISTS service_domains (
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE,
domain varchar(255) NOT NULL,
is_default boolean NOT NULL DEFAULT false,
cert_type varchar(32) NOT NULL DEFAULT 'letsencrypt',
cert_status varchar(32) NOT NULL DEFAULT 'pending',
last_checked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (service_id, domain)
);
CREATE INDEX IF NOT EXISTS idx_service_domains_service ON service_domains(service_id);
INSERT INTO service_domains (service_id, domain, is_default)
SELECT id, domain, true FROM services WHERE domain <> '' AND domain IS NOT NULL
ON CONFLICT (service_id, domain) DO NOTHING;
-- Access control: maintenance mode redirects to a platform page; basic auth
-- gates the route behind htpasswd-format user:hash pairs (Traefik middleware).
ALTER TABLE services ADD COLUMN IF NOT EXISTS maintenance_mode boolean NOT NULL DEFAULT false;
ALTER TABLE services ADD COLUMN IF NOT EXISTS basic_auth_users text NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE services DROP COLUMN IF EXISTS maintenance_mode;
ALTER TABLE services DROP COLUMN IF EXISTS basic_auth_users;
DROP TABLE IF EXISTS service_domains;
+9 -7
View File
@@ -1,18 +1,18 @@
-- name: ListServiceTemplatesForUser :many -- name: ListServiceTemplatesForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates FROM service_templates
WHERE is_official = true OR owner_id = sqlc.narg(owner_id) WHERE is_official = true OR is_public = true OR owner_id = sqlc.narg(owner_id)
ORDER BY is_official DESC, name ASC; ORDER BY is_official DESC, name ASC;
-- name: ListServiceTemplatesByCategoryForUser :many -- name: ListServiceTemplatesByCategoryForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates FROM service_templates
WHERE category = sqlc.arg(category) WHERE category = sqlc.arg(category)
AND (is_official = true OR owner_id = sqlc.narg(owner_id)) AND (is_official = true OR is_public = true OR owner_id = sqlc.narg(owner_id))
ORDER BY is_official DESC, name ASC; ORDER BY is_official DESC, name ASC;
-- name: GetServiceTemplateByID :one -- name: GetServiceTemplateByID :one
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates FROM service_templates
WHERE id = $1; WHERE id = $1;
@@ -39,7 +39,7 @@ SET name = EXCLUDED.name,
updated_at = NOW(); updated_at = NOW();
-- name: CreateUserTemplate :exec -- name: CreateUserTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id) INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id, is_public)
VALUES ( VALUES (
sqlc.arg(id), sqlc.arg(id),
sqlc.arg(name), sqlc.arg(name),
@@ -49,7 +49,8 @@ VALUES (
sqlc.arg(config), sqlc.arg(config),
sqlc.narg(variables), sqlc.narg(variables),
false, false,
sqlc.arg(owner_id) sqlc.arg(owner_id),
sqlc.arg(is_public)
); );
-- name: UpdateUserTemplate :execrows -- name: UpdateUserTemplate :execrows
@@ -60,6 +61,7 @@ SET name = sqlc.arg(name),
logo = sqlc.narg(logo), logo = sqlc.narg(logo),
config = sqlc.arg(config), config = sqlc.arg(config),
variables = sqlc.narg(variables), variables = sqlc.narg(variables),
is_public = sqlc.arg(is_public),
updated_at = NOW() updated_at = NOW()
WHERE id = sqlc.arg(id) WHERE id = sqlc.arg(id)
AND owner_id = sqlc.arg(owner_id) AND owner_id = sqlc.arg(owner_id)
+24
View File
@@ -0,0 +1,24 @@
-- name: CreateUserToken :one
INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: ListUserTokens :many
SELECT * FROM user_tokens
WHERE user_id = $1 AND revoked_at IS NULL
ORDER BY created_at DESC;
-- name: GetUserTokenByHash :one
SELECT * FROM user_tokens
WHERE token_hash = $1
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
LIMIT 1;
-- name: TouchUserToken :exec
UPDATE user_tokens SET last_used_at = NOW()
WHERE id = $1 AND revoked_at IS NULL;
-- name: RevokeUserToken :execrows
UPDATE user_tokens SET revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL;
+36 -1
View File
@@ -52,7 +52,28 @@ CREATE TABLE services (
git_branch VARCHAR(100), git_branch VARCHAR(100),
build_path VARCHAR(500), build_path VARCHAR(500),
cpu VARCHAR(50), cpu VARCHAR(50),
memory VARCHAR(50) memory VARCHAR(50),
replicas INTEGER NOT NULL DEFAULT 1,
port INTEGER NOT NULL DEFAULT 0,
domain VARCHAR(255) NOT NULL DEFAULT '',
healthcheck_path VARCHAR(255) NOT NULL DEFAULT '',
restart_policy VARCHAR(50) NOT NULL DEFAULT 'unless-stopped',
published_port INTEGER NOT NULL DEFAULT 0,
volumes JSONB NOT NULL DEFAULT '[]'::jsonb,
maintenance_mode BOOLEAN NOT NULL DEFAULT false,
basic_auth_users TEXT NOT NULL DEFAULT ''
);
CREATE TABLE service_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
domain VARCHAR(255) NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT false,
cert_type VARCHAR(32) NOT NULL DEFAULT 'letsencrypt',
cert_status VARCHAR(32) NOT NULL DEFAULT 'pending',
last_checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (service_id, domain)
); );
CREATE TABLE deployments ( CREATE TABLE deployments (
@@ -84,6 +105,7 @@ CREATE TABLE service_templates (
variables JSONB DEFAULT '[]', variables JSONB DEFAULT '[]',
is_official BOOLEAN DEFAULT false, is_official BOOLEAN DEFAULT false,
owner_id UUID, owner_id UUID,
is_public BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
@@ -197,3 +219,16 @@ CREATE TABLE notifications (
read_at TIMESTAMP WITH TIME ZONE, read_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
CREATE TABLE user_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
key_prefix VARCHAR(16) NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
scope VARCHAR(20) NOT NULL DEFAULT 'write',
expires_at TIMESTAMP WITH TIME ZONE,
last_used_at TIMESTAMP WITH TIME ZONE,
revoked_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
+2
View File
@@ -0,0 +1,2 @@
.vercel
.env*
+1 -1
View File
@@ -32,7 +32,7 @@ describe('App smoke routes', () => {
expect(await screen.findByRole('heading', { name: /^projects\s*_?$/i })).toBeInTheDocument(); expect(await screen.findByRole('heading', { name: /^projects\s*_?$/i })).toBeInTheDocument();
expect(screen.getByText(/recent deployments/i)).toBeInTheDocument(); expect(screen.getByText(/recent deployments/i)).toBeInTheDocument();
expect(screen.getAllByText(/core-services/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/core platform/i).length).toBeGreaterThan(0);
}); });
it('renders builds page in demo mode', async () => { it('renders builds page in demo mode', async () => {
+28 -10
View File
@@ -1,6 +1,6 @@
import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom'; import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ShieldCheck } from 'lucide-react'; import { Compass, ShieldCheck } from 'lucide-react';
import { PlatformShell } from './layout/PlatformShell'; import { PlatformShell } from './layout/PlatformShell';
import { ProjectsPage } from '@/features/projects/pages/ProjectsPage'; import { ProjectsPage } from '@/features/projects/pages/ProjectsPage';
import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage'; import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage';
@@ -23,14 +23,10 @@ import {
} from '@/features/secondary/pages'; } from '@/features/secondary/pages';
import { SignInPage, SignUpPage } from '@/features/auth/pages'; import { SignInPage, SignUpPage } from '@/features/auth/pages';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { getCurrentUserProfile } from '@/lib/api-client'; import { getCurrentUserProfile } from '@/lib/api-client';
import { ErrorBoundary, LoadingState } from '@/shared/components'; import { ErrorBoundary, LoadingState } from '@/shared/components';
function useDemoMode() {
const location = useLocation();
return new URLSearchParams(location.search).get('demo') === '1';
}
function AuthRequired() { function AuthRequired() {
const location = useLocation(); const location = useLocation();
const isDemoMode = useDemoMode(); const isDemoMode = useDemoMode();
@@ -107,6 +103,26 @@ function AdminRequired() {
return <Outlet />; return <Outlet />;
} }
function NotFoundPage() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-6 text-center">
<Compass size={32} className="text-[var(--text-tertiary)]" />
<div>
<h1 className="text-lg font-semibold text-[var(--text-primary)]">Page not found</h1>
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
The page you are looking for does not exist or was moved.
</p>
</div>
<Link
to="/"
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Back to home
</Link>
</div>
);
}
export default function App() { export default function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
@@ -116,10 +132,12 @@ export default function App() {
<Route path="/auth/sign-up" element={<SignUpPage />} /> <Route path="/auth/sign-up" element={<SignUpPage />} />
{/* Public browsing — approved projects, templates, docs. */} {/* Public browsing — approved projects, templates, docs. */}
<Route element={<PlatformShell />}> {/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} /> <Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} /> <Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
<Route element={<PlatformShell />}>
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/templates" element={<TemplatesPage />} /> <Route path="/templates" element={<TemplatesPage />} />
<Route path="/databases" element={<DatabasesPage />} /> <Route path="/databases" element={<DatabasesPage />} />
<Route path="/ha" element={<HighAvailabilityPage />} /> <Route path="/ha" element={<HighAvailabilityPage />} />
@@ -140,14 +158,14 @@ export default function App() {
<Route element={<AdminRequired />}> <Route element={<AdminRequired />}>
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<AdminPage />} />
</Route> </Route>
<Route path="*" element={<NotFoundPage />} />
</Route> </Route>
{/* Full-bleed design demo — renders its own chrome, no app shell */} {/* Full-bleed design demo — renders its own chrome, no app shell */}
<Route element={<AuthRequired />}> <Route element={<AuthRequired />}>
<Route path="/metrics-demo" element={<ServiceMetricsDashboard />} /> <Route path="/metrics-demo" element={<ServiceMetricsDashboard />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</ErrorBoundary> </ErrorBoundary>
); );
+35 -10
View File
@@ -37,6 +37,7 @@ import {
pullUpgradeImage, pullUpgradeImage,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { signOutAuthSession } from '@/lib/auth-client'; import { signOutAuthSession } from '@/lib/auth-client';
import { isDemoSearch } from '@/lib/demo-mode';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useToast } from '@/shared/components'; import { useToast } from '@/shared/components';
@@ -103,8 +104,18 @@ export function PlatformShell() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showToast } = useToast(); const { showToast } = useToast();
const isDemoMode = new URLSearchParams(location.search).get('demo') === '1'; const isDemoMode = isDemoSearch(location.search);
const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target); const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target);
// Auth/admin-scoped pages have no demo fixtures — keep the demo nav honest.
const visibleNavSections = isDemoMode
? navSections
.map((section) =>
section.title === 'Workspace'
? { ...section, items: section.items.filter((item) => item.href === '/docs') }
: section,
)
.filter((section) => section.items.length > 0)
: navSections;
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme()); const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar()); const [sidebarExpanded, setSidebarExpanded] = useState<boolean>(() => getInitialSidebar());
const [notificationsOpen, setNotificationsOpen] = useState(false); const [notificationsOpen, setNotificationsOpen] = useState(false);
@@ -118,7 +129,7 @@ export function PlatformShell() {
staleTime: 60_000, staleTime: 60_000,
retry: false, retry: false,
}); });
const isAdmin = isDemoMode || Boolean(profileQuery.data?.isAdmin); const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
const upgradeQuery = useQuery({ const upgradeQuery = useQuery({
queryKey: ['upgrade-status'], queryKey: ['upgrade-status'],
queryFn: getUpgradeStatus, queryFn: getUpgradeStatus,
@@ -174,8 +185,8 @@ export function PlatformShell() {
const isActiveRoute = (itemHref: string) => const isActiveRoute = (itemHref: string) =>
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`); location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
const userName = profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account'; const userName = isDemoMode ? 'Demo workspace' : profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account';
const userEmail = profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session'; const userEmail = isDemoMode ? 'Sample data · read-only' : profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session';
const initials = userName const initials = userName
.split(' ') .split(' ')
.map((part) => part[0]) .map((part) => part[0])
@@ -220,7 +231,7 @@ export function PlatformShell() {
</div> </div>
<nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2"> <nav className="mt-3 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
{navSections.map((section) => ( {visibleNavSections.map((section) => (
<div key={section.title}> <div key={section.title}>
{sidebarExpanded ? ( {sidebarExpanded ? (
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]"> <p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
@@ -297,6 +308,8 @@ export function PlatformShell() {
<p className="truncate text-xs text-[var(--text-tertiary)]">{userEmail}</p> <p className="truncate text-xs text-[var(--text-tertiary)]">{userEmail}</p>
</div> </div>
<DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" /> <DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" />
{!isDemoMode ? (
<>
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<button type="button" onClick={() => navigate(href('/settings'))} className={menuItemClass}> <button type="button" onClick={() => navigate(href('/settings'))} className={menuItemClass}>
<User size={14} /> Profile & account <User size={14} /> Profile & account
@@ -319,6 +332,8 @@ export function PlatformShell() {
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
) : null} ) : null}
</>
) : null}
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<button <button
type="button" type="button"
@@ -331,6 +346,15 @@ export function PlatformShell() {
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" /> <DropdownMenu.Separator className="my-1 h-px bg-[var(--border-subtle)]" />
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
{isDemoMode ? (
<button
type="button"
onClick={() => navigate('/')}
className={`${menuItemClass} text-[var(--error)] hover:!bg-[var(--error-soft)] hover:!text-[var(--error)]`}
>
<LogOut size={14} /> Exit demo
</button>
) : (
<button <button
type="button" type="button"
onClick={() => void signOut()} onClick={() => void signOut()}
@@ -338,6 +362,7 @@ export function PlatformShell() {
> >
<LogOut size={14} /> Sign out <LogOut size={14} /> Sign out
</button> </button>
)}
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
@@ -382,7 +407,7 @@ export function PlatformShell() {
</div> </div>
<div className="ml-auto flex items-center gap-4"> <div className="ml-auto flex items-center gap-4">
<span className="v-env">env:production</span> <span className="v-env">{isDemoMode ? 'env:demo' : 'env:production'}</span>
<button <button
type="button" type="button"
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))} onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
@@ -393,11 +418,11 @@ export function PlatformShell() {
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />} {theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
</button> </button>
{isAdmin ? ( {isAdmin && !isDemoMode ? (
<button <button
type="button" type="button"
onClick={() => pullMutation.mutate()} onClick={() => pullMutation.mutate()}
disabled={isDemoMode || pullMutation.isPending || !upgradeQuery.data?.imageRef} disabled={pullMutation.isPending || !upgradeQuery.data?.imageRef}
className="inline-flex h-8 items-center gap-2 rounded-[var(--radius-md)] px-3 text-xs font-semibold text-[var(--accent-on)] disabled:cursor-not-allowed disabled:opacity-50" className="inline-flex h-8 items-center gap-2 rounded-[var(--radius-md)] px-3 text-xs font-semibold text-[var(--accent-on)] disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }} style={{ background: 'var(--accent-primary)' }}
title={upgradeQuery.data?.message || 'Pull latest configured image'} title={upgradeQuery.data?.message || 'Pull latest configured image'}
@@ -407,7 +432,7 @@ export function PlatformShell() {
</button> </button>
) : null} ) : null}
{signedIn ? ( {signedIn && !isDemoMode ? (
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
@@ -504,7 +529,7 @@ export function PlatformShell() {
</button> </button>
</div> </div>
<nav className="flex gap-1 overflow-x-auto px-3 pb-3"> <nav className="flex gap-1 overflow-x-auto px-3 pb-3">
{navSections.flatMap((section) => section.items).map((item) => { {visibleNavSections.flatMap((section) => section.items).map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = isActiveRoute(item.href); const isActive = isActiveRoute(item.href);
return ( return (
@@ -23,7 +23,8 @@ import {
type ProjectEntity, type ProjectEntity,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { LoadingState, useToast } from '@/shared/components'; import { useDemoMode } from '@/lib/demo-mode';
import { DemoRestricted, LoadingState, useToast } from '@/shared/components';
const statLabels: Record<string, string> = { const statLabels: Record<string, string> = {
users: 'Users', users: 'Users',
@@ -42,12 +43,13 @@ const statLabels: Record<string, string> = {
export function AdminPage() { export function AdminPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showToast } = useToast(); const { showToast } = useToast();
const sessionQuery = useAuthSession(); const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const myId = sessionQuery.data?.user.id; const myId = sessionQuery.data?.user.id;
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview }); const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview, enabled: !isDemoMode });
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers }); const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode });
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }) }); const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode });
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null); const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
const [editForm, setEditForm] = useState({ name: '', description: '' }); const [editForm, setEditForm] = useState({ name: '', description: '' });
@@ -99,6 +101,10 @@ export function AdminPage() {
onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined), onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined),
}); });
if (isDemoMode) {
return <DemoRestricted feature="The admin console" />;
}
if (overviewQuery.isPending) { if (overviewQuery.isPending) {
return <LoadingState message="Loading admin console…" className="py-24" />; return <LoadingState message="Loading admin console…" className="py-24" />;
} }
@@ -1,7 +1,9 @@
import { useDeferredValue, useState } from 'react'; import { useDeferredValue, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { listAuditLogs } from '@/lib/api-client'; import { listAuditLogs } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode';
import { formatDate, formatRelative } from '@/lib/time'; import { formatDate, formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components';
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -17,6 +19,7 @@ const inputClass =
'h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all'; 'h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all';
export function AuditLogsPage() { export function AuditLogsPage() {
const isDemoMode = useDemoMode();
const [resource, setResource] = useState(''); const [resource, setResource] = useState('');
const [action, setAction] = useState(''); const [action, setAction] = useState('');
const [actor, setActor] = useState(''); const [actor, setActor] = useState('');
@@ -42,8 +45,13 @@ export function AuditLogsPage() {
const since = option && 'ms' in option ? new Date(Date.now() - option.ms).toISOString() : undefined; const since = option && 'ms' in option ? new Date(Date.now() - option.ms).toISOString() : undefined;
return listAuditLogs({ ...filters, since }); return listAuditLogs({ ...filters, since });
}, },
enabled: !isDemoMode,
}); });
if (isDemoMode) {
return <DemoRestricted feature="Audit logs" />;
}
const logs = logsQuery.data ?? []; const logs = logsQuery.data ?? [];
const hasNext = logs.length === PAGE_SIZE; const hasNext = logs.length === PAGE_SIZE;
const hasFilters = Boolean(resource || action || actor || range); const hasFilters = Boolean(resource || action || actor || range);
@@ -1,6 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { import {
cancelBuild, cancelBuild,
getBuildLogs, getBuildLogs,
@@ -9,6 +8,8 @@ import {
type BuildStatus, type BuildStatus,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { useBuildUpdates } from '@/lib/use-build-updates'; import { useBuildUpdates } from '@/lib/use-build-updates';
import { useDemoMode } from '@/lib/demo-mode';
import { demoBuilds } from '@/lib/demo-data';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import { import {
Check, Check,
@@ -33,36 +34,6 @@ const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
{ value: 'cancelled', label: 'Cancelled' }, { value: 'cancelled', label: 'Cancelled' },
]; ];
const demoBuilds: BuildEntity[] = [
{
id: 'build-demo-01',
projectId: 'project-demo',
serviceId: 'service-api',
status: 'success',
progress: 100,
startedAt: new Date(Date.now() - 20 * 60_000).toISOString(),
completedAt: new Date(Date.now() - 18 * 60_000).toISOString(),
imageName: 'ghcr.io/containr/api',
imageTag: 'sha-1f2e3d4',
size: 156_000_000,
log: '[demo] Build finished successfully.',
metadata: { branch: 'main' },
},
{
id: 'build-demo-02',
projectId: 'project-demo',
serviceId: 'service-worker',
status: 'running',
progress: 54,
startedAt: new Date(Date.now() - 2 * 60_000).toISOString(),
imageName: 'ghcr.io/containr/worker',
imageTag: 'sha-9a8b7c6',
size: 0,
log: '[demo] Building image layers...',
metadata: { branch: 'feature/queue' },
},
];
function StatusBadge({ status }: { status: BuildStatus }) { function StatusBadge({ status }: { status: BuildStatus }) {
const config = { const config = {
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false }, success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
@@ -99,8 +70,7 @@ function bytesToHumanReadable(bytes: number): string {
export function BuildsPage() { export function BuildsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchParams] = useSearchParams(); const isDemoMode = useDemoMode();
const isDemoMode = searchParams.get('demo') === '1';
const [projectFilter, setProjectFilter] = useState(''); const [projectFilter, setProjectFilter] = useState('');
const [serviceFilter, setServiceFilter] = useState(''); const [serviceFilter, setServiceFilter] = useState('');
@@ -14,6 +14,8 @@ import {
import { listProjects, listTemplates } from '@/lib/api-client'; import { listProjects, listTemplates } from '@/lib/api-client';
import { DocsBrowser } from '@/features/docs/DocsBrowser'; import { DocsBrowser } from '@/features/docs/DocsBrowser';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects, demoTemplates } from '@/lib/demo-data';
const features = [ const features = [
{ icon: Rocket, title: 'Git-push deploys', text: 'Connect a repository and ship. Builds, rollouts, and rollbacks are handled for you.' }, { icon: Rocket, title: 'Git-push deploys', text: 'Connect a repository and ship. Builds, rollouts, and rollbacks are handled for you.' },
@@ -25,14 +27,16 @@ const features = [
]; ];
export function LandingPage() { export function LandingPage() {
const sessionQuery = useAuthSession(); const isDemoMode = useDemoMode();
const signedIn = Boolean(sessionQuery.data); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = !isDemoMode && Boolean(sessionQuery.data);
const demo = isDemoMode ? '?demo=1' : '';
const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000 }); const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000, enabled: !isDemoMode });
const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000 }); const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000, enabled: !isDemoMode });
const projects = projectsQuery.data ?? []; const projects = isDemoMode ? demoProjects : (projectsQuery.data ?? []);
const templates = (templatesQuery.data ?? []).slice(0, 12); const templates = (isDemoMode ? demoTemplates : (templatesQuery.data ?? [])).slice(0, 12);
return ( return (
<div className="min-h-screen bg-[var(--bg-void)] text-[var(--text-secondary)]"> <div className="min-h-screen bg-[var(--bg-void)] text-[var(--text-secondary)]">
@@ -51,7 +55,7 @@ export function LandingPage() {
<a href="#templates" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Templates</a> <a href="#templates" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Templates</a>
<a href="#docs" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Docs</a> <a href="#docs" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Docs</a>
<Link <Link
to="/projects" to={`/projects${demo}`}
className="rounded-[var(--radius-md)] px-3 py-1.5 font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]" className="rounded-[var(--radius-md)] px-3 py-1.5 font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
> >
Browse Browse
@@ -65,10 +69,10 @@ export function LandingPage() {
</Link> </Link>
) : ( ) : (
<Link <Link
to="/auth/sign-in" to={isDemoMode ? `/projects${demo}` : '/auth/sign-in'}
className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]" className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]"
> >
Sign in <ArrowRight size={13} /> {isDemoMode ? 'Live demo' : 'Sign in'} <ArrowRight size={13} />
</Link> </Link>
)} )}
</nav> </nav>
@@ -91,10 +95,10 @@ export function LandingPage() {
</p> </p>
<div className="mt-8 flex items-center justify-center gap-3"> <div className="mt-8 flex items-center justify-center gap-3">
<Link <Link
to="/projects" to={`/projects${demo}`}
className="inline-flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-5 py-2.5 text-sm font-semibold text-[var(--accent-on)] transition-transform hover:scale-[1.02]" className="inline-flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-5 py-2.5 text-sm font-semibold text-[var(--accent-on)] transition-transform hover:scale-[1.02]"
> >
Browse projects <ArrowRight size={15} /> {isDemoMode ? 'Explore the live demo' : 'Browse projects'} <ArrowRight size={15} />
</Link> </Link>
<a <a
href="#docs" href="#docs"
@@ -152,7 +156,7 @@ export function LandingPage() {
</p> </p>
</div> </div>
<Link <Link
to="/templates" to={`/templates${demo}`}
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline" className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline"
> >
Browse all <ArrowRight size={13} /> Browse all <ArrowRight size={13} />
@@ -162,7 +166,7 @@ export function LandingPage() {
{templates.map((template) => ( {templates.map((template) => (
<Link <Link
key={template.id} key={template.id}
to="/templates" to={`/templates?template=${template.id}${isDemoMode ? '&demo=1' : ''}`}
className="group rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-4 transition-colors hover:border-[var(--border-strong)]" className="group rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-4 transition-colors hover:border-[var(--border-strong)]"
> >
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
@@ -212,9 +216,9 @@ export function LandingPage() {
Containr — self-hosted container platform Containr — self-hosted container platform
</span> </span>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link to="/projects" className="hover:text-[var(--text-secondary)]">Projects</Link> <Link to={`/projects${demo}`} className="hover:text-[var(--text-secondary)]">Projects</Link>
<Link to="/templates" className="hover:text-[var(--text-secondary)]">Templates</Link> <Link to={`/templates${demo}`} className="hover:text-[var(--text-secondary)]">Templates</Link>
<Link to="/admin" className="hover:text-[var(--text-secondary)]">Admin</Link> {!isDemoMode ? <Link to="/admin" className="hover:text-[var(--text-secondary)]">Admin</Link> : null}
</div> </div>
</div> </div>
</footer> </footer>
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
createProject, createProject,
@@ -15,39 +15,20 @@ import {
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
const demoProjects: ProjectEntity[] = [ import { demoProjects } from '@/lib/demo-data';
{
id: 'project-demo',
name: 'core-services',
description: 'Sample project with mock services for UI preview.',
isApproved: true,
createdAt: new Date(Date.now() - 14 * 86_400_000).toISOString(),
updatedAt: new Date().toISOString(),
stats: { service_count: 5, deployment_count: 12, running_services: 5, last_deployment: null },
},
{
id: 'project-staging',
name: 'ml-pipeline',
description: 'Pre-production environment for testing new releases.',
isApproved: true,
createdAt: new Date(Date.now() - 7 * 86_400_000).toISOString(),
updatedAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
stats: { service_count: 2, deployment_count: 5, running_services: 1, last_deployment: null },
},
];
const demoDeploys = [ const demoDeploys = [
{ id: 'd1', name: 'api-gateway', project: 'core-services', status: 'DEPLOYED', when: '4m' }, { id: 'd1', name: 'API Gateway', project: 'Core Platform', status: 'DEPLOYED', when: '4m' },
{ id: 'd2', name: 'web-frontend', project: 'core-services', status: 'BUILDING', when: '6m' }, { id: 'd2', name: 'Web Frontend', project: 'Core Platform', status: 'BUILDING', when: '6m' },
{ id: 'd3', name: 'worker', project: 'ml-pipeline', status: 'FAILED', when: '1h' }, { id: 'd3', name: 'Batch Evaluator', project: 'Inference Lab', status: 'FAILED', when: '1h' },
{ id: 'd4', name: 'site', project: 'growth-site', status: 'DEPLOYED', when: '2h' }, { id: 'd4', name: 'Marketing Site', project: 'Growth Surface', status: 'DEPLOYED', when: '2h' },
]; ];
function getHealthStatus(stats: ProjectStats): 'healthy' | 'degraded' | 'critical' { function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' | 'critical' {
if (stats.service_count === 0) return 'healthy'; if (stats.service_count === 0) return 'idle';
if (stats.running_services === stats.service_count) return 'healthy'; if (stats.running_services === stats.service_count) return 'healthy';
if (stats.running_services >= stats.service_count / 2) return 'degraded'; if (stats.running_services > 0) return 'degraded';
return 'critical'; return 'critical';
} }
@@ -55,6 +36,8 @@ function healthBadge(health: ReturnType<typeof getHealthStatus>) {
switch (health) { switch (health) {
case 'healthy': case 'healthy':
return { label: 'RUNNING', cls: 'v-st-ok' }; return { label: 'RUNNING', cls: 'v-st-ok' };
case 'idle':
return { label: 'IDLE', cls: '' };
case 'degraded': case 'degraded':
return { label: 'DEGRADED', cls: 'v-st-warn' }; return { label: 'DEGRADED', cls: 'v-st-warn' };
case 'critical': case 'critical':
@@ -89,7 +72,6 @@ function MiniCanvas({ project }: { project: ProjectEntity }) {
{Array.from({ length: shown }).map((_, i) => ( {Array.from({ length: shown }).map((_, i) => (
<div key={i} className="v-node" style={positions[i]}> <div key={i} className="v-node" style={positions[i]}>
<b className={`v-nd ${i < running ? '' : 'r'}`} /> <b className={`v-nd ${i < running ? '' : 'r'}`} />
svc{i + 1}
</div> </div>
))} ))}
{extra > 0 ? ( {extra > 0 ? (
@@ -104,6 +86,7 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
const health = getHealthStatus(project.stats); const health = getHealthStatus(project.stats);
const badge = healthBadge(health); const badge = healthBadge(health);
const envOk = health === 'healthy'; const envOk = health === 'healthy';
const envLabel = envOk ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded';
return ( return (
<article <article
@@ -120,10 +103,10 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
<div className="v-mono mt-3 flex items-center gap-2 text-[10.5px] text-[var(--text-tertiary)]"> <div className="v-mono mt-3 flex items-center gap-2 text-[10.5px] text-[var(--text-tertiary)]">
<i <i
className="inline-block h-1.5 w-1.5 rounded-full" className="inline-block h-1.5 w-1.5 rounded-full"
style={{ background: envOk ? 'var(--success)' : 'var(--warning)' }} style={{ background: envOk ? 'var(--success)' : health === 'idle' ? 'var(--text-tertiary)' : health === 'critical' ? 'var(--error)' : 'var(--warning)' }}
/> />
<span className="text-[var(--text-secondary)]"> <span className="text-[var(--text-secondary)]">
{envOk ? 'production' : 'degraded'} {envLabel}
</span> </span>
<span>·</span> <span>·</span>
<span> <span>
@@ -137,9 +120,8 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
export function ProjectsPage() { export function ProjectsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isDemoMode = searchParams.get('demo') === '1'; const isDemoMode = useDemoMode();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [isCreateOpen, setCreateOpen] = useState(false); const [isCreateOpen, setCreateOpen] = useState(false);
@@ -199,6 +181,7 @@ export function ProjectsPage() {
const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0); const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0);
const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0); const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0);
const host = hostQuery.data; const host = hostQuery.data;
const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'HOST';
const cpuPct = isDemoMode ? 14 : host && host.cpu.cores > 0 const cpuPct = isDemoMode ? 14 : host && host.cpu.cores > 0
? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100)) ? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100))
: null; : null;
@@ -240,7 +223,7 @@ export function ProjectsPage() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="search-box" style={{ width: 240 }}> <div className="search-box" style={{ width: 'min(240px, 42vw)' }}>
<Search size={13} /> <Search size={13} />
<input <input
value={search} value={search}
@@ -250,16 +233,18 @@ export function ProjectsPage() {
style={{ fontSize: 11.5 }} style={{ fontSize: 11.5 }}
/> />
</div> </div>
{signedIn && (
<button onClick={openCreate} className="v-btn"> <button onClick={openCreate} className="v-btn">
+ new project + new project
</button> </button>
)}
</div> </div>
</div> </div>
{/* Host stat row */} {/* Host stat row */}
<div className="mb-7 grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="mb-7 grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="v-stat"> <div className="v-stat">
<div className="v-k"><span>HOST.CPU</span><span className="v-live" /></div> <div className="v-k"><span>{hostLabel}.CPU</span><span className="v-live" /></div>
<div className="v-v">{cpuPct ?? '—'}<span>%</span></div> <div className="v-v">{cpuPct ?? '—'}<span>%</span></div>
{isDemoMode ? ( {isDemoMode ? (
<div className="v-spark"> <div className="v-spark">
@@ -275,13 +260,13 @@ export function ProjectsPage() {
</div> </div>
</div> </div>
<div className="v-stat"> <div className="v-stat">
<div className="v-k"><span>HOST.MEM</span><span className="v-live" /></div> <div className="v-k"><span>{hostLabel}.MEM</span><span className="v-live" /></div>
<div className="v-v">{memPct ?? '—'}<span>%</span></div> <div className="v-v">{memPct ?? '—'}<span>%</span></div>
<div className="v-meter"><i className={memPct !== null && memPct > 85 ? 'r' : 'y'} style={{ width: `${memPct ?? 0}%` }} /></div> <div className="v-meter"><i className={memPct !== null && memPct > 85 ? 'r' : 'y'} style={{ width: `${memPct ?? 0}%` }} /></div>
<div className="v-d">{isDemoMode ? '9.8G / 13G' : host ? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)}` : 'no telemetry'}</div> <div className="v-d">{isDemoMode ? '9.8G / 13G' : host ? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)}` : 'no telemetry'}</div>
</div> </div>
<div className="v-stat"> <div className="v-stat">
<div className="v-k"><span>HOST.DISK</span><span className="v-live" /></div> <div className="v-k"><span>{hostLabel}.DISK</span><span className="v-live" /></div>
<div className="v-v">{diskPct ?? '—'}<span>%</span></div> <div className="v-v">{diskPct ?? '—'}<span>%</span></div>
<div className="v-meter"><i className="r" style={{ width: `${diskPct ?? 0}%` }} /></div> <div className="v-meter"><i className="r" style={{ width: `${diskPct ?? 0}%` }} /></div>
<div className="v-d">{isDemoMode ? '338G of 465G · high' : host ? `${fmtGB(host.storage.used)} of ${fmtGB(host.storage.total)}` : 'no telemetry'}</div> <div className="v-d">{isDemoMode ? '338G of 465G · high' : host ? `${fmtGB(host.storage.used)} of ${fmtGB(host.storage.total)}` : 'no telemetry'}</div>
+279 -61
View File
@@ -17,6 +17,9 @@ import {
createAgentToken, createAgentToken,
listAgentTokens, listAgentTokens,
revokeAgentToken, revokeAgentToken,
createUserToken,
listUserTokens,
revokeUserToken,
listDatabases, listDatabases,
databaseAction, databaseAction,
updateDatabaseBackupSchedule, updateDatabaseBackupSchedule,
@@ -46,17 +49,35 @@ import {
updateVulnerability, updateVulnerability,
getSecurityMetrics, getSecurityMetrics,
type AgentAuthTokenCreated, type AgentAuthTokenCreated,
type UserTokenCreated,
type UserTokenScope,
type CreateDatabaseInput, type CreateDatabaseInput,
type DatabaseEntity, type DatabaseEntity,
type FailoverPolicy, type FailoverPolicy,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { demoDatabases } from '@/lib/demo-data'; import {
demoBuilds,
demoDatabases,
demoHAAlerts,
demoHAHealthResults,
demoHAPolicies,
demoHAStatus,
demoHostMonitoring,
demoProjects,
demoSecurityMetrics,
demoSecurityScans,
demoVulnerabilities,
getDemoServicesByProject,
} from '@/lib/demo-data';
import { ServiceIcon } from '@/features/workspace/canvas/ServiceIcon';
import { serviceAccent } from '@/features/workspace/canvas/service-visuals';
import { DocsBrowser } from '@/features/docs/DocsBrowser'; import { DocsBrowser } from '@/features/docs/DocsBrowser';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { formatRelative } from '@/lib/time'; import { formatRelative } from '@/lib/time';
import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client'; import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client';
import { useBuildUpdates } from '@/lib/use-build-updates'; import { useBuildUpdates } from '@/lib/use-build-updates';
import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart } from '@/shared/components'; import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart, DemoRestricted } from '@/shared/components';
import { import {
Clock, Clock,
Activity, Activity,
@@ -203,25 +224,27 @@ function formatUptime(seconds: number): string {
export function UsagePage() { export function UsagePage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const sessionQuery = useAuthSession(); const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const profileQuery = useQuery({ const profileQuery = useQuery({
queryKey: ['user-profile'], queryKey: ['user-profile'],
queryFn: getCurrentUserProfile, queryFn: getCurrentUserProfile,
enabled: Boolean(sessionQuery.data), enabled: !isDemoMode && Boolean(sessionQuery.data),
retry: false, retry: false,
}); });
const signedIn = Boolean(sessionQuery.data); const signedIn = isDemoMode || Boolean(sessionQuery.data);
const isAdmin = Boolean(profileQuery.data?.isAdmin); const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
const buildsQuery = useQuery({ const buildsQuery = useQuery({
queryKey: ['usage-builds'], queryKey: ['usage-builds'],
queryFn: () => listBuilds({ page: 1, limit: 100 }), queryFn: () => listBuilds({ page: 1, limit: 100 }),
enabled: signedIn, enabled: signedIn && !isDemoMode,
retry: false, retry: false,
}); });
const hostQuery = useQuery({ const hostQuery = useQuery({
queryKey: ['usage-host-monitoring'], queryKey: ['usage-host-monitoring'],
queryFn: getHostMonitoring, queryFn: getHostMonitoring,
refetchInterval: 15_000, refetchInterval: 15_000,
enabled: !isDemoMode,
}); });
const agentsQuery = useQuery({ const agentsQuery = useQuery({
queryKey: ['usage-agents'], queryKey: ['usage-agents'],
@@ -251,9 +274,12 @@ export function UsagePage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }),
}); });
const builds = buildsQuery.data?.builds ?? []; const builds = useMemo(
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
[isDemoMode, buildsQuery.data],
);
const liveStatus = useBuildUpdates( const liveStatus = useBuildUpdates(
builds.map((build) => build.id), isDemoMode ? [] : builds.map((build) => build.id),
() => { () => {
queryClient.invalidateQueries({ queryKey: ['usage-builds'] }); queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
}, },
@@ -280,20 +306,19 @@ export function UsagePage() {
const avgBuildMinutes = averageBuildDurationMs / 60_000; const avgBuildMinutes = averageBuildDurationMs / 60_000;
const activeBuildPressure = runningBuilds + pendingBuilds; const activeBuildPressure = runningBuilds + pendingBuilds;
const uniqueServices = new Set(builds.map((build) => build.serviceId).filter(Boolean)).size; const uniqueServices = new Set(builds.map((build) => build.serviceId).filter(Boolean)).size;
const host = hostQuery.data; const host = isDemoMode ? demoHostMonitoring : hostQuery.data;
const agents = agentsQuery.data ?? []; const agents = agentsQuery.data ?? [];
// Accumulate host samples for the load sparkline (polled every 15s). // Accumulate host samples for the load sparkline (polled every 15s).
const [loadHistory, setLoadHistory] = useState<number[]>([]); const [loadHistory, setLoadHistory] = useState<number[]>([]);
useEffect(() => { useEffect(() => {
const sample = hostQuery.data; if (!host || host.cpu.cores <= 0) {
if (!sample || sample.cpu.cores <= 0) {
return; return;
} }
const loadPercent = Math.min(100, (sample.load.load1m / sample.cpu.cores) * 100); const loadPercent = Math.min(100, (host.load.load1m / host.cpu.cores) * 100);
// eslint-disable-next-line react-hooks/set-state-in-effect -- accumulating polled samples is a legitimate sync-to-external pattern // eslint-disable-next-line react-hooks/set-state-in-effect -- accumulating polled samples is a legitimate sync-to-external pattern
setLoadHistory((prev) => [...prev, loadPercent].slice(-24)); setLoadHistory((prev) => [...prev, loadPercent].slice(-24));
}, [hostQuery.data]); }, [host]);
const hostLoadPercent = host && host.cpu.cores > 0 const hostLoadPercent = host && host.cpu.cores > 0
? Math.min(100, (host.load.load1m / host.cpu.cores) * 100) ? Math.min(100, (host.load.load1m / host.cpu.cores) * 100)
@@ -307,14 +332,14 @@ export function UsagePage() {
const buildsPerDay = useMemo(() => { const buildsPerDay = useMemo(() => {
const days = 14; const days = 14;
const counts = new Array<number>(days).fill(0); const counts = new Array<number>(days).fill(0);
const timestamps = (buildsQuery.data?.builds ?? []) const timestamps = builds
.map((build) => new Date((build.startedAt ?? build.completedAt) as string).getTime()) .map((build) => new Date((build.startedAt ?? build.completedAt) as string).getTime())
.filter((t) => Number.isFinite(t)); .filter((t) => Number.isFinite(t));
if (timestamps.length === 0) { if (timestamps.length === 0) {
return counts; return counts;
} }
const anchor = Math.max(...timestamps); const anchor = Math.max(...timestamps);
(buildsQuery.data?.builds ?? []).forEach((build) => { builds.forEach((build) => {
const timestamp = build.startedAt ?? build.completedAt; const timestamp = build.startedAt ?? build.completedAt;
if (!timestamp) { if (!timestamp) {
return; return;
@@ -327,7 +352,7 @@ export function UsagePage() {
}); });
const peak = Math.max(...counts, 1); const peak = Math.max(...counts, 1);
return counts.map((count) => (count / peak) * 100); return counts.map((count) => (count / peak) * 100);
}, [buildsQuery.data]); }, [builds]);
const onlineAgents = agents.filter((agent) => agent.status === 'online' || agent.status === 'connecting').length; const onlineAgents = agents.filter((agent) => agent.status === 'online' || agent.status === 'connecting').length;
const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0); const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0); const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
@@ -382,12 +407,12 @@ containr-agent`;
</span> </span>
</div> </div>
{buildsQuery.isLoading ? ( {!isDemoMode && buildsQuery.isLoading ? (
<div className="py-16 text-center"> <div className="py-16 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" /> <Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading usage data...</p> <p className="mt-3 text-sm text-[var(--text-muted)]">Loading usage data...</p>
</div> </div>
) : buildsQuery.isError ? ( ) : !isDemoMode && buildsQuery.isError ? (
<div className="panel p-8 text-center"> <div className="panel p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center"> <div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<AlertCircle size={24} className="text-[var(--error)]" /> <AlertCircle size={24} className="text-[var(--error)]" />
@@ -455,7 +480,7 @@ containr-agent`;
</button> </button>
</div> </div>
{hostQuery.isError ? ( {!isDemoMode && hostQuery.isError ? (
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]"> <div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
Host monitoring unavailable. Host monitoring unavailable.
</div> </div>
@@ -672,9 +697,11 @@ containr-agent`;
export function PeoplePage() { export function PeoplePage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isDemoMode = useDemoMode();
const profileQuery = useQuery({ const profileQuery = useQuery({
queryKey: ['user-profile'], queryKey: ['user-profile'],
queryFn: getCurrentUserProfile, queryFn: getCurrentUserProfile,
enabled: !isDemoMode,
}); });
const [newUserName, setNewUserName] = useState(''); const [newUserName, setNewUserName] = useState('');
const [newUserEmail, setNewUserEmail] = useState(''); const [newUserEmail, setNewUserEmail] = useState('');
@@ -699,12 +726,18 @@ export function PeoplePage() {
const projectsQuery = useQuery({ const projectsQuery = useQuery({
queryKey: ['people-projects'], queryKey: ['people-projects'],
queryFn: () => listProjects(), queryFn: () => listProjects(),
enabled: !isDemoMode,
}); });
const auditLogsQuery = useQuery({ const auditLogsQuery = useQuery({
queryKey: ['people-audit-logs'], queryKey: ['people-audit-logs'],
queryFn: () => listAuditLogs({ page: 1, limit: 20 }), queryFn: () => listAuditLogs({ page: 1, limit: 20 }),
enabled: !isDemoMode,
}); });
if (isDemoMode) {
return <DemoRestricted feature="People and access management" />;
}
let membersBody = 'Owner-only mode currently. Multi-user collaboration is planned.'; let membersBody = 'Owner-only mode currently. Multi-user collaboration is planned.';
if (profileQuery.isLoading) { if (profileQuery.isLoading) {
@@ -1239,12 +1272,172 @@ function PlatformSettingsSection() {
); );
} }
const PAT_EXPIRY_OPTIONS: { label: string; days: number }[] = [
{ label: 'Never', days: 0 },
{ label: '30 days', days: 30 },
{ label: '90 days', days: 90 },
{ label: '1 year', days: 365 },
];
function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
const queryClient = useQueryClient();
const tokensQuery = useQuery({
queryKey: ['user-tokens'],
queryFn: listUserTokens,
});
const [tokenName, setTokenName] = useState('');
const [tokenScope, setTokenScope] = useState<UserTokenScope>('write');
const [expiryDays, setExpiryDays] = useState(0);
const [issuedToken, setIssuedToken] = useState<UserTokenCreated | null>(null);
const createMutation = useMutation({
mutationFn: () =>
createUserToken({
name: tokenName.trim(),
scope: tokenScope,
expiresInDays: expiryDays,
}),
onSuccess: (data) => {
setIssuedToken(data);
setTokenName('');
queryClient.invalidateQueries({ queryKey: ['user-tokens'] });
},
});
const revokeMutation = useMutation({
mutationFn: (id: string) => revokeUserToken(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['user-tokens'] }),
});
const tokens = tokensQuery.data ?? [];
const scopeOptions: { value: UserTokenScope; label: string }[] = isAdmin
? [
{ value: 'read', label: 'Read - GET requests only' },
{ value: 'write', label: 'Write - read and manage resources' },
{ value: 'admin', label: 'Admin - everything, incl. platform settings' },
]
: [
{ value: 'read', label: 'Read - GET requests only' },
{ value: 'write', label: 'Write - read and manage resources' },
];
return (
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Key size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Personal Access Tokens</h2>
<p className="text-xs text-[var(--text-tertiary)]">Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope</p>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<input
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
placeholder="Token name (e.g. laptop cli)"
className="h-9 flex-1 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
<select
value={tokenScope}
onChange={(e) => setTokenScope(e.target.value as UserTokenScope)}
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
>
{scopeOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<select
value={expiryDays}
onChange={(e) => setExpiryDays(Number(e.target.value))}
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
>
{PAT_EXPIRY_OPTIONS.map((option) => (
<option key={option.days} value={option.days}>{option.label}</option>
))}
</select>
<button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !tokenName.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: 'var(--accent-primary)' }}
>
{createMutation.isPending ? 'Creating…' : 'Create token'}
</button>
</div>
{issuedToken ? (
<div className="rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
<div className="flex items-center justify-between gap-2">
<span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
<button
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
>
<Copy size={11} />
Copy
</button>
</div>
<p className="mt-1 text-xs text-[var(--text-tertiary)]">
Shown once - store it now. Use with <span className="mono">containr auth login --token</span> or as a Bearer token.
</p>
</div>
) : null}
{createMutation.isError ? (
<p className="text-xs text-[var(--error)]">
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create token'}
</p>
) : null}
{tokensQuery.isLoading ? (
<div className="py-4 text-center">
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : tokens.length === 0 ? (
<p className="text-xs text-[var(--text-tertiary)]">No tokens yet. Create one to connect the CLI or an agent.</p>
) : (
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
{tokens.map((token) => (
<div key={token.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0">
<span className="text-[var(--text-primary)]">{token.name}</span>
<span className="ml-2 mono text-[var(--text-muted)]">{token.key_prefix}…</span>
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">{token.scope}</span>
<span className="ml-2 text-[var(--text-muted)]">
{token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'}
{token.expires_at ? ` · expires ${formatRelative(token.expires_at)}` : ''}
</span>
</div>
<button
onClick={() => {
if (window.confirm(`Revoke token "${token.name}"? Clients using it lose access immediately.`)) {
revokeMutation.mutate(token.id ?? '');
}
}}
disabled={revokeMutation.isPending}
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
>
Revoke
</button>
</div>
))}
</div>
)}
</div>
</section>
);
}
export function SettingsPage() { export function SettingsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isDemoMode = useDemoMode();
const profileQuery = useQuery({ const profileQuery = useQuery({
queryKey: ['settings-profile'], queryKey: ['settings-profile'],
queryFn: getCurrentUserProfile, queryFn: getCurrentUserProfile,
enabled: !isDemoMode,
}); });
const [nameDraft, setNameDraft] = useState<string | null>(null); const [nameDraft, setNameDraft] = useState<string | null>(null);
@@ -1308,6 +1501,10 @@ export function SettingsPage() {
refreshStorage(); refreshStorage();
}; };
if (isDemoMode) {
return <DemoRestricted feature="Account and platform settings" />;
}
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<SecondaryPageHeader <SecondaryPageHeader
@@ -1404,6 +1601,9 @@ export function SettingsPage() {
)} )}
</section> </section>
{/* Personal Access Tokens */}
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
{/* Platform Settings - owner only */} {/* Platform Settings - owner only */}
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null} {profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
@@ -1561,8 +1761,7 @@ const DATABASE_PLANS = ['hobby', 'starter', 'standard', 'business'] as const;
export function DatabasesPage() { export function DatabasesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchParams] = useSearchParams(); const isDemoMode = useDemoMode();
const isDemoMode = searchParams.get('demo') === '1';
const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data); const signedIn = isDemoMode || Boolean(sessionQuery.data);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
@@ -1668,8 +1867,14 @@ export function DatabasesPage() {
onClick={() => setExpandedId(expanded ? null : (db.id ?? null))} onClick={() => setExpandedId(expanded ? null : (db.id ?? null))}
className="flex w-full items-center gap-3 p-4 text-left" className="flex w-full items-center gap-3 p-4 text-left"
> >
<div className="w-9 h-9 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center shrink-0"> <div
<Database size={17} className="text-[var(--accent-primary)]" /> className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center shrink-0"
style={{
background: `${serviceAccent({ name: db.type })}1c`,
color: serviceAccent({ name: db.type }),
}}
>
<ServiceIcon service={{ name: db.type, type: 'database' }} size={17} />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -2074,14 +2279,15 @@ const failoverStrategies = [
export function HighAvailabilityPage() { export function HighAvailabilityPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const sessionQuery = useAuthSession(); const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const profileQuery = useQuery({ const profileQuery = useQuery({
queryKey: ['user-profile'], queryKey: ['user-profile'],
queryFn: getCurrentUserProfile, queryFn: getCurrentUserProfile,
enabled: Boolean(sessionQuery.data), enabled: !isDemoMode && Boolean(sessionQuery.data),
retry: false, retry: false,
}); });
const isAdmin = Boolean(profileQuery.data?.isAdmin); const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
const [failoverReason, setFailoverReason] = useState(''); const [failoverReason, setFailoverReason] = useState('');
const [policyForm, setPolicyForm] = useState<{ const [policyForm, setPolicyForm] = useState<{
serviceId: string; serviceId: string;
@@ -2093,11 +2299,11 @@ export function HighAvailabilityPage() {
} | null>(null); } | null>(null);
const [policyProjectId, setPolicyProjectId] = useState(''); const [policyProjectId, setPolicyProjectId] = useState('');
const statusQuery = useQuery({ queryKey: ['ha-status'], queryFn: getHAStatus, refetchInterval: 15_000 }); const statusQuery = useQuery({ queryKey: ['ha-status'], queryFn: getHAStatus, refetchInterval: 15_000, enabled: !isDemoMode });
const policiesQuery = useQuery({ queryKey: ['ha-policies'], queryFn: listFailoverPolicies }); const policiesQuery = useQuery({ queryKey: ['ha-policies'], queryFn: listFailoverPolicies, enabled: !isDemoMode });
const alertsQuery = useQuery({ queryKey: ['ha-alerts'], queryFn: listActiveAlerts, refetchInterval: 15_000 }); const alertsQuery = useQuery({ queryKey: ['ha-alerts'], queryFn: listActiveAlerts, refetchInterval: 15_000, enabled: !isDemoMode });
const healthQuery = useQuery({ queryKey: ['ha-health'], queryFn: listHealthResults, refetchInterval: 30_000 }); const healthQuery = useQuery({ queryKey: ['ha-health'], queryFn: listHealthResults, refetchInterval: 30_000, enabled: !isDemoMode });
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects() }); const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
const servicesQuery = useQuery({ const servicesQuery = useQuery({
queryKey: ['services', policyProjectId], queryKey: ['services', policyProjectId],
queryFn: () => listServicesByProject(policyProjectId), queryFn: () => listServicesByProject(policyProjectId),
@@ -2142,7 +2348,10 @@ export function HighAvailabilityPage() {
onSuccess: invalidate, onSuccess: invalidate,
}); });
const status = statusQuery.data; const status = isDemoMode ? demoHAStatus : statusQuery.data;
const alerts = isDemoMode ? demoHAAlerts : alertsQuery.data ?? [];
const policies = isDemoMode ? demoHAPolicies : policiesQuery.data ?? [];
const healthResults = isDemoMode ? demoHAHealthResults : healthQuery.data ?? [];
const pageError = const pageError =
statusQuery.error ?? policiesQuery.error ?? alertsQuery.error ?? healthQuery.error ?? statusQuery.error ?? policiesQuery.error ?? alertsQuery.error ?? healthQuery.error ??
toggleMutation.error ?? failoverMutation.error ?? savePolicyMutation.error ?? toggleMutation.error ?? failoverMutation.error ?? savePolicyMutation.error ??
@@ -2158,7 +2367,7 @@ export function HighAvailabilityPage() {
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]"> <div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<ShieldCheck size={13} className="text-[var(--accent-primary)]" /> <ShieldCheck size={13} className="text-[var(--accent-primary)]" />
<span>HA state feeds the platform security posture.</span> <span>HA state feeds the platform security posture.</span>
<Link to="/security" className="font-medium text-[var(--accent-primary)] hover:underline"> <Link to={isDemoMode ? '/security?demo=1' : '/security'} className="font-medium text-[var(--accent-primary)] hover:underline">
Open Security → Open Security →
</Link> </Link>
</div> </div>
@@ -2246,13 +2455,13 @@ export function HighAvailabilityPage() {
<AlertCircle size={18} className="text-[var(--accent-primary)]" /> <AlertCircle size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Active alerts</h2> <h2 className="text-base font-semibold text-[var(--text-primary)]">Active alerts</h2>
</div> </div>
{alertsQuery.isLoading ? ( {!isDemoMode && alertsQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (alertsQuery.data ?? []).length === 0 ? ( ) : alerts.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No active alerts.</p> <p className="text-sm text-[var(--text-tertiary)]">No active alerts.</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{(alertsQuery.data ?? []).map((alert) => ( {alerts.map((alert) => (
<div <div
key={alert.id} key={alert.id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3" className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
@@ -2398,13 +2607,13 @@ export function HighAvailabilityPage() {
</div> </div>
)} )}
{policiesQuery.isLoading ? ( {!isDemoMode && policiesQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (policiesQuery.data ?? []).length === 0 ? ( ) : policies.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No failover policies configured.</p> <p className="text-sm text-[var(--text-tertiary)]">No failover policies configured.</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{(policiesQuery.data ?? []).map((policy: FailoverPolicy) => ( {policies.map((policy: FailoverPolicy) => (
<div <div
key={policy.service_id} key={policy.service_id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3" className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
@@ -2461,13 +2670,13 @@ export function HighAvailabilityPage() {
<HeartPulse size={18} className="text-[var(--accent-primary)]" /> <HeartPulse size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Health check results</h2> <h2 className="text-base font-semibold text-[var(--text-primary)]">Health check results</h2>
</div> </div>
{healthQuery.isLoading ? ( {!isDemoMode && healthQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (healthQuery.data ?? []).length === 0 ? ( ) : healthResults.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No health checks configured.</p> <p className="text-sm text-[var(--text-tertiary)]">No health checks configured.</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{(healthQuery.data ?? []).slice(0, 20).map((result, i) => ( {healthResults.slice(0, 20).map((result, i) => (
<div <div
key={`${result.check_id}-${i}`} key={`${result.check_id}-${i}`}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3" className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
@@ -2509,34 +2718,37 @@ const severityColor: Record<string, string> = {
export function SecurityPage() { export function SecurityPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const sessionQuery = useAuthSession(); const isDemoMode = useDemoMode();
const signedIn = Boolean(sessionQuery.data); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const [projectId, setProjectId] = useState(''); const signedIn = !isDemoMode && Boolean(sessionQuery.data);
const [projectId, setProjectId] = useState(isDemoMode ? 'demo-project-core' : '');
const [scanType, setScanType] = useState<string>('comprehensive'); const [scanType, setScanType] = useState<string>('comprehensive');
const [scanServiceId, setScanServiceId] = useState(''); const [scanServiceId, setScanServiceId] = useState('');
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects() }); const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
const projects = isDemoMode ? demoProjects : projectsQuery.data ?? [];
const servicesQuery = useQuery({ const servicesQuery = useQuery({
queryKey: ['services', projectId], queryKey: ['services', projectId],
queryFn: () => listServicesByProject(projectId), queryFn: () => listServicesByProject(projectId),
enabled: Boolean(projectId), enabled: !isDemoMode && Boolean(projectId),
}); });
const services = isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? [];
const metricsQuery = useQuery({ const metricsQuery = useQuery({
queryKey: ['security-metrics', projectId], queryKey: ['security-metrics', projectId],
queryFn: () => getSecurityMetrics(projectId), queryFn: () => getSecurityMetrics(projectId),
enabled: Boolean(projectId), enabled: !isDemoMode && Boolean(projectId),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
const historyQuery = useQuery({ const historyQuery = useQuery({
queryKey: ['security-history', projectId], queryKey: ['security-history', projectId],
queryFn: () => getSecurityHistory(projectId), queryFn: () => getSecurityHistory(projectId),
enabled: Boolean(projectId), enabled: !isDemoMode && Boolean(projectId),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
const vulnsQuery = useQuery({ const vulnsQuery = useQuery({
queryKey: ['security-vulns', projectId], queryKey: ['security-vulns', projectId],
queryFn: () => listVulnerabilities(projectId), queryFn: () => listVulnerabilities(projectId),
enabled: Boolean(projectId), enabled: !isDemoMode && Boolean(projectId),
}); });
const invalidate = () => { const invalidate = () => {
@@ -2559,8 +2771,14 @@ export function SecurityPage() {
onSuccess: invalidate, onSuccess: invalidate,
}); });
const metrics = metricsQuery.data; const metrics = isDemoMode ? demoSecurityMetrics : metricsQuery.data;
const vulns = (vulnsQuery.data ?? []).filter((v) => v.status !== 'resolved'); const scanHistory = isDemoMode
? demoSecurityScans.filter((s) => s.project_id === projectId)
: historyQuery.data ?? [];
const vulns = (isDemoMode
? demoVulnerabilities.filter((v) => v.project_id === projectId)
: vulnsQuery.data ?? []
).filter((v) => v.status !== 'resolved');
const pageError = const pageError =
projectsQuery.error ?? metricsQuery.error ?? historyQuery.error ?? vulnsQuery.error ?? projectsQuery.error ?? metricsQuery.error ?? historyQuery.error ?? vulnsQuery.error ??
scanMutation.error ?? vulnMutation.error; scanMutation.error ?? vulnMutation.error;
@@ -2575,7 +2793,7 @@ export function SecurityPage() {
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]"> <div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<HeartPulse size={13} className="text-[var(--accent-primary)]" /> <HeartPulse size={13} className="text-[var(--accent-primary)]" />
<span>Platform availability and failover live under High availability.</span> <span>Platform availability and failover live under High availability.</span>
<Link to="/ha" className="font-medium text-[var(--accent-primary)] hover:underline"> <Link to={isDemoMode ? '/ha?demo=1' : '/ha'} className="font-medium text-[var(--accent-primary)] hover:underline">
Open HA → Open HA →
</Link> </Link>
</div> </div>
@@ -2594,7 +2812,7 @@ export function SecurityPage() {
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] w-56" className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] w-56"
> >
<option value="">Select project…</option> <option value="">Select project…</option>
{(projectsQuery.data ?? []).map((p) => ( {projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option> <option key={p.id} value={p.id}>{p.name}</option>
))} ))}
</select> </select>
@@ -2621,7 +2839,7 @@ export function SecurityPage() {
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]" 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="">All services</option> <option value="">All services</option>
{(servicesQuery.data ?? []).map((s) => ( {services.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option> <option key={s.id} value={s.id}>{s.name}</option>
))} ))}
</select> </select>
@@ -2636,7 +2854,7 @@ export function SecurityPage() {
{scanMutation.isPending ? 'Starting…' : 'Run scan'} {scanMutation.isPending ? 'Starting…' : 'Run scan'}
</button> </button>
) : ( ) : (
<span className="self-end pb-2 text-xs text-[var(--text-tertiary)]">Sign in to run scans.</span> <span className="self-end pb-2 text-xs text-[var(--text-tertiary)]">{isDemoMode ? 'Read-only in demo.' : 'Sign in to run scans.'}</span>
)} )}
</> </>
)} )}
@@ -2679,7 +2897,7 @@ export function SecurityPage() {
<Shield size={18} className="text-[var(--accent-primary)]" /> <Shield size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Findings</h2> <h2 className="text-base font-semibold text-[var(--text-primary)]">Findings</h2>
</div> </div>
{vulnsQuery.isLoading ? ( {!isDemoMode && vulnsQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : vulns.length === 0 ? ( ) : vulns.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No open findings.</p> <p className="text-sm text-[var(--text-tertiary)]">No open findings.</p>
@@ -2735,13 +2953,13 @@ export function SecurityPage() {
<Clock size={18} className="text-[var(--accent-primary)]" /> <Clock size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Scan history</h2> <h2 className="text-base font-semibold text-[var(--text-primary)]">Scan history</h2>
</div> </div>
{historyQuery.isLoading ? ( {!isDemoMode && historyQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p> <p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (historyQuery.data ?? []).length === 0 ? ( ) : scanHistory.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No scans yet.</p> <p className="text-sm text-[var(--text-tertiary)]">No scans yet.</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{(historyQuery.data ?? []).map((scan) => ( {scanHistory.map((scan) => (
<div <div
key={scan.id} key={scan.id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3" className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
@@ -28,14 +28,26 @@ import {
manualScaleService, manualScaleService,
execInService, execInService,
rollbackDeployment, rollbackDeployment,
cancelDeployment,
updateServiceVariables, updateServiceVariables,
updateService, updateService,
restartService, restartService,
stopService, stopService,
getServiceRuntime, getServiceRuntime,
type CronJobEntity, type CronJobEntity,
type ServiceVolume,
type ServiceDomain,
type DomainCheckResult,
listServiceDomains,
addServiceDomain,
removeServiceDomain,
setDefaultServiceDomain,
checkServiceDomains,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data'; import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
import { useDemoMode } from '@/lib/demo-mode';
import { ServiceIcon } from '@/features/workspace/canvas/ServiceIcon';
import { serviceAccent } from '@/features/workspace/canvas/service-visuals';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { getCurrentUserProfile } from '@/lib/api-client'; import { getCurrentUserProfile } from '@/lib/api-client';
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils'; import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
@@ -63,6 +75,7 @@ import {
Sparkles, Sparkles,
RefreshCw, RefreshCw,
Box, Box,
Ban,
Play, Play,
ChevronDown, ChevronDown,
GitPullRequest, GitPullRequest,
@@ -101,7 +114,9 @@ function StatusBadge({ status }: { status: string }) {
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false }, failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false },
building: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true }, building: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
pending: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true }, pending: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
queued: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Clock, animate: false },
rolling_back: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: RefreshCw, animate: true }, rolling_back: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: RefreshCw, animate: true },
cancelled: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Ban, animate: false },
stopped: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false }, stopped: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false },
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false }; }[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false };
@@ -120,10 +135,10 @@ function StatusBadge({ status }: { status: string }) {
export function ServiceDetailPage() { export function ServiceDetailPage() {
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>(); const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isDemoMode = searchParams.get('demo') === '1'; const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data); const signedIn = isDemoMode || Boolean(sessionQuery.data);
const profileQuery = useQuery({ const profileQuery = useQuery({
@@ -134,7 +149,10 @@ export function ServiceDetailPage() {
}); });
const isAdmin = Boolean(profileQuery.data?.isAdmin); const isAdmin = Boolean(profileQuery.data?.isAdmin);
const [activeSection, setActiveSection] = useState<ServiceSection>('metrics'); const [activeSection, setActiveSection] = useState<ServiceSection>(() => {
const requested = searchParams.get('section');
return sectionItems.some((item) => item.key === requested) ? (requested as ServiceSection) : 'metrics';
});
const [logTail, setLogTail] = useState('100'); const [logTail, setLogTail] = useState('100');
const [varDrafts, setVarDrafts] = useState<VariableDraft[] | null>(null); const [varDrafts, setVarDrafts] = useState<VariableDraft[] | null>(null);
const [bulkOpen, setBulkOpen] = useState(false); const [bulkOpen, setBulkOpen] = useState(false);
@@ -368,6 +386,14 @@ export function ServiceDetailPage() {
}, },
}); });
const cancelDeploymentMutation = useMutation({
mutationFn: (deploymentId: string) => cancelDeployment(deploymentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['service-deployments', serviceId] });
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
},
});
const rollbackMutation = useMutation({ const rollbackMutation = useMutation({
mutationFn: (deploymentId: string) => rollbackDeployment(deploymentId), mutationFn: (deploymentId: string) => rollbackDeployment(deploymentId),
onSuccess: () => { onSuccess: () => {
@@ -382,7 +408,7 @@ export function ServiceDetailPage() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] }); queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
queryClient.invalidateQueries({ queryKey: ['projects'] }); queryClient.invalidateQueries({ queryKey: ['projects'] });
navigate(`/projects/${projectId}`); navigate(isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`);
}, },
}); });
@@ -418,6 +444,24 @@ export function ServiceDetailPage() {
const [networkForm, setNetworkForm] = useState<{ const [networkForm, setNetworkForm] = useState<{
port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string; port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string;
} | null>(null); } | null>(null);
const [volumesForm, setVolumesForm] = useState<ServiceVolume[] | null>(null);
const [domainInput, setDomainInput] = useState('');
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
const domainsQuery = useQuery({
queryKey: ['service-domains', serviceId],
queryFn: () => listServiceDomains(serviceId),
enabled: Boolean(serviceId) && !isDemoMode,
});
const domainMutation = useMutation({
mutationFn: (fn: () => Promise<unknown>) => fn(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['service-domains', serviceId] });
invalidateService();
},
});
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data; const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data; const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
@@ -508,7 +552,7 @@ export function ServiceDetailPage() {
const deployments = isDemoMode ? [] : deploymentsQuery.data ?? []; const deployments = isDemoMode ? [] : deploymentsQuery.data ?? [];
const total = deployments.length; const total = deployments.length;
const active = deployments.filter((deployment) => const active = deployments.filter((deployment) =>
['pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status), ['queued', 'pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status),
).length; ).length;
const failed = deployments.filter((deployment) => deployment.status === 'failed').length; const failed = deployments.filter((deployment) => deployment.status === 'failed').length;
@@ -595,13 +639,12 @@ export function ServiceDetailPage() {
style={{ style={{
width: '46px', width: '46px',
height: '46px', height: '46px',
background: 'var(--accent-primary)', background: `${serviceAccent(service)}1f`,
color: serviceAccent(service),
marginRight: '14px' marginRight: '14px'
}} }}
> >
<svg width="22" height="22" viewBox="0 0 24 24" fill="white"> <ServiceIcon service={service} size={22} />
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
</svg>
</div> </div>
<div> <div>
<div className="flex items-center" style={{ gap: '10px' }}> <div className="flex items-center" style={{ gap: '10px' }}>
@@ -967,31 +1010,60 @@ export function ServiceDetailPage() {
<p className="text-sm font-medium text-[var(--text-primary)]">Networking</p> <p className="text-sm font-medium text-[var(--text-primary)]">Networking</p>
<p className="text-xs text-[var(--text-tertiary)]">Public endpoints and ports</p> <p className="text-xs text-[var(--text-tertiary)]">Public endpoints and ports</p>
</div> </div>
{runtimeQuery.data?.health === 'healthy' && (
<span className="ml-auto v-st v-st-ok">HEALTHY</span>
)}
{runtimeQuery.data?.health === 'unhealthy' && (
<span className="ml-auto v-st v-st-fail">UNHEALTHY</span>
)}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{service.domain && (
<div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]"> <div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--success)]" /> <div className="w-2 h-2 rounded-full bg-[var(--success)]" />
<span className="text-xs text-[var(--text-secondary)]">HTTPS</span> <span className="text-xs text-[var(--text-secondary)]">Domain</span>
</div> </div>
<a <a
href={`https://${service.name}.containr.dev`} href={`https://${service.domain}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mono text-xs text-[var(--accent-primary)] hover:underline" className="mono text-xs text-[var(--accent-primary)] hover:underline"
> >
{service.name}.containr.dev {service.domain}
</a> </a>
</div> </div>
)}
{(runtimeQuery.data?.urls ?? []).map((url) => (
<div key={url} className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
<span className="text-xs text-[var(--text-secondary)]">HTTP</span>
</div>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="mono text-xs text-[var(--accent-primary)] hover:underline"
>
{url.replace(/^https?:\/\//, '')}
</a>
</div>
))}
<div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]"> <div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--accent-secondary)]" /> <div className="w-2 h-2 rounded-full bg-[var(--accent-secondary)]" />
<span className="text-xs text-[var(--text-secondary)]">Port</span> <span className="text-xs text-[var(--text-secondary)]">Internal</span>
</div> </div>
<span className="mono text-xs text-[var(--text-primary)]">8080 → 443</span> <span className="mono text-xs text-[var(--text-primary)]">{service.name}{service.port ? `:${service.port}` : ''}</span>
</div> </div>
</div> </div>
{!service.domain && (runtimeQuery.data?.urls ?? []).length === 0 && (
<p className="mt-3 text-xs text-[var(--text-tertiary)]">
No public endpoint. Publish a port or set a domain in Settings → Networking to expose this service.
</p>
)}
</div> </div>
)} )}
</div> </div>
@@ -2020,6 +2092,304 @@ export function ServiceDetailPage() {
</div> </div>
)} )}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Volumes</h3>
{volumesForm === null && (
<button
type="button"
onClick={() => setVolumesForm([...(service.volumes ?? [])])}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Edit
</button>
)}
</div>
{volumesForm === null ? (
(service.volumes ?? []).length === 0 ? (
<p className="text-xs text-[var(--text-tertiary)]">No volume mounts configured.</p>
) : (
<div className="space-y-2">
{(service.volumes ?? []).map((v, i) => (
<div key={i} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 py-2 text-xs">
<span className="px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-tertiary)] uppercase tracking-wide">{v.type || 'volume'}</span>
<span className="mono text-[var(--text-primary)]">{v.source}</span>
<span className="text-[var(--text-tertiary)]">→</span>
<span className="mono text-[var(--text-primary)]">{v.target}</span>
{v.read_only && <span className="text-[var(--text-tertiary)]">(read-only)</span>}
</div>
))}
</div>
)
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-3">
{volumesForm.map((v, i) => (
<div key={i} className="grid grid-cols-[80px_1fr_1fr_auto_auto] items-center gap-2">
<select
value={v.type || 'volume'}
onChange={(e) => {
const next = [...volumesForm];
next[i] = { ...v, type: e.target.value as 'volume' | 'bind' };
setVolumesForm(next);
}}
className="px-2 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)]"
>
<option value="volume">volume</option>
<option value="bind">bind</option>
</select>
<input
value={v.source}
onChange={(e) => {
const next = [...volumesForm];
next[i] = { ...v, source: e.target.value };
setVolumesForm(next);
}}
placeholder={v.type === 'bind' ? '/host/path' : 'volume-name'}
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-xs mono text-[var(--text-primary)]"
/>
<input
value={v.target}
onChange={(e) => {
const next = [...volumesForm];
next[i] = { ...v, target: e.target.value };
setVolumesForm(next);
}}
placeholder="/container/path"
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-xs mono text-[var(--text-primary)]"
/>
<label className="flex items-center gap-1 text-[10px] text-[var(--text-tertiary)]">
<input
type="checkbox"
checked={Boolean(v.read_only)}
onChange={(e) => {
const next = [...volumesForm];
next[i] = { ...v, read_only: e.target.checked };
setVolumesForm(next);
}}
/>
ro
</label>
<button
type="button"
onClick={() => setVolumesForm(volumesForm.filter((_, j) => j !== i))}
className="px-2 py-1 text-xs text-[var(--error)]"
>
<X size={12} />
</button>
</div>
))}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setVolumesForm([...volumesForm, { type: 'volume', source: '', target: '', read_only: false }])}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Add mount
</button>
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() =>
updateServiceMutation.mutate(
{
volumes: volumesForm
.filter((v) => v.source.trim() && v.target.startsWith('/'))
.map((v) => ({ type: v.type ?? 'volume', source: v.source, target: v.target, read_only: v.read_only ?? false })),
},
{ onSuccess: () => setVolumesForm(null) },
)
}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setVolumesForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
<p className="text-[10px] text-[var(--text-tertiary)]">Replaces all mounts; applies on next deploy or redeploy</p>
</div>
{updateServiceMutation.isError && (
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
)}
</div>
)}
</div>
)}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Domains</h3>
<button
type="button"
disabled={domainMutation.isPending}
onClick={async () => {
const results = await checkServiceDomains(serviceId);
setDomainChecks(Object.fromEntries(results.map((r) => [r.domain, r])));
}}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Check DNS
</button>
</div>
<div className="space-y-2 mb-3">
{(domainsQuery.data ?? []).length === 0 && !domainsQuery.isLoading && (
<p className="text-xs text-[var(--text-tertiary)]">No domains attached.</p>
)}
{(domainsQuery.data ?? []).map((d: ServiceDomain) => {
const check = domainChecks?.[d.domain];
return (
<div key={d.id} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 py-2 text-xs">
<span className="mono text-[var(--text-primary)]">{d.domain}</span>
{d.is_default && <span className="px-1.5 py-0.5 rounded bg-[var(--accent-primary)]/15 text-[var(--accent-primary)]">default</span>}
{check && (
<span className={`px-1.5 py-0.5 rounded ${check.status === 'ok' ? 'bg-[var(--success-soft)] text-[var(--success)]' : check.status === 'pending' ? 'bg-[var(--warning-soft)] text-[var(--warning)]' : 'bg-[var(--error-soft)] text-[var(--error)]'}`}>
{check.status}
</span>
)}
<span className="flex-1" />
{!d.is_default && (
<button
type="button"
onClick={() => domainMutation.mutate(() => setDefaultServiceDomain(serviceId, d.id))}
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
>
set default
</button>
)}
<button
type="button"
onClick={() => domainMutation.mutate(() => removeServiceDomain(serviceId, d.id))}
className="text-[var(--error)]"
>
<X size={12} />
</button>
</div>
);
})}
</div>
<div className="flex items-center gap-2">
<input
value={domainInput}
onChange={(e) => setDomainInput(e.target.value)}
placeholder="app.example.com"
className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
<button
type="button"
disabled={domainMutation.isPending || !domainInput.trim()}
onClick={() =>
domainMutation.mutate(() => addServiceDomain(serviceId, domainInput.trim()), {
onSuccess: () => setDomainInput(''),
})
}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
Add
</button>
</div>
{domainMutation.isError && (
<p className="mt-2 text-xs text-[var(--error)]">{(domainMutation.error as Error).message}</p>
)}
<p className="mt-2 text-[10px] text-[var(--text-tertiary)]">Point the domain at this node (A record or CNAME), then Check DNS. Applies on next deploy or redeploy.</p>
</div>
)}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Access</h3>
{accessForm === null && (
<button
type="button"
onClick={() =>
setAccessForm({ maintenance: Boolean(service.maintenanceMode), basicAuth: '' })
}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Edit
</button>
)}
</div>
{accessForm === null ? (
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Maintenance mode</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.maintenanceMode ? 'on' : 'off'}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Basic auth</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{(service.basicAuth ?? []).length > 0 ? (service.basicAuth ?? []).join(', ') : 'off'}
</p>
</div>
</div>
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
<input
type="checkbox"
checked={accessForm.maintenance}
onChange={(e) => setAccessForm({ ...accessForm, maintenance: e.target.checked })}
/>
Maintenance mode — redirect all traffic to the maintenance page
</label>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Basic auth credentials (user:password per line — replaces all; empty clears)</label>
<textarea
value={accessForm.basicAuth}
onChange={(e) => setAccessForm({ ...accessForm, basicAuth: e.target.value })}
rows={2}
placeholder="admin:s3cret"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() => {
const creds = accessForm.basicAuth
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
const i = l.indexOf(':');
return i > 0 ? { username: l.slice(0, i), password: l.slice(i + 1) } : null;
})
.filter((x): x is { username: string; password: string } => x !== null);
updateServiceMutation.mutate(
{ maintenance_mode: accessForm.maintenance, basic_auth: creds },
{ onSuccess: () => setAccessForm(null) },
);
}}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setAccessForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
<p className="text-[10px] text-[var(--text-tertiary)]">Applies on next deploy or redeploy</p>
</div>
</div>
)}
</div>
)}
{!isDemoMode && ( {!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]"> <div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -2087,6 +2457,19 @@ export function ServiceDetailPage() {
> >
Logs Logs
</button> </button>
{['queued', 'pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status) && (
<button
onClick={() => {
if (window.confirm(`Cancel deployment ${deployment.id.slice(0, 8)}?`)) {
cancelDeploymentMutation.mutate(deployment.id);
}
}}
disabled={cancelDeploymentMutation.isPending}
className="px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--error)] hover:border-[var(--error)]/50 disabled:opacity-50 transition-colors"
>
{cancelDeploymentMutation.isPending && cancelDeploymentMutation.variables === deployment.id ? '...' : 'Cancel'}
</button>
)}
<button <button
onClick={() => { onClick={() => {
if (window.confirm(`Rollback to deployment ${deployment.id.slice(0, 8)}?`)) { if (window.confirm(`Rollback to deployment ${deployment.id.slice(0, 8)}?`)) {
@@ -10,11 +10,12 @@ import {
listProjects, listProjects,
listTemplates, listTemplates,
updateTemplate, updateTemplate,
type TemplateDetailEntity,
type TemplateEntity, type TemplateEntity,
type TemplateWriteInput, type TemplateWriteInput,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { demoProjects, demoTemplates, demoTemplateDetails } from '@/lib/demo-data';
import { import {
Search, Search,
Filter, Filter,
@@ -37,327 +38,6 @@ import {
X, X,
} from 'lucide-react'; } from 'lucide-react';
const demoTemplates: TemplateEntity[] = [
{
id: 'tpl-react',
name: 'React Application',
description: 'Single-page frontend with Vite and static serving runtime.',
category: 'frontend',
logo: 'https://cdn.simpleicons.org/react',
configRaw: '{"runtime":"node"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-go',
name: 'Go API Service',
description: 'API-ready Go runtime with direct binary startup.',
category: 'web',
logo: 'https://cdn.simpleicons.org/go',
configRaw: '{"runtime":"go"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-postgres',
name: 'PostgreSQL Database',
description: 'Managed PostgreSQL service with credential setup variables.',
category: 'database',
logo: 'https://cdn.simpleicons.org/postgresql',
configRaw: '{"runtime":"postgres"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-mysql',
name: 'MySQL Database',
description: 'Managed MySQL service for transactional workloads.',
category: 'database',
logo: 'https://cdn.simpleicons.org/mysql',
configRaw: '{"runtime":"mysql"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-mariadb',
name: 'MariaDB Database',
description: 'Managed MariaDB service with MySQL compatibility.',
category: 'database',
logo: 'https://cdn.simpleicons.org/mariadb',
configRaw: '{"runtime":"mariadb"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-clickhouse',
name: 'ClickHouse Database',
description: 'Columnar analytics database template for high-speed queries.',
category: 'database',
logo: 'https://cdn.simpleicons.org/clickhouse',
configRaw: '{"runtime":"clickhouse"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
{
id: 'tpl-dragonfly',
name: 'Dragonfly Database',
description: 'Redis-compatible in-memory store powered by Dragonfly.',
category: 'database',
logo: 'https://cdn.simpleicons.org/redis',
configRaw: '{"runtime":"dragonfly"}',
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
},
];
const demoTemplateDetails: Record<string, TemplateDetailEntity> = {
'tpl-react': {
template: demoTemplates[0],
config: {
type: 'web',
runtime: 'node',
buildCommand: 'npm install && npm run build',
startCommand: 'npx serve -s dist',
port: 3000,
healthCheck: '/health',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'VITE_API_URL',
label: 'API URL',
defaultValue: 'https://api.example.com',
required: true,
secret: false,
description: 'Public API endpoint for frontend calls',
},
],
},
'tpl-go': {
template: demoTemplates[1],
config: {
type: 'web',
runtime: 'go',
buildCommand: 'go build -o app .',
startCommand: './app',
port: 8080,
healthCheck: '/health',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'GO_ENV',
label: 'Go Environment',
defaultValue: 'production',
required: false,
secret: false,
description: 'Runtime environment value',
},
],
},
'tpl-postgres': {
template: demoTemplates[2],
config: {
type: 'database',
runtime: 'postgres',
buildCommand: '',
startCommand: '',
port: 5432,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'POSTGRES_USER',
label: 'Username',
defaultValue: 'postgres',
required: true,
secret: false,
description: 'Database user',
},
{
key: 'POSTGRES_PASSWORD',
label: 'Password',
defaultValue: '',
required: true,
secret: true,
description: 'Database password',
},
],
},
'tpl-mysql': {
template: demoTemplates[3],
config: {
type: 'database',
runtime: 'mysql',
buildCommand: '',
startCommand: '',
port: 3306,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'MYSQL_DATABASE',
label: 'Database Name',
defaultValue: 'app',
required: true,
secret: false,
description: 'Initial database to create',
},
{
key: 'MYSQL_USER',
label: 'Username',
defaultValue: 'app',
required: true,
secret: false,
description: 'Application DB user',
},
{
key: 'MYSQL_PASSWORD',
label: 'User Password',
defaultValue: '',
required: true,
secret: true,
description: 'Application DB password',
},
{
key: 'MYSQL_ROOT_PASSWORD',
label: 'Root Password',
defaultValue: '',
required: true,
secret: true,
description: 'Root account password',
},
],
},
'tpl-mariadb': {
template: demoTemplates[4],
config: {
type: 'database',
runtime: 'mariadb',
buildCommand: '',
startCommand: '',
port: 3306,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'MARIADB_DATABASE',
label: 'Database Name',
defaultValue: 'app',
required: true,
secret: false,
description: 'Initial database to create',
},
{
key: 'MARIADB_USER',
label: 'Username',
defaultValue: 'app',
required: true,
secret: false,
description: 'Application DB user',
},
{
key: 'MARIADB_PASSWORD',
label: 'User Password',
defaultValue: '',
required: true,
secret: true,
description: 'Application DB password',
},
{
key: 'MARIADB_ROOT_PASSWORD',
label: 'Root Password',
defaultValue: '',
required: true,
secret: true,
description: 'Root account password',
},
],
},
'tpl-clickhouse': {
template: demoTemplates[5],
config: {
type: 'database',
runtime: 'clickhouse',
buildCommand: '',
startCommand: '',
port: 8123,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'CLICKHOUSE_DB',
label: 'Database Name',
defaultValue: 'app',
required: false,
secret: false,
description: 'Default database name',
},
{
key: 'CLICKHOUSE_USER',
label: 'Username',
defaultValue: 'default',
required: false,
secret: false,
description: 'ClickHouse user',
},
{
key: 'CLICKHOUSE_PASSWORD',
label: 'Password',
defaultValue: '',
required: false,
secret: true,
description: 'ClickHouse password',
},
],
},
'tpl-dragonfly': {
template: demoTemplates[6],
config: {
type: 'database',
runtime: 'dragonfly',
buildCommand: '',
startCommand: '',
port: 6379,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'DRAGONFLY_PASSWORD',
label: 'Password',
defaultValue: '',
required: false,
secret: true,
description: 'Optional Redis-compatible password',
},
],
},
};
const demoProjects = [
{ id: 'project-demo', name: 'Demo Project' },
{ id: 'project-internal', name: 'Internal Tooling' },
];
function toServiceName(value: string): string { function toServiceName(value: string): string {
const normalized = value const normalized = value
.toLowerCase() .toLowerCase()
@@ -428,8 +108,8 @@ const EMPTY_TEMPLATE_JSON = JSON.stringify(
export function TemplatesPage() { export function TemplatesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const isDemoMode = searchParams.get('demo') === '1'; const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data); const signedIn = isDemoMode || Boolean(sessionQuery.data);
@@ -445,11 +125,23 @@ export function TemplatesPage() {
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [editorJson, setEditorJson] = useState(EMPTY_TEMPLATE_JSON); const [editorJson, setEditorJson] = useState(EMPTY_TEMPLATE_JSON);
const [editingTemplate, setEditingTemplate] = useState<TemplateEntity | null>(null); const [editingTemplate, setEditingTemplate] = useState<TemplateEntity | null>(null);
const [editorIsPublic, setEditorIsPublic] = useState(false);
const [editorError, setEditorError] = useState<string | null>(null); const [editorError, setEditorError] = useState<string | null>(null);
const [categoryFilter, setCategoryFilter] = useState(''); const [categoryFilter, setCategoryFilter] = useState('');
const [searchFilter, setSearchFilter] = useState(''); const [searchFilter, setSearchFilter] = useState('');
const [selectedTemplateIdState, setSelectedTemplateId] = useState<string | null>(null); // Selection lives in the URL (?template=<id>) so catalog deep links and
// card clicks share one source of truth.
const templateParam = searchParams.get('template');
const selectTemplate = (id: string | null) => {
const next = new URLSearchParams(searchParams);
if (id) {
next.set('template', id);
} else {
next.delete('template');
}
setSearchParams(next, { replace: true });
};
const [deployProjectIdState, setDeployProjectId] = useState(searchParams.get('project') ?? ''); const [deployProjectIdState, setDeployProjectId] = useState(searchParams.get('project') ?? '');
const [deployNameByTemplate, setDeployNameByTemplate] = useState<Record<string, string>>({}); const [deployNameByTemplate, setDeployNameByTemplate] = useState<Record<string, string>>({});
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState< const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
@@ -507,11 +199,11 @@ export function TemplatesPage() {
if (filteredTemplates.length === 0) { if (filteredTemplates.length === 0) {
return null; return null;
} }
if (selectedTemplateIdState && filteredTemplates.some((template) => template.id === selectedTemplateIdState)) { if (templateParam && filteredTemplates.some((template) => template.id === templateParam)) {
return selectedTemplateIdState; return templateParam;
} }
return filteredTemplates[0].id; return filteredTemplates[0].id;
}, [filteredTemplates, selectedTemplateIdState]); }, [filteredTemplates, templateParam]);
const templateDetailQuery = useQuery({ const templateDetailQuery = useQuery({
queryKey: ['template-detail-page', selectedTemplateId], queryKey: ['template-detail-page', selectedTemplateId],
@@ -637,7 +329,7 @@ export function TemplatesPage() {
setEditorOpen(false); setEditorOpen(false);
setEditingTemplate(null); setEditingTemplate(null);
setEditorError(null); setEditorError(null);
setSelectedTemplateId(template.id); selectTemplate(template.id);
}, },
onError: (error) => { onError: (error) => {
setEditorError(error instanceof Error ? error.message : 'Failed to save template'); setEditorError(error instanceof Error ? error.message : 'Failed to save template');
@@ -648,13 +340,14 @@ export function TemplatesPage() {
mutationFn: (id: string) => deleteTemplate(id), mutationFn: (id: string) => deleteTemplate(id),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['templates-page'] }); queryClient.invalidateQueries({ queryKey: ['templates-page'] });
setSelectedTemplateId(null); selectTemplate(null);
}, },
}); });
const openCreateEditor = () => { const openCreateEditor = () => {
setEditingTemplate(null); setEditingTemplate(null);
setEditorJson(EMPTY_TEMPLATE_JSON); setEditorJson(EMPTY_TEMPLATE_JSON);
setEditorIsPublic(false);
setEditorError(null); setEditorError(null);
setEditorOpen(true); setEditorOpen(true);
}; };
@@ -687,6 +380,7 @@ export function TemplatesPage() {
2, 2,
), ),
); );
setEditorIsPublic(template.isPublic);
setEditorError(null); setEditorError(null);
setEditorOpen(true); setEditorOpen(true);
}; };
@@ -711,6 +405,7 @@ export function TemplatesPage() {
logo: body.logo ?? '', logo: body.logo ?? '',
config: body.config as Record<string, unknown>, config: body.config as Record<string, unknown>,
variables: Array.isArray(body.variables) ? body.variables : [], variables: Array.isArray(body.variables) ? body.variables : [],
isPublic: editorIsPublic,
}); });
}; };
@@ -846,7 +541,7 @@ export function TemplatesPage() {
return ( return (
<button <button
key={template.id} key={template.id}
onClick={() => setSelectedTemplateId(template.id)} onClick={() => selectTemplate(template.id)}
className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${ className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${
selected selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]' ? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
@@ -880,6 +575,11 @@ export function TemplatesPage() {
Mine Mine
</span> </span>
) : null} ) : null}
{!template.isOfficial && template.isPublic ? (
<span className="px-1.5 py-0.5 rounded bg-[var(--info-soft)] text-[var(--info)] text-[10px] font-semibold">
Public
</span>
) : null}
</div> </div>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span <span
@@ -955,6 +655,11 @@ export function TemplatesPage() {
Official Official
</span> </span>
)} )}
{!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && (
<span className="px-2 py-0.5 rounded-full bg-[var(--info-soft)] text-[var(--info)] text-xs font-medium">
Public
</span>
)}
</div> </div>
<p className="text-sm text-[var(--text-secondary)] mt-1">{selectedDetail.template.description}</p> <p className="text-sm text-[var(--text-secondary)] mt-1">{selectedDetail.template.description}</p>
</div> </div>
@@ -1230,9 +935,15 @@ export function TemplatesPage() {
</div> </div>
) : null} ) : null}
<div className="flex items-center justify-between border-t border-[var(--border-subtle)] px-5 py-3.5"> <div className="flex items-center justify-between border-t border-[var(--border-subtle)] px-5 py-3.5">
<p className="text-[11px] text-[var(--text-tertiary)]"> <label className="inline-flex cursor-pointer items-center gap-2 text-[11px] text-[var(--text-secondary)]">
Saved as a user template — only you (and admins) can edit or delete it. <input
</p> type="checkbox"
checked={editorIsPublic}
onChange={(e) => setEditorIsPublic(e.target.checked)}
className="h-3.5 w-3.5 accent-[var(--accent-primary)]"
/>
List in the public catalog - anyone browsing this instance can see and deploy it.
</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,26 @@
import { useState } from 'react';
import { serviceIcon, serviceIconUrl, serviceTypeIcon, type ServiceIconInput } from './service-visuals';
export function ServiceIcon({
service,
size = 16,
}: {
service: ServiceIconInput;
size?: number;
}) {
const [failed, setFailed] = useState(false);
const brand = serviceIcon(service);
if (brand && !failed) {
return (
<img
src={serviceIconUrl(brand)}
width={size}
height={size}
alt=""
loading="lazy"
onError={() => setFailed(true)}
/>
);
}
return serviceTypeIcon(service.type ?? 'service', size);
}
@@ -0,0 +1,112 @@
import type { ServiceEntity } from '@/lib/api-client';
import type { ServiceVariable } from '../auto-connections';
import { inferAutoConnections } from '../auto-connections';
export const NODE_WIDTH = 240;
export const NODE_HEIGHT = 104;
// Multiples of the 26px canvas grid so laid-out nodes land on the lattice.
export const COL_GAP = 338;
export const ROW_GAP = 182;
type LinkEdge = { sourceServiceId: string; targetServiceId: string };
// Layered left-to-right layout: a service's depth is the longest chain of
// dependencies beneath it, so consumers sit left and datastores right.
// Unconnected services get their own trailing column separated by a gap.
export function computeLayeredLayout(
services: ServiceEntity[],
edges: LinkEdge[],
): Map<string, { x: number; y: number }> {
const positions = new Map<string, { x: number; y: number }>();
if (services.length === 0) {
return positions;
}
const ids = new Set(services.map((service) => service.id));
const outgoing = new Map<string, string[]>();
const connected = new Set<string>();
for (const edge of edges) {
if (!ids.has(edge.sourceServiceId) || !ids.has(edge.targetServiceId)) {
continue;
}
connected.add(edge.sourceServiceId);
connected.add(edge.targetServiceId);
const list = outgoing.get(edge.sourceServiceId) ?? [];
list.push(edge.targetServiceId);
outgoing.set(edge.sourceServiceId, list);
}
const depthMemo = new Map<string, number>();
const depthOf = (id: string, trail: Set<string>): number => {
const memoized = depthMemo.get(id);
if (memoized !== undefined) {
return memoized;
}
if (trail.has(id)) {
return 0; // cycle guard
}
trail.add(id);
let best = 0;
for (const target of outgoing.get(id) ?? []) {
best = Math.max(best, depthOf(target, trail) + 1);
}
trail.delete(id);
depthMemo.set(id, best);
return best;
};
const connectedServices = services
.filter((service) => connected.has(service.id))
.sort((a, b) => a.name.localeCompare(b.name));
const looseServices = services
.filter((service) => !connected.has(service.id))
.sort((a, b) => a.name.localeCompare(b.name));
const depthById = new Map(connectedServices.map((service) => [service.id, depthOf(service.id, new Set())]));
const maxDepth = Math.max(0, ...depthById.values());
// Column = maxDepth - depth so deeper consumers render further left.
const columns = new Map<number, ServiceEntity[]>();
for (const service of connectedServices) {
const col = maxDepth - (depthById.get(service.id) ?? 0);
const list = columns.get(col) ?? [];
list.push(service);
columns.set(col, list);
}
const connectedColCount = columns.size;
const maxRows = Math.max(
1,
...Array.from(columns.values()).map((list) => list.length),
looseServices.length,
);
const gridOffset = (value: number) => Math.round(value / 26) * 26;
for (const [col, list] of columns) {
const offsetY = gridOffset(((maxRows - list.length) * ROW_GAP) / 2);
list.forEach((service, row) => {
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
});
}
looseServices.forEach((service, row) => {
const col = connectedColCount > 0 ? connectedColCount : 0;
const offsetY = gridOffset(((maxRows - looseServices.length) * ROW_GAP) / 2);
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
});
return positions;
}
export function layoutFromVariables(
services: ServiceEntity[],
variablesByService: Record<string, ServiceVariable[]>,
): Map<string, { x: number; y: number }> {
const links = inferAutoConnections(services, variablesByService);
return computeLayeredLayout(
services,
links.map((link) => link.edge),
);
}
@@ -0,0 +1,415 @@
import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client';
import type { ServiceVariable } from '../auto-connections';
import { ServiceIcon } from './ServiceIcon';
import { serviceAccent } from './service-visuals';
import { formatRelative } from '@/lib/time';
import {
ArrowUpRight,
Copy,
Globe,
KeyRound,
Layers,
Link2,
Loader2,
Play,
Rocket,
Square,
Trash2,
X,
} from 'lucide-react';
import { useState } from 'react';
export type InspectorGroup = { id: string; title: string };
export type InspectorConnection = {
id: string;
outbound: boolean;
peer: string;
peerId: string;
reasons: string[];
};
export type InspectorActions = {
deploy: () => void;
start: () => void;
restart: () => void;
stop: () => void;
remove: () => void;
pending: boolean;
deployPending: boolean;
restartPending: boolean;
};
function statusLabel(status: string): string {
switch (status) {
case 'running':
return 'Online';
case 'deployed':
return 'Deployed';
case 'building':
return 'Building';
case 'deploying':
return 'Deploying';
case 'pending':
return 'Pending';
case 'failed':
return 'Failed';
case 'stopped':
return 'Stopped';
case 'rolling_back':
return 'Rolling back';
default:
return status;
}
}
function statusColor(status: string): string {
switch (status) {
case 'running':
case 'deployed':
return 'var(--success)';
case 'failed':
return 'var(--error)';
case 'building':
case 'deploying':
case 'pending':
case 'rolling_back':
return 'var(--warning)';
default:
return 'var(--text-tertiary)';
}
}
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{title}</p>
{action}
</div>
{children}
</div>
);
}
function SectionLink({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className="text-[10px] text-[var(--accent-primary)] hover:underline flex items-center gap-0.5"
>
{children}
<ArrowUpRight size={9} />
</button>
);
}
export function ServiceInspector({
service,
variables,
connections,
groups,
groupId,
readOnly,
actions,
onClose,
onOpenService,
onOpenSection,
onSelectPeer,
onAssignGroup,
onNewGroupFor,
}: {
service: ServiceEntity;
variables: ServiceVariable[];
connections: InspectorConnection[];
groups: InspectorGroup[];
groupId: string | undefined;
readOnly?: boolean;
actions: InspectorActions;
onClose: () => void;
onOpenService: (serviceId: string) => void;
onOpenSection: (serviceId: string, section: string) => void;
onSelectPeer: (serviceId: string) => void;
onAssignGroup: (serviceId: string, groupId: string | null) => void;
onNewGroupFor: (serviceId: string) => void;
}) {
const accent = serviceAccent(service);
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
const [copied, setCopied] = useState(false);
const copyAddress = () => {
const text = publicUrl ?? (service.port ? `${service.name}:${service.port}` : service.name);
void navigator.clipboard?.writeText(text).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
});
};
const configRows: [string, string | undefined][] = [
['Image', service.image],
['Command', service.command],
['Port', service.port ? String(service.port) : undefined],
['Domain', service.domain],
['Git repo', service.gitRepo],
['Branch', service.gitBranch],
['Build path', service.buildPath],
['CPU', service.cpu],
['Memory', service.memory],
['Restart', service.restartPolicy],
['Healthcheck', service.healthcheckPath],
];
const visibleConfig = configRows.filter(([, value]) => value);
return (
<aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[312px] panel-glass flex flex-col overflow-hidden">
<div className="flex items-center gap-3 px-4 pt-4 pb-3 border-b border-[var(--border-subtle)]">
<div
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0"
style={{ background: `${accent}1c`, color: accent }}
>
<ServiceIcon service={service} size={16} />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{service.name}</h3>
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span style={{ color: statusColor(service.status) }}>{statusLabel(service.status)}</span>
<span>· {service.type}</span>
{service.environment && service.environment !== 'production' && <span>· {service.environment}</span>}
</div>
</div>
<button
type="button"
onClick={onClose}
className="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]"
title="Close (Esc)"
>
<X size={14} />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{(publicUrl || service.port) && (
<Section title="Address">
<div className="flex items-center gap-1.5 group">
<Globe size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
{publicUrl ? (
<a
href={publicUrl}
target="_blank"
rel="noopener noreferrer"
className="mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
>
{publicUrl.replace(/^https?:\/\//, '')}
</a>
) : (
<span className="mono text-[11px] text-[var(--text-secondary)]">
{service.name}:{service.port}
</span>
)}
<button
type="button"
onClick={copyAddress}
className="ml-auto text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
title="Copy address"
>
{copied ? <span className="text-[10px] text-[var(--success)]">Copied</span> : <Copy size={11} />}
</button>
</div>
{publicUrl && service.port ? (
<p className="mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5 pl-4">
{service.name}:{service.port}
</p>
) : null}
</Section>
)}
<Section title="Group">
<div className="flex items-center gap-2">
<Layers size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
<select
value={groupId ?? ''}
onChange={(event) => {
const next = event.target.value;
if (next === '__new__') {
onNewGroupFor(service.id);
return;
}
onAssignGroup(service.id, next === '' ? null : next);
}}
className="nodrag flex-1 h-7 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-[11px] text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]"
>
<option value="">Ungrouped</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.title}
</option>
))}
<option value="__new__">+ New group…</option>
</select>
</div>
</Section>
<Section
title={`Variables (${variables.length})`}
action={
<SectionLink onClick={() => onOpenSection(service.id, 'variables')}>
Manage
</SectionLink>
}
>
{variables.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]">No variables yet.</p>
) : (
<div className="space-y-1">
{variables.slice(0, 6).map((variable) => (
<div
key={variable.key}
className="flex items-center gap-2 px-2 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/50"
>
<KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" />
<span className="mono text-[10px] text-[var(--text-primary)] truncate font-medium">
{variable.key}
</span>
<span className="mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}>
{variable.isSecret ? '••••••••' : variable.value}
</span>
</div>
))}
{variables.length > 6 && (
<p className="text-[10px] text-[var(--text-muted)] pl-1">+{variables.length - 6} more</p>
)}
</div>
)}
</Section>
<Section
title="Config"
action={
<SectionLink onClick={() => onOpenSection(service.id, 'config')}>
Edit
</SectionLink>
}
>
{visibleConfig.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]">No configuration set.</p>
) : (
<dl className="space-y-1">
{visibleConfig.map(([label, value]) => (
<div key={label} className="flex items-baseline gap-2">
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">{label}</dt>
<dd className="mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}>
{value}
</dd>
</div>
))}
{(service.replicas ?? 0) > 0 && (
<div className="flex items-baseline gap-2">
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">Replicas</dt>
<dd className="mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd>
</div>
)}
</dl>
)}
</Section>
<Section title={`Connections (${connections.length})`}>
{connections.length === 0 ? (
<p className="text-[11px] text-[var(--text-muted)]">
No inferred connections. Reference another service via{' '}
<span className="mono">{'{{variable}}'}</span> placeholders in env vars.
</p>
) : (
<div className="space-y-1.5">
{connections.map((conn) => (
<button
key={conn.id}
type="button"
onClick={() => onSelectPeer(conn.peerId)}
className="w-full text-left px-2.5 py-2 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 hover:bg-[var(--surface-muted)] transition-colors"
>
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
<Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" />
<span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span>
</div>
<p className="mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
{conn.reasons.join(' · ')}
</p>
</button>
))}
</div>
)}
</Section>
{service.updatedAt && (
<p className="text-[10px] text-[var(--text-muted)] pt-1">
Updated {formatRelative(service.updatedAt)}
</p>
)}
</div>
<div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2">
<button
type="button"
onClick={() => onOpenService(service.id)}
className="w-full flex items-center justify-center gap-2 h-9 rounded-[var(--radius-md)] text-[var(--accent-on)] text-[13px] font-medium"
style={{ background: 'var(--accent-primary)' }}
>
<ArrowUpRight size={14} />
Open service
</button>
<div className="grid grid-cols-3 gap-1.5">
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.deploy}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
{actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
Deploy
</button>
{service.status !== 'running' ? (
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.start}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
<Play size={11} />
Start
</button>
) : (
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.restart}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
{actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
Restart
</button>
)}
<button
type="button"
disabled={actions.pending || readOnly || service.status !== 'running'}
onClick={actions.stop}
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
>
<Square size={11} />
Stop
</button>
</div>
<button
type="button"
disabled={actions.pending || readOnly}
onClick={actions.remove}
className="w-full flex items-center justify-center gap-1.5 h-8 rounded-[var(--radius-sm)] text-[11px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-40"
>
<Trash2 size={11} />
Delete service
</button>
</div>
</aside>
);
}
@@ -1,8 +1,10 @@
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
import type { ServiceEntity } from '@/lib/api-client'; import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client'; import { serviceStatusClass } from '@/lib/api-client';
import { Box, Globe, Database, Terminal, ExternalLink, Copy, Layers } from 'lucide-react'; import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { serviceIcon, serviceAccent } from './service-visuals';
import { ServiceIcon } from './ServiceIcon';
export type ServiceNodeData = { export type ServiceNodeData = {
service: ServiceEntity; service: ServiceEntity;
@@ -12,37 +14,13 @@ export type ServiceNodeData = {
export type GroupNodeData = { export type GroupNodeData = {
title: string; title: string;
onRename?: (groupId: string, title: string) => void;
renameNonce?: number;
}; };
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>; export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>; export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
function renderServiceTypeIcon(type: string, size = 18) {
switch (type) {
case 'web':
return <Globe size={size} />;
case 'database':
return <Database size={size} />;
case 'worker':
return <Terminal size={size} />;
default:
return <Box size={size} />;
}
}
function serviceTypeColor(type: string): string {
switch (type) {
case 'web':
return '#7ab8ff';
case 'database':
return '#b4e34a';
case 'worker':
return 'var(--accent-primary)';
default:
return '#9295a4';
}
}
function statusLabel(status: string): string { function statusLabel(status: string): string {
switch (status) { switch (status) {
case 'running': case 'running':
@@ -72,27 +50,30 @@ function statusColor(status: string): string {
} }
} }
const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']);
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) { export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
const { service, selected } = data; const { service, selected } = data;
const typeColor = serviceTypeColor(service.type); const brand = serviceIcon(service);
const typeColor = brand?.color ?? serviceAccent(service);
const replicas = service.replicas ?? 0; const replicas = service.replicas ?? 0;
const stateColor = statusColor(service.status); const stateColor = statusColor(service.status);
const isTransient = TRANSIENT_STATUSES.has(service.status);
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl; const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
const internalAddr = service.port ? `${service.name}:${service.port}` : service.name; const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
return ( return (
<div <div
className={`rounded-[var(--radius-lg)] border transition-all duration-200 group relative overflow-hidden ${ className={`w-full rounded-[var(--radius-lg)] border transition-[border-color,box-shadow,transform] duration-150 group relative overflow-hidden ${
selected selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]' ? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-lg' : 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-[0_12px_32px_rgba(0,0,0,0.45)] hover:-translate-y-px'
}`} }`}
style={{ minWidth: 220, maxWidth: 280 }}
> >
<div <div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
style={{ background: `${typeColor}10` }} style={{ background: `${typeColor}08` }}
/> />
<Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" /> <Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
@@ -105,22 +86,26 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
style={{ background: stateColor }} style={{ background: stateColor }}
/> />
<div className="relative p-4"> <div className="relative px-3.5 pt-3.5 pb-3">
<div className="flex items-start gap-3 mb-3"> <div className="flex items-center gap-3 mb-2.5">
<div <div
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${ className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
selected ? 'ring-2 ring-white/20' : ''
}`}
style={{ style={{
background: selected ? typeColor : `${typeColor}20`, background: selected && !brand ? typeColor : `${typeColor}1c`,
color: selected ? 'white' : typeColor, color: selected && !brand ? 'var(--bg-void)' : typeColor,
}} }}
> >
{renderServiceTypeIcon(service.type, 18)} {isTransient ? (
<Loader2 size={16} className="animate-spin" />
) : (
<ServiceIcon service={service} size={17} />
)}
</div> </div>
<div className="min-w-0 flex-1 pt-0.5"> <div className="min-w-0 flex-1">
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4> <h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5"> {service.name}
</h4>
<p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5">
{service.type} {service.type}
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''} {service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
</p> </p>
@@ -133,7 +118,7 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
)} )}
</div> </div>
<div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50 group/addr"> <div className="flex items-center gap-2 mb-2.5 px-2.5 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/60 border border-[var(--border-subtle)]/60 group/addr">
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" /> <ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
{publicUrl ? ( {publicUrl ? (
<a <a
@@ -141,12 +126,12 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="text-[10px] text-[var(--accent-primary)] truncate mono hover:underline" className="text-[10.5px] text-[var(--accent-primary)] truncate mono hover:underline"
> >
{publicUrl.replace(/^https?:\/\//, '')} {publicUrl.replace(/^https?:\/\//, '')}
</a> </a>
) : ( ) : (
<span className="text-[10px] text-[var(--text-secondary)] truncate mono" title="Internal network address"> <span className="text-[10.5px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
{internalAddr} {internalAddr}
</span> </span>
)} )}
@@ -164,14 +149,14 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<span className={`status-dot ${serviceStatusClass(service.status)}`} /> <span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{ color: stateColor }}> <span className="text-[10px] font-semibold tracking-[0.08em] uppercase" style={{ color: stateColor }}>
{statusLabel(service.status)} {statusLabel(service.status)}
</span> </span>
</div> </div>
{service.image && ( {service.image && (
<span className="text-[10px] text-[var(--text-tertiary)] truncate mono max-w-[120px]" title={service.image}> <span className="text-[9.5px] text-[var(--text-muted)] truncate mono max-w-[110px]" title={service.image}>
{service.image} {service.image}
</span> </span>
)} )}
@@ -181,29 +166,40 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
); );
} }
export function GroupNode({ data }: NodeProps<GroupNodeType>) { export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(data.title);
// Context-menu rename requests bump the nonce; enter edit mode when it
// changes. Render-phase state adjustment — the React-sanctioned pattern.
const [seenRenameNonce, setSeenRenameNonce] = useState(data.renameNonce ?? 0);
const incomingNonce = data.renameNonce ?? 0;
if (incomingNonce > seenRenameNonce) {
setSeenRenameNonce(incomingNonce);
setDraft(data.title);
setEditing(true);
}
const commit = () => {
const next = draft.trim() || data.title;
setEditing(false);
if (next !== data.title) {
data.onRename?.(id, next);
}
};
return ( return (
<div <div
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden" className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative"
style={{ style={{
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)', borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30', background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)',
}} }}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<div
className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none"
style={{
background: 'rgba(232,49,106,0.03)',
opacity: isHovered ? 1 : 0,
}}
/>
<div className="relative p-4"> <div className="relative p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div <div
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200" className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
@@ -214,8 +210,34 @@ export function GroupNode({ data }: NodeProps<GroupNodeType>) {
> >
<Box size={14} /> <Box size={14} />
</div> </div>
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3> {editing ? (
</div> <input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') {
setDraft(data.title);
setEditing(false);
}
}}
className="nodrag h-7 px-2 rounded-[var(--radius-sm)] border border-[var(--border-default)] bg-[var(--surface-card)] text-sm font-semibold text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]"
/>
) : (
<h3
className="text-sm font-semibold text-[var(--text-primary)] tracking-tight"
onDoubleClick={(e) => {
e.stopPropagation();
setDraft(data.title);
setEditing(true);
}}
title="Double-click to rename"
>
{data.title}
</h3>
)}
</div> </div>
<p <p
@@ -0,0 +1,112 @@
import { Box, Globe, Database, Terminal, Clock } from 'lucide-react';
export function serviceTypeIcon(type: string, size = 18) {
switch (type) {
case 'web':
return <Globe size={size} />;
case 'database':
return <Database size={size} />;
case 'worker':
return <Terminal size={size} />;
case 'cron':
return <Clock size={size} />;
default:
return <Box size={size} />;
}
}
export function serviceTypeColor(type: string): string {
switch (type) {
case 'web':
return '#7ab8ff';
case 'database':
return '#b4e34a';
case 'worker':
return '#f2c94c';
case 'cron':
return '#f2994a';
default:
return '#9295a4';
}
}
export type ServiceBrandIcon = {
slug: string;
color: string;
};
const TECH_ICONS: (ServiceBrandIcon & { patterns: RegExp[] })[] = [
{ slug: 'postgresql', color: '#7ab8ff', patterns: [/postgres/i, /postgis/i] },
{ slug: 'redis', color: '#ff6b6b', patterns: [/redis/i, /dragonfly/i, /valkey/i, /keydb/i] },
{ slug: 'mysql', color: '#5ba4e5', patterns: [/mysql/i] },
{ slug: 'mariadb', color: '#4ea5ff', patterns: [/mariadb/i] },
{ slug: 'mongodb', color: '#47a248', patterns: [/mongo/i] },
{ slug: 'clickhouse', color: '#ffcc01', patterns: [/clickhouse/i] },
{ slug: 'couchdb', color: '#e42528', patterns: [/couchdb/i] },
{ slug: 'sqlite', color: '#69b7e4', patterns: [/sqlite/i] },
{ slug: 'influxdb', color: '#22adf6', patterns: [/influx/i] },
{ slug: 'elasticsearch', color: '#fec514', patterns: [/elastic/i, /opensearch/i] },
{ slug: 'meilisearch', color: '#ff5caa', patterns: [/meili/i] },
{ slug: 'typesense', color: '#d90368', patterns: [/typesense/i] },
{ slug: 'qdrant', color: '#dc244c', patterns: [/qdrant/i] },
{ slug: 'weaviate', color: '#01c9a7', patterns: [/weaviate/i] },
{ slug: 'minio', color: '#c72e49', patterns: [/minio/i] },
{ slug: 'rabbitmq', color: '#ff6600', patterns: [/rabbitmq/i, /rabbit/i] },
{ slug: 'apachekafka', color: '#d6dae4', patterns: [/kafka/i] },
{ slug: 'natsdotio', color: '#8be1d3', patterns: [/\bnats/i] },
{ slug: 'nginx', color: '#009639', patterns: [/nginx/i] },
{ slug: 'caddy', color: '#1f88c0', patterns: [/caddy/i] },
{ slug: 'traefikproxy', color: '#24a1c1', patterns: [/traefik/i] },
{ slug: 'nodedotjs', color: '#5fa04e', patterns: [/node(?=[:\-\d]|$)/i, /nodejs/i] },
{ slug: 'go', color: '#00add8', patterns: [/golang/i, /(?:^|[^a-z])go(?=[:\-\d]|$)/i] },
{ slug: 'python', color: '#6fa8dc', patterns: [/python/i, /\bpy(?:thon)?(?=[:\-\d]|$)/i] },
{ slug: 'rust', color: '#f74c00', patterns: [/rust/i] },
{ slug: 'php', color: '#8892bf', patterns: [/php/i] },
{ slug: 'ruby', color: '#cc342d', patterns: [/ruby/i, /rails/i] },
{ slug: 'openjdk', color: '#e6a23c', patterns: [/java/i, /openjdk/i, /jdk/i, /jre/i] },
{ slug: 'dotnet', color: '#6f4bd8', patterns: [/dotnet/i, /aspnet/i] },
{ slug: 'deno', color: '#70ffaf', patterns: [/\bdeno/i] },
{ slug: 'bun', color: '#f9f1e1', patterns: [/bun(?=[:\-\d]|$)/i] },
{ slug: 'react', color: '#61dafb', patterns: [/react/i] },
{ slug: 'vuedotjs', color: '#4fc08d', patterns: [/vue/i] },
{ slug: 'angular', color: '#dd0031', patterns: [/angular/i] },
{ slug: 'svelte', color: '#ff3e00', patterns: [/svelte/i] },
{ slug: 'nextdotjs', color: '#e6edf3', patterns: [/nextjs/i, /next\.js/i] },
{ slug: 'nuxtdotjs', color: '#00dc82', patterns: [/nuxt/i] },
{ slug: 'astro', color: '#ff5d01', patterns: [/astro/i] },
{ slug: 'prometheus', color: '#e6522c', patterns: [/prometheus/i] },
{ slug: 'grafana', color: '#f46800', patterns: [/grafana/i] },
{ slug: 'uptimekuma', color: '#5cdd8b', patterns: [/uptime.?kuma/i, /\bkuma/i] },
{ slug: 'n8n', color: '#ea4b71', patterns: [/n8n/i] },
{ slug: 'wordpress', color: '#4b9cd3', patterns: [/wordpress/i, /\bwp-/i] },
{ slug: 'ghost', color: '#a8b8c4', patterns: [/ghost/i] },
{ slug: 'nextcloud', color: '#0082c9', patterns: [/nextcloud/i] },
{ slug: 'immich', color: '#7c9ef5', patterns: [/immich/i] },
{ slug: 'supabase', color: '#3fcf8e', patterns: [/supabase/i] },
{ slug: 'pocketbase', color: '#b8dbe4', patterns: [/pocketbase/i] },
{ slug: 'keycloak', color: '#d6dae4', patterns: [/keycloak/i] },
{ slug: 'authentik', color: '#fd4b2d', patterns: [/authentik/i] },
{ slug: 'portainer', color: '#13bef9', patterns: [/portainer/i] },
{ slug: 'docker', color: '#2496ed', patterns: [/docker/i, /dind/i] },
{ slug: 'kubernetes', color: '#326ce5', patterns: [/kubernetes/i, /\bk8s/i] },
];
export type ServiceIconInput = { image?: string; name?: string; type?: string };
export function serviceIcon(service: ServiceIconInput): ServiceBrandIcon | null {
const text = `${service.image ?? ''} ${service.name ?? ''}`.toLowerCase();
for (const icon of TECH_ICONS) {
if (icon.patterns.some((pattern) => pattern.test(text))) {
return icon;
}
}
return null;
}
export function serviceIconUrl(icon: ServiceBrandIcon): string {
return `https://cdn.simpleicons.org/${icon.slug}/${icon.color.replace('#', '')}`;
}
export function serviceAccent(service: ServiceIconInput): string {
return serviceIcon(service)?.color ?? serviceTypeColor(service.type ?? 'service');
}
+5 -2
View File
@@ -44,14 +44,17 @@ export const DEFAULT_VIEWPORT: CanvasViewport = {
zoom: 1, zoom: 1,
}; };
export function createDefaultCanvasMetadata(services: ServiceEntity[]): ProjectCanvasMetadata { export function createDefaultCanvasMetadata(
services: ServiceEntity[],
layout?: Map<string, CanvasPoint>,
): ProjectCanvasMetadata {
const nodes = services.map((service, index) => { const nodes = services.map((service, index) => {
const col = index % 3; const col = index % 3;
const row = Math.floor(index / 3); const row = Math.floor(index / 3);
return { return {
serviceId: service.id, serviceId: service.id,
position: { position: layout?.get(service.id) ?? {
x: 70 + col * 260, x: 70 + col * 260,
y: 80 + row * 170, y: 80 + row * 170,
}, },
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more