Compare commits

...
Author SHA1 Message Date
Tomas DvorakandDevin c4f36e4953 refactor(db): port metrics storage + service domains to sqlc
First tranche of the ~300-site raw SQL port. Metrics: node/service/
instance rows via generated queries; aggregation moved off TimescaleDB's
time_bucket onto portable date_bin (PG14+); interval uptime returns epoch
seconds; per-container writes dropped (container_metrics FKs a different
model — collector shape never matched). Domains: all reads/writes through
sqlcdb including the create transaction via WithTx; default-domain and
services.domain invariants unchanged. Both paths covered by live
Postgres tests gated on METRICS_TEST_DSN.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 03:06:42 +02:00
Tomáš DvořákandDevin ca8eab4a65 feat(templates): visual graph builder (#74)
Canvas mode in the template editor (lazy-loaded React Flow): service
nodes for web/worker/cron/database, connect handles to set depends_on,
per-node inspector (image/repo, engine, port, health check, env,
volumes), live validation (dup keys, missing deps, cycles, missing
source), and an Apply step that serializes back to the version-2 graph
config JSON.

- builder-model.ts is pure: config↔canvas round-trip, layered
  auto-layout, flat-legacy→single-node migration, validation —
  16 vitest cases cover all of it
- node positions persist under config.canvas (backend ignores unknown
  config keys, so no schema change)
- flat legacy templates open as a single node so nothing is lost on
  edit

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 03:03:31 +02:00
Tomáš DvořákandDevin a6b8397dd8 test: cron schedule + webhook URL + canvas layout coverage (#73)
- calculateNextRun: field-count, garbage, bad-zone errors; CRON_TZ
  actually shifts the instant (UTC vs Asia/Tokyo)
- validWebhookURL: http/https only, host required, whitespace trimmed
- canvas layout math extracted to canvas/layout.ts (component files may
  only export components) and covered: rectsOverlap gap zone,
  resolveFreeSpot grid-snap/collision/bounds-fallback, groupSlot
  fit/append/grow/ignore-dragged, buildMetadataFromFlow serialization

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 02:34:51 +02:00
Tomáš DvořákandDevin c4307034ec feat(setup): first-run wizard — admin, GitHub App, tunnel, done (#71)
* feat(setup): first-run wizard — admin, GitHub App, tunnel, done

- GET /api/v1/setup/status (public): has_users, needs_setup,
  has_github_app, has_tunnel. app_settings.setup_completed flag; installs
  with existing projects auto-complete so upgrades never see the wizard.
- POST /api/v1/setup/complete records the flag.
- /setup page: progress rail + four steps — create admin (links into the
  bootstrap sign-up with redirect back), GitHub App manifest flow,
  optional Cloudflare tunnel token, finish. Every optional step is
  skippable; nothing blocks.
- PlatformShell redirects signed-in users to /setup while needs_setup.
- The GitHub App callback honors a sessionStorage return marker set by
  the wizard so provisioning resumes at /setup instead of /settings.
- OpenAPI documents both endpoints.

Verified live: status reports auto-completed on this install,
complete persists the flag, response shape matches the client.

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

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

* test(setup): wizard step-state coverage

Five cases across the state machine: fresh-install account prompt,
sign-in resume, git-provider step, tunnel step, and finish.

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-10 02:17:41 +02:00
Tomáš DvořákandDevin 985d22798c fix(deploy): don't split digest-pinned image refs (#72)
splitImageReference checked for '@' AFTER the last colon — but digests
put '@' before it, so `nginx@sha256:abc` split into
name=nginx@sha256/tag=abc. The deploy sync loop wrote those mangled
pairs into the deployments table for any digest-pinned image.

Any ref containing '@' now returns whole with the fallback tag. Table
tests cover registry ports, digests, and tag+digest combos.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 02:17:36 +02:00
Tomáš DvořákandDevin 26f495ac8e feat(offline): persisted query cache + unreachable banner (web + mobile) (#70)
* feat(offline): persisted query cache + unreachable banner (web + mobile)

- Web: PersistQueryClientProvider on localStorage (24h maxAge); mobile:
  AsyncStorage persister (7d). Both exclude high-churn keys (logs,
  notifications, metrics) — stale tails are worse than none.
- networkMode 'offlineFirst' so cached data serves while the refetch
  attempt fails.
- Web: ConnectivityBanner in PlatformShell pings public /health every
  15s + listens to online/offline events — slim "last-known data" bar
  so persisted state can't masquerade as live.
- Mobile: same banner in the tabs layout — /health ping plus
  expo-network's local signal distinguishes "no connection" from
  "instance down".

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

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

* feat(mobile): cron jobs screen — schedules, run-now, execution history

- /service/cron lists the service's jobs with schedule, timezone,
  last/next run, and last status; "Run now" triggers via the existing
  POST /cron-jobs/:id/trigger and "History" expands recent executions
  with status + output/error.
- Manage row on the service detail + Stack registration.

Verified live: created */5 job on a running service, triggered it —
execution recorded with status success and captured stdout.

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-10 01:55:34 +02:00
Tomáš DvořákandDevin 9243d020cc feat(notifications): ntfy/Gotify push channels with live delivery (#69)
Per-user notification_channels (ntfy + Gotify): every
insertUserNotification now fans out best-effort to enabled channels —
deployment/backup/security/agent alerts reach the user's phone via the
ntfy app with zero extra infrastructure.

- POST/GET/DELETE /notifications/channels + POST .../:id/test which
  pushes a real probe so misconfiguration surfaces before alerts depend
  on it (502 DELIVERY_FAILED on endpoint errors).
- ntfy: POST to endpoint (topic in path), Title/Tags headers, optional
  Bearer token. Gotify: POST {endpoint}/message?token=...
- Delivery is best-effort by design — a dead relay never breaks the
  producing flow; one shared 5s-timeout client.
- CLI `notifications channels list|add|test|remove`, MCP tools,
  OpenAPI paths + NotificationChannel schema.
- Web settings "Push notifications" section and a Push channels card on
  the mobile notifications tab (add/test/remove).

Verified live: channel CRUD, test-ping hit a recording endpoint with
correct headers/body, and a real deployment fanned out "Deployment
succeeded" through the channel end-to-end.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 01:39:09 +02:00
Tomáš DvořákandDevin 60145f7b21 feat(environments): env CRUD, workspace switcher, CLI + MCP parity (#68)
Environment lanes existed as seeded rows (production/preview/development)
with a per-service environment label, but there was no way to list,
create, or delete them. Adds the management surface and the switcher:

- GET /projects/:id/environments with live service counts (sqlc)
- POST /projects/:id/environments — lowercase dns-label names, 1-50
  chars, unique per project (409 on dupe)
- DELETE /environments/:id — refuses while it holds services (409)
- Workspace service list gains env filter chips with counts plus an
  inline "+ env" creator
- CLI: containr environments list|create|delete (--yes on delete);
  MCP: containr_environments_list/create/delete (delete needs confirm)
- OpenAPI: new Environments tag + ProjectEnvironment schema

Live-verified: list counts, name validation, dupe 409, empty delete,
non-empty 409, CLI list.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 01:17:32 +02:00
Tomáš DvořákandDevin dd7b735b38 feat(preview): real preview environments — clone, deploy, TTL, promote (#67)
Preview environments were bookkeeping only: a fabricated deployment row
marked "running" and a fake *.preview.containr.local URL. Now a preview
is a real service clone — full config copy (source, env, volumes, access),
git_branch pinned to the requested branch, its own preview hostname under
the clone's node default domain, and a real deployment through the queue.

- cloneServiceRow extracted from handleCloneService for reuse
- preview_environments.preview_service_id links to the clone
- minute sweeper: expires past-TTL previews (removes clone runtime + row)
  and syncs preview status from the clone's live service status
- delete/cleanup paths tear the clone down, not just the row
- promote redeploys the SOURCE service at the preview branch and retires
  the clone — a real deployment, not a pending stub

Verified live: create → clone → deploy → running container; TTL expiry →
clone + container gone; promote → real deploy on the source, preview
stopped. Unit test covers the DNS-safe clone naming.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 00:54:07 +02:00
Tomáš DvořákandDevin d5909172ae feat(gateway): /g/:slug proxy data plane with key auth and rate limits (#66)
The admin surface under /admin/gateway managed api_services and api_keys,
but no public route ever consumed them. Mounts /g/:slug and /g/:slug/* on
the root router (outside /api/v1 so route_prefix stays clean) and runs the
full pipeline: slug lookup -> X-API-Key/Bearer extraction -> prefix-indexed
bcrypt key auth -> allowed_service_ids scope -> per-minute buckets (key and
service caps) -> monthly usage_counters quotas -> gateway.ProxyRequest with
per-service timeout and upstream auth headers -> usage/metrics/incident
recording.

Live-verified: proxied GET/POST, 401/404/429 boundaries, counter upserts,
UPSTREAM_5XX incidents. Unit tests cover URL building, the rpm bucket, key
extraction, and the service allowlist.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 00:29:57 +02:00
Tomáš DvořákandDevin 1409186a1f feat(nodes): per-node default domain with auto service hostnames (#65)
node_agents.default_domain gives each agent a base domain. Services
placed on it (explicit pin or spread) with no explicit service_domains
get <name>.<default_domain> — resolved in serviceDomainNames so edge
routing, wake placeholders, and public_url all see the same names.
Spread services union every eligible node's base domain; explicit
domains always win.

Surfaces: PUT /agents/:id {default_domain} (hostname-validated), CLI
nodes update --domain, MCP arg docs, OpenAPI + regenerated types,
node-detail inline editor.

Also marks the already-shipped backup-import roadmap item.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 00:07:30 +02:00
Tomáš DvořákandDevin a4bb7ad559 feat(services): traefik routing overrides via allowlisted middleware labels (#64)
services.traefik_labels (jsonb) holds operator-defined Traefik
middlewares keyed 'middlewares.<name>.<type>[.<field>]', validated
against an allowlist at write time. edgeLabels (extracted from
createReplica) scopes each definition to the service router as
<router>-<name> and auto-attaches it to the router's middleware chain
after builtins. Router/service/provider-level keys are rejected.

Surfaces: create/update API fields, services create/update
--traefik-label (repeatable, key=value), MCP arg docs, OpenAPI +
regenerated types, service-page Access editor (Routing overrides
card + textarea).

Also marks three stale ROADMAP2 items shipped: notification producers
round-out, build-on-node (PR #60), mobile env/domains/scaling (PR #61).

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 23:47:25 +02:00
Tomáš DvořákandDevin 4071ed2991 feat(templates): git compose import, six stack seeds, empty-default vars (#63)
* feat(templates): git compose import, six stack seeds, empty-default vars

- POST /templates/import/git fetches a compose file from any git remote
  (clone URL, ssh, owner/repo via connected providers — private repos
  included), ref selects branch or commit. Path defaults through
  compose.yaml/yml then docker-compose.yml/yaml. Reuses the build-on-node
  checkout machinery; repo-relative paths are containment-checked.
- CLI `templates import-git <repo> [path] [--ref]`, MCP
  containr_templates_import_git, OpenAPI entry, and a paste/git toggle
  in the template import dialog.
- {{VAR:-}} with an empty default now resolves instead of reporting the
  var missing — matches shell :- semantics.
- SeedTemplates gains six official graph stacks — n8n (pg+redis+worker),
  Gitea, Nextcloud, Vaultwarden, Umami, Immich — wired with
  {{service.*}} refs and {{secret}} credentials; no hardcoded passwords.
- TestSeedTemplatesResolveCleanly parses, topo-sorts, validates, and
  resolves every seeded graph — catalog drift can't merge silently.
- templates/n8n.md compose block had invalid YAML (mixed depends_on
  list/map) — fixed so the documented stack actually parses.

Verified live: import/git on a dumb-HTTP repo returns the converted
graph (db image → managed postgresql), path/ref/traversal/unauth edges
return clean errors, tpl-n8n plan resolves with zero unresolved vars.

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

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

* ci: pre-pull compose-smoke images via gcr mirror

Anonymous Docker Hub pulls rate-limit shared CI egress IPs — the compose
boot smoke flaked on traefik:pull. postgres/traefik are library/* images
served by mirror.gcr.io without auth; compose reuses the retagged local
images, falling back to a normal pull when the mirror misses.

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

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

* ci: route docker.io through gcr mirror for compose smoke

Pull-time pre-pulls don't cover BuildKit's base-image metadata lookups
(node:22-alpine, nginx:alpine, golang:1.26-alpine all 429). Daemon-level
registry-mirrors covers pulls and resolution in one move.

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

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

* ci: mirror docker.io in buildx for image publish job

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

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

* ci: route smoke pulls/builds through mirror.gcr.io via compose overlay

daemon.json registry-mirrors isn't honored by the runner's containerd
image store. Qualify refs directly: ARG-parameterized Dockerfile FROMs
(defaults unchanged) plus a CI-only compose overlay that points image
pulls and build args at mirror.gcr.io.

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-09 23:25:34 +02:00
Tomáš DvořákandDevin c5562109bf fix(sleep): request-triggered wake, create-time config, agent release assets (#62)
- Wake placeholder fired its wake callback unconditionally at boot, so
  sleeping services flapped sleep→wake on every idle timeout. The wake
  call now lives in a CGI script the busybox httpd runs on inbound hits;
  E404 covers non-root paths. URLs/page travel via env, no quoting.
- Wake endpoint tries ordered candidate addresses (self-name on shared
  net, host.docker.internal, edge gateway, probe) — Docker Desktop's VM
  can't reach the bridge gateway, so single-URL wake was dead there.
- CreateServiceRequest accepts sleep_enabled/sleep_idle_minutes and the
  INSERT persists them — previously update-only, silently dropped at
  create. CLI `services create` gains --sleep/--sleep-idle; MCP doc lists
  both; OpenAPI node_id description updated for build-on-node.
- Wake goroutine logs spec/reconcile failures instead of a bare return,
  and marks the service failed when spec build errors.
- scripts/build-cli.sh ships containr-agent-linux-{amd64,arm64} so the
  install-agent.sh GH-releases fallback actually resolves.

Verified live: create-time flags persist and validate (1-1440), service
sleeps on idle and stays asleep, an inbound hit on the placeholder wakes
it (~15s to running), and the placeholder is pruned after reconcile.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 22:40:35 +02:00
Tomáš Dvořák 604319ee06 feat(mobile): env editing, domains, scaling, notifications (#61)
Companion app was read-mostly — quick fixes still needed a workstation.

- service/variables: full env editor — add/edit/delete rows, secret
  toggle, masked-secret preservation (untouched secrets resubmit the
  "********" placeholder so stored ciphertext survives the PUT).
- service/domains: list with default/TLS status, add, remove,
  set-default.
- service/settings: replicas stepper (0–16), container port, scale-to-
  zero toggle + idle minutes — saves via PUT /services/:id.
- (tabs)/notifications: alerts tab with unread badge on the tab bar,
  mark-read per item and mark-all.
- Service detail gains a Manage section linking all three screens.

State initializes at mount inside a child component rather than an
effect — post-save refetches no longer clobber in-flight edits, and
the react-hooks/set-state-in-effect lint stays clean.

Verified: tsc --noEmit, expo lint, expo export (android bundle,
1378 modules — router graph compiles).

Authored By: TDvorak <[email protected]>
2026-10-09 22:33:06 +02:00
Tomáš Dvořák 3cc84553f4 feat: build-on-node — git checkouts, remote image builds, image transport (#60)
Git-sourced deploys never materialized a checkout: sourcePath was
build_path resolved against the server cwd, so builds consumed the
backend's own directory. Remote placement could only run
registry-pulled images.

- internal/source: per-deployment checkout (init/fetch/checkout) at
  branch or commit; GIT_ASKPASS keeps tokens out of argv, .git/config,
  and the build context; dumb-transport and SHA-refusal fallbacks;
  build_path validated inside the checkout.
- BuildManager.PackageContext: produces the same tar context Build
  ships to the daemon — generated Dockerfile at tar root, .git
  excluded. Fixes nixpacks passing the Dockerfile path as content.
- Agent artifact endpoint: GET /api/agents/artifacts/:id streams
  build contexts and docker-save tarballs behind agent-token auth;
  hex-validated ids, per-deployment cleanup, boot-time purge.
- Agent commands: build_image (docker build -, --build-arg values via
  process env, not argv) and load_image (docker load for containr-*
  rollback tags no registry serves).
- Deployment flow: remote git builds package context → artifact →
  node docker-builds the deployment tag; remote rollbacks of local
  tags export via SaveImage → artifact → node docker-loads.

Verified end-to-end: agent-pinned git service built on the node
(build_image → create_container → deployed) and rollback shipped a
docker-save tarball (load_image → deployed). Artifact auth returns
401/404 correctly; workspaces and blobs are removed after deploy.

Authored By: TDvorak <[email protected]>
2026-10-09 20:30:33 +02:00
107 changed files with 10444 additions and 926 deletions

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
# CI-only compose overlay: pull and build through mirror.gcr.io so shared
# runner egress IPs never touch docker.io (anonymous pulls get 429'd).
# Production refs in infra/docker-compose.yml stay unchanged.
services:
postgres:
image: mirror.gcr.io/library/postgres:15-alpine
traefik:
image: mirror.gcr.io/library/traefik:v3.2
backend:
build:
args:
GO_IMAGE: mirror.gcr.io/library/golang:1.26-alpine
NODE_IMAGE: mirror.gcr.io/library/node:22-alpine
frontend:
build:
args:
NODE_IMAGE: mirror.gcr.io/library/node:22-alpine
NGINX_IMAGE: mirror.gcr.io/library/nginx:alpine
+9 -1
View File
@@ -163,11 +163,13 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Boot stack - name: Boot stack
# .github/compose.ci.yml swaps every docker.io ref for
# mirror.gcr.io — anonymous Hub pulls 429 on shared CI IPs.
run: | run: |
BETTER_AUTH_SECRET=$(openssl rand -hex 32) BETTER_AUTH_SECRET=$(openssl rand -hex 32)
BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32) BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN
docker compose -f infra/docker-compose.yml up -d --build postgres dragonfly backend frontend traefik docker compose -f infra/docker-compose.yml -f .github/compose.ci.yml up -d --build postgres dragonfly backend frontend traefik
- name: Wait for backend health - name: Wait for backend health
run: | run: |
@@ -204,7 +206,13 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Docker Buildx - name: Setup Docker Buildx
# Mirror docker.io through gcr — shared CI IPs hit anonymous
# rate limits on base-image metadata resolution otherwise.
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
with:
config-inline: |
[registry."docker.io"]
mirrors = ["mirror.gcr.io"]
- name: Login to GHCR - name: Login to GHCR
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
+1 -1
View File
@@ -49,7 +49,7 @@ dedicated console.
- **Public project catalog** — approved projects are browsable anonymously; new projects stay private until an admin approves them - **Public project catalog** — approved projects are browsable anonymously; new projects stay private until an admin approves them
- **Admin console** (`/admin`) — platform stats, project approval/edit/delete, user admin management - **Admin console** (`/admin`) — platform stats, project approval/edit/delete, user admin management
- **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references - **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references
- **Deployments & builds** — Docker deploys with history, image rollback, live build status - **Deployments & builds** — Docker deploys with history, image rollback, live build status; git-sourced services build directly on remote node agents (no registry required)
- **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page - **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page
- **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App - **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App
- **Managed databases** — create PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Dragonfly, or ClickHouse directly from `/databases`, with backup/restore - **Managed databases** — create PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Dragonfly, or ClickHouse directly from `/databases`, with backup/restore
+94 -49
View File
@@ -192,8 +192,11 @@ goroutines; this fixes correctness *and* unlocks the ops surface.
compares vs edge IP → `ok|wrong-target|pending`. compares vs edge IP → `ok|wrong-target|pending`.
- [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels; - [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
no proxy code needed. no proxy code needed.
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with - [x] **Routing overrides** — `services.traefik_labels` jsonb with
allowlist validation (middlewares, headers, redirects). allowlist validation. Keys `middlewares.<name>.<type>[.<field>]` scope
to the service router (`svc-<id>-<name>`), auto-attach after builtins;
router/service/provider-level keys rejected. API + CLI
(`--traefik-label`) + MCP + service page Access editor.
- [x] **Builder override** — `services.builder` enum - [x] **Builder override** — `services.builder` enum
`auto|railpack|nixpacks|dockerfile|static`; static builder = build + `auto|railpack|nixpacks|dockerfile|static`; static builder = build +
nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug. nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug.
@@ -223,29 +226,43 @@ goroutines; this fixes correctness *and* unlocks the ops surface.
{target_node_id}`: dump → stream transfer → provision → restore → {target_node_id}`: dump → stream transfer → provision → restore →
re-point bound services. Deferred until Phase F lands real second re-point bound services. Deferred until Phase F lands real second
nodes — nothing to migrate between today. nodes — nothing to migrate between today.
- [ ] **Backup import** — upload archive → new restore point (pairs with - [x] **Backup import** — `POST /databases/:id/backups/import` accepts a
`.tar.gz` (multipart `file` or raw body, 2 GiB cap, gzip-magic
validated), stores it in the backups volume via a holder container,
registers a completed restore point, and ships offsite when a backup
target is configured. `api/backuptargets.go`. (pairs with
migrators). migrators).
## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates) ## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates)
- [ ] **Template = ordered service graph** — `services[]` entries with - [x] **Template = ordered service graph** — `services[]` entries with
`{name, type, source, build, ports, volumes, variables, depends_on}`; `{name, type, source, build, ports, volumes, variables, depends_on}`;
deploy order from dependency refs. deploy order from dependency refs. `templategraph.go`,
- [ ] **Expressions** — `${{service.KEY}}` (already the runtime ref `topoSortServices`.
syntax — reuse), `{{secret(len,"charset")}}`, `{{random:int}}`, - [x] **Expressions** — `{{service.KEY.prop}}` (host/port/user/password/
`{{VAR:-default}}`; `POST /templates/:id/plan` returns the resolved database/url), `{{secret}}`/`{{secret(N)}}`, `{{random:N}}`,
graph without deploying (agents love this). `{{VAR:-default}}` (incl. explicitly-empty defaults); `POST
- [ ] **Compose import** — `POST /templates/import/compose` (compose-go /templates/:id/plan` returns the resolved graph without deploying.
parser → graph), `POST /templates/import/github` (compose file from - [x] **Compose import** — `POST /templates/import/compose` (yaml →
repo path). Implements `docs/superpowers/specs/2026-04-14-*`. graph, known DB images become managed databases), `POST
- [ ] **Graph deploy pipeline** — creates project + services in /templates/import/git` (compose file fetched from any git repo — clone
dependency order, resolves vars, triggers builds; defined URL, ssh remote, or owner/repo via connected providers, `ref` selects
partial-failure policy. branch/commit); `templates import-compose|import-git` CLI, MCP tools,
- [ ] **Visual template builder** — canvas mode: drop git/image/db paste-or-git toggle in the template UI.
nodes, wire refs, set order, save/publish. Reuses existing canvas - [x] **Graph deploy pipeline** — creates services in dependency order,
primitives (dflow's `templates/compose` is exactly this). resolves vars/secrets, triggers builds. `deployTemplateGraph`,
- [ ] **Catalog growth** — convert `templates/*.md` (20 app guides) into `POST /templates/deploy` (inline), `POST /projects/:id/import-compose`.
real graph templates; screenshots + badges per the old spec. - [x] **Visual template builder** — canvas mode in the template editor:
web/worker/cron/database nodes, handles wire `depends_on`, inspector
edits image/repo/engine/port/env/volumes, live validation, applies
back to the version-2 graph JSON. Positions persist under
`config.canvas`; flat legacy configs open as a single node.
`builder-model.ts` + `TemplateBuilder.tsx`.
- [x] **Catalog growth** — seeded graph stacks for n8n, Gitea,
Nextcloud, Vaultwarden, Umami, Immich (Plausible already shipped).
`TestSeedTemplatesResolveCleanly` keeps every seed parseable,
topo-sortable, and fully resolved. Remaining single-service guides can
convert incrementally via the same endpoint.
**Gate**: paste n8n compose → 3-service project with generated secrets **Gate**: paste n8n compose → 3-service project with generated secrets
and wired refs, one click or one CLI call. and wired refs, one click or one CLI call.
@@ -334,19 +351,22 @@ and wired refs, one click or one CLI call.
CLI, `containr_nodes_upgrade` MCP (confirm-gated), node-detail page CLI, `containr_nodes_upgrade` MCP (confirm-gated), node-detail page
button (online-gated). Ceilings: linux amd64/arm64 only; agents on button (online-gated). Ceilings: linux amd64/arm64 only; agents on
other platforms get a `NO_BINARY` 409. other platforms get a `NO_BINARY` 409.
- [🟡] **Build-on-node** — private registry pulls shipped: the dispatch - [x] **Build-on-node** — private registry pulls + real builds shipped.
payload carries the project owner's `registries` credentials for the Git sources materialize via `internal/source` checkout (branch/commit,
image host; the agent `docker login`s for the pull and logs out after, `GIT_ASKPASS` credential injection, dumb-transport fallback), package
and command payloads are scrubbed of passwords on completion. Agent through `BuildManager.PackageContext`, ship as agent-token artifacts,
also verifies the container is still running ~1.5s after `run` so and `docker build` on the node (`build_image` command). Remote rollbacks
crash-loops fail the deploy instead of reporting healthy. Remaining: `docker save` → artifact → `load_image` on the node. Verified end-to-end
real builds on the node (source checkout + build daemon) — pulls on a live agent (PR #60).
already work.
- [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/ - [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/
OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module
(dflow's second provider), cloud-init → auto-enroll. (dflow's second provider), cloud-init → auto-enroll.
- [ ] **Global/wildcard domain per node** (`nodes.default_domain`) — - [x] **Global/wildcard domain per node** (`node_agents.default_domain`) —
auto-domains for services on that node. `PUT /agents/:id {default_domain}`; domainless services placed on the
node get `<name>.<default_domain>` (pinned → node's base; spread →
union of eligible nodes). `serviceDomainNames` resolves them so
routing, wake placeholders, and `public_url` all pick them up.
CLI `nodes update --domain`, MCP arg, node-detail editor.
## 8. Phase G — Platform polish ## 8. Phase G — Platform polish
@@ -419,8 +439,11 @@ and wired refs, one click or one CLI call.
filters. Applied: audit logs (server-backed), builds (server + client filters. Applied: audit logs (server-backed), builds (server + client
date-range), activity (severity server + client category/search). date-range), activity (severity server + client category/search).
Notifications table has no list surface yet — apply when one lands. Notifications table has no list surface yet — apply when one lands.
- [ ] **Notification producers round-out** — backups, agent offline, - [x] **Notification producers round-out** — all shipped: deployments
upgrade available, security findings. (deployments.go), backups (databases.go `createBackupProcess` → owner),
agent offline/online sweep (`reconcileAgentHealth` → admins),
upgrade-available (`checkForUpgrade`, tag-persisted) and security-scan
findings (`scanner.OnScanComplete` → project owner).
- [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) — - [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) —
`services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper `services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper
polls container NetworkIO deltas; idle past threshold → workload polls container NetworkIO deltas; idle past threshold → workload
@@ -485,28 +508,50 @@ and wired refs, one click or one CLI call.
a prior deployment, database start/stop/restart/backup-now. a prior deployment, database start/stop/restart/backup-now.
- **Observe** — polling log tail (300 lines, 5s refresh), per-service - **Observe** — polling log tail (300 lines, 5s refresh), per-service
deployment history, env-doctor issues surfaced on the service view. deployment history, env-doctor issues surfaced on the service view.
- [ ] **env var edit + apply-redeploy, domain management, replica - [x] **env var edit + domain management + replica scaling** (PR #61 —
scaling, cron execution history** — next iteration. mobile v1.5: variables editor, domains screen, scale + sleep
- [ ] **Push notifications** — zero-infra path = self-hosted settings, notifications tab, cron jobs screen with run-now and
ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional. execution history).
- [ ] **Offline resilience** — cached last-known state + unreachable - [x] **Push notifications** — per-user `notification_channels`
banner. (ntfy + Gotify): every `insertUserNotification` fans out best-effort
- [ ] **Status/offline resilience** — app + CLI degrade gracefully when to enabled channels; CRUD + live test-ping via web settings, CLI
the instance is unreachable (cached last-known state, clear banner). `notifications channels`, and MCP. FCM/APNs remain optional.
- [x] **Offline resilience** — AsyncStorage-persisted query cache
(7d, high-churn keys excluded) + banner when the instance is
unreachable (`/health` ping + expo-network).
- [x] **Status/offline resilience** — web persists the query cache to
localStorage (24h) and PlatformShell shows a "last-known data" banner
on offline/unreachable; CLI exits `ExitUnavailable` with a clean
"cannot reach" message (no stack traces).
## 10. Carried-over debt (ROADMAP.md items still open) ## 10. Carried-over debt (ROADMAP.md items still open)
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit → - [x] **Gateway traffic path** — `/g/:slug/*` mounted → api_key auth →
`gateway/proxy.go` → metrics. Management API exists; proxy unwired. rpm + monthly quota → `gateway/proxy.go` → usage_counters /
- [ ] **Preview environments runtime** — real build+deploy+subdomain+TTL metrics_timeseries / incident_events.
(bookkeeping only now). Pairs with clone + multi-domain. - [x] **Preview environments runtime** — previews are real service clones:
- [ ] **Environments** — env switcher, per-env vars/domains, promote. branch override, queued deployment, node-domain hostnames, TTL sweeper,
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`. promote = redeploy of the source at the preview branch.
- [x] **Environments** — CRUD (`GET/POST /projects/:id/environments`,
`DELETE /environments/:id`), switcher chips with service counts in the
workspace, CLI `environments` + MCP parity. Per-env vars/domains ride the
service's own scope (one env per service); promote ships via preview
promote.
- [ ] **sqlc port** — ~300 raw `database/sql` sites → `sqlc/queries/`. Done in tranches (dynamic queries stay raw): metrics storage + service domains ported.
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/ - [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
webhook table tests, Playwright e2e golden path. webhook table tests, Playwright e2e golden path.
- [ ] **First-run wizard** — admin → GitHub App (manifest flow from - [x] **First-run wizard** — public `GET /setup/status` + authed
Phase G makes this self-serve) → Tunnel → done. `POST /setup/complete`; `/setup` page steps admin account → GitHub App
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release. manifest flow (callback returns to the wizard via a session marker) →
optional Cloudflare tunnel token → done. Shell redirects signed-in
users to `/setup` while `needs_setup`; installs with existing projects
auto-complete so nobody gets nagged retroactively.
- [x] **Self-upgrade loop verification** — live-verified: admin
`POST /agents/:id/upgrade` → `self_upgrade` command → agent downloads
`/api/agents/download/linux-amd64` → sha256 verify → `--version`
sanity → atomic rename → result posts → `syscall.Exec` re-exec. Agent
ran `v0.0.1-old`, came back `v9.9.9-test`, command row `completed`.
- [ ] **Docs freshness, v1.0.0 release.**
## 11. Deliberately not imported from dflow ## 11. Deliberately not imported from dflow
+6 -4
View File
@@ -1,5 +1,7 @@
# Build Go API binary # Build Go API binary
FROM golang:1.26-alpine AS go-builder ARG GO_IMAGE=golang:1.26-alpine
ARG NODE_IMAGE=node:22-alpine
FROM ${GO_IMAGE} AS go-builder
RUN apk add --no-cache git ca-certificates tzdata RUN apk add --no-cache git ca-certificates tzdata
@@ -18,7 +20,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X main.version=${V
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-X main.version=${VERSION}" -o /agents/containr-agent-linux-arm64 ./cmd/agent CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-X main.version=${VERSION}" -o /agents/containr-agent-linux-arm64 ./cmd/agent
# Build embedded Better Auth runtime dependencies # Build embedded Better Auth runtime dependencies
FROM node:22-alpine AS auth-builder FROM ${NODE_IMAGE} AS auth-builder
WORKDIR /auth WORKDIR /auth
@@ -28,9 +30,9 @@ RUN npm ci --omit=dev
COPY auth/src ./src COPY auth/src ./src
# Final runtime image (Go API + Better Auth sidecar in one container) # Final runtime image (Go API + Better Auth sidecar in one container)
FROM node:22-alpine FROM ${NODE_IMAGE}
RUN apk --no-cache add ca-certificates tzdata wget RUN apk --no-cache add ca-certificates tzdata wget git
RUN addgroup -g 1001 -S appgroup && \ RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup adduser -u 1001 -S appuser -G appgroup
+129 -2
View File
@@ -316,7 +316,7 @@ func processCommands(ctx context.Context, client *http.Client, cfg agentConfig,
if item.Type == "self_upgrade" { if item.Type == "self_upgrade" {
result, reexecPath, runErr = selfUpgrade(cfg, item.Payload) result, reexecPath, runErr = selfUpgrade(cfg, item.Payload)
} else { } else {
result, runErr = executeCommand(item) result, runErr = executeCommand(cfg, item)
} }
status := "completed" status := "completed"
errText := "" errText := ""
@@ -343,7 +343,7 @@ func processCommands(ctx context.Context, client *http.Client, cfg agentConfig,
return nil return nil
} }
func executeCommand(item command) (string, error) { func executeCommand(cfg agentConfig, item command) (string, error) {
switch item.Type { switch item.Type {
case "create_container": case "create_container":
return createContainer(item.Payload) return createContainer(item.Payload)
@@ -360,6 +360,10 @@ func executeCommand(item command) (string, error) {
return out, nil return out, nil
} }
return out, err return out, err
case "build_image":
return buildImage(cfg, item.Payload)
case "load_image":
return loadImage(cfg, item.Payload)
case "prune": case "prune":
return dockerPrune(item.Payload) return dockerPrune(item.Payload)
case "system_df": case "system_df":
@@ -568,6 +572,120 @@ func createContainer(payload map[string]interface{}) (string, error) {
return string(out), nil return string(out), nil
} }
// fetchArtifact downloads a server-side deploy artifact (build context or
// docker-save tarball) into a temp file the docker CLI consumes.
func fetchArtifact(cfg agentConfig, artifactID string) (*os.File, error) {
if len(artifactID) != 32 {
return nil, fmt.Errorf("invalid artifact id")
}
url := fmt.Sprintf("%s/api/agents/artifacts/%s", cfg.APIURL, artifactID)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Containr-Agent-Token", cfg.Token)
resp, err := (&http.Client{Timeout: 10 * time.Minute}).Do(req)
if err != nil {
return nil, fmt.Errorf("download artifact: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download artifact: server returned %d", resp.StatusCode)
}
f, err := os.CreateTemp("", "containr-artifact-*.tar")
if err != nil {
return nil, err
}
if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
os.Remove(f.Name())
return nil, fmt.Errorf("write artifact: %w", err)
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
f.Close()
os.Remove(f.Name())
return nil, err
}
return f, nil
}
// buildImage docker-builds a packaged context tar on this node. The context
// already carries its Dockerfile at the tar root, so `docker build -` can
// stream it straight from stdin. Result reports the image digest so the
// server records what each node actually produced.
func buildImage(cfg agentConfig, payload map[string]interface{}) (string, error) {
image := stringValue(payload["image"])
artifactID := stringValue(payload["artifact_id"])
if image == "" || artifactID == "" {
return "", errors.New("build_image requires image and artifact_id")
}
f, err := fetchArtifact(cfg, artifactID)
if err != nil {
return "", err
}
defer f.Close()
defer os.Remove(f.Name())
args := []string{"build", "-t", image}
if boolValue(payload["no_cache"]) {
args = append(args, "--no-cache")
}
// Bare --build-arg keys read their values from the process env — keeps
// token-bearing args out of the node's process list.
env := os.Environ()
if buildArgs, ok := payload["build_args"].(map[string]interface{}); ok {
for k, v := range buildArgs {
args = append(args, "--build-arg", k)
env = append(env, fmt.Sprintf("%s=%v", k, v))
}
}
args = append(args, "-")
cmd := exec.Command("docker", args...)
cmd.Stdin = f
cmd.Env = env
outBytes, err := cmd.CombinedOutput()
out := strings.TrimSpace(string(outBytes))
if err != nil {
return "", fmt.Errorf("docker build failed: %s", tailOutput(out, 4096))
}
digest, _ := runDocker("inspect", "-f", "{{.Id}}", image)
size, _ := runDocker("inspect", "-f", "{{.Size}}", image)
result, _ := json.Marshal(map[string]string{
"image": image, "id": digest, "size": size,
"log_tail": tailOutput(out, 4096),
})
return string(result), nil
}
// loadImage imports a docker-save tarball — rollbacks of locally-built tags
// reach remote nodes this way since no registry serves containr-* images.
func loadImage(cfg agentConfig, payload map[string]interface{}) (string, error) {
artifactID := stringValue(payload["artifact_id"])
if artifactID == "" {
return "", errors.New("load_image requires artifact_id")
}
f, err := fetchArtifact(cfg, artifactID)
if err != nil {
return "", err
}
defer f.Close()
defer os.Remove(f.Name())
out, err := runDockerStdin(f, "load")
if err != nil {
return "", fmt.Errorf("docker load failed: %s", tailOutput(out, 4096))
}
return out, nil
}
// tailOutput bounds command results — full docker build logs would swamp
// the agent_commands row.
func tailOutput(s string, max int) string {
if len(s) <= max {
return s
}
return s[len(s)-max:]
}
// registryHostOf mirrors the server's registryHost: first path segment with // registryHostOf mirrors the server's registryHost: first path segment with
// a "." or ":" is a registry host; bare names mean Docker Hub. // a "." or ":" is a registry host; bare names mean Docker Hub.
func registryHostOf(imageRef string) string { func registryHostOf(imageRef string) string {
@@ -779,6 +897,15 @@ func runDocker(args ...string) (string, error) {
return strings.TrimSpace(string(output)), err return strings.TrimSpace(string(output)), err
} }
// runDockerStdin pipes stdin into docker — build contexts and saved image
// tarballs stream in without a second copy on disk.
func runDockerStdin(stdin io.Reader, args ...string) (string, error) {
cmd := exec.Command("docker", args...)
cmd.Stdin = stdin
output, err := cmd.CombinedOutput()
return strings.TrimSpace(string(output)), err
}
func dockerAvailable() bool { func dockerAvailable() bool {
_, err := exec.LookPath("docker") _, err := exec.LookPath("docker")
if err != nil { if err != nil {
+63 -32
View File
@@ -30,21 +30,24 @@ import (
// NodeAgent represents a container orchestration agent // NodeAgent represents a container orchestration agent
type NodeAgent struct { type NodeAgent struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
IPAddress string `json:"ip_address"` IPAddress string `json:"ip_address"`
Port int `json:"port"` Port int `json:"port"`
Status string `json:"status"` Status string `json:"status"`
Version string `json:"version"` Version string `json:"version"`
Capabilities AgentCapabilities `json:"capabilities"` Capabilities AgentCapabilities `json:"capabilities"`
Resources NodeResources `json:"resources"` Resources NodeResources `json:"resources"`
LastHeartbeat time.Time `json:"last_heartbeat"` LastHeartbeat time.Time `json:"last_heartbeat"`
AutoPrune bool `json:"auto_prune"` AutoPrune bool `json:"auto_prune"`
Schedulable bool `json:"schedulable"` Schedulable bool `json:"schedulable"`
// Tags are operator-set placement labels; services with placement_tags // Tags are operator-set placement labels; services with placement_tags
// only schedule onto agents carrying every required tag. // only schedule onto agents carrying every required tag.
Tags []string `json:"tags"` Tags []string `json:"tags"`
// DefaultDomain is this node's base domain — services placed here with
// no explicit domains get <name>.<default_domain> automatically.
DefaultDomain string `json:"default_domain"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata"` Metadata map[string]interface{} `json:"metadata"`
@@ -272,17 +275,18 @@ func (h *NodeAgentHandler) ListNodeOptions(c *gin.Context) {
func agentFromRow(row sqlcdb.NodeAgent) NodeAgent { func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
agent := NodeAgent{ agent := NodeAgent{
ID: row.ID, ID: row.ID,
Name: row.Name, Name: row.Name,
Hostname: row.Hostname, Hostname: row.Hostname,
IPAddress: row.IpAddress, IPAddress: row.IpAddress,
Port: int(row.Port), Port: int(row.Port),
Status: row.Status.String, Status: row.Status.String,
Version: row.Version.String, Version: row.Version.String,
AutoPrune: row.AutoPrune, AutoPrune: row.AutoPrune,
Schedulable: row.Schedulable, Schedulable: row.Schedulable,
Tags: []string{}, Tags: []string{},
Metadata: map[string]interface{}{}, DefaultDomain: row.DefaultDomain,
Metadata: map[string]interface{}{},
} }
if row.LastHeartbeat.Valid { if row.LastHeartbeat.Valid {
agent.LastHeartbeat = row.LastHeartbeat.Time agent.LastHeartbeat = row.LastHeartbeat.Time
@@ -572,6 +576,18 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
// Tags persist through their own column write — UpdateAgent keeps the // Tags persist through their own column write — UpdateAgent keeps the
// register-path signature untouched. // register-path signature untouched.
resp := agentFromRow(updated) resp := agentFromRow(updated)
if raw, ok := updates["default_domain"]; ok {
domain := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", raw)))
if domain != "" && !validHostname(domain) {
c.JSON(http.StatusBadRequest, gin.H{"error": "default_domain must be a valid hostname", "code": "VALIDATION"})
return
}
if err := h.q.SetAgentDefaultDomain(ctx, sqlcdb.SetAgentDefaultDomainParams{ID: id, DefaultDomain: domain}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update default domain"})
return
}
resp.DefaultDomain = domain
}
if raw, ok := updates["tags"]; ok { if raw, ok := updates["tags"]; ok {
var tags []string var tags []string
if b, mErr := json.Marshal(raw); mErr == nil { if b, mErr := json.Marshal(raw); mErr == nil {
@@ -769,7 +785,9 @@ func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
} }
// scrubCommandSecrets rewrites a completed command's payload with any // scrubCommandSecrets rewrites a completed command's payload with any
// registry password blanked. Best-effort — a failed scrub leaves the row. // credential-bearing fields blanked — registry passwords and build-time
// args (tokens like NPM_TOKEN ride along as build args). Best-effort — a
// failed scrub leaves the row.
func scrubCommandSecrets(ctx context.Context, q *sqlcdb.Queries, commandID string, payload pqtype.NullRawMessage) { func scrubCommandSecrets(ctx context.Context, q *sqlcdb.Queries, commandID string, payload pqtype.NullRawMessage) {
if !payload.Valid || len(payload.RawMessage) == 0 { if !payload.Valid || len(payload.RawMessage) == 0 {
return return
@@ -778,15 +796,24 @@ func scrubCommandSecrets(ctx context.Context, q *sqlcdb.Queries, commandID strin
if err := json.Unmarshal(payload.RawMessage, &doc); err != nil { if err := json.Unmarshal(payload.RawMessage, &doc); err != nil {
return return
} }
container, ok := doc["container"].(map[string]interface{}) dirty := false
if !ok { if container, ok := doc["container"].(map[string]interface{}); ok {
if reg, ok := container["registry"].(map[string]interface{}); ok && reg["password"] != nil {
reg["password"] = "***"
dirty = true
}
}
for _, key := range []string{"build_args", "environment"} {
if args, ok := doc[key].(map[string]interface{}); ok && len(args) > 0 {
for k := range args {
args[k] = "***"
}
dirty = true
}
}
if !dirty {
return return
} }
reg, ok := container["registry"].(map[string]interface{})
if !ok || reg["password"] == nil {
return
}
reg["password"] = "***"
if err := q.ScrubCommandPayload(ctx, sqlcdb.ScrubCommandPayloadParams{ if err := q.ScrubCommandPayload(ctx, sqlcdb.ScrubCommandPayloadParams{
ID: commandID, ID: commandID,
Payload: rawJSON(doc), Payload: rawJSON(doc),
@@ -1458,6 +1485,10 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
// public; the enroll token is the actual secret. // public; the enroll token is the actual secret.
agents.GET("/install.sh", h.ServeInstallScript) agents.GET("/install.sh", h.ServeInstallScript)
agents.GET("/download/:platform", h.ServeAgentBinary) agents.GET("/download/:platform", h.ServeAgentBinary)
// Deploy artifacts (build contexts, image tarballs) — token-gated;
// the blobs are per-deployment source code, not public bootstrap.
agents.GET("/artifacts/:id", h.ServeArtifact)
} }
} }
+136
View File
@@ -0,0 +1,136 @@
package api
import (
"crypto/rand"
"encoding/hex"
"io"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// containrWorkDir is the build workspace root — same dir the BuildManager
// was constructed with; checkouts and agent artifacts live beneath it.
const containrWorkDir = "/tmp/containr-builds"
// artifacts is a one-shot blob store for node agents: build contexts and
// docker-save tarballs the server produced on behalf of a deployment. Files
// live under workdir/artifacts/<id> and are removed when the deployment
// finishes or after maxArtifactAge.
var artifacts = &artifactStore{files: map[string]string{}}
type artifactStore struct {
mu sync.Mutex
dir string
files map[string]string
}
const maxArtifactAge = 2 // hours — a deployment can never outlive this
// initArtifacts points the store at dir (created on demand).
func initArtifacts(dir string) {
artifacts.mu.Lock()
defer artifacts.mu.Unlock()
artifacts.dir = dir
_ = os.MkdirAll(dir, 0o755)
}
// artifactDir returns the store root, defaulting under the build workdir.
func artifactDir() string {
artifacts.mu.Lock()
defer artifacts.mu.Unlock()
if artifacts.dir != "" {
return artifacts.dir
}
return filepath.Join(containrWorkDir, "artifacts")
}
// putArtifact streams rc into the store and returns the artifact id.
func putArtifact(rc io.Reader) (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
id := hex.EncodeToString(buf)
dir := artifactDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
path := filepath.Join(dir, id+".tar")
f, err := os.Create(path)
if err != nil {
return "", err
}
if _, err := io.Copy(f, rc); err != nil {
f.Close()
os.Remove(path)
return "", err
}
f.Close()
artifacts.mu.Lock()
artifacts.files[id] = path
artifacts.mu.Unlock()
return id, nil
}
// removeArtifact deletes the file and forgets the id. Safe on missing ids.
func removeArtifact(id string) {
artifacts.mu.Lock()
path, ok := artifacts.files[id]
delete(artifacts.files, id)
artifacts.mu.Unlock()
if ok {
_ = os.Remove(path)
return
}
// Registered before a restart — remove best-effort by name.
if id != "" {
_ = os.Remove(filepath.Join(artifactDir(), id+".tar"))
}
}
// ServeArtifact streams a stored blob to a token-authenticated agent. Files
// persist across downloads — spread builds share one context — and are
// deleted by the deployment that created them.
func (h *NodeAgentHandler) ServeArtifact(c *gin.Context) {
if !h.isValidAgentAuthToken(c.Request.Context(), agentAuthTokenFromRequest(c)) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
return
}
id := c.Param("id")
raw, err := hex.DecodeString(id)
if len(id) != 32 || err != nil || len(raw) != 16 {
c.JSON(http.StatusNotFound, gin.H{"error": "Artifact not found"})
return
}
path := filepath.Join(artifactDir(), id+".tar")
info, err := os.Stat(path)
if err != nil || info.IsDir() {
c.JSON(http.StatusNotFound, gin.H{"error": "Artifact not found"})
return
}
c.Header("Content-Type", "application/x-tar")
c.File(path)
}
// purgeStaleArtifacts drops files older than maxArtifactAge. Called on boot.
func purgeStaleArtifacts() {
entries, err := os.ReadDir(artifactDir())
if err != nil {
return
}
for _, e := range entries {
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().Before(time.Now().Add(-maxArtifactAge * time.Hour)) {
_ = os.Remove(filepath.Join(artifactDir(), e.Name()))
}
}
}
+66
View File
@@ -0,0 +1,66 @@
package api
import (
"strings"
"testing"
"time"
)
func TestCalculateNextRun(t *testing.T) {
cases := []struct {
name string
schedule string
timezone string
wantErr bool
}{
{"every minute", "* * * * *", "", false},
{"daily at 4am", "0 4 * * *", "UTC", false},
{"weekday run", "0 9 * * 1-5", "Europe/Prague", false},
{"whitespace tolerated", " */15 * * * * ", "", false},
{"empty schedule", "", "", true},
{"bad field count", "* * *", "", true},
{"garbage", "not a cron", "", true},
{"bad timezone", "0 4 * * *", "Mars/Olympus", true},
}
now := time.Now()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
next, err := calculateNextRun(tc.schedule, tc.timezone)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got next=%v", next)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if next == nil {
t.Fatal("expected non-nil next run")
}
if !next.After(now) {
t.Errorf("next run %v is not after %v", next, now)
}
})
}
}
func TestCalculateNextRunTimezone(t *testing.T) {
// CRON_TZ must change the computed instant: 04:00 in a fixed-offset zone
// lands at a different UTC hour than 04:00 UTC.
utcNext, err := calculateNextRun("0 4 * * *", "UTC")
if err != nil {
t.Fatalf("utc: %v", err)
}
tokyoNext, err := calculateNextRun("0 4 * * *", "Asia/Tokyo")
if err != nil {
t.Fatalf("tokyo: %v", err)
}
if utcNext.Equal(*tokyoNext) {
t.Fatalf("timezone had no effect: both %v", utcNext)
}
if !strings.EqualFold(utcNext.Location().String(), "Local") {
// UTC-scheduled runs still land in server-local time representation.
t.Logf("utc next: %v / tokyo next: %v", utcNext, tokyoNext)
}
}
+128 -29
View File
@@ -5,11 +5,15 @@ import (
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue" "containr/internal/deployqueue"
"containr/internal/docker" "containr/internal/docker"
"containr/internal/source"
"containr/internal/types"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -377,6 +381,23 @@ func runDeploymentAndSyncWithImage(
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute) ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
defer cancel() defer cancel()
// The checkout and any shipped artifacts die with this deployment.
var artifactIDs []string
checkoutRoot := filepath.Join(containrWorkDir, "repos", dbDeployment.ID.String())
defer func() {
_ = os.RemoveAll(checkoutRoot)
for _, id := range artifactIDs {
removeArtifact(id)
}
}()
failDeploy := func(msg string) {
failedAt := time.Now()
_, _ = db.Exec(
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
msg, failedAt, dbDeployment.ID,
)
}
sourcePath := strings.TrimSpace(service.BuildPath) sourcePath := strings.TrimSpace(service.BuildPath)
if sourcePath == "" { if sourcePath == "" {
sourcePath = "." sourcePath = "."
@@ -421,19 +442,31 @@ func runDeploymentAndSyncWithImage(
maintURL = maintenanceURL(db) maintURL = maintenanceURL(db)
} }
// Node-pinned services dispatch to the agent's command queue. Builds run // Node-pinned and spread services dispatch to agent command queues.
// on the local host, so remote placement only works for registry-pulled
// images — git-sourced and rollback deploys fail fast with that message.
nodeID := serviceNodeID(db, service.ID) nodeID := serviceNodeID(db, service.ID)
spread := serviceSpread(db, service.ID) spread := serviceSpread(db, service.ID)
if (nodeID != "" || spread) && (service.GitRepo != "" || imageOverride != "") { remote := nodeID != "" || spread
failedAt := time.Now()
msg := "service uses remote node placement but remote nodes only run registry-pulled images (git builds and rollbacks stay local — build-on-node is not yet supported)" // Materialize the git checkout — local builds consume it directly;
_, _ = db.Exec( // remote builds package it into a context artifact the node builds.
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`, if service.GitRepo != "" && imageOverride == "" {
msg, failedAt, dbDeployment.ID, var ownerID string
) _ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
return branch := req.Branch
if branch == "" {
branch = service.GitBranch
}
cloneSpec, err := resolveGitCloneSpec(db, ownerID, service.GitRepo, branch, req.CommitHash, service.BuildPath)
if err != nil {
failDeploy("resolve git source: " + err.Error())
return
}
src, err := source.Checkout(ctx, checkoutRoot, cloneSpec)
if err != nil {
failDeploy("git checkout: " + err.Error())
return
}
sourcePath = src
} }
deployReq := &deployment.DeploymentRequest{ deployReq := &deployment.DeploymentRequest{
@@ -480,14 +513,36 @@ func runDeploymentAndSyncWithImage(
if image == "" { if image == "" {
image = service.Image image = service.Image
} }
var ownerID string if remote && strings.HasPrefix(image, "containr-") {
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID) // Locally-built tag — no registry serves it. Ship the image
auth := registryAuthFor(db, ownerID, image) // itself as a docker-save artifact; the node loads it.
deployReq.BuildConfig = &deployment.BuildConfig{ rc, err := engine.DockerClient().SaveImage(ctx, image)
BuildType: "prebuilt", if err != nil {
PrebuiltImage: image, failDeploy("export rollback image: " + err.Error())
PullUsername: auth.Username, return
PullPassword: auth.Password, }
artifactID, err := putArtifact(rc)
_ = rc.Close()
if err != nil {
failDeploy("store rollback image artifact: " + err.Error())
return
}
artifactIDs = append(artifactIDs, artifactID)
deployReq.Config.RemoteLoad = &deployment.RemoteLoadSpec{ArtifactID: artifactID}
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "remote",
PrebuiltImage: image,
}
} else {
var ownerID string
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
auth := registryAuthFor(db, ownerID, image)
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "prebuilt",
PrebuiltImage: image,
PullUsername: auth.Username,
PullPassword: auth.Password,
}
} }
} else { } else {
// "auto" leaves BuildType empty so the manager detects it; the static // "auto" leaves BuildType empty so the manager detects it; the static
@@ -497,16 +552,54 @@ func runDeploymentAndSyncWithImage(
if service.Builder != "" && service.Builder != "auto" { if service.Builder != "" && service.Builder != "auto" {
buildType = service.Builder buildType = service.Builder
} }
deployReq.BuildConfig = &deployment.BuildConfig{ buildArgs := map[string]string{}
BuildType: buildType, buildCommand := ""
SourcePath: sourcePath,
Branch: req.Branch,
Commit: req.CommitHash,
NoCache: req.NoCache,
}
if buildType == "static" { if buildType == "static" {
deployReq.BuildConfig.BuildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build") buildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build")
deployReq.BuildConfig.BuildArgs = map[string]string{"STATIC_DIR": firstNonEmpty(service.StaticDir, "dist")} buildArgs["STATIC_DIR"] = firstNonEmpty(service.StaticDir, "dist")
}
if remote {
// Package the context — the node docker-builds it itself.
packReq := &types.BuildRequest{
BuildType: buildType,
SourcePath: sourcePath,
BuildCommand: buildCommand,
BuildArgs: buildArgs,
Environment: env,
}
rc, err := engine.BuildManager().PackageContext(ctx, packReq)
if err != nil {
failDeploy("package build context: " + err.Error())
return
}
artifactID, err := putArtifact(rc)
_ = rc.Close()
if err != nil {
failDeploy("store build context artifact: " + err.Error())
return
}
artifactIDs = append(artifactIDs, artifactID)
deployReq.Config.RemoteBuild = &deployment.RemoteBuildSpec{
ArtifactID: artifactID,
BuildArgs: buildArgs,
NoCache: req.NoCache,
}
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "remote",
Branch: req.Branch,
Commit: req.CommitHash,
NoCache: req.NoCache,
}
} else {
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: buildType,
SourcePath: sourcePath,
BuildCommand: buildCommand,
BuildArgs: buildArgs,
Branch: req.Branch,
Commit: req.CommitHash,
NoCache: req.NoCache,
}
} }
} }
@@ -710,9 +803,15 @@ func splitImageReference(image, fallbackTag string) (string, string) {
return "", fallbackTag return "", fallbackTag
} }
// Digest-pinned refs (name[:tag]@sha256:…) carry no usable tag — the
// colon lives inside the digest and must not be split.
if strings.Contains(image, "@") {
return image, fallbackTag
}
lastSlash := strings.LastIndex(image, "/") lastSlash := strings.LastIndex(image, "/")
lastColon := strings.LastIndex(image, ":") lastColon := strings.LastIndex(image, ":")
if lastColon > lastSlash && !strings.Contains(image[lastColon:], "@") { if lastColon > lastSlash {
return image[:lastColon], image[lastColon+1:] return image[:lastColon], image[lastColon+1:]
} }
@@ -0,0 +1,46 @@
package api
import "testing"
func TestSplitImageReference(t *testing.T) {
cases := []struct {
name string
image string
fallback string
wantImage string
wantTag string
}{
{"empty uses fallback tag", "", "latest", "", "latest"},
{"bare image", "nginx", "latest", "nginx", "latest"},
{"tagged image", "nginx:1.27", "latest", "nginx", "1.27"},
{"registry with port untagged", "registry.local:5000/app", "latest", "registry.local:5000/app", "latest"},
{"registry with port tagged", "registry.local:5000/app:v2", "latest", "registry.local:5000/app", "v2"},
{"digest stays whole", "nginx@sha256:abc123", "latest", "nginx@sha256:abc123", "latest"},
{"tag and digest", "nginx:1.27@sha256:abc", "latest", "nginx:1.27@sha256:abc", "latest"},
{"namespaced tagged", "ghcr.io/owner/repo:dev", "latest", "ghcr.io/owner/repo", "dev"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
img, tag := splitImageReference(tc.image, tc.fallback)
if img != tc.wantImage || tag != tc.wantTag {
t.Fatalf("splitImageReference(%q, %q) = (%q, %q), want (%q, %q)",
tc.image, tc.fallback, img, tag, tc.wantImage, tc.wantTag)
}
})
}
}
func TestMapEngineStatusToDBStatus(t *testing.T) {
cases := map[string]string{
"running": "deployed",
"building": "building",
"failed": "failed",
"cancelled": "cancelled",
"queued": "queued",
}
for in, want := range cases {
if got := mapEngineStatusToDBStatus(in); got != want {
t.Fatalf("mapEngineStatusToDBStatus(%q) = %q, want %q", in, got, want)
}
}
}
+123 -42
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"net" "net"
"net/http" "net/http"
"regexp" "regexp"
@@ -10,6 +11,7 @@ import (
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -42,19 +44,26 @@ func validHostname(d string) bool {
} }
func loadServiceDomains(db *database.DB, serviceID uuid.UUID) []ServiceDomain { func loadServiceDomains(db *database.DB, serviceID uuid.UUID) []ServiceDomain {
rows, err := db.Query( rows, err := sqlcdb.New(db.DB).ListServiceDomains(context.Background(), serviceID)
`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 { if err != nil {
return nil return nil
} }
defer rows.Close() out := make([]ServiceDomain, 0, len(rows))
out := []ServiceDomain{} for _, r := range rows {
for rows.Next() { d := ServiceDomain{
var d ServiceDomain ID: r.ID,
if err := rows.Scan(&d.ID, &d.ServiceID, &d.Domain, &d.IsDefault, &d.CertType, &d.CertStatus, &d.LastCheckedAt, &d.CreatedAt); err == nil { ServiceID: r.ServiceID,
out = append(out, d) Domain: r.Domain,
IsDefault: r.IsDefault,
CertType: r.CertType,
CertStatus: r.CertStatus,
CreatedAt: r.CreatedAt,
} }
if r.LastCheckedAt.Valid {
t := r.LastCheckedAt.Time
d.LastCheckedAt = &t
}
out = append(out, d)
} }
return out return out
} }
@@ -68,30 +77,95 @@ func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) [
if len(out) == 0 && fallback != "" { if len(out) == 0 && fallback != "" {
out = []string{fallback} out = []string{fallback}
} }
if len(out) == 0 {
out = serviceAutoDomains(db, serviceID)
}
return out
}
// dnsLabel normalizes a service name into a DNS label: lowercase alnum
// plus single dashes, never leading/trailing, ≤63 chars.
func dnsLabel(name string) string {
var b strings.Builder
lastWasSep := false
for _, r := range strings.ToLower(strings.TrimSpace(name)) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastWasSep = false
continue
}
if !lastWasSep && b.Len() > 0 {
b.WriteByte('-')
lastWasSep = true
}
}
out := strings.Trim(b.String(), "-")
if len(out) > 63 {
out = strings.Trim(out[:63], "-")
}
return out
}
// serviceAutoDomains generates <service>.<node-default-domain> hostnames
// for services with no explicit domains. Pinned services take their node's
// base domain; spread services union every eligible node's base domain.
// Local services get nothing — there is no instance-level base domain yet.
func serviceAutoDomains(db *database.DB, serviceID uuid.UUID) []string {
ctx := context.Background()
q := sqlcdb.New(db.DB)
brief, err := q.GetServicePlacementBrief(ctx, serviceID)
if err != nil {
return nil
}
label := dnsLabel(brief.Name)
if label == "" {
return nil
}
var bases []string
nodeID := brief.NodeID
spread := brief.Spread
switch {
case nodeID.Valid && nodeID.String != "":
if a, err := q.GetAgent(ctx, nodeID.String); err == nil && a.DefaultDomain != "" {
bases = []string{a.DefaultDomain}
}
case spread:
var err error
bases, err = q.ListSchedulableAgentDomains(ctx, json.RawMessage(tagsJSON(servicePlacementTags(db, serviceID))))
if err != nil {
bases = nil
}
}
out := make([]string, 0, len(bases))
for _, base := range bases {
out = append(out, label+"."+base)
}
return out return out
} }
// syncDefaultDomain writes the is_default row back to services.domain so // syncDefaultDomain writes the is_default row back to services.domain so
// legacy reads (CLI tables, public_url) keep working. // legacy reads (CLI tables, public_url) keep working.
func syncDefaultDomain(db *database.DB, serviceID uuid.UUID) { func syncDefaultDomain(db *database.DB, serviceID uuid.UUID) {
var domain string ctx := context.Background()
err := db.QueryRow( q := sqlcdb.New(db.DB)
`SELECT domain FROM service_domains WHERE service_id = $1 AND is_default`, serviceID).Scan(&domain) domain, err := q.GetServiceDomainDefault(ctx, serviceID)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
err = db.QueryRow( domain, err = q.GetServiceDomainFirst(ctx, serviceID)
`SELECT domain FROM service_domains WHERE service_id = $1 ORDER BY created_at ASC LIMIT 1`, serviceID).Scan(&domain)
} }
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
domain = "" domain = ""
} }
_, _ = db.Exec(`UPDATE services SET domain = $1 WHERE id = $2`, domain, serviceID) _ = q.SetServiceLegacyDomain(ctx, sqlcdb.SetServiceLegacyDomainParams{Domain: domain, ID: serviceID})
} }
// serviceAccess loads the maintenance/basic-auth columns for the runtime spec. // serviceAccess loads the maintenance/basic-auth columns for the runtime spec.
func serviceAccess(db *database.DB, serviceID uuid.UUID) (maintenance bool, basicAuth string) { 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). row, err := sqlcdb.New(db.DB).GetServiceAccess(context.Background(), serviceID)
Scan(&maintenance, &basicAuth) if err != nil {
return maintenance, basicAuth return false, ""
}
return row.MaintenanceMode, row.BasicAuthUsers
} }
// maintenanceURL is the redirect target for maintenance mode — the public // maintenanceURL is the redirect target for maintenance mode — the public
@@ -136,11 +210,12 @@ func basicAuthUsernames(encoded string) []string {
// serviceWriteAccess mirrors the service update handler's access check. // serviceWriteAccess mirrors the service update handler's access check.
func serviceWriteAccess(c *gin.Context, db *database.DB, serviceID uuid.UUID) bool { func serviceWriteAccess(c *gin.Context, db *database.DB, serviceID uuid.UUID) bool {
userID, _ := c.Get("user_id") userID, _ := c.Get("user_id")
var ok bool uid, _ := userID.(uuid.UUID)
err := db.QueryRow( ok, err := sqlcdb.New(db.DB).ServiceWriteAccess(context.Background(), sqlcdb.ServiceWriteAccessParams{
`SELECT EXISTS(SELECT 1 FROM services s JOIN projects p ON s.project_id = p.id ID: serviceID,
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool))`, OwnerID: uid,
serviceID, userID, contextIsAdmin(c)).Scan(&ok) Column3: contextIsAdmin(c),
})
return err == nil && ok return err == nil && ok
} }
@@ -187,9 +262,11 @@ func handleAddServiceDomain(c *gin.Context) {
return return
} }
ctx := c.Request.Context()
q := sqlcdb.New(db.(*database.DB).DB)
// First domain on a service becomes the default automatically. // First domain on a service becomes the default automatically.
var count int count, _ := q.CountServiceDomains(ctx, serviceID)
_ = db.(*database.DB).QueryRow(`SELECT COUNT(*) FROM service_domains WHERE service_id = $1`, serviceID).Scan(&count)
makeDefault := req.IsDefault || count == 0 makeDefault := req.IsDefault || count == 0
tx, err := db.(*database.DB).Begin() tx, err := db.(*database.DB).Begin()
@@ -198,19 +275,18 @@ func handleAddServiceDomain(c *gin.Context) {
return return
} }
defer tx.Rollback() defer tx.Rollback()
qtx := q.WithTx(tx)
if makeDefault { if makeDefault {
if _, err := tx.Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID); err != nil { if err := qtx.ClearServiceDomainDefault(ctx, serviceID); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain") respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return return
} }
} }
var inserted ServiceDomain row, err := qtx.CreateServiceDomain(ctx, sqlcdb.CreateServiceDomainParams{
err = tx.QueryRow( ServiceID: serviceID,
`INSERT INTO service_domains (service_id, domain, is_default) Domain: domain,
VALUES ($1, $2, $3) IsDefault: makeDefault,
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 err != nil {
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") { if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
respondError(c, http.StatusConflict, "CONFLICT", "Domain already attached to this service") respondError(c, http.StatusConflict, "CONFLICT", "Domain already attached to this service")
@@ -224,7 +300,15 @@ func handleAddServiceDomain(c *gin.Context) {
return return
} }
syncDefaultDomain(db.(*database.DB), serviceID) syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusCreated, gin.H{"domain": inserted}) c.JSON(http.StatusCreated, gin.H{"domain": ServiceDomain{
ID: row.ID,
ServiceID: row.ServiceID,
Domain: row.Domain,
IsDefault: row.IsDefault,
CertType: row.CertType,
CertStatus: row.CertStatus,
CreatedAt: row.CreatedAt,
}})
} }
func handleDeleteServiceDomain(c *gin.Context) { func handleDeleteServiceDomain(c *gin.Context) {
@@ -247,9 +331,8 @@ func handleDeleteServiceDomain(c *gin.Context) {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID") respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
return return
} }
res, err := db.(*database.DB).Exec( affected, err := sqlcdb.New(db.(*database.DB).DB).DeleteServiceDomain(
`DELETE FROM service_domains WHERE id = $1 AND service_id = $2`, domainID, serviceID) c.Request.Context(), sqlcdb.DeleteServiceDomainParams{ID: domainID, ServiceID: serviceID})
affected, _ := res.RowsAffected()
if err != nil || affected == 0 { if err != nil || affected == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found") respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
return return
@@ -278,14 +361,12 @@ func handleSetDefaultDomain(c *gin.Context) {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID") respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
return return
} }
res, err := db.(*database.DB).Exec( affected, err := sqlcdb.New(db.(*database.DB).DB).SetServiceDomainDefault(
`UPDATE service_domains SET is_default = (id = $1) WHERE service_id = $2`, c.Request.Context(), sqlcdb.SetServiceDomainDefaultParams{ID: domainID, ServiceID: serviceID})
domainID, serviceID)
if err != nil { if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to set default domain") respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to set default domain")
return return
} }
affected, _ := res.RowsAffected()
if affected == 0 { if affected == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found") respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
return return
@@ -353,7 +434,7 @@ func handleCheckServiceDomains(c *gin.Context) {
r.Status = "ok" r.Status = "ok"
} }
results = append(results, r) results = append(results, r)
_, _ = dbh.Exec(`UPDATE service_domains SET last_checked_at = NOW() WHERE id = $1`, d.ID) _ = sqlcdb.New(dbh.DB).TouchServiceDomainChecked(c.Request.Context(), d.ID)
} }
c.JSON(http.StatusOK, gin.H{"domains": results}) c.JSON(http.StatusOK, gin.H{"domains": results})
} }
+145
View File
@@ -0,0 +1,145 @@
package api
import (
"context"
"os"
"strings"
"testing"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/google/uuid"
)
func testDSNString(t *testing.T) string {
t.Helper()
dsn := os.Getenv("METRICS_TEST_DSN")
if dsn == "" {
dsn = os.Getenv("DATABASE_URL")
}
if dsn == "" {
t.Skip("METRICS_TEST_DSN not set")
}
return dsn
}
func TestDNSLabel(t *testing.T) {
cases := map[string]string{
"my-app": "my-app",
"My App_2": "my-app-2",
" web ": "web",
"__weird__name": "weird-name",
"---": "",
"": "",
}
for in, want := range cases {
if got := dnsLabel(in); got != want {
t.Errorf("dnsLabel(%q) = %q, want %q", in, got, want)
}
}
}
func TestDNSLabelLengthCap(t *testing.T) {
long := "a" + string(make([]byte, 0)) + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
if got := dnsLabel(long); len(got) > 63 {
t.Fatalf("label over 63 chars: %d", len(got))
}
}
// Live invariant test against real Postgres — set METRICS_TEST_DSN (or
// DATABASE_URL). Verifies the generated domain queries preserve the
// default-domain and legacy services.domain invariants.
func TestServiceDomainsLiveInvariants(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
serviceID := uuid.New()
suffix := strings.ReplaceAll(serviceID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
userID, "domtest-"+suffix+"@test.local")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
projectID, "domtest-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type)
VALUES ($1, 'domtest-`+suffix+`', $2, $3, 'web', 'image')`,
serviceID, projectID, envID)
must(err)
defer func() {
_, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID)
}()
d1, err := q.CreateServiceDomain(ctx, sqlcdb.CreateServiceDomainParams{
ServiceID: serviceID, Domain: "a-" + suffix + ".example.com", IsDefault: true})
if err != nil {
t.Fatalf("create d1: %v", err)
}
d2, err := q.CreateServiceDomain(ctx, sqlcdb.CreateServiceDomainParams{
ServiceID: serviceID, Domain: "b-" + suffix + ".example.com", IsDefault: false})
if err != nil {
t.Fatalf("create d2: %v", err)
}
syncDefaultDomain(db, serviceID)
var legacy string
if err := db.QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&legacy); err != nil {
t.Fatalf("read legacy: %v", err)
}
if legacy != d1.Domain {
t.Fatalf("legacy domain = %q, want %q", legacy, d1.Domain)
}
// Switch default: exactly one row stays default, legacy follows.
if err := q.ClearServiceDomainDefault(ctx, serviceID); err != nil {
t.Fatalf("clear: %v", err)
}
if _, err := q.SetServiceDomainDefault(ctx, sqlcdb.SetServiceDomainDefaultParams{
ID: d2.ID, ServiceID: serviceID}); err != nil {
t.Fatalf("set default: %v", err)
}
syncDefaultDomain(db, serviceID)
if err := db.QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&legacy); err != nil {
t.Fatalf("read legacy2: %v", err)
}
if legacy != d2.Domain {
t.Fatalf("legacy after switch = %q, want %q", legacy, d2.Domain)
}
doms := loadServiceDomains(db, serviceID)
if len(doms) != 2 || !doms[0].IsDefault || doms[0].Domain != d2.Domain {
t.Fatalf("list order wrong: %+v", doms)
}
// Deleting the default falls back to the remaining domain.
if _, err := q.DeleteServiceDomain(ctx, sqlcdb.DeleteServiceDomainParams{
ID: d2.ID, ServiceID: serviceID}); err != nil {
t.Fatalf("delete d2: %v", err)
}
syncDefaultDomain(db, serviceID)
if err := db.QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&legacy); err != nil {
t.Fatalf("read legacy3: %v", err)
}
if legacy != d1.Domain {
t.Fatalf("legacy after delete = %q, want %q", legacy, d1.Domain)
}
}
+140
View File
@@ -0,0 +1,140 @@
package api
// Project environments — the named lanes services deploy into
// (production/development/preview are seeded; custom names allowed).
import (
"context"
"net/http"
"regexp"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
var envNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$`)
type environmentDTO struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
ProjectID uuid.UUID `json:"project_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ServiceCount int64 `json:"service_count"`
}
func toEnvironmentDTO(id uuid.UUID, name string, projectID uuid.UUID, created, updated time.Time, count int64) environmentDTO {
return environmentDTO{ID: id, Name: name, ProjectID: projectID, CreatedAt: created, UpdatedAt: updated, ServiceCount: count}
}
// handleListEnvironments returns the project's environments with live
// service counts — the data behind the env switcher.
func handleListEnvironments(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid project ID")
return
}
if exists, allowed := projectReadAccess(c, db, projectID); !exists || !allowed {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Project not found")
return
}
rows, err := sqlcdb.New(db.DB).ListProjectEnvironments(context.Background(), projectID)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list environments")
return
}
envs := make([]environmentDTO, 0, len(rows))
for _, r := range rows {
envs = append(envs, toEnvironmentDTO(r.ID, r.Name, r.ProjectID, r.CreatedAt.Time, r.UpdatedAt.Time, r.ServiceCount))
}
c.JSON(http.StatusOK, gin.H{"environments": envs})
}
// handleCreateEnvironment adds a named environment lane to the project.
func handleCreateEnvironment(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid project ID")
return
}
userID, exists := c.Get("user_id")
if !exists || !ownsProject(db, projectID, userID.(string), contextIsAdmin(c)) {
respondError(c, http.StatusForbidden, "FORBIDDEN", "Access denied")
return
}
var req struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "name is required")
return
}
name := strings.ToLower(strings.TrimSpace(req.Name))
if !envNamePattern.MatchString(name) {
respondError(c, http.StatusBadRequest, "VALIDATION", "Name must be lowercase alphanumeric with dashes, 1-50 chars")
return
}
q := sqlcdb.New(db.DB)
if _, err := q.GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
ProjectID: projectID, Name: name,
}); err == nil {
respondError(c, http.StatusConflict, "CONFLICT", "Environment already exists")
return
}
if err := q.InsertProjectEnvironment(context.Background(), sqlcdb.InsertProjectEnvironmentParams{
Name: name, ProjectID: projectID,
}); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create environment")
return
}
env, _ := q.GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
ProjectID: projectID, Name: name,
})
LogAuditWithRequest(c, "project", projectID.String(), "environment.create", map[string]interface{}{"name": name})
c.JSON(http.StatusCreated, gin.H{"environment": toEnvironmentDTO(env.ID, env.Name, env.ProjectID, env.CreatedAt.Time, env.UpdatedAt.Time, 0)})
}
// handleDeleteEnvironment removes an environment that holds no services.
func handleDeleteEnvironment(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
envID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid environment ID")
return
}
userID, exists := c.Get("user_id")
if !exists {
respondError(c, http.StatusUnauthorized, "UNAUTHORIZED", "User not authenticated")
return
}
var projectID uuid.UUID
if err := db.QueryRow(`SELECT project_id FROM environments WHERE id = $1`, envID).Scan(&projectID); err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Environment not found")
return
}
if !ownsProject(db, projectID, userID.(string), contextIsAdmin(c)) {
respondError(c, http.StatusForbidden, "FORBIDDEN", "Access denied")
return
}
q := sqlcdb.New(db.DB)
if n, err := q.CountEnvironmentServices(context.Background(), envID); err == nil && n > 0 {
respondError(c, http.StatusConflict, "CONFLICT", "Environment still contains services — move them first")
return
}
if err := q.DeleteProjectEnvironment(context.Background(), sqlcdb.DeleteProjectEnvironmentParams{
ID: envID, ProjectID: projectID,
}); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete environment")
return
}
LogAuditWithRequest(c, "project", projectID.String(), "environment.delete", map[string]interface{}{"environment_id": envID.String()})
c.JSON(http.StatusOK, gin.H{"message": "Environment deleted"})
}
+311
View File
@@ -0,0 +1,311 @@
package api
// Data plane for the public API gateway: /g/:slug/* proxies to the
// upstream_url of the matching api_services row. Callers authenticate with
// api_keys (X-API-Key or Authorization: Bearer), pass the rpm + monthly
// quota gates, and every request lands in usage_counters /
// metrics_timeseries / incident_events for the /admin/gateway analytics
// surfaces.
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"containr/internal/database"
"containr/internal/gateway"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
// jarvis: ceiling — a single shared transport; per-request timeouts come
// from api_services.request_timeout_ms on the request context.
var gatewayHTTPClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
// gatewayRPM is a process-local per-minute counter map. Containr runs a
// single API process, so an in-memory bucket is sufficient — a multi-node
// control plane would need a Redis-backed limiter instead.
var gatewayRPM = struct {
sync.Mutex
buckets map[string]*rpmBucket
}{buckets: map[string]*rpmBucket{}}
type rpmBucket struct {
window time.Time
count int
}
// gatewayRateAllow returns false when the bucket for the current minute has
// already reached limit. A limit <= 0 disables the check. `now` is injected
// so tests can freeze the window.
func gatewayRateAllow(bucketKey string, limit int, now time.Time) bool {
if limit <= 0 {
return true
}
window := now.Truncate(time.Minute)
gatewayRPM.Lock()
defer gatewayRPM.Unlock()
b := gatewayRPM.buckets[bucketKey]
if b == nil || !b.window.Equal(window) {
b = &rpmBucket{window: window}
gatewayRPM.buckets[bucketKey] = b
// Best-effort sweep so the map cannot grow without bound.
for k, old := range gatewayRPM.buckets {
if old.window.Before(window.Add(-time.Minute)) {
delete(gatewayRPM.buckets, k)
}
}
}
b.count++
return b.count <= limit
}
type gatewayService struct {
ID string
UpstreamURL string
RoutePrefix string
UpstreamAuthHeader sql.NullString
UpstreamAuthValue sql.NullString
Enabled bool
RPMLimit sql.NullInt64
MonthlyQuota sql.NullInt64
RequestTimeoutMS int
}
type gatewayKey struct {
ID string
Hash string
Enabled bool
RPMLimit sql.NullInt64
MonthlyQuota sql.NullInt64
AllowedServiceIDs string
}
// extractGatewayKey reads the caller's API key from X-API-Key first, then
// Authorization: Bearer.
func extractGatewayKey(c *gin.Context) string {
if k := strings.TrimSpace(c.GetHeader("X-API-Key")); k != "" {
return k
}
auth := strings.TrimSpace(c.GetHeader("Authorization"))
if len(auth) > 7 && strings.EqualFold(auth[:7], "Bearer ") {
return strings.TrimSpace(auth[7:])
}
return ""
}
// lookupGatewayService resolves the slug to an enabled service row.
func lookupGatewayService(ctx context.Context, db *database.DB, slug string) (*gatewayService, error) {
var s gatewayService
var enabled int
err := db.QueryRowContext(ctx, `
SELECT id, upstream_url, route_prefix, upstream_auth_header,
upstream_auth_value, enabled, rpm_limit, monthly_quota,
request_timeout_ms
FROM api_services WHERE slug = $1
`, slug).Scan(&s.ID, &s.UpstreamURL, &s.RoutePrefix,
&s.UpstreamAuthHeader, &s.UpstreamAuthValue, &enabled,
&s.RPMLimit, &s.MonthlyQuota, &s.RequestTimeoutMS)
if err != nil {
return nil, err
}
s.Enabled = enabled != 0
return &s, nil
}
// authenticateGatewayKey resolves a presented key by its indexed prefix and
// bcrypt-verifies candidates. Returns nil when nothing matches.
func authenticateGatewayKey(ctx context.Context, db *database.DB, presented string) (*gatewayKey, error) {
prefix := presented
if len(prefix) > 8 {
prefix = prefix[:8]
}
rows, err := db.QueryContext(ctx, `
SELECT id, key_hash, enabled, rpm_limit, monthly_quota, allowed_service_ids
FROM api_keys WHERE key_prefix = $1
`, prefix)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var k gatewayKey
var enabled int
if err := rows.Scan(&k.ID, &k.Hash, &enabled, &k.RPMLimit, &k.MonthlyQuota, &k.AllowedServiceIDs); err != nil {
return nil, err
}
k.Enabled = enabled != 0
if bcrypt.CompareHashAndPassword([]byte(k.Hash), []byte(presented)) == nil {
return &k, nil
}
}
return nil, rows.Err()
}
// keyAllowsService applies the allowed_service_ids JSON allowlist. An empty
// list means the key may call every service.
func keyAllowsService(k *gatewayKey, serviceID string) bool {
var allowed []string
if err := json.Unmarshal([]byte(k.AllowedServiceIDs), &allowed); err != nil || len(allowed) == 0 {
return true
}
for _, id := range allowed {
if id == serviceID {
return true
}
}
return false
}
// monthlyUsage sums a counter dimension for the current calendar month.
func monthlyUsage(ctx context.Context, db *database.DB, column, id, period string) (int, error) {
var total int
err := db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(request_count), 0) FROM usage_counters
WHERE `+column+` = $1 AND period_month = $2
`, id, period).Scan(&total)
return total, err
}
// recordGatewayRequest persists usage, metrics, and failure incidents for a
// proxied call. Runs in the request path but is three small writes.
func recordGatewayRequest(ctx context.Context, db *database.DB, svc *gatewayService, k *gatewayKey, status int, period string) {
_, _ = db.ExecContext(ctx, `
INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at)
VALUES ($1, $2, $3, 1, NOW())
ON CONFLICT (api_key_id, service_id, period_month)
DO UPDATE SET request_count = usage_counters.request_count + 1, updated_at = NOW()
`, k.ID, svc.ID, period)
_, _ = db.ExecContext(ctx, `
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1
`, k.ID)
labels, _ := json.Marshal(map[string]interface{}{
"service_id": svc.ID,
"key_id": k.ID,
"status": status,
})
_, _ = db.ExecContext(ctx, `
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
VALUES ('gateway_request', 1, $1, NOW())
`, string(labels))
}
func recordGatewayIncident(ctx context.Context, db *database.DB, svcID string, keyID *string, code, msg, severity string, status int) {
var kid interface{}
if keyID != nil {
kid = *keyID
}
_, _ = db.ExecContext(ctx, `
INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`, svcID, kid, code, msg, severity, status)
}
// handleGatewayProxy is mounted at /g/:slug and /g/:slug/*path on the root
// router — outside /api/v1 so route_prefix stays clean.
func handleGatewayProxy(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
ctx := c.Request.Context()
slug := c.Param("slug")
svc, err := lookupGatewayService(ctx, db, slug)
if err == sql.ErrNoRows {
respondError(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Gateway service not found")
return
}
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to resolve gateway service")
return
}
if !svc.Enabled {
respondError(c, http.StatusServiceUnavailable, "SERVICE_DISABLED", "Gateway service is disabled")
return
}
presented := extractGatewayKey(c)
if presented == "" {
recordGatewayIncident(ctx, db, svc.ID, nil, "MISSING_API_KEY", "Request without API key", "low", http.StatusUnauthorized)
respondError(c, http.StatusUnauthorized, "API_KEY_REQUIRED", "An API key is required for this service")
return
}
key, err := authenticateGatewayKey(ctx, db, presented)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to authenticate API key")
return
}
// Disabled and unknown keys collapse to the same 401 — a disabled key
// must not leak its existence.
if key == nil || !key.Enabled {
recordGatewayIncident(ctx, db, svc.ID, nil, "INVALID_API_KEY", "Request with invalid or disabled API key", "low", http.StatusUnauthorized)
respondError(c, http.StatusUnauthorized, "INVALID_API_KEY", "Invalid API key")
return
}
if !keyAllowsService(key, svc.ID) {
recordGatewayIncident(ctx, db, svc.ID, &key.ID, "KEY_SCOPE_DENIED", "API key not allowed on this service", "medium", http.StatusForbidden)
respondError(c, http.StatusForbidden, "KEY_SCOPE_DENIED", "API key is not permitted for this service")
return
}
now := time.Now()
if !gatewayRateAllow("key:"+key.ID, int(key.RPMLimit.Int64), now) ||
!gatewayRateAllow("svc:"+svc.ID, int(svc.RPMLimit.Int64), now) {
recordGatewayIncident(ctx, db, svc.ID, &key.ID, "RATE_LIMITED", "Per-minute rate limit exceeded", "medium", http.StatusTooManyRequests)
respondError(c, http.StatusTooManyRequests, "RATE_LIMITED", "Rate limit exceeded")
return
}
period := now.Format("2006-01")
if key.MonthlyQuota.Valid && key.MonthlyQuota.Int64 > 0 {
if used, err := monthlyUsage(ctx, db, "api_key_id", key.ID, period); err == nil && int64(used) >= key.MonthlyQuota.Int64 {
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Monthly request quota exceeded")
return
}
}
if svc.MonthlyQuota.Valid && svc.MonthlyQuota.Int64 > 0 {
if used, err := monthlyUsage(ctx, db, "service_id", svc.ID, period); err == nil && int64(used) >= svc.MonthlyQuota.Int64 {
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Service monthly quota exceeded")
return
}
}
target, err := gateway.BuildTargetURL(svc.UpstreamURL, svc.RoutePrefix, c.Request.URL.Path, c.Request.URL.RawQuery)
if err != nil {
respondError(c, http.StatusBadGateway, "UPSTREAM_URL_INVALID", "Gateway upstream is misconfigured")
return
}
headers := map[string]string{}
if svc.UpstreamAuthHeader.Valid && svc.UpstreamAuthHeader.String != "" {
headers[svc.UpstreamAuthHeader.String] = svc.UpstreamAuthValue.String
}
timeout := time.Duration(svc.RequestTimeoutMS) * time.Millisecond
if timeout <= 0 {
timeout = 8 * time.Second
}
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
status, _, err := gateway.ProxyRequest(gatewayHTTPClient, c.Writer, c.Request.WithContext(reqCtx), target, headers)
if err != nil {
recordGatewayIncident(reqCtx, db, svc.ID, &key.ID, "UPSTREAM_ERROR", err.Error(), "high", http.StatusBadGateway)
recordGatewayRequest(ctx, db, svc, key, http.StatusBadGateway, period)
respondError(c, http.StatusBadGateway, "UPSTREAM_ERROR", "Upstream request failed")
return
}
recordGatewayRequest(ctx, db, svc, key, status, period)
if status >= 500 {
recordGatewayIncident(ctx, db, svc.ID, &key.ID, "UPSTREAM_5XX", "Upstream returned "+http.StatusText(status), "high", status)
}
}
@@ -0,0 +1,81 @@
package api
import (
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestGatewayRateAllow(t *testing.T) {
now := time.Date(2026, 10, 15, 12, 0, 30, 0, time.UTC)
for i := 0; i < 3; i++ {
if !gatewayRateAllow("test:a", 3, now) {
t.Fatalf("call %d within limit rejected", i+1)
}
}
if gatewayRateAllow("test:a", 3, now) {
t.Fatal("call beyond limit allowed")
}
if !gatewayRateAllow("test:a", 3, now.Add(time.Minute)) {
t.Fatal("new minute window still rejected")
}
if !gatewayRateAllow("test:unlimited", 0, now) {
t.Fatal("non-positive limit should disable the check")
}
}
func TestExtractGatewayKey(t *testing.T) {
mkctx := func(headers map[string]string) *gin.Context {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/g/x", nil)
for k, v := range headers {
c.Request.Header.Set(k, v)
}
return c
}
if got := extractGatewayKey(mkctx(map[string]string{"X-API-Key": "ap_abc"})); got != "ap_abc" {
t.Fatalf("X-API-Key not extracted: %q", got)
}
if got := extractGatewayKey(mkctx(map[string]string{"Authorization": "Bearer ap_def"})); got != "ap_def" {
t.Fatalf("Bearer not extracted: %q", got)
}
// X-API-Key wins when both are present.
if got := extractGatewayKey(mkctx(map[string]string{
"X-API-Key": "ap_first",
"Authorization": "Bearer ap_second",
})); got != "ap_first" {
t.Fatalf("X-API-Key should take precedence: %q", got)
}
if got := extractGatewayKey(mkctx(map[string]string{})); got != "" {
t.Fatalf("expected empty key, got %q", got)
}
}
func TestKeyAllowsService(t *testing.T) {
svcID := "11111111-2222-3333-4444-555555555555"
other := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
cases := []struct {
name string
allowed string
want bool
}{
{"empty list permits all", "[]", true},
{"blank permits all", "", true},
{"invalid json permits all", "not-json", true},
{"listed service allowed", `["` + svcID + `"]`, true},
{"unlisted service denied", `["` + other + `"]`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
k := &gatewayKey{AllowedServiceIDs: tc.allowed}
if got := keyAllowsService(k, svcID); got != tc.want {
t.Fatalf("keyAllowsService(%q) = %v, want %v", tc.allowed, got, tc.want)
}
})
}
}
+84
View File
@@ -0,0 +1,84 @@
package api
import (
"fmt"
"strconv"
"strings"
"containr/internal/database"
"containr/internal/source"
)
// resolveGitCloneSpec turns a service's git_repo (clone URL, ssh remote, or
// owner/repo) into a source.Spec with the project owner's provider token.
// Public repos get no token — the clone proceeds anonymously. github_app
// providers store the installation id, not a usable token; a fresh
// installation token is minted per deploy.
func resolveGitCloneSpec(db *database.DB, ownerID, gitRepo, branch, commit, buildPath string) (source.Spec, error) {
raw := strings.TrimSpace(gitRepo)
cloneURL, err := source.NormalizeCloneURL(raw)
if err != nil {
return source.Spec{}, err
}
spec := source.Spec{Branch: branch, Commit: commit, BuildPath: buildPath}
var providerID string
if !strings.Contains(cloneURL, "://") {
// Bare owner/repo — the connected repository row knows the host.
err = db.QueryRow(
`SELECT clone_url, provider_id FROM git_repositories
WHERE full_name = $1 AND user_id = $2 LIMIT 1`,
cloneURL, ownerID,
).Scan(&cloneURL, &providerID)
if err != nil {
return source.Spec{}, fmt.Errorf("repository %s is not connected — connect it under Git settings or store a clone URL", raw)
}
} else {
// URL form — match a connected repo for its provider when possible;
// an unmatched public URL still clones, just without credentials.
_ = db.QueryRow(
`SELECT provider_id FROM git_repositories
WHERE clone_url = $1 AND user_id = $2 LIMIT 1`,
cloneURL, ownerID,
).Scan(&providerID)
}
if err := source.ValidateRemoteURL(cloneURL); err != nil {
return source.Spec{}, err
}
spec.CloneURL = cloneURL
if providerID == "" {
return spec, nil
}
var name, apiURL, token string
if err := db.QueryRow(
`SELECT name, api_url, access_token FROM git_providers
WHERE id = $1 AND user_id = $2`,
providerID, ownerID,
).Scan(&name, &apiURL, &token); err != nil {
return spec, nil // provider row gone — anonymous clone still works for public repos
}
switch strings.ToLower(name) {
case "github_app":
installationID, err := strconv.ParseInt(strings.TrimSpace(token), 10, 64)
if err == nil && installationID > 0 {
if minted, err := getGitHubAppInstallationToken(apiURL, installationID); err == nil {
spec.Token = minted
spec.User = "x-access-token"
}
}
case "gitlab":
spec.Token = token
spec.User = "oauth2"
case "bitbucket":
spec.Token = token
spec.User = "x-token-auth"
default:
spec.Token = token
spec.User = "x-access-token"
}
return spec, nil
}
+44 -1
View File
@@ -157,6 +157,45 @@ func (r *agentNodeRunner) reconcileOnAgents(ctx context.Context, spec deployment
}) })
} }
// Ship the image to every distinct target agent before replicas start.
// RemoteBuild nodes docker-build from the packaged context artifact;
// RemoteLoad nodes docker-load a saved tarball (local-tag rollbacks).
// Registry-pullable images skip this — docker run pulls on demand.
if spec.RemoteBuild != nil || spec.RemoteLoad != nil {
seen := map[string]bool{}
for i := 0; i < replicas; i++ {
agentID := targetFor(i)
if seen[agentID] {
continue
}
seen[agentID] = true
if spec.RemoteBuild != nil {
buildArgs := map[string]interface{}{}
for k, v := range spec.RemoteBuild.BuildArgs {
buildArgs[k] = v
}
_, err := r.enqueueAndWaitTimeout(ctx, agentID, "", "build_image",
map[string]interface{}{
"artifact_id": spec.RemoteBuild.ArtifactID,
"image": spec.Image,
"build_args": buildArgs,
"no_cache": spec.RemoteBuild.NoCache,
}, 25*time.Minute)
if err != nil {
return nil, fmt.Errorf("build image on node %s: %w", agentID, err)
}
} else if spec.RemoteLoad != nil {
_, err := r.enqueueAndWaitTimeout(ctx, agentID, "", "load_image",
map[string]interface{}{
"artifact_id": spec.RemoteLoad.ArtifactID,
}, 10*time.Minute)
if err != nil {
return nil, fmt.Errorf("load image on node %s: %w", agentID, err)
}
}
}
}
state := &deployment.RuntimeState{Desired: replicas, Status: "running"} state := &deployment.RuntimeState{Desired: replicas, Status: "running"}
for i := 0; i < replicas; i++ { for i := 0; i < replicas; i++ {
agentID := targetFor(i) agentID := targetFor(i)
@@ -375,6 +414,10 @@ func (r *agentNodeRunner) RetireService(ctx context.Context, serviceID string) e
// poll on their own interval (≥5s), so this blocks — it runs inside the // poll on their own interval (≥5s), so this blocks — it runs inside the
// deploy queue worker, never on a request path. // deploy queue worker, never on a request path.
func (r *agentNodeRunner) enqueueAndWait(ctx context.Context, agentID, containerID, cmdType string, payload map[string]interface{}) (string, error) { func (r *agentNodeRunner) enqueueAndWait(ctx context.Context, agentID, containerID, cmdType string, payload map[string]interface{}) (string, error) {
return r.enqueueAndWaitTimeout(ctx, agentID, containerID, cmdType, payload, 8*time.Minute)
}
func (r *agentNodeRunner) enqueueAndWaitTimeout(ctx context.Context, agentID, containerID, cmdType string, payload map[string]interface{}, timeout time.Duration) (string, error) {
var cid sql.NullString var cid sql.NullString
if containerID != "" { if containerID != "" {
cid = sql.NullString{String: containerID, Valid: true} cid = sql.NullString{String: containerID, Valid: true}
@@ -390,7 +433,7 @@ func (r *agentNodeRunner) enqueueAndWait(ctx context.Context, agentID, container
return "", err return "", err
} }
deadline := time.Now().Add(8 * time.Minute) // image pulls on a cold node deadline := time.Now().Add(timeout) // image pulls/builds on a cold node
for { for {
row, err := r.q.GetCommandForAgent(ctx, sqlcdb.GetCommandForAgentParams{ row, err := r.q.GetCommandForAgent(ctx, sqlcdb.GetCommandForAgentParams{
ID: cmd.ID, ID: cmd.ID,
@@ -0,0 +1,225 @@
package api
// Push notification channels — self-hosted ntfy and Gotify endpoints users
// attach to their account. insertUserNotification fans out best-effort.
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// jarvis: ceiling — one shared client; no retry/backoff, delivery is
// best-effort and failures are silent by design (notifications must never
// break the producing flow).
var pushHTTPClient = &http.Client{Timeout: 5 * time.Second}
type notificationChannelDTO struct {
ID string `json:"id"`
Kind string `json:"kind"`
Endpoint string `json:"endpoint"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
}
func mapChannel(row sqlcdb.NotificationChannel) notificationChannelDTO {
return notificationChannelDTO{
ID: row.ID.String(),
Kind: row.Kind,
Endpoint: row.Endpoint,
Enabled: row.Enabled,
CreatedAt: row.CreatedAt.Time.Format(time.RFC3339),
}
}
func handleListNotificationChannels(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
uid, _ := uuid.Parse(userID)
rows, err := sqlcdb.New(db.DB).ListAllNotificationChannelsByUser(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list channels")
return
}
out := make([]notificationChannelDTO, 0, len(rows))
for _, r := range rows {
out = append(out, mapChannel(r))
}
c.JSON(http.StatusOK, gin.H{"channels": out})
}
func handleCreateNotificationChannel(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
var req struct {
Kind string `json:"kind" binding:"required,oneof=ntfy gotify"`
Endpoint string `json:"endpoint" binding:"required"`
Token string `json:"token"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "kind (ntfy|gotify) and endpoint are required")
return
}
endpoint := strings.TrimRight(strings.TrimSpace(req.Endpoint), "/")
u, err := url.Parse(endpoint)
if err != nil || u.Scheme == "" || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
respondError(c, http.StatusBadRequest, "VALIDATION", "endpoint must be a valid http(s) URL")
return
}
db := c.MustGet("db").(*database.DB)
uid, _ := uuid.Parse(userID)
row, err := sqlcdb.New(db.DB).InsertNotificationChannel(c.Request.Context(), sqlcdb.InsertNotificationChannelParams{
UserID: uid,
Kind: req.Kind,
Endpoint: endpoint,
Token: sql.NullString{String: req.Token, Valid: req.Token != ""},
})
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create channel")
return
}
c.JSON(http.StatusCreated, gin.H{"channel": mapChannel(row)})
}
func handleDeleteNotificationChannel(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid channel id")
return
}
db := c.MustGet("db").(*database.DB)
uid, _ := uuid.Parse(userID)
n, err := sqlcdb.New(db.DB).DeleteNotificationChannel(c.Request.Context(), sqlcdb.DeleteNotificationChannelParams{
ID: id, UserID: uid,
})
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete channel")
return
}
if n == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Channel not found")
return
}
c.JSON(http.StatusOK, gin.H{"message": "Channel deleted"})
}
// handleTestNotificationChannel pushes a probe notification through the
// channel — surfaces misconfiguration before real alerts depend on it.
func handleTestNotificationChannel(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid channel id")
return
}
db := c.MustGet("db").(*database.DB)
uid, _ := uuid.Parse(userID)
rows, err := sqlcdb.New(db.DB).ListAllNotificationChannelsByUser(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load channel")
return
}
var target *sqlcdb.NotificationChannel
for i := range rows {
if rows[i].ID == id {
target = &rows[i]
break
}
}
if target == nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Channel not found")
return
}
if err := deliverPush(c.Request.Context(), *target, "test", "Containr test notification", "Your push channel is configured correctly."); err != nil {
respondError(c, http.StatusBadGateway, "DELIVERY_FAILED", fmt.Sprintf("Push failed: %v", err))
return
}
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
}
// deliverPush sends one notification through a channel. ntfy expects the
// body at POST {endpoint} with Title/Priority headers; Gotify takes JSON at
// POST {endpoint}/message?token=...
func deliverPush(ctx context.Context, ch sqlcdb.NotificationChannel, kind, title, body string) error {
var req *http.Request
var err error
switch ch.Kind {
case "ntfy":
req, err = http.NewRequestWithContext(ctx, http.MethodPost, ch.Endpoint, strings.NewReader(body))
if err == nil {
req.Header.Set("Title", title)
req.Header.Set("Tags", "containr,"+kind)
if ch.Token.Valid && ch.Token.String != "" {
req.Header.Set("Authorization", "Bearer "+ch.Token.String)
}
}
case "gotify":
payload, _ := json.Marshal(map[string]interface{}{
"title": title,
"message": body,
"priority": 5,
})
endpoint := ch.Endpoint + "/message"
if ch.Token.Valid && ch.Token.String != "" {
endpoint += "?token=" + url.QueryEscape(ch.Token.String)
}
req, err = http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err == nil {
req.Header.Set("Content-Type", "application/json")
}
default:
return fmt.Errorf("unsupported channel kind %q", ch.Kind)
}
if err != nil {
return err
}
res, err := pushHTTPClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return fmt.Errorf("push endpoint returned %d", res.StatusCode)
}
return nil
}
// fanoutUserPush delivers a notification to every enabled channel the user
// configured. Errors are swallowed — the in-app row already landed.
func fanoutUserPush(db *database.DB, userID, kind, title, body string) {
uid, err := uuid.Parse(userID)
if err != nil {
return
}
rows, err := sqlcdb.New(db.DB).ListNotificationChannelsByUser(context.Background(), uid)
if err != nil || len(rows) == 0 {
return
}
for _, ch := range rows {
_ = deliverPush(context.Background(), ch, kind, title, body)
}
}
@@ -0,0 +1,80 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"containr/internal/database/sqlcdb"
)
func TestDeliverPushNtfy(t *testing.T) {
var gotAuth, gotTitle, gotTags, gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotTitle = r.Header.Get("Title")
gotTags = r.Header.Get("Tags")
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ch := sqlcdb.NotificationChannel{
Kind: "ntfy",
Endpoint: srv.URL + "/alerts",
Token: sql.NullString{String: "tk_abc", Valid: true},
}
if err := deliverPush(context.Background(), ch, "deployment", "Deploy done", "svc is live"); err != nil {
t.Fatalf("deliverPush: %v", err)
}
if gotAuth != "Bearer tk_abc" {
t.Fatalf("auth header = %q", gotAuth)
}
if gotTitle != "Deploy done" || gotTags != "containr,deployment" || gotBody != "svc is live" {
t.Fatalf("title=%q tags=%q body=%q", gotTitle, gotTags, gotBody)
}
}
func TestDeliverPushGotify(t *testing.T) {
var gotPath, gotToken string
var payload map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotToken = r.URL.Query().Get("token")
_ = json.NewDecoder(r.Body).Decode(&payload)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ch := sqlcdb.NotificationChannel{
Kind: "gotify",
Endpoint: srv.URL,
Token: sql.NullString{String: "apptoken", Valid: true},
}
if err := deliverPush(context.Background(), ch, "backup", "Backup done", "db backup ok"); err != nil {
t.Fatalf("deliverPush: %v", err)
}
if gotPath != "/message" || gotToken != "apptoken" {
t.Fatalf("path=%q token=%q", gotPath, gotToken)
}
if payload["title"] != "Backup done" || payload["message"] != "db backup ok" {
t.Fatalf("payload = %v", payload)
}
}
func TestDeliverPushFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
ch := sqlcdb.NotificationChannel{Kind: "ntfy", Endpoint: srv.URL}
if err := deliverPush(context.Background(), ch, "k", "t", "b"); err == nil {
t.Fatal("expected error on 500")
}
}
@@ -41,6 +41,7 @@ func insertUserNotification(db *database.DB, userID, kind, title, body, resource
ResourceID: sql.NullString{String: resourceID, Valid: resourceID != ""}, ResourceID: sql.NullString{String: resourceID, Valid: resourceID != ""},
CreatedAt: sql.NullTime{Time: time.Now(), Valid: true}, CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
}) })
go fanoutUserPush(db, userID, kind, title, body)
} }
func mapNotification(row sqlcdb.Notification) NotificationItem { func mapNotification(row sqlcdb.Notification) NotificationItem {
+301 -96
View File
@@ -2,7 +2,12 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/deployqueue"
"context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -26,6 +31,10 @@ type PreviewEnvironment struct {
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"`
// PreviewServiceID is the cloned service this preview actually deploys;
// its status is the source of truth while the preview is active.
PreviewServiceID *uuid.UUID `json:"preview_service_id,omitempty" db:"preview_service_id"`
// Related data // Related data
Service *Service `json:"service,omitempty"` Service *Service `json:"service,omitempty"`
DeploymentID *uuid.UUID `json:"deployment_id,omitempty"` DeploymentID *uuid.UUID `json:"deployment_id,omitempty"`
@@ -78,12 +87,12 @@ func handleGetPreviewEnvironments(c *gin.Context) {
// Get preview environments for the project with service info // Get preview environments for the project with service info
rows, err := db.(*database.DB).Query( rows, err := db.(*database.DB).Query(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number, `SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at, pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id as service_id, s.name as service_name, s.type as service_type s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id LEFT JOIN services s ON pe.service_id = s.id
WHERE pe.project_id = $1 WHERE pe.project_id = $1
ORDER BY pe.created_at DESC`, ORDER BY pe.created_at DESC`,
projectID, projectID,
) )
@@ -101,7 +110,7 @@ func handleGetPreviewEnvironments(c *gin.Context) {
var serviceType sql.NullString var serviceType sql.NullString
err := rows.Scan( err := rows.Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber, &env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt, &env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
&serviceID, &serviceName, &serviceType, &serviceID, &serviceName, &serviceType,
) )
@@ -215,87 +224,111 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
ttlHours = 24 // Default 24 hours ttlHours = 24 // Default 24 hours
} }
// Create preview environment // A preview is a real service: clone the source, pin it to the
// requested branch, swap its domains for a preview hostname, and run a
// normal deployment through the queue. Expiry sweeps the clone away.
envName := generatePreviewEnvironmentName(req.BranchName)
cloneName := previewServiceName(service.Name, req.BranchName)
cloneID, err := cloneServiceRow(db.(*database.DB), req.ServiceID, req.ProjectID, cloneName, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service for preview"})
return
}
// The clone's branch override is what makes it a preview — everything
// else (env vars, volumes, builder, resources) stays identical.
if _, err := db.(*database.DB).Exec(
`UPDATE services SET git_branch = $1, domain = '', updated_at = NOW() WHERE id = $2`,
req.BranchName, cloneID,
); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure preview service"})
return
}
// Cloned service_domains would collide with the parent's Traefik
// router — previews get their own hostname or none.
if _, err := db.(*database.DB).Exec(
`DELETE FROM service_domains WHERE service_id = $1`, cloneID,
); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure preview domains"})
return
}
previewHost := resolvePreviewHost(db.(*database.DB), cloneID, envName)
if previewHost != "" {
_, _ = db.(*database.DB).Exec(
`INSERT INTO service_domains (service_id, domain, is_default, cert_type) VALUES ($1, $2, true, 'tls')`,
cloneID, previewHost,
)
}
expiresAt := time.Now().Add(time.Duration(ttlHours) * time.Hour)
env := PreviewEnvironment{ env := PreviewEnvironment{
ID: uuid.New(), ID: uuid.New(),
ProjectID: req.ProjectID, ProjectID: req.ProjectID,
ServiceID: req.ServiceID, ServiceID: req.ServiceID,
BranchName: req.BranchName, BranchName: req.BranchName,
PRNumber: req.PRNumber, PRNumber: req.PRNumber,
Environment: generatePreviewEnvironmentName(req.BranchName), Environment: envName,
Status: "building", Status: "building",
ExpiresAt: &[]time.Time{time.Now().Add(time.Duration(ttlHours) * time.Hour)}[0], ExpiresAt: &expiresAt,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
if previewHost != "" {
env.URL = "https://" + previewHost
}
// Insert preview environment into database
_, err = db.(*database.DB).Exec( _, err = db.(*database.DB).Exec(
`INSERT INTO preview_environments `INSERT INTO preview_environments
(id, project_id, service_id, branch_name, pr_number, environment, (id, project_id, service_id, preview_service_id, branch_name, pr_number, environment,
status, url, expires_at, created_at, updated_at) status, url, expires_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
env.ID, env.ProjectID, env.ServiceID, env.BranchName, env.PRNumber, env.ID, env.ProjectID, env.ServiceID, cloneID, env.BranchName, env.PRNumber,
env.Environment, env.Status, env.URL, env.ExpiresAt, env.CreatedAt, env.UpdatedAt, env.Environment, env.Status, env.URL, env.ExpiresAt, env.CreatedAt, env.UpdatedAt,
) )
if err != nil { if err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create preview environment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create preview environment"})
return return
} }
deploymentID := uuid.New() deploymentID := uuid.New()
sanitizedBranch := strings.ReplaceAll(req.BranchName, "/", "-") _, err = db.(*database.DB).Exec(
sanitizedBranch = strings.ReplaceAll(sanitizedBranch, "_", "-")
version := fmt.Sprintf("preview-%s-%d", sanitizedBranch, time.Now().Unix())
startedAt := time.Now().UTC()
completedAt := startedAt
_, deployErr := db.(*database.DB).Exec(
`INSERT INTO deployments `INSERT INTO deployments
(id, service_id, version, commit_hash, status, started_at, completed_at, deployment_log, created_at, updated_at) (id, service_id, version, commit_hash, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())`, VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW())`,
deploymentID, deploymentID, cloneID, "preview-"+env.Environment, req.BranchName,
req.ServiceID,
version,
req.BranchName,
"running",
startedAt,
completedAt,
fmt.Sprintf("Preview environment %s activated for branch %s", env.Environment, req.BranchName),
) )
if deployErr != nil { if err != nil {
_, _ = db.(*database.DB).Exec( _, _ = db.(*database.DB).Exec(`DELETE FROM preview_environments WHERE id = $1`, env.ID)
`UPDATE preview_environments _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID)
SET status = 'failed', updated_at = NOW()
WHERE id = $1`,
env.ID,
)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to provision preview deployment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to provision preview deployment"})
return return
} }
if strings.TrimSpace(env.URL) == "" {
env.URL = fmt.Sprintf("https://%s.preview.containr.local", env.Environment)
}
env.Status = "running"
env.UpdatedAt = time.Now().UTC()
env.DeploymentID = &deploymentID env.DeploymentID = &deploymentID
_, err = db.(*database.DB).Exec( if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
`UPDATE preview_environments if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
SET status = $1, url = $2, updated_at = $3 cloneService, loadErr := loadServiceForDeploy(db.(*database.DB), cloneID)
WHERE id = $4`, if loadErr == nil {
env.Status, engineInstance := engine
env.URL, d := DeploymentModel{ID: deploymentID, ServiceID: cloneID, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now()}
env.UpdatedAt, getDeployQueue(c).Enqueue(cloneID, deployqueue.Job{
env.ID, DeploymentID: deploymentID,
) Run: func(jctx context.Context) {
if err != nil { runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, cloneService, CreateDeploymentRequest{Branch: req.BranchName, Trigger: "preview"}, userID.(string))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to finalize preview environment"}) },
return })
}
}
} }
LogAuditWithRequest(c, "service", req.ServiceID.String(), "preview.create", map[string]interface{}{
"preview_environment_id": env.ID.String(),
"preview_service_id": cloneID.String(),
"branch": req.BranchName,
})
c.JSON(http.StatusCreated, gin.H{"preview_environment": env}) c.JSON(http.StatusCreated, gin.H{"preview_environment": env})
} }
@@ -321,7 +354,7 @@ func handleGetPreviewEnvironment(c *gin.Context) {
var serviceName sql.NullString var serviceName sql.NullString
var serviceType sql.NullString var serviceType sql.NullString
err = db.(*database.DB).QueryRow( err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number, `SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at, pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id as service_id, s.name as service_name, s.type as service_type s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe FROM preview_environments pe
@@ -331,7 +364,7 @@ func handleGetPreviewEnvironment(c *gin.Context) {
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`, OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
envID, userID, contextIsAdmin(c), envID, userID, contextIsAdmin(c),
).Scan( ).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber, &env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt, &env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
&serviceID, &serviceName, &serviceType, &serviceID, &serviceName, &serviceType,
) )
@@ -480,22 +513,19 @@ func handleDeletePreviewEnvironment(c *gin.Context) {
return return
} }
cleanupID := uuid.New() // Tear down the clone's runtime and row — the preview is the clone.
cleanupStartedAt := time.Now().UTC() var cloneID *uuid.UUID
cleanupCompletedAt := cleanupStartedAt _ = db.(*database.DB).QueryRow(
`SELECT preview_service_id FROM preview_environments WHERE id = $1`, envID,
_, _ = db.(*database.DB).Exec( ).Scan(&cloneID)
`INSERT INTO deployments if cloneID != nil {
(id, service_id, version, status, started_at, completed_at, deployment_log, created_at, updated_at) if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
VALUES ($1, (SELECT service_id FROM preview_environments WHERE id = $2), $3, $4, $5, $6, $7, NOW(), NOW())`, if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
cleanupID, removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, *cloneID)
envID, }
"preview-cleanup", }
"rolled_back", _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *cloneID)
cleanupStartedAt, }
cleanupCompletedAt,
"Preview environment resources cleaned up",
)
// Delete preview environment // Delete preview environment
_, err = db.(*database.DB).Exec( _, err = db.(*database.DB).Exec(
@@ -542,13 +572,13 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
// Get preview environment details // Get preview environment details
var env PreviewEnvironment var env PreviewEnvironment
err = db.(*database.DB).QueryRow( err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status `SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.environment, pe.status
FROM preview_environments pe FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`, WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
envID, userID, contextIsAdmin(c), envID, userID, contextIsAdmin(c),
).Scan( ).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status, &env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.Environment, &env.Status,
) )
if err != nil { if err != nil {
@@ -562,38 +592,53 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
return return
} }
// Promote is a real redeploy: the source service builds the preview's
// branch onto its own deployment track.
deploymentID := uuid.New() deploymentID := uuid.New()
promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix()) promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix())
_, err = db.(*database.DB).Exec( _, err = db.(*database.DB).Exec(
`INSERT INTO deployments (id, service_id, version, status, created_at, updated_at) `INSERT INTO deployments (id, service_id, version, commit_hash, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())`, VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW())`,
deploymentID, env.ServiceID, promotionVersion, "pending", deploymentID, env.ServiceID, promotionVersion, env.BranchName,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"})
return return
} }
if _, err := db.(*database.DB).Exec( sourceService, loadErr := loadServiceForDeploy(db.(*database.DB), env.ServiceID)
`UPDATE services if loadErr != nil {
SET environment = $1, updated_at = NOW() c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load promotion target service"})
WHERE id = $2`,
req.TargetEnvironment, env.ServiceID,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service target environment"})
return return
} }
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
engineInstance := engine
d := DeploymentModel{ID: deploymentID, ServiceID: env.ServiceID, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now()}
getDeployQueue(c).Enqueue(env.ServiceID, deployqueue.Job{
DeploymentID: deploymentID,
Run: func(jctx context.Context) {
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, sourceService, CreateDeploymentRequest{Branch: env.BranchName, Trigger: "promote"}, userID.(string))
},
})
}
}
previewStatus := "stopped" // Promotion retires the preview — the clone's containers and row go.
if req.CreateBackup { if env.PreviewServiceID != nil {
previewStatus = "expired" if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, *env.PreviewServiceID)
}
}
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *env.PreviewServiceID)
} }
if _, err := db.(*database.DB).Exec( if _, err := db.(*database.DB).Exec(
`UPDATE preview_environments `UPDATE preview_environments
SET status = $1, updated_at = NOW() SET status = 'stopped', updated_at = NOW()
WHERE id = $2`, WHERE id = $1`,
previewStatus, env.ID, env.ID,
); err != nil { ); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment status"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment status"})
return return
@@ -608,7 +653,7 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
"create_backup": req.CreateBackup, "create_backup": req.CreateBackup,
"deployment_id": deploymentID, "deployment_id": deploymentID,
"status": "queued", "status": "queued",
"preview_status": previewStatus, "preview_status": "stopped",
}, },
}) })
} }
@@ -639,7 +684,7 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
// Find expired preview environments for user's projects // Find expired preview environments for user's projects
rows, err := db.(*database.DB).Query( rows, err := db.(*database.DB).Query(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment `SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.environment
FROM preview_environments pe FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id JOIN projects p ON pe.project_id = p.id
WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`, WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`,
@@ -655,7 +700,7 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
for rows.Next() { for rows.Next() {
var env PreviewEnvironment var env PreviewEnvironment
err := rows.Scan( err := rows.Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.Environment,
) )
if err != nil { if err != nil {
continue continue
@@ -663,10 +708,9 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
expiredEnvs = append(expiredEnvs, env) expiredEnvs = append(expiredEnvs, env)
} }
// Mark expired environments as expired and trigger cleanup // Mark expired environments as expired and tear down their clones
cleanupCount := 0 cleanupCount := 0
for _, env := range expiredEnvs { for _, env := range expiredEnvs {
// Update status to expired
_, err := db.(*database.DB).Exec( _, err := db.(*database.DB).Exec(
"UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1", "UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1",
env.ID, env.ID,
@@ -674,7 +718,14 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
if err != nil { if err != nil {
continue continue
} }
if env.PreviewServiceID != nil {
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, *env.PreviewServiceID)
}
}
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *env.PreviewServiceID)
}
cleanupCount++ cleanupCount++
} }
@@ -684,3 +735,157 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
"expired_environments": expiredEnvs, "expired_environments": expiredEnvs,
}) })
} }
// previewServiceName builds the clone's service name — deterministic enough
// to spot in lists, short enough for dnsLabel-derived hostnames.
func previewServiceName(serviceName, branch string) string {
label := dnsLabel(serviceName + "-" + branch)
if label == "" {
return "preview-" + uuid.NewString()[:8]
}
return label + "-preview"
}
// resolvePreviewHost picks a preview hostname under the clone's node default
// domain — same source as serviceAutoDomains. Returns "" when no node
// carries a default domain; the preview still runs, reachable internally.
func resolvePreviewHost(db *database.DB, cloneID uuid.UUID, envName string) string {
label := dnsLabel(envName)
if label == "" {
return ""
}
var nodeID sql.NullString
var spread bool
if err := db.QueryRow(
`SELECT node_id, COALESCE(spread, false) FROM services WHERE id = $1`, cloneID,
).Scan(&nodeID, &spread); err != nil {
return ""
}
q := sqlcdb.New(db.DB)
ctx := context.Background()
var base string
switch {
case nodeID.Valid && nodeID.String != "":
if a, err := q.GetAgent(ctx, nodeID.String); err == nil {
base = a.DefaultDomain
}
case spread:
if bases, err := q.ListSchedulableAgentDomains(ctx, json.RawMessage(tagsJSON(servicePlacementTags(db, cloneID)))); err == nil && len(bases) > 0 {
base = bases[0]
}
default:
// Local preview: borrow the first node default domain if any —
// still better than no hostname for a Traefik-fronted local host.
if bases, err := q.ListSchedulableAgentDomains(ctx, json.RawMessage("[]")); err == nil && len(bases) > 0 {
base = bases[0]
}
}
if base == "" {
return ""
}
return label + "." + base
}
// loadServiceForDeploy hydrates the Service fields runDeploymentAndSync
// needs — mirrors the loader in handleDeployService.
func loadServiceForDeploy(db *database.DB, serviceID uuid.UUID) (Service, error) {
var service Service
err := db.QueryRow(
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at
FROM services s
WHERE s.id = $1`,
serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt,
)
return service, err
}
// StartPreviewSweeper expires preview environments past their TTL — tears
// down each clone's runtime and row, marks the preview expired.
func StartPreviewSweeper(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine) {
if db == nil || engine == nil {
return
}
go func() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
sweepExpiredPreviews(ctx, db, engine)
syncPreviewStatuses(ctx, db)
}
}
}()
}
// sweepExpiredPreviews retires previews whose TTL elapsed.
func sweepExpiredPreviews(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine) {
rows, err := db.QueryContext(ctx,
`SELECT id, preview_service_id FROM preview_environments
WHERE expires_at < NOW() AND status NOT IN ('expired', 'stopped')`)
if err != nil {
return
}
var ids []uuid.UUID
var clones []uuid.UUID
for rows.Next() {
var id uuid.UUID
var cloneID *uuid.UUID
if err := rows.Scan(&id, &cloneID); err == nil {
ids = append(ids, id)
if cloneID != nil {
clones = append(clones, *cloneID)
}
}
}
rows.Close()
for _, cloneID := range clones {
removeServiceRuntime(ctx, db, engine, cloneID)
_, _ = db.ExecContext(ctx, `DELETE FROM services WHERE id = $1`, cloneID)
}
for _, id := range ids {
_, _ = db.ExecContext(ctx,
`UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1`, id)
}
}
// syncPreviewStatuses folds the clone's live service status into the
// preview row so list/detail endpoints reflect the real deploy state.
func syncPreviewStatuses(ctx context.Context, db *database.DB) {
_, _ = db.ExecContext(ctx, `
UPDATE preview_environments pe
SET status = derived.st, updated_at = NOW()
FROM (
SELECT pe2.id,
CASE
WHEN s.status IN ('running', 'deployed') THEN 'running'
WHEN s.status IN ('failed', 'error') THEN 'failed'
WHEN s.status IN ('building', 'deploying', 'queued', 'pending', 'cloning') THEN 'building'
ELSE pe2.status
END AS st
FROM preview_environments pe2
JOIN services s ON pe2.preview_service_id = s.id
) derived
WHERE pe.id = derived.id
AND pe.status IN ('building', 'running', 'failed')
AND pe.status <> derived.st
`)
}
@@ -0,0 +1,30 @@
package api
import (
"strings"
"testing"
)
func TestPreviewServiceName(t *testing.T) {
cases := []struct {
svc, branch string
want string
}{
{"whoami-web", "feature-x", "whoami-web-feature-x-preview"},
{"My App", "feat/JARV-123_foo", "my-app-feat-jarv-123-foo-preview"},
{"svc", "", "svc-preview"},
}
for _, tc := range cases {
got := previewServiceName(tc.svc, tc.branch)
if got != tc.want {
t.Fatalf("previewServiceName(%q,%q) = %q, want %q", tc.svc, tc.branch, got, tc.want)
}
if len(got) > 63 {
t.Fatalf("name exceeds DNS label limit: %q", got)
}
}
// A name that sanitizes to nothing must still be usable.
if got := previewServiceName("!!!", "%%%"); !strings.HasPrefix(got, "preview-") {
t.Fatalf("expected fallback name, got %q", got)
}
}
+20
View File
@@ -5,6 +5,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"path/filepath"
"time" "time"
"containr/internal/build" "containr/internal/build"
@@ -43,6 +44,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
dockerClient = client dockerClient = client
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient) buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient) deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
initArtifacts(filepath.Join("/tmp/containr-builds", "artifacts"))
purgeStaleArtifacts()
// Node-pinned services dispatch container lifecycle to the agent's // Node-pinned services dispatch container lifecycle to the agent's
// command queue instead of the local Docker socket. // command queue instead of the local Docker socket.
deploymentEngine.SetNodeRunner(newAgentNodeRunner(db)) deploymentEngine.SetNodeRunner(newAgentNodeRunner(db))
@@ -108,6 +111,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Sleep sweeper — scales idle services to zero and prunes wake containers. // Sleep sweeper — scales idle services to zero and prunes wake containers.
StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port) StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port)
StartPreviewSweeper(context.Background(), db, deploymentEngine)
// Audit retention — prunes rows past AUDIT_RETENTION_DAYS (default 90). // Audit retention — prunes rows past AUDIT_RETENTION_DAYS (default 90).
StartAuditRetention(context.Background(), db) StartAuditRetention(context.Background(), db)
@@ -266,6 +270,12 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// endpoint, verified against the provisioned webhook secret. // endpoint, verified against the provisioned webhook secret.
router.POST("/api/git/github-app/webhook", handleGitHubAppWebhook) router.POST("/api/git/github-app/webhook", handleGitHubAppWebhook)
// Public API gateway data plane. Lives outside /api/v1 so each service's
// route_prefix (/g/<slug>) maps cleanly; auth is the caller's api_key,
// not a session.
router.Any("/g/:slug", handleGatewayProxy)
router.Any("/g/:slug/*path", handleGatewayProxy)
v1 := router.Group("/api/v1") v1 := router.Group("/api/v1")
{ {
// Public routes (no authentication required) // Public routes (no authentication required)
@@ -276,6 +286,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.POST("/auth/register", handleRegister) public.POST("/auth/register", handleRegister)
public.GET("/maintenance", handleMaintenancePage) public.GET("/maintenance", handleMaintenancePage)
public.GET("/branding", handleGetBranding) public.GET("/branding", handleGetBranding)
public.GET("/setup/status", handleSetupStatus)
// Invite accept flow — validates the link, then registers // Invite accept flow — validates the link, then registers
// even while public signup is closed. // even while public signup is closed.
@@ -297,6 +308,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
read.GET("/projects", handleGetProjects) read.GET("/projects", handleGetProjects)
read.GET("/projects/:id", handleGetProject) read.GET("/projects/:id", handleGetProject)
read.GET("/projects/:id/services", handleGetServices) read.GET("/projects/:id/services", handleGetServices)
read.GET("/projects/:id/environments", handleListEnvironments)
read.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments) read.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
read.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory) read.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
read.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities) read.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
@@ -344,6 +356,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
{ {
authed.GET("/user/profile", handleGetProfile) authed.GET("/user/profile", handleGetProfile)
authed.PUT("/user/profile", handleUpdateProfile) authed.PUT("/user/profile", handleUpdateProfile)
authed.POST("/setup/complete", handleSetupComplete)
// Personal access tokens for CLI/MCP/agent auth. // Personal access tokens for CLI/MCP/agent auth.
authed.GET("/user/tokens", handleListUserTokens) authed.GET("/user/tokens", handleListUserTokens)
@@ -353,6 +366,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
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)
authed.GET("/notifications/channels", handleListNotificationChannels)
authed.POST("/notifications/channels", handleCreateNotificationChannel)
authed.POST("/notifications/channels/:id/test", handleTestNotificationChannel)
authed.DELETE("/notifications/channels/:id", handleDeleteNotificationChannel)
// WebSocket endpoint // WebSocket endpoint
authed.GET("/ws", handleWebSocket) authed.GET("/ws", handleWebSocket)
@@ -466,6 +483,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.POST("/databases/:id/backups/import", databaseHandler.ImportBackup) authed.POST("/databases/:id/backups/import", databaseHandler.ImportBackup)
authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup) authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
authed.POST("/projects/:id/environments", handleCreateEnvironment)
authed.DELETE("/environments/:id", handleDeleteEnvironment)
authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment) authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
authed.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment) authed.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
authed.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment) authed.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
@@ -484,6 +503,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.POST("/templates/:id/deploy", handleCreateFromTemplate) authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
authed.POST("/templates/:id/plan", handlePlanTemplate) authed.POST("/templates/:id/plan", handlePlanTemplate)
authed.POST("/templates/import/compose", handleImportCompose) authed.POST("/templates/import/compose", handleImportCompose)
authed.POST("/templates/import/git", handleImportGitCompose)
authed.POST("/templates/deploy", handleDeployInlineGraph) authed.POST("/templates/deploy", handleDeployInlineGraph)
authed.POST("/cron-jobs", handleCreateCronJob) authed.POST("/cron-jobs", handleCreateCronJob)
+3
View File
@@ -121,6 +121,7 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
spec.Volumes = loadServiceVolumes(db, service.ID) spec.Volumes = loadServiceVolumes(db, service.ID)
spec.Domains = serviceDomainNames(db, service.ID, service.Domain) spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID) spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
spec.TraefikLabels = serviceTraefikLabels(db, service.ID)
if spec.Maintenance { if spec.Maintenance {
spec.MaintenanceURL = maintenanceURL(db) spec.MaintenanceURL = maintenanceURL(db)
} }
@@ -658,6 +659,8 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
persistPublishedPort(db, service.ID, state.Ports) persistPublishedPort(db, service.ID, state.Ports)
if service.Domain != "" { if service.Domain != "" {
service.PublicURL = "https://" + service.Domain service.PublicURL = "https://" + service.Domain
} else if auto := serviceAutoDomains(db, service.ID); len(auto) > 0 {
service.PublicURL = "https://" + auto[0]
} else if len(state.URLs) > 0 { } else if len(state.URLs) > 0 {
service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c)) service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c))
} }
+42 -24
View File
@@ -67,22 +67,45 @@ func handleCloneService(c *gin.Context) {
if req.Environment != "" { if req.Environment != "" {
environment = req.Environment environment = req.Environment
} }
if environment == "" {
environment = "production" newID, err := cloneServiceRow(db.(*database.DB), source.ID, targetProjectID, name, environment)
}
environmentID, err := getProjectEnvironmentID(db.(*database.DB), targetProjectID, environment)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
return return
} }
maintenance, basicAuth := serviceAccess(db.(*database.DB), source.ID) LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
}
// cloneServiceRow copies a service's full configuration — source, build
// settings, volumes, access gates, domains, and variables — under a new id
// in the target project. The clone starts stopped. An empty environment
// inherits the source's. Used by the clone endpoint and preview
// environments, which additionally override git_branch and domains.
func cloneServiceRow(db *database.DB, sourceID uuid.UUID, targetProjectID uuid.UUID, name, environment string) (uuid.UUID, error) {
var sourceEnv string
if err := db.QueryRow(`SELECT COALESCE(environment, '') FROM services WHERE id = $1`, sourceID).Scan(&sourceEnv); err != nil {
return uuid.Nil, err
}
if environment == "" {
environment = sourceEnv
}
if environment == "" {
environment = "production"
}
environmentID, err := getProjectEnvironmentID(db, targetProjectID, environment)
if err != nil {
return uuid.Nil, err
}
maintenance, basicAuth := serviceAccess(db, sourceID)
newID := uuid.New() newID := uuid.New()
now := time.Now() now := time.Now()
// string() so pq sends text — []byte would go as bytea and fail ::jsonb. // string() so pq sends text — []byte would go as bytea and fail ::jsonb.
volumesJSON := string(loadServiceVolumesJSON(db.(*database.DB), source.ID)) volumesJSON := string(loadServiceVolumesJSON(db, sourceID))
_, err = db.(*database.DB).Exec( _, err = db.Exec(
`INSERT INTO services `INSERT INTO services
(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,
@@ -98,35 +121,30 @@ func handleCloneService(c *gin.Context) {
$9, $9 $9, $9
FROM services WHERE id = $10`, FROM services WHERE id = $10`,
newID, targetProjectID, name, environmentID, environment, volumesJSON, newID, targetProjectID, name, environmentID, environment, volumesJSON,
maintenance, basicAuth, now, source.ID, maintenance, basicAuth, now, sourceID,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"}) return uuid.Nil, err
return
} }
if _, err := db.(*database.DB).Exec( if _, err := db.Exec(
`INSERT INTO service_domains (service_id, domain, is_default, cert_type) `INSERT INTO service_domains (service_id, domain, is_default, cert_type)
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`, SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
newID, source.ID, newID, sourceID,
); err != nil { ); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID) _, _ = db.Exec(`DELETE FROM services WHERE id = $1`, newID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service domains"}) return uuid.Nil, err
return
} }
// Variable values are copied as stored — ciphertext stays ciphertext. // Variable values are copied as stored — ciphertext stays ciphertext.
if _, err := db.(*database.DB).Exec( if _, err := db.Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret) `INSERT INTO environment_variables (id, service_id, key, value, is_secret)
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`, SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
newID, source.ID, newID, sourceID,
); err != nil { ); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID) _, _ = db.Exec(`DELETE FROM services WHERE id = $1`, newID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service variables"}) return uuid.Nil, err
return
} }
return newID, nil
LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
} }
type MoveServiceRequest struct { type MoveServiceRequest struct {
+127 -6
View File
@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"regexp"
"strings" "strings"
"time" "time"
@@ -58,9 +59,12 @@ type Service struct {
Spread bool `json:"spread" db:"-"` Spread bool `json:"spread" db:"-"`
// PlacementTags restrict auto/spread candidates to agents carrying all // PlacementTags restrict auto/spread candidates to agents carrying all
// listed tags. An explicit pin overrides them — pin + tags is rejected. // listed tags. An explicit pin overrides them — pin + tags is rejected.
PlacementTags []string `json:"placement_tags,omitempty" db:"-"` PlacementTags []string `json:"placement_tags,omitempty" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"` // TraefikLabels holds per-service middleware overrides —
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` // "middlewares.<name>.<type>.<field>" → value. Lazy-read.
TraefikLabels map[string]string `json:"traefik_labels,omitempty" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
// loadServiceSleep fills the sleep-mode columns, which live outside the // loadServiceSleep fills the sleep-mode columns, which live outside the
@@ -123,6 +127,78 @@ func normalizePlacementTags(tags []string) []string {
return out return out
} }
// traefikLabelKey matches "middlewares.<name>.<type>" with an optional
// ".<subkey>" tail — e.g. "middlewares.rl.ratelimit.average".
var traefikLabelKey = regexp.MustCompile(`^middlewares\.([A-Za-z0-9][A-Za-z0-9_-]{0,39})\.([A-Za-z]+)(\.[A-Za-z0-9_.*+-]+)?$`)
// traefikMiddlewareTypes are the Traefik middleware kinds a service may
// define. Router/service/provider-level keys are never accepted — those
// would let a tenant hijack someone else's routing.
var traefikMiddlewareTypes = map[string]bool{
"addprefix": true, "basicauth": true, "buffering": true, "chain": true,
"circuitbreaker": true, "compress": true, "digestauth": true,
"errors": true, "forwardauth": true, "grpcweb": true, "headers": true,
"inflightreq": true, "ipallowlist": true, "ipwhitelist": true,
"passtlsclientcert": true, "ratelimit": true, "redirectregex": true,
"redirectscheme": true, "replacepath": true, "replacepathregex": true,
"retry": true, "stripprefix": true, "stripprefixregex": true,
}
// normalizeTraefikLabels validates a raw override map. Returns the cleaned
// map (middleware type lowercased — Traefik label keys are canonical-lower)
// or an error naming the offending key.
func normalizeTraefikLabels(raw map[string]string) (map[string]string, error) {
if len(raw) > 32 {
return nil, fmt.Errorf("traefik_labels: at most 32 entries (got %d)", len(raw))
}
out := make(map[string]string, len(raw))
names := map[string]bool{}
for k, v := range raw {
m := traefikLabelKey.FindStringSubmatch(k)
if m == nil {
return nil, fmt.Errorf("traefik_labels: invalid key %q — want middlewares.<name>.<type>[.<field>]", k)
}
mwType := strings.ToLower(m[2])
if !traefikMiddlewareTypes[mwType] {
return nil, fmt.Errorf("traefik_labels: unsupported middleware type %q", m[2])
}
if len(v) == 0 {
return nil, fmt.Errorf("traefik_labels: %q has an empty value", k)
}
if len(v) > 512 || strings.ContainsAny(v, "\r\n") {
return nil, fmt.Errorf("traefik_labels: %q value must be a single line under 512 chars", k)
}
out["middlewares."+m[1]+"."+mwType+m[3]] = v
names[m[1]] = true
}
if len(names) > 16 {
return nil, fmt.Errorf("traefik_labels: at most 16 middleware names")
}
return out, nil
}
// serviceTraefikLabels lazy-reads the traefik_labels column.
func serviceTraefikLabels(db *database.DB, serviceID uuid.UUID) map[string]string {
var raw []byte
if err := db.QueryRow(`SELECT traefik_labels FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
return nil
}
var labels map[string]string
if err := json.Unmarshal(raw, &labels); err != nil || len(labels) == 0 {
return nil
}
return labels
}
// traefikLabelsJSON marshals an override map for the jsonb column.
func traefikLabelsJSON(labels map[string]string) []byte {
if len(labels) == 0 {
return []byte("{}")
}
raw, _ := json.Marshal(labels)
return raw
}
// tagsJSON marshals a tag list for a jsonb column; empty stays an array, not null. // tagsJSON marshals a tag list for a jsonb column; empty stays an array, not null.
func tagsJSON(tags []string) []byte { func tagsJSON(tags []string) []byte {
if len(tags) == 0 { if len(tags) == 0 {
@@ -139,6 +215,7 @@ func loadServiceNode(db *database.DB, s *Service) {
s.NodeID = serviceNodeID(db, s.ID) s.NodeID = serviceNodeID(db, s.ID)
s.Spread = serviceSpread(db, s.ID) s.Spread = serviceSpread(db, s.ID)
s.PlacementTags = servicePlacementTags(db, s.ID) s.PlacementTags = servicePlacementTags(db, s.ID)
s.TraefikLabels = serviceTraefikLabels(db, s.ID)
if s.NodeID == "" { if s.NodeID == "" {
return return
} }
@@ -224,6 +301,10 @@ type CreateServiceRequest struct {
MemoryReserve string `json:"memory_reserve"` MemoryReserve string `json:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd"` StaticBuildCmd string `json:"static_build_cmd"`
StaticDir string `json:"static_dir"` StaticDir string `json:"static_dir"`
// SleepEnabled/SleepIdleMinutes opt the service into scale-to-zero at
// create time — previously update-only, which surprised API/CLI users.
SleepEnabled *bool `json:"sleep_enabled"`
SleepIdleMinutes *int `json:"sleep_idle_minutes"`
// NodeID pins placement to a node agent; "auto" picks the least-loaded // NodeID pins placement to a node agent; "auto" picks the least-loaded
// online agent, "local"/"" runs on the Containr host. // online agent, "local"/"" runs on the Containr host.
NodeID string `json:"node_id"` NodeID string `json:"node_id"`
@@ -233,6 +314,10 @@ type CreateServiceRequest struct {
// PlacementTags restrict auto/spread candidates to agents carrying all // PlacementTags restrict auto/spread candidates to agents carrying all
// listed tags. Mutually exclusive with an explicit node pin. // listed tags. Mutually exclusive with an explicit node pin.
PlacementTags []string `json:"placement_tags"` PlacementTags []string `json:"placement_tags"`
// TraefikLabels defines extra Traefik middlewares for this service's
// router — keys "middlewares.<name>.<type>[.<field>]", validated against
// an allowlist. Defined middlewares attach to the router automatically.
TraefikLabels map[string]string `json:"traefik_labels"`
} }
// UpdateServiceRequest represents a request to update a service // UpdateServiceRequest represents a request to update a service
@@ -271,6 +356,9 @@ type UpdateServiceRequest struct {
// listed tags; pointer so [] clears the requirement. Mutually exclusive // listed tags; pointer so [] clears the requirement. Mutually exclusive
// with an explicit node pin. // with an explicit node pin.
PlacementTags *[]string `json:"placement_tags"` PlacementTags *[]string `json:"placement_tags"`
// TraefikLabels replaces the service's middleware overrides; pointer so
// {} clears them and nil leaves them untouched.
TraefikLabels *map[string]string `json:"traefik_labels"`
} }
// handleGetServices retrieves all services for a project // handleGetServices retrieves all services for a project
@@ -474,6 +562,14 @@ func handleCreateService(c *gin.Context) {
if service.Builder == "static" && service.StaticDir == "" { if service.Builder == "static" && service.StaticDir == "" {
service.StaticDir = "dist" service.StaticDir = "dist"
} }
if len(req.TraefikLabels) > 0 {
labels, err := normalizeTraefikLabels(req.TraefikLabels)
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
service.TraefikLabels = labels
}
for label, check := range map[string]func(string) error{ for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec, "cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec, "memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
@@ -511,6 +607,16 @@ func handleCreateService(c *gin.Context) {
sourceType := inferServiceSourceType(service) sourceType := inferServiceSourceType(service)
if req.SleepEnabled != nil {
service.SleepEnabled = *req.SleepEnabled
}
if req.SleepIdleMinutes != nil {
if *req.SleepIdleMinutes < 1 || *req.SleepIdleMinutes > 1440 {
c.JSON(http.StatusBadRequest, gin.H{"error": "sleep_idle_minutes must be 1-1440", "code": "VALIDATION"})
return
}
service.SleepIdleMinutes = *req.SleepIdleMinutes
}
if req.Spread != nil { if req.Spread != nil {
service.Spread = *req.Spread service.Spread = *req.Spread
} }
@@ -542,9 +648,10 @@ func handleCreateService(c *gin.Context) {
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, volumes, healthcheck_path, restart_policy, volumes,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir, builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
node_id, spread, placement_tags, created_at, updated_at) node_id, spread, placement_tags, sleep_enabled, sleep_idle_minutes,
traefik_labels, 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, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
$27, $28, $29, $30, $31, $32, $33, $34, $35, $36)`, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39)`,
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,
@@ -555,6 +662,9 @@ func handleCreateService(c *gin.Context) {
sql.NullString{String: service.NodeID, Valid: service.NodeID != ""}, sql.NullString{String: service.NodeID, Valid: service.NodeID != ""},
service.Spread, service.Spread,
tagsJSON(service.PlacementTags), tagsJSON(service.PlacementTags),
service.SleepEnabled,
sql.NullInt32{Int32: int32(service.SleepIdleMinutes), Valid: service.SleepIdleMinutes > 0},
traefikLabelsJSON(service.TraefikLabels),
service.CreatedAt, service.UpdatedAt, service.CreatedAt, service.UpdatedAt,
) )
@@ -831,6 +941,16 @@ func handleUpdateService(c *gin.Context) {
} }
existingService.SleepIdleMinutes = *req.SleepIdleMinutes existingService.SleepIdleMinutes = *req.SleepIdleMinutes
} }
if req.TraefikLabels != nil {
labels, err := normalizeTraefikLabels(*req.TraefikLabels)
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
existingService.TraefikLabels = labels
} else {
existingService.TraefikLabels = serviceTraefikLabels(db.(*database.DB), serviceID)
}
for label, check := range map[string]func(string) error{ for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec, "cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec, "memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
@@ -907,7 +1027,7 @@ func handleUpdateService(c *gin.Context) {
builder = $19, cpu_reserve = $20, memory_reserve = $21, builder = $19, cpu_reserve = $20, memory_reserve = $21,
static_build_cmd = $22, static_dir = $23, static_build_cmd = $22, static_dir = $23,
sleep_enabled = $24, sleep_idle_minutes = $25, node_id = $26, spread = $27, sleep_enabled = $24, sleep_idle_minutes = $25, node_id = $26, spread = $27,
placement_tags = $28::jsonb placement_tags = $28::jsonb, traefik_labels = $29::jsonb
WHERE id = $18`, WHERE id = $18`,
existingService.Name, existingService.Type, existingService.Image, existingService.Command, existingService.Name, existingService.Type, existingService.Image, existingService.Command,
existingService.Environment, existingService.GitRepo, existingService.GitBranch, existingService.Environment, existingService.GitRepo, existingService.GitBranch,
@@ -921,6 +1041,7 @@ func handleUpdateService(c *gin.Context) {
sql.NullString{String: existingService.NodeID, Valid: existingService.NodeID != ""}, sql.NullString{String: existingService.NodeID, Valid: existingService.NodeID != ""},
existingService.Spread, existingService.Spread,
tagsJSON(existingService.PlacementTags), tagsJSON(existingService.PlacementTags),
traefikLabelsJSON(existingService.TraefikLabels),
) )
if err != nil { if err != nil {
@@ -0,0 +1,64 @@
package api
import (
"strings"
"testing"
)
func TestNormalizeTraefikLabels(t *testing.T) {
valid := map[string]string{
"middlewares.rl.ratelimit.average": "100",
"middlewares.rl.ratelimit.period": "1m",
"middlewares.sec.headers.customrequestheaders.X-Frame-Options": "DENY",
"middlewares.gzip.compress": "true",
}
out, err := normalizeTraefikLabels(valid)
if err != nil {
t.Fatalf("valid labels rejected: %v", err)
}
if len(out) != len(valid) {
t.Fatalf("expected %d labels, got %d", len(valid), len(out))
}
if out["middlewares.gzip.compress"] != "true" {
t.Fatalf("flag-style middleware missing: %v", out)
}
}
func TestNormalizeTraefikLabelsLowercasesType(t *testing.T) {
out, err := normalizeTraefikLabels(map[string]string{
"middlewares.rl.RateLimit.average": "10",
})
if err != nil {
t.Fatalf("mixed-case type rejected: %v", err)
}
if out["middlewares.rl.ratelimit.average"] != "10" {
t.Fatalf("type not lowercased: %v", out)
}
}
func TestNormalizeTraefikLabelsRejects(t *testing.T) {
cases := map[string]string{
"traefik.enable": "true", // provider-level
"middlewares..ratelimit.average": "1", // empty name
"middlewares.x.evilinject.thing": "1", // unknown type
"routers.svc.rule": "x", // router hijack
"middlewares.x.ratelimit.average.bad\ninjection": "1", // control char in key fails regex
}
for k, v := range cases {
if _, err := normalizeTraefikLabels(map[string]string{k: v}); err == nil {
t.Fatalf("key %q should have been rejected", k)
}
}
}
func TestNormalizeTraefikLabelsRejectsBadValues(t *testing.T) {
if _, err := normalizeTraefikLabels(map[string]string{"middlewares.a.ratelimit.average": ""}); err == nil {
t.Fatal("empty value accepted")
}
if _, err := normalizeTraefikLabels(map[string]string{"middlewares.a.ratelimit.average": strings.Repeat("x", 600)}); err == nil {
t.Fatal("oversized value accepted")
}
if _, err := normalizeTraefikLabels(map[string]string{"middlewares.a.ratelimit.average": "1\nmalicious"}); err == nil {
t.Fatal("newline value accepted")
}
}
+78
View File
@@ -0,0 +1,78 @@
package api
// First-run setup wizard state. GET /setup/status is public so the web
// shell can redirect fresh installs before a session exists; the flag
// lives in app_settings so it survives restarts.
import (
"net/http"
"strings"
"containr/internal/database"
"github.com/gin-gonic/gin"
)
const settingSetupCompleted = "setup_completed"
// setupComplete resolves the flag. Installs that predate the wizard
// (users + projects already exist) auto-complete — nobody who has been
// running Containr for months should get a welcome screen.
func setupComplete(db *database.DB, hasUsers bool) bool {
if strings.EqualFold(settingValue(db, settingSetupCompleted, "", ""), "true") {
return true
}
if !hasUsers {
return false
}
var projects int
if err := db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&projects); err == nil && projects > 0 {
return true
}
return false
}
// GET /api/v1/setup/status — public: fresh installs hit it before any
// session exists.
func handleSetupStatus(c *gin.Context) {
db, ok := c.MustGet("db").(*database.DB)
if !ok {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Database connection not available")
return
}
count, err := countLocalUsers(db)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to inspect setup state")
return
}
hasUsers := count > 0
completed := setupComplete(db, hasUsers)
tunnelToken, _ := resolvedTunnelToken(db)
c.JSON(http.StatusOK, gin.H{
"has_users": hasUsers,
"setup_completed": completed,
"needs_setup": !completed,
"has_github_app": githubAppConfigured(db),
"has_tunnel": tunnelToken != "",
"signup_enabled": signupEnabled(db),
})
}
// POST /api/v1/setup/complete — marks the wizard done. Open to any
// authenticated user: the first account is the owner, and the flag only
// suppresses a banner.
func handleSetupComplete(c *gin.Context) {
if _, ok := requireAuthenticatedUserID(c); !ok {
return
}
db, ok := c.MustGet("db").(*database.DB)
if !ok {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Database connection not available")
return
}
if err := setSetting(db, settingSetupCompleted, "true", false); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to save setup state")
return
}
c.JSON(http.StatusOK, gin.H{"message": "Setup completed"})
}
+63 -23
View File
@@ -264,9 +264,20 @@ func putToSleep(ctx context.Context, db *database.DB, dc *docker.Client, engine
} }
} }
// wakeCGIScript is installed as /cgi-bin/wake in the wake container. It
// fires the wake callback on the first reachable candidate URL, in the
// background, then serves the reloading placeholder page. Deliberately
// single-quote-free — the file is written through printf '%s'.
const wakeCGIScript = `#!/bin/sh
for u in $WAKE_URLS; do wget -q -O /dev/null --timeout=20 "$u" && break; done >/dev/null 2>&1 &
printf "Content-Type: text/html\r\n\r\n"
printf "%s" "$WAKE_PAGE"
`
// spawnWakeContainer runs a busybox httpd holding the service's Traefik // spawnWakeContainer runs a busybox httpd holding the service's Traefik
// host rule at higher priority. On boot it calls the wake endpoint, then // host rule at higher priority. Inbound hits redirect to /cgi-bin/wake,
// keeps serving a reloading page until the next reconcile removes it. // which calls the internal wake endpoint and serves a reloading page
// until the reconcile removes the placeholder.
func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, svc sleepCandidate, domains []string, apiPort int) error { func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, svc sleepCandidate, domains []string, apiPort int) error {
edgeNet := "" edgeNet := ""
for _, n := range networkList(ctx, dc) { for _, n := range networkList(ctx, dc) {
@@ -296,19 +307,25 @@ func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client,
"traefik.http.services." + router + ".loadbalancer.server.port": "80", "traefik.http.services." + router + ".loadbalancer.server.port": "80",
} }
wakeURL := wakeEndpointURL(ctx, dc, apiPort, svc.ID, edgeNet) wakeURLs := wakeEndpointURLs(ctx, dc, apiPort, svc.ID, edgeNet)
page := `<!doctype html><html><head><meta http-equiv="refresh" content="4"><title>Waking up</title>` + page := `<!doctype html><html><head><meta http-equiv="refresh" content="4"><title>Waking up</title>` +
`<style>body{font-family:system-ui,sans-serif;background:#0b0d12;color:#dde2ea;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}` + `<style>body{font-family:system-ui,sans-serif;background:#0b0d12;color:#dde2ea;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}` +
`.card{text-align:center;max-width:34rem;padding:2rem}h1{font-size:1.25rem;font-weight:600}p{color:#98a2b3}</style></head>` + `.card{text-align:center;max-width:34rem;padding:2rem}h1{font-size:1.25rem;font-weight:600}p{color:#98a2b3}</style></head>` +
`<body><div class="card"><h1>Service is waking up</h1><p>This Containr service was sleeping and is resuming now. The page reloads automatically.</p></div></body></html>` `<body><div class="card"><h1>Service is waking up</h1><p>This Containr service was sleeping and is resuming now. The page reloads automatically.</p></div></body></html>`
// httpd without -f daemonizes, so the sequence is: write page, start // The wake callback must fire when a request arrives, not at boot —
// httpd, fire the wake callback, then hold PID1 on sleep infinity. // firing unconditionally makes the service flap sleep→wake forever.
// index.html redirects to /cgi-bin/wake; the CGI fires the callback
// (first reachable URL wins) and serves the reloading page.
indexPage := `<!doctype html><html><head><meta http-equiv="refresh" content="0;url=/cgi-bin/wake"></head><body></body></html>`
// httpd without -f daemonizes, so PID1 ends on sleep infinity. URLs
// and the page travel via env so the CGI script needs no quoting.
cmd := fmt.Sprintf( cmd := fmt.Sprintf(
`mkdir -p /www && printf '%%s' '%s' > /www/index.html && `+ `mkdir -p /www/cgi-bin && printf '%%s' '%s' > /www/index.html && `+
`httpd -p 80 -h /www && `+ `printf '%%s' '%s' > /www/cgi-bin/wake && chmod +x /www/cgi-bin/wake && `+
`(sleep 2; wget -q -O /dev/null --timeout=20 '%s' 2>/dev/null || true) && `+ `printf '%%s\n' 'E404:/cgi-bin/wake' > /www/httpd.conf && `+
`sleep infinity`, `httpd -p 80 -h /www && sleep infinity`,
strings.ReplaceAll(page, "'", "'\\''"), wakeURL) strings.ReplaceAll(indexPage, "'", "'\\''"),
strings.ReplaceAll(wakeCGIScript, "'", "'\\''"))
endpoints := map[string]*network.EndpointSettings{edgeNet: {}} endpoints := map[string]*network.EndpointSettings{edgeNet: {}}
if ownNet := dc.OwnNetworkName(ctx); ownNet != "" && ownNet != edgeNet { if ownNet := dc.OwnNetworkName(ctx); ownNet != "" && ownNet != edgeNet {
@@ -329,9 +346,13 @@ func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client,
} }
id, err := dc.CreateContainer(ctx, docker.ContainerConfig{ id, err := dc.CreateContainer(ctx, docker.ContainerConfig{
Name: name, Name: name,
Image: wakeImage, Image: wakeImage,
Cmd: []string{"sh", "-c", cmd}, Cmd: []string{"sh", "-c", cmd},
Env: []string{
"WAKE_URLS=" + strings.Join(wakeURLs, " "),
"WAKE_PAGE=" + page,
},
Labels: labels, Labels: labels,
Networks: endpoints, Networks: endpoints,
}) })
@@ -353,25 +374,41 @@ func networkList(ctx context.Context, dc *docker.Client) []string {
return names return names
} }
// wakeEndpointURL builds the internal URL the wake container calls. The // wakeEndpointURLs builds ordered candidate URLs for the wake container to
// edge network's gateway reaches the host's published API port; when the // call, deduplicated. No single address covers every topology: a containerized
// API itself is containerized its own name resolves on the shared network. // API resolves by name on the shared network; Docker Desktop containers reach
func wakeEndpointURL(ctx context.Context, dc *docker.Client, apiPort int, serviceID uuid.UUID, edgeNet string) string { // the host via host.docker.internal; on native Docker the edge network's
// gateway routes to host-published ports. busybox wget walks the chain until
// one answers.
func wakeEndpointURLs(ctx context.Context, dc *docker.Client, apiPort int, serviceID uuid.UUID, edgeNet string) []string {
var hosts []string
if dc.OwnNetworkName(ctx) != "" { if dc.OwnNetworkName(ctx) != "" {
if self, err := dc.GetContainer(ctx, selfID()); err == nil { if self, err := dc.GetContainer(ctx, selfID()); err == nil {
name := strings.TrimPrefix(self.Name, "/") if name := strings.TrimPrefix(self.Name, "/"); name != "" {
if name != "" { hosts = append(hosts, name)
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", name, apiPort, serviceID)
} }
} }
} }
hosts = append(hosts, "host.docker.internal")
if gw := networkGateway(ctx, dc, edgeNet); gw != "" { if gw := networkGateway(ctx, dc, edgeNet); gw != "" {
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", gw, apiPort, serviceID) hosts = append(hosts, gw)
} }
if probe := dc.HostProbeHost(ctx); probe != "" { if probe := dc.HostProbeHost(ctx); probe != "" {
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", probe, apiPort, serviceID) hosts = append(hosts, probe)
} }
return fmt.Sprintf("http://127.0.0.1:%d/api/v1/internal/wake/%s", apiPort, serviceID) seen := map[string]bool{}
var urls []string
for _, h := range hosts {
if h == "" || seen[h] {
continue
}
seen[h] = true
urls = append(urls, fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", h, apiPort, serviceID))
}
if len(urls) == 0 {
urls = append(urls, fmt.Sprintf("http://127.0.0.1:%d/api/v1/internal/wake/%s", apiPort, serviceID))
}
return urls
} }
func networkGateway(ctx context.Context, dc *docker.Client, name string) string { func networkGateway(ctx context.Context, dc *docker.Client, name string) string {
@@ -490,10 +527,13 @@ func triggerServiceWake(c *gin.Context) (int, gin.H) {
defer cancel() defer cancel()
spec, err := serviceRuntimeSpec(db, service) spec, err := serviceRuntimeSpec(db, service)
if err != nil { if err != nil {
log.Printf("wake: runtime spec for %s failed: %v", service.ID, err)
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
return return
} }
state, err := engine.ReconcileService(ctx, spec) state, err := engine.ReconcileService(ctx, spec)
if err != nil { if err != nil {
log.Printf("wake: reconcile for %s failed: %v", service.ID, err)
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID) _, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
return return
} }
+130 -1
View File
@@ -11,6 +11,8 @@ import (
"log" "log"
"math/big" "math/big"
"net/http" "net/http"
"os"
"path/filepath"
"regexp" "regexp"
"sort" "sort"
"strconv" "strconv"
@@ -22,6 +24,7 @@ import (
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue" "containr/internal/deployqueue"
"containr/internal/secrets" "containr/internal/secrets"
"containr/internal/source"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -227,7 +230,9 @@ func resolveStaticExpr(value string, userVars map[string]string, unresolved *[]s
if val, ok := userVars[key]; ok { if val, ok := userVars[key]; ok {
return val return val
} }
if sub[2] != "" { // {{VAR:-x}} supplies a default; {{VAR:-}} declares an explicitly
// empty one. Both resolve — only a bare {{VAR}} is unresolved.
if strings.Contains(m, ":-") {
return sub[2] return sub[2]
} }
if unresolved != nil { if unresolved != nil {
@@ -1112,6 +1117,130 @@ func handleImportCompose(c *gin.Context) {
}) })
} }
// handleImportGitCompose fetches a compose file out of a git repository and
// runs it through the same conversion as handleImportCompose. Works with any
// git remote — connected providers (including GitHub App installs) supply
// credentials for private repos. Nothing is persisted; the response feeds
// the template create form or a direct deploy.
func handleImportGitCompose(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
var req struct {
Repo string `json:"repo" binding:"required"`
Path string `json:"path"`
Ref string `json:"ref"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
branch, commit := "", ""
if ref := strings.TrimSpace(req.Ref); ref != "" {
if isCommitSHA(ref) {
commit = ref
} else {
branch = ref
}
}
spec, err := resolveGitCloneSpec(db, userID, req.Repo, branch, commit, "")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Minute)
defer cancel()
checkoutRoot := filepath.Join(containrWorkDir, "repos", "import-"+uuid.NewString())
defer os.RemoveAll(checkoutRoot)
repoDir, err := source.Checkout(ctx, checkoutRoot, spec)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "git fetch failed: " + err.Error(), "code": "GIT_FETCH"})
return
}
// Default search order mirrors common compose conventions.
candidates := []string{req.Path}
if strings.TrimSpace(req.Path) == "" {
candidates = []string{"compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"}
}
var raw []byte
for _, rel := range candidates {
p, rerr := resolveRepoFile(repoDir, rel)
if rerr != nil {
continue
}
raw, err = os.ReadFile(p)
if err == nil {
break
}
}
if raw == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "compose file not found in repository", "code": "NOT_FOUND"})
return
}
if len(raw) > 512*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Compose file exceeds 512 KiB"})
return
}
var file composeFileDef
if err := yaml.Unmarshal(raw, &file); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid compose YAML: " + err.Error()})
return
}
services, warnings, convErr := composeToSpecs(&file)
if convErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": convErr.Error()})
return
}
config := TemplateConfig{Version: 2, Services: services}
configJSON, _ := json.Marshal(config)
c.JSON(http.StatusOK, gin.H{
"config": json.RawMessage(configJSON),
"variables": []TemplateVariable{},
"warnings": warnings,
})
}
// resolveRepoFile maps a repo-relative file path onto the checkout and
// rejects escapes — same containment rule as ResolveBuildPath, for files.
func resolveRepoFile(checkoutDir, rel string) (string, error) {
rel = strings.TrimSpace(rel)
if rel == "" || filepath.IsAbs(rel) {
return "", fmt.Errorf("invalid repo path %q", rel)
}
clean := filepath.Clean(rel)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes the checkout: %q", rel)
}
resolved := filepath.Join(checkoutDir, clean)
info, err := os.Stat(resolved)
if err != nil {
return "", fmt.Errorf("path %q not found in checkout", rel)
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("path %q is not a file", rel)
}
return resolved, nil
}
func isCommitSHA(ref string) bool {
if len(ref) != 40 {
return false
}
for _, r := range ref {
if !('0' <= r && r <= '9') && !('a' <= r && r <= 'f') {
return false
}
}
return true
}
// composeToSpecs converts a parsed compose file into graph service specs. // composeToSpecs converts a parsed compose file into graph service specs.
// Compose service names become keys; declaration order follows YAML document // Compose service names become keys; declaration order follows YAML document
// order — yaml.v3 maps lose order, so sort keys for stability. // order — yaml.v3 maps lose order, so sort keys for stability.
@@ -1,6 +1,8 @@
package api package api
import ( import (
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -124,3 +126,42 @@ func TestComposeToSpecsWarnings(t *testing.T) {
t.Errorf("missing env warning: %v", warnings) t.Errorf("missing env warning: %v", warnings)
} }
} }
func TestResolveRepoFileContainment(t *testing.T) {
dir := t.TempDir()
inner := filepath.Join(dir, "sub")
if err := os.MkdirAll(inner, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(inner, "compose.yml"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
p, err := resolveRepoFile(dir, "sub/compose.yml")
if err != nil {
t.Fatalf("valid file rejected: %v", err)
}
if p != filepath.Join(dir, "sub", "compose.yml") {
t.Errorf("unexpected path %q", p)
}
for _, bad := range []string{"", "/etc/passwd", "../escape.yml", "..", "sub/../../out.yml"} {
if _, err := resolveRepoFile(dir, bad); err == nil {
t.Errorf("path %q should have been rejected", bad)
}
}
if _, err := resolveRepoFile(dir, "sub"); err == nil {
t.Error("directory accepted as file")
}
}
func TestIsCommitSHA(t *testing.T) {
if !isCommitSHA("0123456789abcdef0123456789abcdef01234567") {
t.Error("40-hex ref not recognised as a commit")
}
for _, bad := range []string{"main", "v1.2.3", "0123456789abcdef0123456789abcdef0123456", "0123456789abcdef0123456789abcdef0123456G"} {
if isCommitSHA(bad) {
t.Errorf("ref %q misclassified as commit SHA", bad)
}
}
}
+92
View File
@@ -1102,6 +1102,98 @@ func SeedTemplates() []ServiceTemplate {
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`, Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true, IsOfficial: true,
}, },
{
ID: "tpl-n8n",
Name: "n8n + PostgreSQL + Redis",
Description: "Workflow automation in queue mode with managed PostgreSQL and Redis",
Category: "stack",
Logo: "https://cdn.simpleicons.org/n8n",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"n8n","POSTGRES_USER":"n8n"},"volumes":[{"source":"n8n-db","target":"/var/lib/postgresql/data"}]},
{"key":"redis","type":"database","runtime":"redis"},
{"key":"n8n","type":"web","runtime":"docker.n8n.io/n8nio/n8n:latest","port":5678,"health_check":"/healthz","depends_on":["db","redis"],
"environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"5432","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","QUEUE_BULL_REDIS_HOST":"{{service.redis.host}}","QUEUE_BULL_REDIS_PASSWORD":"{{service.redis.password}}","EXECUTIONS_MODE":"queue","N8N_ENCRYPTION_KEY":"{{secret(32)}}","N8N_HOST":"{{APP_URL:-http://localhost:5678}}","N8N_EDITOR_BASE_URL":"{{APP_URL:-http://localhost:5678}}"},
"volumes":[{"source":"n8n-data","target":"/home/node/.n8n"}]},
{"key":"worker","name":"n8n-worker","type":"worker","runtime":"docker.n8n.io/n8nio/n8n:latest","start_command":"worker","depends_on":["n8n","db","redis"],
"environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"5432","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","QUEUE_BULL_REDIS_HOST":"{{service.redis.host}}","QUEUE_BULL_REDIS_PASSWORD":"{{service.redis.password}}","EXECUTIONS_MODE":"queue","N8N_ENCRYPTION_KEY":"{{secret(32)}}","QUEUE_HEALTH_CHECK_ACTIVE":"true"},
"volumes":[{"source":"n8n-data","target":"/home/node/.n8n"}]}]}`,
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-gitea",
Name: "Gitea + PostgreSQL",
Description: "Self-hosted git service with managed PostgreSQL",
Category: "stack",
Logo: "https://cdn.simpleicons.org/gitea",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"gitea","POSTGRES_USER":"gitea"},"volumes":[{"source":"gitea-db","target":"/var/lib/postgresql/data"}]},
{"key":"gitea","type":"web","runtime":"gitea/gitea:latest","port":3000,"health_check":"/api/healthz","depends_on":["db"],
"environment":{"GITEA__database__DB_TYPE":"postgres","GITEA__database__HOST":"{{service.db.host}}:{{service.db.port}}","GITEA__database__NAME":"{{service.db.database}}","GITEA__database__USER":"{{service.db.user}}","GITEA__database__PASSWD":"{{service.db.password}}","GITEA__security__INSTALL_LOCK":"true","GITEA__security__SECRET_KEY":"{{secret(32)}}","GITEA__security__INTERNAL_TOKEN":"{{secret(64)}}","GITEA__server__DOMAIN":"{{DOMAIN:-localhost}}","GITEA__server__ROOT_URL":"{{ROOT_URL:-http://localhost:3000}}","GITEA__server__SSH_PORT":"2222","USER_UID":"1000","USER_GID":"1000"},
"volumes":[{"source":"gitea-data","target":"/data"}]}]}`,
Variables: `[{"key":"DOMAIN","label":"Public domain","default":"","required":false,"secret":false},{"key":"ROOT_URL","label":"Root URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-nextcloud",
Name: "Nextcloud + PostgreSQL + Redis",
Description: "Self-hosted file sync and collaboration with managed PostgreSQL and Redis",
Category: "stack",
Logo: "https://cdn.simpleicons.org/nextcloud",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"nextcloud","POSTGRES_USER":"nextcloud"},"volumes":[{"source":"nextcloud-db","target":"/var/lib/postgresql/data"}]},
{"key":"redis","type":"database","runtime":"redis"},
{"key":"nextcloud","type":"web","runtime":"nextcloud:latest","port":80,"depends_on":["db","redis"],
"environment":{"POSTGRES_HOST":"{{service.db.host}}","POSTGRES_DB":"{{service.db.database}}","POSTGRES_USER":"{{service.db.user}}","POSTGRES_PASSWORD":"{{service.db.password}}","REDIS_HOST":"{{service.redis.host}}","REDIS_HOST_PASSWORD":"{{service.redis.password}}","NEXTCLOUD_ADMIN_USER":"admin","NEXTCLOUD_ADMIN_PASSWORD":"{{secret(24)}}","NEXTCLOUD_TRUSTED_DOMAINS":"{{DOMAIN:-localhost}}","OVERWRITEHOST":"{{DOMAIN:-}}","OVERWRITEPROTOCOL":"https"},
"volumes":[{"source":"nextcloud-data","target":"/var/www/html"}]}]}`,
Variables: `[{"key":"DOMAIN","label":"Trusted domain","default":"localhost","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-vaultwarden",
Name: "Vaultwarden + PostgreSQL",
Description: "Bitwarden-compatible password manager with managed PostgreSQL",
Category: "stack",
Logo: "https://cdn.simpleicons.org/vaultwarden",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"vaultwarden","POSTGRES_USER":"vaultwarden"},"volumes":[{"source":"vaultwarden-db","target":"/var/lib/postgresql/data"}]},
{"key":"vaultwarden","type":"web","runtime":"vaultwarden/server:latest","port":80,"health_check":"/alive","depends_on":["db"],
"environment":{"DATABASE_URL":"{{service.db.url}}","ADMIN_TOKEN":"{{secret(48)}}","DOMAIN":"{{DOMAIN:-http://localhost:80}}","SIGNUPS_ALLOWED":"false","WEBSOCKET_ENABLED":"true"},
"volumes":[{"source":"vaultwarden-data","target":"/data"}]}]}`,
Variables: `[{"key":"DOMAIN","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-umami",
Name: "Umami + PostgreSQL",
Description: "Lightweight privacy-first web analytics with managed PostgreSQL",
Category: "stack",
Logo: "https://cdn.simpleicons.org/umami",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"umami","POSTGRES_USER":"umami"},"volumes":[{"source":"umami-db","target":"/var/lib/postgresql/data"}]},
{"key":"umami","type":"web","runtime":"ghcr.io/umami-software/umami:postgresql-latest","port":3000,"depends_on":["db"],
"environment":{"DATABASE_URL":"{{service.db.url}}","APP_SECRET":"{{secret(32)}}"}}]}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-immich",
Name: "Immich + PostgreSQL + Redis",
Description: "Self-hosted photo and video backup with managed PostgreSQL and Redis",
Category: "stack",
Logo: "https://cdn.simpleicons.org/immich",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"immich","POSTGRES_USER":"immich"},"volumes":[{"source":"immich-db","target":"/var/lib/postgresql/data"}]},
{"key":"redis","type":"database","runtime":"redis"},
{"key":"ml","name":"immich-machine-learning","type":"worker","runtime":"ghcr.io/immich-app/immich-machine-learning:release",
"environment":{"DB_DATABASE_NAME":"{{service.db.database}}","DB_HOSTNAME":"{{service.db.host}}","DB_USERNAME":"{{service.db.user}}","DB_PASSWORD":"{{service.db.password}}","REDIS_HOSTNAME":"{{service.redis.host}}","REDIS_PASSWORD":"{{service.redis.password}}"},
"volumes":[{"source":"immich-model-cache","target":"/cache"}]},
{"key":"immich","name":"immich-server","type":"web","runtime":"ghcr.io/immich-app/immich-server:release","port":2283,"health_check":"/api/server/ping","depends_on":["db","redis","ml"],
"environment":{"DB_DATABASE_NAME":"{{service.db.database}}","DB_HOSTNAME":"{{service.db.host}}","DB_USERNAME":"{{service.db.user}}","DB_PASSWORD":"{{service.db.password}}","REDIS_HOSTNAME":"{{service.redis.host}}","REDIS_PASSWORD":"{{service.redis.password}}"},
"volumes":[{"source":"immich-upload","target":"/usr/src/app/upload"}]}]}`,
Variables: `[]`,
IsOfficial: true,
},
} }
return templates return templates
} }
+33 -1
View File
@@ -1,6 +1,9 @@
package api package api
import "testing" import (
"encoding/json"
"testing"
)
func TestNormalizeTemplateServiceType(t *testing.T) { func TestNormalizeTemplateServiceType(t *testing.T) {
valid := []string{"web", "worker", "database", "cron"} valid := []string{"web", "worker", "database", "cron"}
@@ -71,3 +74,32 @@ func TestResolveTemplateRuntimeImage(t *testing.T) {
t.Fatalf("expected unknown runtime passthrough, got %q", got) t.Fatalf("expected unknown runtime passthrough, got %q", got)
} }
} }
func TestSeedTemplatesResolveCleanly(t *testing.T) {
for _, tpl := range SeedTemplates() {
var config TemplateConfig
if err := json.Unmarshal([]byte(tpl.Config), &config); err != nil {
t.Fatalf("%s: invalid config JSON: %v", tpl.ID, err)
}
var variables []TemplateVariable
if err := json.Unmarshal([]byte(tpl.Variables), &variables); err != nil {
t.Fatalf("%s: invalid variables JSON: %v", tpl.ID, err)
}
if len(config.Services) == 0 {
continue // legacy single-service template
}
sorted, err := topoSortServices(config.Services)
if err != nil {
t.Fatalf("%s: topo sort: %v", tpl.ID, err)
}
if err := validateTemplateGraph(sorted); err != nil {
t.Fatalf("%s: graph validation: %v", tpl.ID, err)
}
// User vars left empty — every expression must still resolve via
// defaults, secrets, or service refs.
graph := resolveTemplateGraph(sorted, map[string]string{})
if len(graph.unresolved) > 0 {
t.Fatalf("%s: unresolved expressions: %v", tpl.ID, graph.unresolved)
}
}
}
+30
View File
@@ -31,3 +31,33 @@ func TestWebhookEventMatches(t *testing.T) {
} }
} }
} }
func TestValidWebhookURL(t *testing.T) {
cases := []struct {
name string
url string
wantErr bool
}{
{"https", "https://hooks.example.com/wh/abc", false},
{"http", "http://internal.lan:8080/notify", false},
{"whitespace trimmed", " https://example.com/hook ", false},
{"with query", "https://example.com/hook?token=x", false},
{"ftp rejected", "ftp://files.example.com/x", true},
{"no scheme", "example.com/hook", true},
{"bare scheme", "https://", true},
{"empty", "", true},
{"javascript rejected", "javascript:alert(1)", true},
{"host empty", "http:///path-only", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validWebhookURL(tc.url)
if tc.wantErr && err == nil {
t.Errorf("validWebhookURL(%q) = nil, want error", tc.url)
}
if !tc.wantErr && err != nil {
t.Errorf("validWebhookURL(%q) = %v, want nil", tc.url, err)
}
})
}
}
+5 -1
View File
@@ -539,12 +539,16 @@ func createBuildContext(sourcePath, dockerfileContent string) (io.ReadCloser, er
return nil, err return nil, err
} }
// Add source files to tar // Add source files to tar. The checkout's .git metadata is never build
// context — it bloats the tar and ships clone URLs to remote daemons.
err = filepath.Walk(sourcePath, func(file string, fi os.FileInfo, err error) error { err = filepath.Walk(sourcePath, func(file string, fi os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
if fi.IsDir() { if fi.IsDir() {
if fi.Name() == ".git" {
return filepath.SkipDir
}
return nil return nil
} }
+71
View File
@@ -3,6 +3,8 @@ package build
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"os"
"path/filepath" "path/filepath"
"time" "time"
@@ -92,6 +94,75 @@ func (bm *BuildManager) Build(ctx context.Context, req *types.BuildRequest) (*ty
} }
} }
// PackageContext produces the same build-context tar Build would hand the
// local daemon, without building it. Node agents download the tar and run
// docker build themselves — the Dockerfile lands at the tar root.
func (bm *BuildManager) PackageContext(ctx context.Context, req *types.BuildRequest) (io.ReadCloser, error) {
if req.BuildType == "" {
detectedType, err := bm.DetectBuildType(ctx, req.SourcePath)
if err != nil {
return nil, fmt.Errorf("failed to detect build type: %w", err)
}
req.BuildType = string(detectedType)
}
var dockerfileContent string
switch BuildType(req.BuildType) {
case BuildTypeDockerfile:
name, err := bm.dockerfileBuilder.DetectDockerfile(ctx, req.SourcePath)
if err != nil {
return nil, fmt.Errorf("failed to detect Dockerfile: %w", err)
}
path := filepath.Join(req.SourcePath, name)
if optimized, err := bm.dockerfileBuilder.OptimizeDockerfile(ctx, path); err == nil {
path = optimized
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read Dockerfile: %w", err)
}
dockerfileContent = string(data)
case BuildTypeRailpack:
if err := bm.railpackBuilder.DetectRailpack(ctx, req.SourcePath); err != nil {
return nil, fmt.Errorf("Railpack cannot build this project: %w", err)
}
content, err := bm.railpackBuilder.generateDockerfile(ctx, req.SourcePath, &RailpackConfig{
BuildCmd: req.BuildCommand,
StartCmd: req.StartCommand,
Env: req.Environment,
})
if err != nil {
return nil, fmt.Errorf("failed to generate Dockerfile with Railpack: %w", err)
}
dockerfileContent = content
case BuildTypeNixpacks:
plan, err := bm.nixpacksBuilder.GeneratePlan(ctx, req.SourcePath, &NixpacksConfig{
BuildCmd: req.BuildCommand,
StartCmd: req.StartCommand,
Env: req.Environment,
})
if err != nil {
return nil, fmt.Errorf("failed to generate build plan: %w", err)
}
path, err := bm.nixpacksBuilder.generateDockerfile(plan)
if err != nil {
return nil, fmt.Errorf("failed to generate Dockerfile: %w", err)
}
defer os.Remove(path)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read generated Dockerfile: %w", err)
}
dockerfileContent = string(data)
case BuildTypeStatic:
dockerfileContent = staticDockerfile
default:
return nil, fmt.Errorf("unsupported build type for remote packaging: %s", req.BuildType)
}
return createBuildContext(req.SourcePath, dockerfileContent)
}
// buildPrebuilt handles prebuilt image deployments // buildPrebuilt handles prebuilt image deployments
func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) { func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
if req.PrebuiltImage == "" { if req.PrebuiltImage == "" {
+9 -4
View File
@@ -476,17 +476,22 @@ func (n *NixpacksBuilder) Build(ctx context.Context, req *types.BuildRequest) (*
} }
// Create temporary Dockerfile // Create temporary Dockerfile
dockerfile, err := n.generateDockerfile(plan) dockerfilePath, err := n.generateDockerfile(plan)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate Dockerfile: %w", err) return nil, fmt.Errorf("failed to generate Dockerfile: %w", err)
} }
defer os.Remove(dockerfile) defer os.Remove(dockerfilePath)
// Build Docker image // Build Docker image
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag) imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
// Create build context tar // Create build context tar — createBuildContext takes the Dockerfile
buildCtx, err := createBuildContext(req.SourcePath, dockerfile) // contents, not its path.
dockerfileData, err := os.ReadFile(dockerfilePath)
if err != nil {
return nil, fmt.Errorf("failed to read generated Dockerfile: %w", err)
}
buildCtx, err := createBuildContext(req.SourcePath, string(dockerfileData))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create build context: %w", err) return nil, fmt.Errorf("failed to create build context: %w", err)
} }
@@ -0,0 +1,97 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// EnvironmentsCmd manages a project's environment lanes
// (production/development/preview are seeded; custom names allowed).
var EnvironmentsCmd = &cobra.Command{
Use: "environments",
Aliases: []string{"env", "envs"},
Short: "Manage project environments",
}
var envListCmd = &cobra.Command{
Use: "list",
Short: "List environments in a project",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
project, _ := cmd.Flags().GetString("project")
if project == "" {
return fmt.Errorf("--project is required")
}
data, err := c.Do("GET", "/projects/"+project+"/environments", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "environments")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, e := range items {
rows = append(rows, []string{
str(e, "id"), str(e, "name"), str(e, "service_count"), relTime(e, "created_at"),
})
}
printRows(data, []string{"ID", "NAME", "SERVICES", "CREATED"}, rows)
return nil
},
}
var envCreateCmd = &cobra.Command{
Use: "create <name>",
Short: "Create an environment in a project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
project, _ := cmd.Flags().GetString("project")
if project == "" {
return fmt.Errorf("--project is required")
}
data, err := c.Do("POST", "/projects/"+project+"/environments", map[string]interface{}{
"name": args[0],
})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var envDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete an empty environment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !Confirm("Delete environment " + args[0] + "?") {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
c, err := client()
if err != nil {
return err
}
_, err = c.Do("DELETE", "/environments/"+args[0], nil)
if err != nil {
return err
}
fmt.Println("Environment deleted")
return nil
},
}
func init() {
envListCmd.Flags().String("project", "", "project ID (required)")
envCreateCmd.Flags().String("project", "", "project ID (required)")
EnvironmentsCmd.AddCommand(envListCmd, envCreateCmd, envDeleteCmd)
}
+6 -2
View File
@@ -84,7 +84,7 @@ var nodesDeleteCmd = &cobra.Command{
var nodesUpdateCmd = &cobra.Command{ var nodesUpdateCmd = &cobra.Command{
Use: "update <id>", Use: "update <id>",
Short: "Update a node agent (--name, --auto-prune, --tags)", Short: "Update a node agent (--name, --auto-prune, --tags, --domain)",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := client() c, err := client()
@@ -108,8 +108,11 @@ var nodesUpdateCmd = &cobra.Command{
} }
body["tags"] = tags body["tags"] = tags
} }
if cmd.Flags().Changed("domain") {
body["default_domain"], _ = cmd.Flags().GetString("domain")
}
if len(body) == 0 { if len(body) == 0 {
return &APIError{Message: "nothing to update — pass --name, --auto-prune or --tags", ExitCode: ExitError} return &APIError{Message: "nothing to update — pass --name, --auto-prune, --tags or --domain", ExitCode: ExitError}
} }
data, err := c.Do("PUT", "/agents/"+args[0], body) data, err := c.Do("PUT", "/agents/"+args[0], body)
if err != nil { if err != nil {
@@ -304,6 +307,7 @@ func init() {
nodesUpdateCmd.Flags().String("name", "", "node display name") nodesUpdateCmd.Flags().String("name", "", "node display name")
nodesUpdateCmd.Flags().Bool("auto-prune", false, "enqueue a daily bounded docker prune") nodesUpdateCmd.Flags().Bool("auto-prune", false, "enqueue a daily bounded docker prune")
nodesUpdateCmd.Flags().String("tags", "", "comma-separated placement tags (services with placement_tags only schedule onto matching nodes)") nodesUpdateCmd.Flags().String("tags", "", "comma-separated placement tags (services with placement_tags only schedule onto matching nodes)")
nodesUpdateCmd.Flags().String("domain", "", "base domain for this node — domainless services placed here get <name>.<domain> automatically (empty clears)")
nodesPruneCmd.Flags().String("until", "", "only prune objects older than this duration (e.g. 168h)") nodesPruneCmd.Flags().String("until", "", "only prune objects older than this duration (e.g. 168h)")
nodesPruneCmd.Flags().Bool("volumes", false, "also prune volumes (destructive — asks twice)") nodesPruneCmd.Flags().Bool("volumes", false, "also prune volumes (destructive — asks twice)")
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd) nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
@@ -1,6 +1,8 @@
package commands package commands
import ( import (
"fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -60,6 +62,104 @@ var notifReadCmd = &cobra.Command{
}, },
} }
var notifChannelsCmd = &cobra.Command{
Use: "channels",
Short: "Manage push notification channels (ntfy/Gotify)",
}
var notifChannelsListCmd = &cobra.Command{
Use: "list",
Short: "List push channels",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/notifications/channels", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "channels")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, n := range items {
rows = append(rows, []string{
str(n, "id"), str(n, "kind"), str(n, "endpoint"), str(n, "enabled"), relTime(n, "created_at"),
})
}
printRows(data, []string{"ID", "KIND", "ENDPOINT", "ENABLED", "AGE"}, rows)
return nil
},
}
var notifChannelsAddCmd = &cobra.Command{
Use: "add <ntfy|gotify> <endpoint>",
Short: "Add a push channel (endpoint includes topic for ntfy)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
token, _ := cmd.Flags().GetString("token")
data, err := c.Do("POST", "/notifications/channels", map[string]interface{}{
"kind": args[0],
"endpoint": args[1],
"token": token,
})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var notifChannelsTestCmd = &cobra.Command{
Use: "test <id>",
Short: "Send a test notification through a channel",
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", "/notifications/channels/"+args[0]+"/test", map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var notifChannelsRemoveCmd = &cobra.Command{
Use: "remove <id>",
Short: "Remove a push channel",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
yes, _ := cmd.Flags().GetBool("yes")
if !yes && !Confirm(fmt.Sprintf("Remove channel %s?", args[0])) {
return nil
}
c, err := client()
if err != nil {
return err
}
data, err := c.Do("DELETE", "/notifications/channels/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() { func init() {
NotificationsCmd.AddCommand(notifListCmd, notifReadCmd) notifChannelsAddCmd.Flags().String("token", "", "Auth token (ntfy access token or Gotify app token)")
notifChannelsRemoveCmd.Flags().Bool("yes", false, "Skip confirmation")
notifChannelsCmd.AddCommand(notifChannelsListCmd, notifChannelsAddCmd, notifChannelsTestCmd, notifChannelsRemoveCmd)
NotificationsCmd.AddCommand(notifListCmd, notifReadCmd, notifChannelsCmd)
} }
+45 -1
View File
@@ -117,6 +117,14 @@ var servicesCreateCmd = &cobra.Command{
if v, _ := cmd.Flags().GetBool("spread"); v { if v, _ := cmd.Flags().GetBool("spread"); v {
body["spread"] = v body["spread"] = v
} }
if cmd.Flags().Changed("sleep") {
v, _ := cmd.Flags().GetBool("sleep")
body["sleep_enabled"] = v
}
if cmd.Flags().Changed("sleep-idle") {
v, _ := cmd.Flags().GetInt("sleep-idle")
body["sleep_idle_minutes"] = v
}
if v, _ := cmd.Flags().GetString("placement-tags"); v != "" { if v, _ := cmd.Flags().GetString("placement-tags"); v != "" {
tags := []string{} tags := []string{}
for _, t := range strings.Split(v, ",") { for _, t := range strings.Split(v, ",") {
@@ -126,6 +134,11 @@ var servicesCreateCmd = &cobra.Command{
} }
body["placement_tags"] = tags body["placement_tags"] = tags
} }
if labels, err := traefikLabelFlag(cmd); err != nil {
return err
} else if labels != nil {
body["traefik_labels"] = labels
}
data, err := c.Do("POST", "/projects/"+args[0]+"/services", body) data, err := c.Do("POST", "/projects/"+args[0]+"/services", body)
if err != nil { if err != nil {
return err return err
@@ -140,6 +153,28 @@ var servicesCreateCmd = &cobra.Command{
}, },
} }
// traefikLabelFlag parses --traefik-label key=value pairs into the API's
// map shape. Returns nil when the flag was never passed; an empty map when
// only empty values were given (clears overrides on update).
func traefikLabelFlag(cmd *cobra.Command) (map[string]string, error) {
if !cmd.Flags().Changed("traefik-label") {
return nil, nil
}
pairs, _ := cmd.Flags().GetStringArray("traefik-label")
labels := map[string]string{}
for _, p := range pairs {
if strings.TrimSpace(p) == "" {
continue
}
k, v, ok := strings.Cut(p, "=")
if !ok || strings.TrimSpace(k) == "" {
return nil, &APIError{Message: fmt.Sprintf("invalid --traefik-label %q — want key=value", p), ExitCode: ExitError}
}
labels[strings.TrimSpace(k)] = v
}
return labels, nil
}
var servicesDeleteCmd = &cobra.Command{ var servicesDeleteCmd = &cobra.Command{
Use: "delete <id>", Use: "delete <id>",
Short: "Delete a service", Short: "Delete a service",
@@ -233,6 +268,11 @@ var servicesUpdateCmd = &cobra.Command{
v, _ := cmd.Flags().GetInt("sleep-idle") v, _ := cmd.Flags().GetInt("sleep-idle")
body["sleep_idle_minutes"] = v body["sleep_idle_minutes"] = v
} }
if labels, err := traefikLabelFlag(cmd); err != nil {
return err
} else if labels != nil {
body["traefik_labels"] = labels
}
if cmd.Flags().Changed("maintenance") { if cmd.Flags().Changed("maintenance") {
v, _ := cmd.Flags().GetString("maintenance") v, _ := cmd.Flags().GetString("maintenance")
body["maintenance_mode"] = v == "on" || v == "true" body["maintenance_mode"] = v == "on" || v == "true"
@@ -520,9 +560,12 @@ func init() {
f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)") f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)")
f.String("static-cmd", "", "build command for the static builder") f.String("static-cmd", "", "build command for the static builder")
f.String("static-dir", "", "output dir for the static builder (default dist)") f.String("static-dir", "", "output dir for the static builder (default dist)")
f.String("node", "", "pin to a node agent id, or 'auto' for least-loaded (image services only)") f.String("node", "", "pin to a node agent id, or 'auto' for least-loaded")
f.Bool("spread", false, "distribute replicas across all online schedulable nodes") f.Bool("spread", false, "distribute replicas across all online schedulable nodes")
f.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
f.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)")
f.String("placement-tags", "", "comma-separated node tags required for remote placement (auto/spread only)") f.String("placement-tags", "", "comma-separated node tags required for remote placement (auto/spread only)")
f.StringArray("traefik-label", nil, "Traefik middleware override, key=value (e.g. middlewares.rl.ratelimit.average=100); repeatable")
uf := servicesUpdateCmd.Flags() uf := servicesUpdateCmd.Flags()
uf.String("name", "", "service name") uf.String("name", "", "service name")
@@ -547,6 +590,7 @@ func init() {
uf.String("static-dir", "", "output dir for the static builder (empty clears)") uf.String("static-dir", "", "output dir for the static builder (empty clears)")
uf.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)") uf.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
uf.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)") uf.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)")
uf.StringArray("traefik-label", nil, "Traefik middleware override, key=value; repeatable; empty list clears via --traefik-label ''")
uf.String("node", "", "pin to a node agent id, 'auto' for least-loaded, 'local' to clear") uf.String("node", "", "pin to a node agent id, 'auto' for least-loaded, 'local' to clear")
uf.Bool("spread", false, "spread replicas across all online schedulable nodes (--spread=false to disable)") uf.Bool("spread", false, "spread replicas across all online schedulable nodes (--spread=false to disable)")
uf.String("placement-tags", "", "comma-separated node tags required for remote placement (empty clears)") uf.String("placement-tags", "", "comma-separated node tags required for remote placement (empty clears)")
+28 -1
View File
@@ -194,6 +194,32 @@ var templatesImportComposeCmd = &cobra.Command{
}, },
} }
var templatesImportGitCmd = &cobra.Command{
Use: "import-git <repo> [path]",
Short: "Fetch a compose file from a git repo and convert it into a template graph config",
Long: "repo accepts a clone URL, ssh remote, or owner/repo shorthand resolved via connected git providers (private repos included). path defaults to compose.yaml/yml or docker-compose.yml/yaml. --ref selects a branch or commit.",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
body := map[string]string{"repo": args[0]}
if len(args) > 1 {
body["path"] = args[1]
}
if v, _ := cmd.Flags().GetString("ref"); v != "" {
body["ref"] = v
}
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/templates/import/git", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesDeployGraphCmd = &cobra.Command{ var templatesDeployGraphCmd = &cobra.Command{
Use: "deploy-graph <project-id> <config.json|->", Use: "deploy-graph <project-id> <config.json|->",
Short: "Deploy an ad-hoc service graph (v2 config with services[]) without a stored template", Short: "Deploy an ad-hoc service graph (v2 config with services[]) without a stored template",
@@ -257,5 +283,6 @@ func init() {
templatesDeployCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)") templatesDeployCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
templatesPlanCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)") templatesPlanCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
templatesDeployGraphCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)") templatesDeployGraphCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd, templatesPlanCmd, templatesImportComposeCmd, templatesDeployGraphCmd) templatesImportGitCmd.Flags().String("ref", "", "git branch or commit (default: remote HEAD)")
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd, templatesPlanCmd, templatesImportComposeCmd, templatesImportGitCmd, templatesDeployGraphCmd)
} }
+1
View File
@@ -95,6 +95,7 @@ func init() {
rootCmd.AddCommand(commands.InvitesCmd) rootCmd.AddCommand(commands.InvitesCmd)
rootCmd.AddCommand(commands.UpCmd) rootCmd.AddCommand(commands.UpCmd)
rootCmd.AddCommand(commands.MigrateCmd) rootCmd.AddCommand(commands.MigrateCmd)
rootCmd.AddCommand(commands.EnvironmentsCmd)
} }
func initConfig() { func initConfig() {
@@ -75,7 +75,7 @@ INSERT INTO node_agents (
id, name, hostname, ip_address, port, status, version, id, name, hostname, ip_address, port, status, version,
capabilities, resources, last_heartbeat, metadata capabilities, resources, last_heartbeat, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain
` `
type CreateAgentParams struct { type CreateAgentParams struct {
@@ -124,6 +124,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeA
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags, &i.Tags,
&i.DefaultDomain,
) )
return i, err return i, err
} }
@@ -258,7 +259,7 @@ func (q *Queries) DeleteServiceContainersOnAgent(ctx context.Context, arg Delete
} }
const getAgent = `-- name: GetAgent :one const getAgent = `-- name: GetAgent :one
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags FROM node_agents WHERE id = $1 SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain FROM node_agents WHERE id = $1
` `
func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) { func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
@@ -281,12 +282,13 @@ func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags, &i.Tags,
&i.DefaultDomain,
) )
return i, err return i, err
} }
const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags FROM node_agents SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain FROM node_agents
WHERE hostname = $1 AND ip_address = $2 WHERE hostname = $1 AND ip_address = $2
LIMIT 1 LIMIT 1
` `
@@ -316,6 +318,7 @@ func (q *Queries) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAnd
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags, &i.Tags,
&i.DefaultDomain,
) )
return i, err return i, err
} }
@@ -523,7 +526,7 @@ func (q *Queries) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHea
} }
const listAgents = `-- name: ListAgents :many const listAgents = `-- name: ListAgents :many
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags FROM node_agents ORDER BY created_at ASC SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain FROM node_agents ORDER BY created_at ASC
` `
func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) { func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
@@ -552,6 +555,7 @@ func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags, &i.Tags,
&i.DefaultDomain,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -695,6 +699,39 @@ func (q *Queries) ListPendingCommands(ctx context.Context, nodeAgentID string) (
return items, nil return items, nil
} }
const listSchedulableAgentDomains = `-- name: ListSchedulableAgentDomains :many
SELECT DISTINCT default_domain FROM node_agents
WHERE status = 'online' AND schedulable
AND default_domain <> ''
AND tags @> $1::jsonb
ORDER BY default_domain
`
// Base domains of every online, schedulable agent carrying the required
// placement tags — auto-domain candidates for spread services.
func (q *Queries) ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listSchedulableAgentDomains, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var default_domain string
if err := rows.Scan(&default_domain); err != nil {
return nil, err
}
items = append(items, default_domain)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSchedulableAgents = `-- name: ListSchedulableAgents :many const listSchedulableAgents = `-- name: ListSchedulableAgents :many
SELECT id, name FROM node_agents SELECT id, name FROM node_agents
WHERE status = 'online' AND schedulable WHERE status = 'online' AND schedulable
@@ -910,6 +947,20 @@ func (q *Queries) ScrubCommandPayload(ctx context.Context, arg ScrubCommandPaylo
return err return err
} }
const setAgentDefaultDomain = `-- name: SetAgentDefaultDomain :exec
UPDATE node_agents SET default_domain = $2, updated_at = NOW() WHERE id = $1
`
type SetAgentDefaultDomainParams struct {
ID string `json:"id"`
DefaultDomain string `json:"default_domain"`
}
func (q *Queries) SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error {
_, err := q.db.ExecContext(ctx, setAgentDefaultDomain, arg.ID, arg.DefaultDomain)
return err
}
const setAgentSchedulable = `-- name: SetAgentSchedulable :exec const setAgentSchedulable = `-- name: SetAgentSchedulable :exec
UPDATE node_agents SET schedulable = $2, updated_at = NOW() WHERE id = $1 UPDATE node_agents SET schedulable = $2, updated_at = NOW() WHERE id = $1
` `
@@ -954,7 +1005,7 @@ UPDATE node_agents SET
schedulable = $13, schedulable = $13,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain
` `
type UpdateAgentParams struct { type UpdateAgentParams struct {
@@ -1007,6 +1058,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags, &i.Tags,
&i.DefaultDomain,
) )
return i, err return i, err
} }
@@ -0,0 +1,242 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: domains.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
const clearServiceDomainDefault = `-- name: ClearServiceDomainDefault :exec
UPDATE service_domains SET is_default = false WHERE service_id = $1
`
func (q *Queries) ClearServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, clearServiceDomainDefault, serviceID)
return err
}
const countServiceDomains = `-- name: CountServiceDomains :one
SELECT COUNT(*) FROM service_domains WHERE service_id = $1
`
func (q *Queries) CountServiceDomains(ctx context.Context, serviceID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, countServiceDomains, serviceID)
var count int64
err := row.Scan(&count)
return count, err
}
const createServiceDomain = `-- name: CreateServiceDomain :one
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
`
type CreateServiceDomainParams struct {
ServiceID uuid.UUID `json:"service_id"`
Domain string `json:"domain"`
IsDefault bool `json:"is_default"`
}
type CreateServiceDomainRow 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"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) CreateServiceDomain(ctx context.Context, arg CreateServiceDomainParams) (CreateServiceDomainRow, error) {
row := q.db.QueryRowContext(ctx, createServiceDomain, arg.ServiceID, arg.Domain, arg.IsDefault)
var i CreateServiceDomainRow
err := row.Scan(
&i.ID,
&i.ServiceID,
&i.Domain,
&i.IsDefault,
&i.CertType,
&i.CertStatus,
&i.CreatedAt,
)
return i, err
}
const deleteServiceDomain = `-- name: DeleteServiceDomain :execrows
DELETE FROM service_domains WHERE id = $1 AND service_id = $2
`
type DeleteServiceDomainParams struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
}
func (q *Queries) DeleteServiceDomain(ctx context.Context, arg DeleteServiceDomainParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteServiceDomain, arg.ID, arg.ServiceID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getServiceAccess = `-- name: GetServiceAccess :one
SELECT maintenance_mode, basic_auth_users FROM services WHERE id = $1
`
type GetServiceAccessRow struct {
MaintenanceMode bool `json:"maintenance_mode"`
BasicAuthUsers string `json:"basic_auth_users"`
}
func (q *Queries) GetServiceAccess(ctx context.Context, id uuid.UUID) (GetServiceAccessRow, error) {
row := q.db.QueryRowContext(ctx, getServiceAccess, id)
var i GetServiceAccessRow
err := row.Scan(&i.MaintenanceMode, &i.BasicAuthUsers)
return i, err
}
const getServiceDomainDefault = `-- name: GetServiceDomainDefault :one
SELECT domain FROM service_domains WHERE service_id = $1 AND is_default
`
func (q *Queries) GetServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getServiceDomainDefault, serviceID)
var domain string
err := row.Scan(&domain)
return domain, err
}
const getServiceDomainFirst = `-- name: GetServiceDomainFirst :one
SELECT domain FROM service_domains WHERE service_id = $1 ORDER BY created_at ASC LIMIT 1
`
func (q *Queries) GetServiceDomainFirst(ctx context.Context, serviceID uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getServiceDomainFirst, serviceID)
var domain string
err := row.Scan(&domain)
return domain, err
}
const getServicePlacementBrief = `-- name: GetServicePlacementBrief :one
SELECT name, node_id, COALESCE(spread, false) AS spread FROM services WHERE id = $1
`
type GetServicePlacementBriefRow struct {
Name string `json:"name"`
NodeID sql.NullString `json:"node_id"`
Spread bool `json:"spread"`
}
func (q *Queries) GetServicePlacementBrief(ctx context.Context, id uuid.UUID) (GetServicePlacementBriefRow, error) {
row := q.db.QueryRowContext(ctx, getServicePlacementBrief, id)
var i GetServicePlacementBriefRow
err := row.Scan(&i.Name, &i.NodeID, &i.Spread)
return i, err
}
const listServiceDomains = `-- name: ListServiceDomains :many
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
`
func (q *Queries) ListServiceDomains(ctx context.Context, serviceID uuid.UUID) ([]ServiceDomain, error) {
rows, err := q.db.QueryContext(ctx, listServiceDomains, serviceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ServiceDomain{}
for rows.Next() {
var i ServiceDomain
if err := rows.Scan(
&i.ID,
&i.ServiceID,
&i.Domain,
&i.IsDefault,
&i.CertType,
&i.CertStatus,
&i.LastCheckedAt,
&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 serviceWriteAccess = `-- name: ServiceWriteAccess :one
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)
) AS allowed
`
type ServiceWriteAccessParams struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Column3 bool `json:"column_3"`
}
func (q *Queries) ServiceWriteAccess(ctx context.Context, arg ServiceWriteAccessParams) (bool, error) {
row := q.db.QueryRowContext(ctx, serviceWriteAccess, arg.ID, arg.OwnerID, arg.Column3)
var allowed bool
err := row.Scan(&allowed)
return allowed, err
}
const setServiceDomainDefault = `-- name: SetServiceDomainDefault :execrows
UPDATE service_domains SET is_default = (id = $1) WHERE service_id = $2
`
type SetServiceDomainDefaultParams struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
}
func (q *Queries) SetServiceDomainDefault(ctx context.Context, arg SetServiceDomainDefaultParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setServiceDomainDefault, arg.ID, arg.ServiceID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const setServiceLegacyDomain = `-- name: SetServiceLegacyDomain :exec
UPDATE services SET domain = $1 WHERE id = $2
`
type SetServiceLegacyDomainParams struct {
Domain string `json:"domain"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetServiceLegacyDomain(ctx context.Context, arg SetServiceLegacyDomainParams) error {
_, err := q.db.ExecContext(ctx, setServiceLegacyDomain, arg.Domain, arg.ID)
return err
}
const touchServiceDomainChecked = `-- name: TouchServiceDomainChecked :exec
UPDATE service_domains SET last_checked_at = NOW() WHERE id = $1
`
func (q *Queries) TouchServiceDomainChecked(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, touchServiceDomainChecked, id)
return err
}
@@ -0,0 +1,623 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: metrics.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
)
const aggregateNodeMetrics = `-- name: AggregateNodeMetrics :many
SELECT date_bin($1::interval, timestamp, '2000-01-01'::timestamptz) AS bucket,
AVG(cpu_usage)::float8 AS avg_cpu,
AVG(memory_usage_percent)::float8 AS avg_memory,
AVG(storage_usage_percent)::float8 AS avg_storage
FROM node_metrics
WHERE node_id = $2 AND timestamp BETWEEN $3 AND $4
GROUP BY bucket
ORDER BY bucket ASC
`
type AggregateNodeMetricsParams struct {
Column1 int64 `json:"column_1"`
NodeID string `json:"node_id"`
Timestamp time.Time `json:"timestamp"`
Timestamp_2 time.Time `json:"timestamp_2"`
}
type AggregateNodeMetricsRow struct {
Bucket interface{} `json:"bucket"`
AvgCpu float64 `json:"avg_cpu"`
AvgMemory float64 `json:"avg_memory"`
AvgStorage float64 `json:"avg_storage"`
}
// Portable bucket bucketing (no TimescaleDB): floor epoch seconds into
// bucket-sized buckets, then back to timestamptz.
func (q *Queries) AggregateNodeMetrics(ctx context.Context, arg AggregateNodeMetricsParams) ([]AggregateNodeMetricsRow, error) {
rows, err := q.db.QueryContext(ctx, aggregateNodeMetrics,
arg.Column1,
arg.NodeID,
arg.Timestamp,
arg.Timestamp_2,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []AggregateNodeMetricsRow{}
for rows.Next() {
var i AggregateNodeMetricsRow
if err := rows.Scan(
&i.Bucket,
&i.AvgCpu,
&i.AvgMemory,
&i.AvgStorage,
); 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 listInstanceMetrics = `-- name: ListInstanceMetrics :many
SELECT instance_id, node_id, status, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count
FROM instance_metrics
WHERE service_id = $1 AND timestamp = $2
`
type ListInstanceMetricsParams struct {
ServiceID string `json:"service_id"`
Timestamp time.Time `json:"timestamp"`
}
type ListInstanceMetricsRow struct {
InstanceID string `json:"instance_id"`
NodeID sql.NullString `json:"node_id"`
Status sql.NullString `json:"status"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullInt64 `json:"memory"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
StartTime sql.NullTime `json:"start_time"`
LastSeen sql.NullTime `json:"last_seen"`
HealthStatus sql.NullString `json:"health_status"`
HealthLastCheck sql.NullTime `json:"health_last_check"`
HealthCheckCount sql.NullInt32 `json:"health_check_count"`
HealthFailureCount sql.NullInt32 `json:"health_failure_count"`
}
func (q *Queries) ListInstanceMetrics(ctx context.Context, arg ListInstanceMetricsParams) ([]ListInstanceMetricsRow, error) {
rows, err := q.db.QueryContext(ctx, listInstanceMetrics, arg.ServiceID, arg.Timestamp)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListInstanceMetricsRow{}
for rows.Next() {
var i ListInstanceMetricsRow
if err := rows.Scan(
&i.InstanceID,
&i.NodeID,
&i.Status,
&i.Cpu,
&i.Memory,
&i.NetworkBytesIn,
&i.NetworkBytesOut,
&i.NetworkPacketsIn,
&i.NetworkPacketsOut,
&i.NetworkConnectionsIn,
&i.NetworkConnectionsOut,
&i.NetworkErrorsIn,
&i.NetworkErrorsOut,
&i.StartTime,
&i.LastSeen,
&i.HealthStatus,
&i.HealthLastCheck,
&i.HealthCheckCount,
&i.HealthFailureCount,
); 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 listNodeMetrics = `-- name: ListNodeMetrics :many
SELECT node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15,
memory_total, memory_used, memory_available, memory_usage_percent,
storage_total, storage_used, storage_available, storage_usage_percent,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
coalesce(extract(epoch from uptime), 0)::bigint AS uptime_seconds,
processes, os, kernel, architecture
FROM node_metrics
WHERE node_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC
`
type ListNodeMetricsParams struct {
NodeID string `json:"node_id"`
Timestamp time.Time `json:"timestamp"`
Timestamp_2 time.Time `json:"timestamp_2"`
}
type ListNodeMetricsRow struct {
NodeID string `json:"node_id"`
Timestamp time.Time `json:"timestamp"`
CpuUsage sql.NullString `json:"cpu_usage"`
CpuCores sql.NullString `json:"cpu_cores"`
LoadAvg1 sql.NullString `json:"load_avg_1"`
LoadAvg5 sql.NullString `json:"load_avg_5"`
LoadAvg15 sql.NullString `json:"load_avg_15"`
MemoryTotal sql.NullInt64 `json:"memory_total"`
MemoryUsed sql.NullInt64 `json:"memory_used"`
MemoryAvailable sql.NullInt64 `json:"memory_available"`
MemoryUsagePercent sql.NullString `json:"memory_usage_percent"`
StorageTotal sql.NullInt64 `json:"storage_total"`
StorageUsed sql.NullInt64 `json:"storage_used"`
StorageAvailable sql.NullInt64 `json:"storage_available"`
StorageUsagePercent sql.NullString `json:"storage_usage_percent"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
UptimeSeconds int64 `json:"uptime_seconds"`
Processes sql.NullInt32 `json:"processes"`
Os sql.NullString `json:"os"`
Kernel sql.NullString `json:"kernel"`
Architecture sql.NullString `json:"architecture"`
}
func (q *Queries) ListNodeMetrics(ctx context.Context, arg ListNodeMetricsParams) ([]ListNodeMetricsRow, error) {
rows, err := q.db.QueryContext(ctx, listNodeMetrics, arg.NodeID, arg.Timestamp, arg.Timestamp_2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListNodeMetricsRow{}
for rows.Next() {
var i ListNodeMetricsRow
if err := rows.Scan(
&i.NodeID,
&i.Timestamp,
&i.CpuUsage,
&i.CpuCores,
&i.LoadAvg1,
&i.LoadAvg5,
&i.LoadAvg15,
&i.MemoryTotal,
&i.MemoryUsed,
&i.MemoryAvailable,
&i.MemoryUsagePercent,
&i.StorageTotal,
&i.StorageUsed,
&i.StorageAvailable,
&i.StorageUsagePercent,
&i.NetworkBytesIn,
&i.NetworkBytesOut,
&i.NetworkPacketsIn,
&i.NetworkPacketsOut,
&i.NetworkConnectionsIn,
&i.NetworkConnectionsOut,
&i.NetworkErrorsIn,
&i.NetworkErrorsOut,
&i.UptimeSeconds,
&i.Processes,
&i.Os,
&i.Kernel,
&i.Architecture,
); 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 listServiceMetrics = `-- name: ListServiceMetrics :many
SELECT service_id, service_name, project_id, timestamp,
requests_total, requests_success, requests_errors, requests_avg_latency,
requests_p95_latency, requests_p99_latency, requests_throughput,
errors_total, errors_rate, performance_response_time, performance_throughput,
performance_concurrency, performance_saturation, performance_utilization,
resource_cpu_usage, resource_memory_usage, resource_storage_usage,
resource_network_usage, resource_score
FROM service_metrics
WHERE service_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC
`
type ListServiceMetricsParams struct {
ServiceID string `json:"service_id"`
Timestamp time.Time `json:"timestamp"`
Timestamp_2 time.Time `json:"timestamp_2"`
}
type ListServiceMetricsRow struct {
ServiceID string `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectID string `json:"project_id"`
Timestamp time.Time `json:"timestamp"`
RequestsTotal sql.NullInt64 `json:"requests_total"`
RequestsSuccess sql.NullInt64 `json:"requests_success"`
RequestsErrors sql.NullInt64 `json:"requests_errors"`
RequestsAvgLatency sql.NullString `json:"requests_avg_latency"`
RequestsP95Latency sql.NullString `json:"requests_p95_latency"`
RequestsP99Latency sql.NullString `json:"requests_p99_latency"`
RequestsThroughput sql.NullString `json:"requests_throughput"`
ErrorsTotal sql.NullInt64 `json:"errors_total"`
ErrorsRate sql.NullString `json:"errors_rate"`
PerformanceResponseTime sql.NullString `json:"performance_response_time"`
PerformanceThroughput sql.NullString `json:"performance_throughput"`
PerformanceConcurrency sql.NullInt64 `json:"performance_concurrency"`
PerformanceSaturation sql.NullString `json:"performance_saturation"`
PerformanceUtilization sql.NullString `json:"performance_utilization"`
ResourceCpuUsage sql.NullString `json:"resource_cpu_usage"`
ResourceMemoryUsage sql.NullInt64 `json:"resource_memory_usage"`
ResourceStorageUsage sql.NullInt64 `json:"resource_storage_usage"`
ResourceNetworkUsage sql.NullInt64 `json:"resource_network_usage"`
ResourceScore sql.NullString `json:"resource_score"`
}
func (q *Queries) ListServiceMetrics(ctx context.Context, arg ListServiceMetricsParams) ([]ListServiceMetricsRow, error) {
rows, err := q.db.QueryContext(ctx, listServiceMetrics, arg.ServiceID, arg.Timestamp, arg.Timestamp_2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListServiceMetricsRow{}
for rows.Next() {
var i ListServiceMetricsRow
if err := rows.Scan(
&i.ServiceID,
&i.ServiceName,
&i.ProjectID,
&i.Timestamp,
&i.RequestsTotal,
&i.RequestsSuccess,
&i.RequestsErrors,
&i.RequestsAvgLatency,
&i.RequestsP95Latency,
&i.RequestsP99Latency,
&i.RequestsThroughput,
&i.ErrorsTotal,
&i.ErrorsRate,
&i.PerformanceResponseTime,
&i.PerformanceThroughput,
&i.PerformanceConcurrency,
&i.PerformanceSaturation,
&i.PerformanceUtilization,
&i.ResourceCpuUsage,
&i.ResourceMemoryUsage,
&i.ResourceStorageUsage,
&i.ResourceNetworkUsage,
&i.ResourceScore,
); 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 upsertInstanceMetrics = `-- name: UpsertInstanceMetrics :exec
INSERT INTO instance_metrics (
service_id, timestamp, instance_id, node_id, status, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21
)
ON CONFLICT (service_id, timestamp, instance_id) DO UPDATE SET
node_id = EXCLUDED.node_id,
status = EXCLUDED.status,
cpu = EXCLUDED.cpu,
memory = EXCLUDED.memory,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
network_connections_in = EXCLUDED.network_connections_in,
network_connections_out = EXCLUDED.network_connections_out,
network_errors_in = EXCLUDED.network_errors_in,
network_errors_out = EXCLUDED.network_errors_out,
start_time = EXCLUDED.start_time,
last_seen = EXCLUDED.last_seen,
health_status = EXCLUDED.health_status,
health_last_check = EXCLUDED.health_last_check,
health_check_count = EXCLUDED.health_check_count,
health_failure_count = EXCLUDED.health_failure_count
`
type UpsertInstanceMetricsParams struct {
ServiceID string `json:"service_id"`
Timestamp time.Time `json:"timestamp"`
InstanceID string `json:"instance_id"`
NodeID sql.NullString `json:"node_id"`
Status sql.NullString `json:"status"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullInt64 `json:"memory"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
StartTime sql.NullTime `json:"start_time"`
LastSeen sql.NullTime `json:"last_seen"`
HealthStatus sql.NullString `json:"health_status"`
HealthLastCheck sql.NullTime `json:"health_last_check"`
HealthCheckCount sql.NullInt32 `json:"health_check_count"`
HealthFailureCount sql.NullInt32 `json:"health_failure_count"`
}
func (q *Queries) UpsertInstanceMetrics(ctx context.Context, arg UpsertInstanceMetricsParams) error {
_, err := q.db.ExecContext(ctx, upsertInstanceMetrics,
arg.ServiceID,
arg.Timestamp,
arg.InstanceID,
arg.NodeID,
arg.Status,
arg.Cpu,
arg.Memory,
arg.NetworkBytesIn,
arg.NetworkBytesOut,
arg.NetworkPacketsIn,
arg.NetworkPacketsOut,
arg.NetworkConnectionsIn,
arg.NetworkConnectionsOut,
arg.NetworkErrorsIn,
arg.NetworkErrorsOut,
arg.StartTime,
arg.LastSeen,
arg.HealthStatus,
arg.HealthLastCheck,
arg.HealthCheckCount,
arg.HealthFailureCount,
)
return err
}
const upsertNodeMetrics = `-- name: UpsertNodeMetrics :exec
INSERT INTO node_metrics (
node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15,
memory_total, memory_used, memory_available, memory_usage_percent,
storage_total, storage_used, storage_available, storage_usage_percent,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
uptime, processes, os, kernel, architecture
) 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
)
ON CONFLICT (node_id, timestamp) DO UPDATE SET
cpu_usage = EXCLUDED.cpu_usage,
cpu_cores = EXCLUDED.cpu_cores,
load_avg_1 = EXCLUDED.load_avg_1,
load_avg_5 = EXCLUDED.load_avg_5,
load_avg_15 = EXCLUDED.load_avg_15,
memory_total = EXCLUDED.memory_total,
memory_used = EXCLUDED.memory_used,
memory_available = EXCLUDED.memory_available,
memory_usage_percent = EXCLUDED.memory_usage_percent,
storage_total = EXCLUDED.storage_total,
storage_used = EXCLUDED.storage_used,
storage_available = EXCLUDED.storage_available,
storage_usage_percent = EXCLUDED.storage_usage_percent,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
network_connections_in = EXCLUDED.network_connections_in,
network_connections_out = EXCLUDED.network_connections_out,
network_errors_in = EXCLUDED.network_errors_in,
network_errors_out = EXCLUDED.network_errors_out,
uptime = EXCLUDED.uptime,
processes = EXCLUDED.processes,
os = EXCLUDED.os,
kernel = EXCLUDED.kernel,
architecture = EXCLUDED.architecture
`
type UpsertNodeMetricsParams struct {
NodeID string `json:"node_id"`
Timestamp time.Time `json:"timestamp"`
CpuUsage sql.NullString `json:"cpu_usage"`
CpuCores sql.NullString `json:"cpu_cores"`
LoadAvg1 sql.NullString `json:"load_avg_1"`
LoadAvg5 sql.NullString `json:"load_avg_5"`
LoadAvg15 sql.NullString `json:"load_avg_15"`
MemoryTotal sql.NullInt64 `json:"memory_total"`
MemoryUsed sql.NullInt64 `json:"memory_used"`
MemoryAvailable sql.NullInt64 `json:"memory_available"`
MemoryUsagePercent sql.NullString `json:"memory_usage_percent"`
StorageTotal sql.NullInt64 `json:"storage_total"`
StorageUsed sql.NullInt64 `json:"storage_used"`
StorageAvailable sql.NullInt64 `json:"storage_available"`
StorageUsagePercent sql.NullString `json:"storage_usage_percent"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
Uptime sql.NullInt64 `json:"uptime"`
Processes sql.NullInt32 `json:"processes"`
Os sql.NullString `json:"os"`
Kernel sql.NullString `json:"kernel"`
Architecture sql.NullString `json:"architecture"`
}
func (q *Queries) UpsertNodeMetrics(ctx context.Context, arg UpsertNodeMetricsParams) error {
_, err := q.db.ExecContext(ctx, upsertNodeMetrics,
arg.NodeID,
arg.Timestamp,
arg.CpuUsage,
arg.CpuCores,
arg.LoadAvg1,
arg.LoadAvg5,
arg.LoadAvg15,
arg.MemoryTotal,
arg.MemoryUsed,
arg.MemoryAvailable,
arg.MemoryUsagePercent,
arg.StorageTotal,
arg.StorageUsed,
arg.StorageAvailable,
arg.StorageUsagePercent,
arg.NetworkBytesIn,
arg.NetworkBytesOut,
arg.NetworkPacketsIn,
arg.NetworkPacketsOut,
arg.NetworkConnectionsIn,
arg.NetworkConnectionsOut,
arg.NetworkErrorsIn,
arg.NetworkErrorsOut,
arg.Uptime,
arg.Processes,
arg.Os,
arg.Kernel,
arg.Architecture,
)
return err
}
const upsertServiceMetrics = `-- name: UpsertServiceMetrics :exec
INSERT INTO service_metrics (
service_id, service_name, project_id, timestamp,
requests_total, requests_success, requests_errors, requests_avg_latency,
requests_p95_latency, requests_p99_latency, requests_throughput,
errors_total, errors_rate, performance_response_time, performance_throughput,
performance_concurrency, performance_saturation, performance_utilization,
resource_cpu_usage, resource_memory_usage, resource_storage_usage,
resource_network_usage, resource_score
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21, $22, $23
)
ON CONFLICT (service_id, timestamp) DO UPDATE SET
requests_total = EXCLUDED.requests_total,
requests_success = EXCLUDED.requests_success,
requests_errors = EXCLUDED.requests_errors,
requests_avg_latency = EXCLUDED.requests_avg_latency,
requests_p95_latency = EXCLUDED.requests_p95_latency,
requests_p99_latency = EXCLUDED.requests_p99_latency,
requests_throughput = EXCLUDED.requests_throughput,
errors_total = EXCLUDED.errors_total,
errors_rate = EXCLUDED.errors_rate,
performance_response_time = EXCLUDED.performance_response_time,
performance_throughput = EXCLUDED.performance_throughput,
performance_concurrency = EXCLUDED.performance_concurrency,
performance_saturation = EXCLUDED.performance_saturation,
performance_utilization = EXCLUDED.performance_utilization,
resource_cpu_usage = EXCLUDED.resource_cpu_usage,
resource_memory_usage = EXCLUDED.resource_memory_usage,
resource_storage_usage = EXCLUDED.resource_storage_usage,
resource_network_usage = EXCLUDED.resource_network_usage,
resource_score = EXCLUDED.resource_score
`
type UpsertServiceMetricsParams struct {
ServiceID string `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectID string `json:"project_id"`
Timestamp time.Time `json:"timestamp"`
RequestsTotal sql.NullInt64 `json:"requests_total"`
RequestsSuccess sql.NullInt64 `json:"requests_success"`
RequestsErrors sql.NullInt64 `json:"requests_errors"`
RequestsAvgLatency sql.NullString `json:"requests_avg_latency"`
RequestsP95Latency sql.NullString `json:"requests_p95_latency"`
RequestsP99Latency sql.NullString `json:"requests_p99_latency"`
RequestsThroughput sql.NullString `json:"requests_throughput"`
ErrorsTotal sql.NullInt64 `json:"errors_total"`
ErrorsRate sql.NullString `json:"errors_rate"`
PerformanceResponseTime sql.NullString `json:"performance_response_time"`
PerformanceThroughput sql.NullString `json:"performance_throughput"`
PerformanceConcurrency sql.NullInt64 `json:"performance_concurrency"`
PerformanceSaturation sql.NullString `json:"performance_saturation"`
PerformanceUtilization sql.NullString `json:"performance_utilization"`
ResourceCpuUsage sql.NullString `json:"resource_cpu_usage"`
ResourceMemoryUsage sql.NullInt64 `json:"resource_memory_usage"`
ResourceStorageUsage sql.NullInt64 `json:"resource_storage_usage"`
ResourceNetworkUsage sql.NullInt64 `json:"resource_network_usage"`
ResourceScore sql.NullString `json:"resource_score"`
}
func (q *Queries) UpsertServiceMetrics(ctx context.Context, arg UpsertServiceMetricsParams) error {
_, err := q.db.ExecContext(ctx, upsertServiceMetrics,
arg.ServiceID,
arg.ServiceName,
arg.ProjectID,
arg.Timestamp,
arg.RequestsTotal,
arg.RequestsSuccess,
arg.RequestsErrors,
arg.RequestsAvgLatency,
arg.RequestsP95Latency,
arg.RequestsP99Latency,
arg.RequestsThroughput,
arg.ErrorsTotal,
arg.ErrorsRate,
arg.PerformanceResponseTime,
arg.PerformanceThroughput,
arg.PerformanceConcurrency,
arg.PerformanceSaturation,
arg.PerformanceUtilization,
arg.ResourceCpuUsage,
arg.ResourceMemoryUsage,
arg.ResourceStorageUsage,
arg.ResourceNetworkUsage,
arg.ResourceScore,
)
return err
}
@@ -161,6 +161,31 @@ type EnvironmentVariable struct {
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type InstanceMetric struct {
ServiceID string `json:"service_id"`
Timestamp time.Time `json:"timestamp"`
InstanceID string `json:"instance_id"`
NodeID sql.NullString `json:"node_id"`
Status sql.NullString `json:"status"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullInt64 `json:"memory"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
StartTime sql.NullTime `json:"start_time"`
LastSeen sql.NullTime `json:"last_seen"`
HealthStatus sql.NullString `json:"health_status"`
HealthLastCheck sql.NullTime `json:"health_last_check"`
HealthCheckCount sql.NullInt32 `json:"health_check_count"`
HealthFailureCount sql.NullInt32 `json:"health_failure_count"`
CreatedAt sql.NullTime `json:"created_at"`
}
type NodeAgent struct { type NodeAgent struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -178,6 +203,39 @@ type NodeAgent struct {
AutoPrune bool `json:"auto_prune"` AutoPrune bool `json:"auto_prune"`
Schedulable bool `json:"schedulable"` Schedulable bool `json:"schedulable"`
Tags json.RawMessage `json:"tags"` Tags json.RawMessage `json:"tags"`
DefaultDomain string `json:"default_domain"`
}
type NodeMetric struct {
NodeID string `json:"node_id"`
Timestamp time.Time `json:"timestamp"`
CpuUsage sql.NullString `json:"cpu_usage"`
CpuCores sql.NullString `json:"cpu_cores"`
LoadAvg1 sql.NullString `json:"load_avg_1"`
LoadAvg5 sql.NullString `json:"load_avg_5"`
LoadAvg15 sql.NullString `json:"load_avg_15"`
MemoryTotal sql.NullInt64 `json:"memory_total"`
MemoryUsed sql.NullInt64 `json:"memory_used"`
MemoryAvailable sql.NullInt64 `json:"memory_available"`
MemoryUsagePercent sql.NullString `json:"memory_usage_percent"`
StorageTotal sql.NullInt64 `json:"storage_total"`
StorageUsed sql.NullInt64 `json:"storage_used"`
StorageAvailable sql.NullInt64 `json:"storage_available"`
StorageUsagePercent sql.NullString `json:"storage_usage_percent"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
Uptime sql.NullInt64 `json:"uptime"`
Processes sql.NullInt32 `json:"processes"`
Os sql.NullString `json:"os"`
Kernel sql.NullString `json:"kernel"`
Architecture sql.NullString `json:"architecture"`
CreatedAt sql.NullTime `json:"created_at"`
} }
type Notification struct { type Notification struct {
@@ -192,6 +250,17 @@ type Notification struct {
CreatedAt sql.NullTime `json:"created_at"` CreatedAt sql.NullTime `json:"created_at"`
} }
type NotificationChannel struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Kind string `json:"kind"`
Endpoint string `json:"endpoint"`
Token sql.NullString `json:"token"`
Enabled bool `json:"enabled"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type OutboundWebhook struct { type OutboundWebhook struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"` UserID uuid.UUID `json:"user_id"`
@@ -279,6 +348,7 @@ type Service struct {
NodeID sql.NullString `json:"node_id"` NodeID sql.NullString `json:"node_id"`
Spread bool `json:"spread"` Spread bool `json:"spread"`
PlacementTags json.RawMessage `json:"placement_tags"` PlacementTags json.RawMessage `json:"placement_tags"`
TraefikLabels json.RawMessage `json:"traefik_labels"`
} }
type ServiceDomain struct { type ServiceDomain struct {
@@ -292,6 +362,33 @@ type ServiceDomain struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type ServiceMetric struct {
ServiceID string `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectID string `json:"project_id"`
Timestamp time.Time `json:"timestamp"`
RequestsTotal sql.NullInt64 `json:"requests_total"`
RequestsSuccess sql.NullInt64 `json:"requests_success"`
RequestsErrors sql.NullInt64 `json:"requests_errors"`
RequestsAvgLatency sql.NullString `json:"requests_avg_latency"`
RequestsP95Latency sql.NullString `json:"requests_p95_latency"`
RequestsP99Latency sql.NullString `json:"requests_p99_latency"`
RequestsThroughput sql.NullString `json:"requests_throughput"`
ErrorsTotal sql.NullInt64 `json:"errors_total"`
ErrorsRate sql.NullString `json:"errors_rate"`
PerformanceResponseTime sql.NullString `json:"performance_response_time"`
PerformanceThroughput sql.NullString `json:"performance_throughput"`
PerformanceConcurrency sql.NullInt64 `json:"performance_concurrency"`
PerformanceSaturation sql.NullString `json:"performance_saturation"`
PerformanceUtilization sql.NullString `json:"performance_utilization"`
ResourceCpuUsage sql.NullString `json:"resource_cpu_usage"`
ResourceMemoryUsage sql.NullInt64 `json:"resource_memory_usage"`
ResourceStorageUsage sql.NullInt64 `json:"resource_storage_usage"`
ResourceNetworkUsage sql.NullInt64 `json:"resource_network_usage"`
ResourceScore sql.NullString `json:"resource_score"`
CreatedAt sql.NullTime `json:"created_at"`
}
type ServiceTemplate struct { type ServiceTemplate struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -53,6 +53,135 @@ func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotification
return err return err
} }
const deleteNotificationChannel = `-- name: DeleteNotificationChannel :execrows
DELETE FROM notification_channels WHERE id = $1 AND user_id = $2
`
type DeleteNotificationChannelParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteNotificationChannel, arg.ID, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const insertNotificationChannel = `-- name: InsertNotificationChannel :one
INSERT INTO notification_channels (user_id, kind, endpoint, token)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, kind, endpoint, token, enabled, created_at, updated_at
`
type InsertNotificationChannelParams struct {
UserID uuid.UUID `json:"user_id"`
Kind string `json:"kind"`
Endpoint string `json:"endpoint"`
Token sql.NullString `json:"token"`
}
func (q *Queries) InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error) {
row := q.db.QueryRowContext(ctx, insertNotificationChannel,
arg.UserID,
arg.Kind,
arg.Endpoint,
arg.Token,
)
var i NotificationChannel
err := row.Scan(
&i.ID,
&i.UserID,
&i.Kind,
&i.Endpoint,
&i.Token,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listAllNotificationChannelsByUser = `-- name: ListAllNotificationChannelsByUser :many
SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at
FROM notification_channels
WHERE user_id = $1
ORDER BY created_at
`
func (q *Queries) ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error) {
rows, err := q.db.QueryContext(ctx, listAllNotificationChannelsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []NotificationChannel{}
for rows.Next() {
var i NotificationChannel
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Kind,
&i.Endpoint,
&i.Token,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listNotificationChannelsByUser = `-- name: ListNotificationChannelsByUser :many
SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at
FROM notification_channels
WHERE user_id = $1 AND enabled
ORDER BY created_at
`
func (q *Queries) ListNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error) {
rows, err := q.db.QueryContext(ctx, listNotificationChannelsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []NotificationChannel{}
for rows.Next() {
var i NotificationChannel
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Kind,
&i.Endpoint,
&i.Token,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listNotificationsByUser = `-- name: ListNotificationsByUser :many const listNotificationsByUser = `-- name: ListNotificationsByUser :many
SELECT id, user_id, kind, title, body, resource_type, resource_id, read_at, created_at SELECT id, user_id, kind, title, body, resource_type, resource_id, read_at, created_at
FROM notifications FROM notifications
@@ -12,6 +12,17 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
const countEnvironmentServices = `-- name: CountEnvironmentServices :one
SELECT COUNT(*) FROM services WHERE environment_id = $1
`
func (q *Queries) CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, countEnvironmentServices, environmentID)
var count int64
err := row.Scan(&count)
return count, err
}
const countProjectsByUser = `-- name: CountProjectsByUser :one const countProjectsByUser = `-- name: CountProjectsByUser :one
SELECT COUNT(*)::bigint AS total SELECT COUNT(*)::bigint AS total
FROM projects p FROM projects p
@@ -86,6 +97,20 @@ func (q *Queries) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (i
return result.RowsAffected() return result.RowsAffected()
} }
const deleteProjectEnvironment = `-- name: DeleteProjectEnvironment :exec
DELETE FROM environments WHERE id = $1 AND project_id = $2
`
type DeleteProjectEnvironmentParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error {
_, err := q.db.ExecContext(ctx, deleteProjectEnvironment, arg.ID, arg.ProjectID)
return err
}
const getProjectByIDForUser = `-- name: GetProjectByIDForUser :one const getProjectByIDForUser = `-- name: GetProjectByIDForUser :one
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
FROM projects p FROM projects p
@@ -123,6 +148,30 @@ func (q *Queries) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDF
return i, err return i, err
} }
const getProjectEnvironmentByName = `-- name: GetProjectEnvironmentByName :one
SELECT id, name, project_id, created_at, updated_at
FROM environments
WHERE project_id = $1 AND name = $2
`
type GetProjectEnvironmentByNameParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
}
func (q *Queries) GetProjectEnvironmentByName(ctx context.Context, arg GetProjectEnvironmentByNameParams) (Environment, error) {
row := q.db.QueryRowContext(ctx, getProjectEnvironmentByName, arg.ProjectID, arg.Name)
var i Environment
err := row.Scan(
&i.ID,
&i.Name,
&i.ProjectID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getProjectOwnerByID = `-- name: GetProjectOwnerByID :one const getProjectOwnerByID = `-- name: GetProjectOwnerByID :one
SELECT owner_id SELECT owner_id
FROM projects FROM projects
@@ -212,6 +261,53 @@ func (q *Queries) ListPendingProjects(ctx context.Context) ([]Project, error) {
return items, nil return items, nil
} }
const listProjectEnvironments = `-- name: ListProjectEnvironments :many
SELECT e.id, e.name, e.project_id, e.created_at, e.updated_at,
(SELECT COUNT(*) FROM services s WHERE s.environment_id = e.id) AS service_count
FROM environments e
WHERE e.project_id = $1
ORDER BY e.created_at
`
type ListProjectEnvironmentsRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
ProjectID uuid.UUID `json:"project_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ServiceCount int64 `json:"service_count"`
}
func (q *Queries) ListProjectEnvironments(ctx context.Context, projectID uuid.UUID) ([]ListProjectEnvironmentsRow, error) {
rows, err := q.db.QueryContext(ctx, listProjectEnvironments, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListProjectEnvironmentsRow{}
for rows.Next() {
var i ListProjectEnvironmentsRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.ProjectID,
&i.CreatedAt,
&i.UpdatedAt,
&i.ServiceCount,
); 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 listProjectsWithStatsByUser = `-- name: ListProjectsWithStatsByUser :many const listProjectsWithStatsByUser = `-- name: ListProjectsWithStatsByUser :many
SELECT SELECT
@@ -13,11 +13,17 @@ import (
) )
type Querier interface { type Querier interface {
// Portable bucket bucketing (no TimescaleDB): floor epoch seconds into
// bucket-sized buckets, then back to timestamptz.
AggregateNodeMetrics(ctx context.Context, arg AggregateNodeMetricsParams) ([]AggregateNodeMetricsRow, error)
ClearServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) error
ClearServiceNodePins(ctx context.Context, nodeID sql.NullString) (int64, error) ClearServiceNodePins(ctx context.Context, nodeID sql.NullString) (int64, error)
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error) CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error) CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error) CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error)
CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error)
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
CountServiceDomains(ctx context.Context, serviceID uuid.UUID) (int64, error)
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error) CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error) CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
@@ -32,6 +38,7 @@ type Querier interface {
CreateNotification(ctx context.Context, arg CreateNotificationParams) error CreateNotification(ctx context.Context, arg CreateNotificationParams) error
CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
CreateServiceDomain(ctx context.Context, arg CreateServiceDomainParams) (CreateServiceDomainRow, error)
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
CreateUserInvite(ctx context.Context, arg CreateUserInviteParams) (UserInvite, error) CreateUserInvite(ctx context.Context, arg CreateUserInviteParams) (UserInvite, error)
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
@@ -43,9 +50,12 @@ type Querier interface {
DeleteBanner(ctx context.Context, id uuid.UUID) error DeleteBanner(ctx context.Context, id uuid.UUID) error
DeleteDatabaseServiceByID(ctx context.Context, id string) error DeleteDatabaseServiceByID(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error)
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error
DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error
DeleteServiceDomain(ctx context.Context, arg DeleteServiceDomainParams) (int64, error)
DeleteUserInvite(ctx context.Context, id uuid.UUID) error DeleteUserInvite(ctx context.Context, id uuid.UUID) error
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error) GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
@@ -65,19 +75,26 @@ type Querier interface {
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error) GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error) GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
GetProjectEnvironmentByName(ctx context.Context, arg GetProjectEnvironmentByNameParams) (Environment, error)
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
GetServiceAccess(ctx context.Context, id uuid.UUID) (GetServiceAccessRow, error)
GetServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) (string, error)
GetServiceDomainFirst(ctx context.Context, serviceID uuid.UUID) (string, error)
GetServicePlacementBrief(ctx context.Context, id uuid.UUID) (GetServicePlacementBriefRow, error)
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error) GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error) GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error)
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error)
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
ListActiveBanners(ctx context.Context) ([]Banner, error) ListActiveBanners(ctx context.Context) ([]Banner, error)
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
ListAgents(ctx context.Context) ([]NodeAgent, error) ListAgents(ctx context.Context) ([]NodeAgent, error)
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error) ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error) ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
ListBanners(ctx context.Context) ([]Banner, error) ListBanners(ctx context.Context) ([]Banner, error)
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
@@ -87,20 +104,29 @@ type Querier interface {
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error) ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error) ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error)
ListInstanceMetrics(ctx context.Context, arg ListInstanceMetricsParams) ([]ListInstanceMetricsRow, error)
ListNodeMetrics(ctx context.Context, arg ListNodeMetricsParams) ([]ListNodeMetricsRow, error)
ListNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error) ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error) ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error)
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListPendingProjects(ctx context.Context) ([]Project, error) ListPendingProjects(ctx context.Context) ([]Project, error)
ListProjectEnvironments(ctx context.Context, projectID uuid.UUID) ([]ListProjectEnvironmentsRow, error)
// Visibility model: a project is readable when it is approved for public // Visibility model: a project is readable when it is approved for public
// display, when the caller owns or is a member of it, or when the caller is a // display, when the caller owns or is a member of it, or when the caller is a
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false. // platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
// Base domains of every online, schedulable agent carrying the required
// placement tags — auto-domain candidates for spread services.
ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error)
ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error) ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error)
// Only agents carrying every required placement tag (jsonb array containment). // Only agents carrying every required placement tag (jsonb array containment).
ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error) ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error)
// Distinct agents holding inventory rows for a service. // Distinct agents holding inventory rows for a service.
ListServiceAgents(ctx context.Context, serviceID string) ([]string, error) ListServiceAgents(ctx context.Context, serviceID string) ([]string, error)
ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error) ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error)
ListServiceDomains(ctx context.Context, serviceID uuid.UUID) ([]ServiceDomain, error)
ListServiceMetrics(ctx context.Context, arg ListServiceMetricsParams) ([]ListServiceMetricsRow, 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)
ListUserInvites(ctx context.Context) ([]UserInvite, error) ListUserInvites(ctx context.Context) ([]UserInvite, error)
@@ -118,6 +144,8 @@ type Querier interface {
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error
ServiceWriteAccess(ctx context.Context, arg ServiceWriteAccessParams) (bool, error)
SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error
SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error
SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
@@ -130,7 +158,10 @@ type Querier interface {
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error) SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
SetServiceDomainDefault(ctx context.Context, arg SetServiceDomainDefaultParams) (int64, error)
SetServiceLegacyDomain(ctx context.Context, arg SetServiceLegacyDomainParams) error
TouchAgentAuthToken(ctx context.Context, tokenHash string) error TouchAgentAuthToken(ctx context.Context, tokenHash string) error
TouchServiceDomainChecked(ctx context.Context, id uuid.UUID) error
TouchUserToken(ctx context.Context, id uuid.UUID) error TouchUserToken(ctx context.Context, id uuid.UUID) error
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
@@ -152,7 +183,10 @@ type Querier interface {
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
UpsertInstanceMetrics(ctx context.Context, arg UpsertInstanceMetricsParams) error
UpsertNodeMetrics(ctx context.Context, arg UpsertNodeMetricsParams) error
UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) error UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) error
UpsertServiceMetrics(ctx context.Context, arg UpsertServiceMetricsParams) error
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
} }
@@ -0,0 +1,60 @@
package deployment
import (
"strings"
"testing"
)
func TestEdgeLabelsCustomMiddlewares(t *testing.T) {
spec := RuntimeSpec{
ServiceID: "01234567-89ab-cdef-0123-456789abcdef",
Port: 8080,
Domains: []string{"app.example.com"},
TraefikLabels: map[string]string{
"middlewares.rl.ratelimit.average": "100",
"middlewares.rl.ratelimit.period": "1m",
"middlewares.sec.headers.customresponseheaders.X-Frame-Options": "DENY",
},
}
labels := edgeLabels(spec, "containr-edge")
if got := labels["traefik.http.middlewares.svc-01234567-rl.ratelimit.average"]; got != "100" {
t.Fatalf("ratelimit label missing: %v", labels)
}
if got := labels["traefik.http.middlewares.svc-01234567-sec.headers.customresponseheaders.X-Frame-Options"]; got != "DENY" {
t.Fatalf("headers label missing: %v", labels)
}
chain := labels["traefik.http.routers.svc-01234567.middlewares"]
if chain != "svc-01234567-rl,svc-01234567-sec" {
t.Fatalf("middleware chain wrong: %q", chain)
}
}
func TestEdgeLabelsCustomAfterBuiltins(t *testing.T) {
spec := RuntimeSpec{
ServiceID: "01234567-89ab-cdef-0123-456789abcdef",
Port: 8080,
Domains: []string{"app.example.com"},
BasicAuthUsers: "u:$apr1$x",
TraefikLabels: map[string]string{
"middlewares.rl.ratelimit.average": "10",
},
}
labels := edgeLabels(spec, "containr-edge")
chain := labels["traefik.http.routers.svc-01234567.middlewares"]
if !strings.HasPrefix(chain, "svc-01234567-auth,") || !strings.HasSuffix(chain, "svc-01234567-rl") {
t.Fatalf("builtin should precede custom: %q", chain)
}
}
func TestEdgeLabelsNoCustom(t *testing.T) {
spec := RuntimeSpec{
ServiceID: "01234567-89ab-cdef-0123-456789abcdef",
Port: 8080,
Domains: []string{"app.example.com"},
}
labels := edgeLabels(spec, "containr-edge")
if _, ok := labels["traefik.http.routers.svc-01234567.middlewares"]; ok {
t.Fatal("middleware chain should be absent with no middlewares")
}
}
+37 -4
View File
@@ -95,12 +95,29 @@ type ServiceConfig struct {
// reuses it so public URLs survive restarts. 0 = pick ephemeral. // reuses it so public URLs survive restarts. 0 = pick ephemeral.
PublishedPort int32 `json:"published_port,omitempty"` PublishedPort int32 `json:"published_port,omitempty"`
// NodeID pins the service to a registered node agent; "" = local Docker // NodeID pins the service to a registered node agent; "" = local Docker
// host. Remote placement requires a registry-pullable image — builds stay // host.
// on the local host (build-on-node is not yet supported).
NodeID string `json:"node_id,omitempty"` NodeID string `json:"node_id,omitempty"`
// Spread distributes replicas across every online, schedulable agent. // Spread distributes replicas across every online, schedulable agent.
// Same registry-image constraint as NodeID.
Spread bool `json:"spread,omitempty"` Spread bool `json:"spread,omitempty"`
// RemoteBuild, when set, makes each target node docker-build the image
// itself from a packaged context artifact instead of pulling a registry
// tag. The image name comes from the deployment, not the spec.
RemoteBuild *RemoteBuildSpec `json:"remote_build,omitempty"`
// RemoteLoad ships a docker-save tarball to each target node — used for
// rollbacks of locally-built tags that no registry can serve.
RemoteLoad *RemoteLoadSpec `json:"remote_load,omitempty"`
}
// RemoteBuildSpec is the agent-side docker build request.
type RemoteBuildSpec struct {
ArtifactID string `json:"artifact_id"`
BuildArgs map[string]string `json:"build_args,omitempty"`
NoCache bool `json:"no_cache,omitempty"`
}
// RemoteLoadSpec asks the agent to docker-load an image tarball artifact.
type RemoteLoadSpec struct {
ArtifactID string `json:"artifact_id"`
} }
type PortMapping struct { type PortMapping struct {
@@ -211,6 +228,12 @@ func (de *DeploymentEngine) DockerClient() *docker.Client {
return de.dockerClient return de.dockerClient
} }
// BuildManager exposes the engine's builders for context packaging — remote
// nodes build the tar this produces themselves.
func (de *DeploymentEngine) BuildManager() *build.BuildManager {
return de.buildManager
}
// SetNodeRunner wires remote-node dispatch for services pinned to an agent. // SetNodeRunner wires remote-node dispatch for services pinned to an agent.
func (de *DeploymentEngine) SetNodeRunner(r NodeRunner) { func (de *DeploymentEngine) SetNodeRunner(r NodeRunner) {
de.nodeRunner = r de.nodeRunner = r
@@ -326,11 +349,19 @@ func (de *DeploymentEngine) executeDeployment(ctx context.Context, deployment *D
}) })
} }
// buildImage builds the container image // buildImage builds the container image. "remote" build types produce no
// local image — the target node builds or loads it from an artifact; the
// deployment still needs a deterministic tag so every node names it alike.
func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployment, buildConfig *BuildConfig) (string, error) { func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployment, buildConfig *BuildConfig) (string, error) {
if buildConfig == nil { if buildConfig == nil {
return "", fmt.Errorf("build config is required") return "", fmt.Errorf("build config is required")
} }
if buildConfig.BuildType == "remote" {
if buildConfig.PrebuiltImage != "" {
return buildConfig.PrebuiltImage, nil
}
return fmt.Sprintf("containr-%s-%s:%s", deployment.ServiceID, deployment.Environment, deployment.ID), nil
}
buildReq := &types.BuildRequest{ buildReq := &types.BuildRequest{
BuildType: buildConfig.BuildType, BuildType: buildConfig.BuildType,
@@ -393,6 +424,8 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
BasicAuthUsers: cfg.BasicAuthUsers, BasicAuthUsers: cfg.BasicAuthUsers,
NodeID: cfg.NodeID, NodeID: cfg.NodeID,
Spread: cfg.Spread, Spread: cfg.Spread,
RemoteBuild: cfg.RemoteBuild,
RemoteLoad: cfg.RemoteLoad,
} }
for _, pm := range cfg.PortMappings { for _, pm := range cfg.PortMappings {
if spec.Port == 0 { if spec.Port == 0 {
+87 -37
View File
@@ -55,6 +55,10 @@ type RuntimeSpec struct {
Maintenance bool // redirect traffic to MaintenanceURL Maintenance bool // redirect traffic to MaintenanceURL
MaintenanceURL string // absolute URL; empty = serve empty response MaintenanceURL string // absolute URL; empty = serve empty response
BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
// TraefikLabels defines extra middlewares for this service's router —
// keys "middlewares.<name>.<type>[.<field>]" (API-validated against an
// allowlist). Defined middlewares attach to the router after builtins.
TraefikLabels map[string]string
// NodeID pins the service to a node agent; "" runs on the local host. // NodeID pins the service to a node agent; "" runs on the local host.
NodeID string NodeID string
// Spread distributes replicas across all online, schedulable agents // Spread distributes replicas across all online, schedulable agents
@@ -63,6 +67,12 @@ type RuntimeSpec struct {
// PlacementTags restricts auto/spread candidates to agents carrying // PlacementTags restricts auto/spread candidates to agents carrying
// every listed tag. An explicit NodeID pin bypasses tag filtering. // every listed tag. An explicit NodeID pin bypasses tag filtering.
PlacementTags []string PlacementTags []string
// RemoteBuild makes target nodes docker-build Image from a packaged
// context artifact before replicas start (git-sourced remote deploys).
RemoteBuild *RemoteBuildSpec
// RemoteLoad makes target nodes docker-load a saved image tarball —
// remote rollbacks of locally-built tags.
RemoteLoad *RemoteLoadSpec
} }
// RuntimeContainer describes one live replica. // RuntimeContainer describes one live replica.
@@ -268,44 +278,9 @@ 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" for k, v := range edgeLabels(spec, edgeNet) {
labels["traefik.docker.network"] = edgeNet labels[k] = v
labels["traefik.http.routers."+router+".rule"] = strings.Join(rules, " || ")
labels["traefik.http.routers."+router+".entrypoints"] = "web"
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, ",")
} }
} }
@@ -393,6 +368,81 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
// vpnkit drops a conflicting binding). // vpnkit drops a conflicting binding).
var errPortNotBound = errors.New("requested host port not bound") var errPortNotBound = errors.New("requested host port not bound")
// edgeLabels builds the Traefik router/service/middleware labels for a
// replica on the edge network: host rules, load balancer port, builtin
// maintenance/basic-auth middlewares, then operator-defined overrides.
func edgeLabels(spec RuntimeSpec, edgeNet string) map[string]string {
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+"`)")
}
labels := map[string]string{
"traefik.enable": "true",
"traefik.docker.network": edgeNet,
"traefik.http.routers." + router + ".rule": strings.Join(rules, " || "),
"traefik.http.routers." + router + ".entrypoints": "web",
"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)
}
// Operator-defined middlewares: "middlewares.<name>.<rest>" becomes
// "<router>-<name>.<rest>", and every defined name joins the router's
// chain after builtins. Names sort for a deterministic chain order.
customMWs := map[string][]string{}
for k, v := range spec.TraefikLabels {
rest := strings.TrimPrefix(k, "middlewares.")
dot := strings.Index(rest, ".")
if dot <= 0 {
continue
}
customMWs[rest[:dot]] = append(customMWs[rest[:dot]], rest[dot+1:]+"="+v)
}
mwNames := make([]string, 0, len(customMWs))
for name := range customMWs {
mwNames = append(mwNames, name)
}
sort.Strings(mwNames)
for _, name := range mwNames {
mw := router + "-" + name
sort.Strings(customMWs[name])
for _, kv := range customMWs[name] {
if i := strings.Index(kv, "="); i > 0 {
labels["traefik.http.middlewares."+mw+"."+kv[:i]] = kv[i+1:]
}
}
middlewares = append(middlewares, mw)
}
if len(middlewares) > 0 {
labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
}
return labels
}
// isPortConflict reports whether a container create failed because the // isPortConflict reports whether a container create failed because the
// requested host port was already bound. // requested host port was already bound.
func isPortConflict(err error) bool { func isPortConflict(err error) bool {
+7
View File
@@ -336,6 +336,13 @@ func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options
return resp, nil return resp, nil
} }
// SaveImage streams refs as a docker-save tarball. Remote nodes receive
// these through the artifact endpoint and docker load them — that is how
// locally-built rollback tags reach nodes without a registry.
func (c *Client) SaveImage(ctx context.Context, refs ...string) (io.ReadCloser, error) {
return c.cli.ImageSave(ctx, refs)
}
// RemoveImage removes an image // RemoveImage removes an image
func (c *Client) RemoveImage(ctx context.Context, imageID string, force bool) ([]image.DeleteResponse, error) { func (c *Client) RemoveImage(ctx context.Context, imageID string, force bool) ([]image.DeleteResponse, error) {
return c.cli.ImageRemove(ctx, imageID, image.RemoveOptions{ return c.cli.ImageRemove(ctx, imageID, image.RemoveOptions{
@@ -0,0 +1,64 @@
package gateway
import "testing"
func TestBuildTargetURL(t *testing.T) {
cases := []struct {
name string
upstream, prefix, path, query, want string
}{
{
name: "strips route prefix",
upstream: "https://api.internal:8080",
prefix: "/g/weather",
path: "/g/weather/v1/current",
query: "city=oslo",
want: "https://api.internal:8080/v1/current?city=oslo",
},
{
name: "prefix root maps to upstream root",
upstream: "https://api.internal",
prefix: "/g/weather",
path: "/g/weather",
want: "https://api.internal/",
},
{
name: "upstream base path is preserved",
upstream: "https://api.internal/base",
prefix: "/g/x",
path: "/g/x/ping",
want: "https://api.internal/base/ping",
},
{
name: "root prefix leaves path untouched",
upstream: "https://api.internal",
prefix: "/",
path: "/g/raw",
want: "https://api.internal/g/raw",
},
{
name: "path without prefix is still proxied",
upstream: "https://api.internal",
prefix: "/g/x",
path: "/other",
want: "https://api.internal/other",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := BuildTargetURL(tc.upstream, tc.prefix, tc.path, tc.query)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
func TestBuildTargetURL_InvalidUpstream(t *testing.T) {
if _, err := BuildTargetURL("://bad", "/g/x", "/g/x/y", ""); err == nil {
t.Fatal("expected error for unparsable upstream")
}
}
+13 -3
View File
@@ -50,10 +50,15 @@ func init() {
register(spec{"containr_projects_create", "Create a project. Args: name (required), description.", "POST", "/projects", nil, []string{"name", "description"}, 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}) register(spec{"containr_projects_delete", "Delete a project and all its services. Requires confirm:true.", "DELETE", "/projects/{id}", []string{"id"}, nil, true})
// Environments
register(spec{"containr_environments_list", "List environments in a project with service counts", "GET", "/projects/{id}/environments", []string{"id"}, nil, false})
register(spec{"containr_environments_create", "Create an environment. Args: name (required — lowercase alphanumeric with dashes, 1-50 chars).", "POST", "/projects/{id}/environments", []string{"id"}, []string{"name"}, false})
register(spec{"containr_environments_delete", "Delete an empty environment. Requires confirm:true.", "DELETE", "/environments/{id}", []string{"id"}, nil, true})
// Services // 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_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_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}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir, node_id (agent id | 'auto' = least-loaded online node; image services only — remote nodes can't run git builds), spread (bool — distribute replicas across every online schedulable node; mutually exclusive with a node pin), placement_tags (string array — remote placement only onto nodes carrying every tag; re-resolved per reconcile, pin overrides).", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false}) register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir, sleep_enabled (bool — scale to zero on idle), sleep_idle_minutes (1-1440, default 15), node_id (agent id | 'auto' = least-loaded online node; git services build on the node itself), spread (bool — distribute replicas across every online schedulable node; mutually exclusive with a node pin), placement_tags (string array — remote placement only onto nodes carrying every tag; re-resolved per reconcile, pin overrides), traefik_labels (object — extra Traefik middlewares for this service's router; keys 'middlewares.<name>.<type>[.<field>]', allowlist-validated; defined middlewares attach to the router automatically).", "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_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_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false}) register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
@@ -66,7 +71,7 @@ func init() {
register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false}) register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false})
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false}) register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false}) register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes. node_id pins placement to a node agent ('auto' = least-loaded, 'local' = run on the Containr host). spread (bool) distributes replicas across every online schedulable node; mutually exclusive with a pin — clear node_id to 'local' first if pinned. placement_tags (string array; [] clears) restricts auto/spread placement to nodes carrying every tag; mutually exclusive with an explicit pin — clear the pin first.", "PUT", "/services/{id}", []string{"id"}, nil, false}) register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes. node_id pins placement to a node agent ('auto' = least-loaded, 'local' = run on the Containr host). spread (bool) distributes replicas across every online schedulable node; mutually exclusive with a pin — clear node_id to 'local' first if pinned. placement_tags (string array; [] clears) restricts auto/spread placement to nodes carrying every tag; mutually exclusive with an explicit pin — clear the pin first. traefik_labels (object; {} clears) replaces the service's Traefik middleware overrides — keys 'middlewares.<name>.<type>[.<field>]', e.g. {'middlewares.rl.ratelimit.average': '100'}.", "PUT", "/services/{id}", []string{"id"}, nil, false})
// Domains // Domains
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false}) register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
@@ -133,12 +138,17 @@ func init() {
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false}) register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
register(spec{"containr_templates_plan", "Dry-resolve a template: expanded expressions, service refs, missing variables. Args: id, variables (object).", "POST", "/templates/{id}/plan", []string{"id"}, nil, false}) register(spec{"containr_templates_plan", "Dry-resolve a template: expanded expressions, service refs, missing variables. Args: id, variables (object).", "POST", "/templates/{id}/plan", []string{"id"}, nil, false})
register(spec{"containr_templates_import_compose", "Convert docker-compose YAML into a v2 template graph config. Args: compose_yaml.", "POST", "/templates/import/compose", nil, []string{"compose_yaml"}, false}) register(spec{"containr_templates_import_compose", "Convert docker-compose YAML into a v2 template graph config. Args: compose_yaml.", "POST", "/templates/import/compose", nil, []string{"compose_yaml"}, false})
register(spec{"containr_templates_import_git", "Fetch a compose file from a git repo (clone URL, ssh remote, or owner/repo via connected providers) and convert it into a v2 template graph config. Args: repo (required), path (default: compose.yaml/yml or docker-compose.yml/yaml), ref (branch or commit).", "POST", "/templates/import/git", nil, []string{"repo", "path", "ref"}, false})
register(spec{"containr_templates_deploy_graph", "Deploy an ad-hoc service graph without a stored template. Args: project_id, config ({services:[...]}), variables, plan, region.", "POST", "/templates/deploy", nil, []string{"project_id", "config", "variables", "plan", "region"}, false}) register(spec{"containr_templates_deploy_graph", "Deploy an ad-hoc service graph without a stored template. Args: project_id, config ({services:[...]}), variables, plan, region.", "POST", "/templates/deploy", nil, []string{"project_id", "config", "variables", "plan", "region"}, false})
register(spec{"containr_projects_import_compose", "Deploy a docker-compose file straight into a project. Args: id (project), compose_yaml, variables, plan, region.", "POST", "/projects/{id}/import-compose", []string{"id"}, []string{"compose_yaml", "variables", "plan", "region"}, false}) register(spec{"containr_projects_import_compose", "Deploy a docker-compose file straight into a project. Args: id (project), compose_yaml, variables, plan, region.", "POST", "/projects/{id}/import-compose", []string{"id"}, []string{"compose_yaml", "variables", "plan", "region"}, false})
// Notifications // Notifications
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false}) register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false}) register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
register(spec{"containr_notification_channels_list", "List push notification channels (ntfy/Gotify)", "GET", "/notifications/channels", nil, nil, false})
register(spec{"containr_notification_channels_create", "Add a push notification channel. Args: kind (ntfy|gotify), endpoint (full URL incl. ntfy topic or Gotify base), token (ntfy access token or Gotify app token).", "POST", "/notifications/channels", nil, []string{"kind", "endpoint", "token"}, false})
register(spec{"containr_notification_channels_test", "Send a test notification through a push channel. Args: id.", "POST", "/notifications/channels/{id}/test", []string{"id"}, nil, false})
register(spec{"containr_notification_channels_delete", "Delete a push notification channel. Args: id. Requires confirm=true.", "DELETE", "/notifications/channels/{id}", []string{"id"}, nil, true})
register(spec{"containr_webhooks_list", "List outbound webhooks", "GET", "/webhooks", nil, nil, false}) register(spec{"containr_webhooks_list", "List outbound webhooks", "GET", "/webhooks", nil, nil, false})
register(spec{"containr_webhooks_create", "Create an outbound webhook. Args: name, url, events (list, e.g. [\"service.*\",\"*\"]), secret (auto-generated if omitted), headers, enabled.", "POST", "/webhooks", nil, []string{"name", "url", "events", "secret", "headers", "enabled"}, false}) register(spec{"containr_webhooks_create", "Create an outbound webhook. Args: name, url, events (list, e.g. [\"service.*\",\"*\"]), secret (auto-generated if omitted), headers, enabled.", "POST", "/webhooks", nil, []string{"name", "url", "events", "secret", "headers", "enabled"}, false})
@@ -173,7 +183,7 @@ func init() {
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false}) register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
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_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}) register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
register(spec{"containr_nodes_update", "Update a node agent (admin). Args: id; optional auto_prune, name, tags (string array — placement labels services can require via placement_tags; [] clears).", "PUT", "/agents/{id}", []string{"id"}, []string{"auto_prune", "name", "tags"}, false}) register(spec{"containr_nodes_update", "Update a node agent (admin). Args: id; optional auto_prune, name, tags (string array — placement labels services can require via placement_tags; [] clears), default_domain (base domain — services placed on this node with no explicit domains get <name>.<default_domain>; empty clears).", "PUT", "/agents/{id}", []string{"id"}, []string{"auto_prune", "name", "tags", "default_domain"}, false})
register(spec{"containr_nodes_cordon", "Cordon a node: blocks new pins and 'auto' placements; running replicas stay (admin). Args: id.", "POST", "/agents/{id}/cordon", []string{"id"}, nil, false}) register(spec{"containr_nodes_cordon", "Cordon a node: blocks new pins and 'auto' placements; running replicas stay (admin). Args: id.", "POST", "/agents/{id}/cordon", []string{"id"}, nil, false})
register(spec{"containr_nodes_uncordon", "Re-open a node for placement (admin). Args: id.", "POST", "/agents/{id}/uncordon", []string{"id"}, nil, false}) register(spec{"containr_nodes_uncordon", "Re-open a node for placement (admin). Args: id.", "POST", "/agents/{id}/uncordon", []string{"id"}, nil, false})
register(spec{"containr_nodes_drain", "Drain a node: cordon + remove all service containers via the agent + unpin affected services so they redeploy locally. Requires the node online (admin). Args: id.", "POST", "/agents/{id}/drain", []string{"id"}, nil, true}) register(spec{"containr_nodes_drain", "Drain a node: cordon + remove all service containers via the agent + unpin affected services so they redeploy locally. Requires the node online (admin). Args: id.", "POST", "/agents/{id}/drain", []string{"id"}, nil, true})
+259 -332
View File
@@ -4,293 +4,286 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"strconv"
"sync" "sync"
"time" "time"
"containr/internal/database/sqlcdb"
_ "github.com/lib/pq" _ "github.com/lib/pq"
) )
// PostgreSQLMetricsStorage implements MetricsStorage using PostgreSQL // PostgreSQLMetricsStorage implements MetricsStorage using PostgreSQL
// through sqlc-generated queries (sqlc/queries/metrics.sql).
//
// Per-container node metrics are deliberately not persisted: the
// container_metrics table belongs to the container_instances model and
// has no place for the node/container samples collected here. Node and
// service/instance rows map cleanly, so those are stored.
type PostgreSQLMetricsStorage struct { type PostgreSQLMetricsStorage struct {
db *sql.DB q *sqlcdb.Queries
} }
// NewPostgreSQLMetricsStorage creates a new PostgreSQL metrics storage // NewPostgreSQLMetricsStorage creates a new PostgreSQL metrics storage
func NewPostgreSQLMetricsStorage(db *sql.DB) *PostgreSQLMetricsStorage { func NewPostgreSQLMetricsStorage(db *sql.DB) *PostgreSQLMetricsStorage {
return &PostgreSQLMetricsStorage{db: db} return &PostgreSQLMetricsStorage{q: sqlcdb.New(db)}
} }
func numStr(v float64) sql.NullString {
return sql.NullString{String: strconv.FormatFloat(v, 'f', -1, 64), Valid: true}
}
func int64N(v int64) sql.NullInt64 {
return sql.NullInt64{Int64: v, Valid: true}
}
func int32N(v int64) sql.NullInt32 {
return sql.NullInt32{Int32: int32(v), Valid: true}
}
func strN(v string) sql.NullString {
return sql.NullString{String: v, Valid: v != ""}
}
func timeN(t time.Time) sql.NullTime {
return sql.NullTime{Time: t, Valid: !t.IsZero()}
}
func f64v(n sql.NullString) float64 {
f, _ := strconv.ParseFloat(n.String, 64)
return f
}
func i64v(n sql.NullInt64) int64 { return n.Int64 }
func i32v(n sql.NullInt32) int64 { return int64(n.Int32) }
// StoreNodeMetrics stores node metrics in the database // StoreNodeMetrics stores node metrics in the database
func (s *PostgreSQLMetricsStorage) StoreNodeMetrics(ctx context.Context, metrics *NodeMetrics) error { func (s *PostgreSQLMetricsStorage) StoreNodeMetrics(ctx context.Context, metrics *NodeMetrics) error {
query := ` err := s.q.UpsertNodeMetrics(ctx, sqlcdb.UpsertNodeMetricsParams{
INSERT INTO node_metrics ( NodeID: metrics.NodeID,
node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15, Timestamp: metrics.Timestamp,
memory_total, memory_used, memory_available, memory_usage_percent, CpuUsage: numStr(metrics.CPU.UsagePercent),
storage_total, storage_used, storage_available, storage_usage_percent, CpuCores: numStr(metrics.CPU.UsageCores),
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out, LoadAvg1: numStr(metrics.CPU.LoadAverage1),
network_connections_in, network_connections_out, network_errors_in, network_errors_out, LoadAvg5: numStr(metrics.CPU.LoadAverage5),
uptime, processes, os, kernel, architecture LoadAvg15: numStr(metrics.CPU.LoadAverage15),
) 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) MemoryTotal: int64N(metrics.Memory.Total),
ON CONFLICT (node_id, timestamp) DO UPDATE SET MemoryUsed: int64N(metrics.Memory.Used),
cpu_usage = EXCLUDED.cpu_usage, MemoryAvailable: int64N(metrics.Memory.Available),
cpu_cores = EXCLUDED.cpu_cores, MemoryUsagePercent: numStr(metrics.Memory.UsagePercent),
load_avg_1 = EXCLUDED.load_avg_1, StorageTotal: int64N(metrics.Storage.Total),
load_avg_5 = EXCLUDED.load_avg_5, StorageUsed: int64N(metrics.Storage.Used),
load_avg_15 = EXCLUDED.load_avg_15, StorageAvailable: int64N(metrics.Storage.Available),
memory_total = EXCLUDED.memory_total, StorageUsagePercent: numStr(metrics.Storage.UsagePercent),
memory_used = EXCLUDED.memory_used, NetworkBytesIn: int64N(metrics.Network.BytesIn),
memory_available = EXCLUDED.memory_available, NetworkBytesOut: int64N(metrics.Network.BytesOut),
memory_usage_percent = EXCLUDED.memory_usage_percent, NetworkPacketsIn: int64N(metrics.Network.PacketsIn),
storage_total = EXCLUDED.storage_total, NetworkPacketsOut: int64N(metrics.Network.PacketsOut),
storage_used = EXCLUDED.storage_used, NetworkConnectionsIn: int32N(metrics.Network.ConnectionsIn),
storage_available = EXCLUDED.storage_available, NetworkConnectionsOut: int32N(metrics.Network.ConnectionsOut),
storage_usage_percent = EXCLUDED.storage_usage_percent, NetworkErrorsIn: int64N(metrics.Network.ErrorsIn),
network_bytes_in = EXCLUDED.network_bytes_in, NetworkErrorsOut: int64N(metrics.Network.ErrorsOut),
network_bytes_out = EXCLUDED.network_bytes_out, // INTERVAL param — Postgres casts the bigint as seconds.
network_packets_in = EXCLUDED.network_packets_in, Uptime: int64N(int64(metrics.System.Uptime.Seconds())),
network_packets_out = EXCLUDED.network_packets_out, Processes: sql.NullInt32{Int32: int32(metrics.System.Processes), Valid: true},
network_connections_in = EXCLUDED.network_connections_in, Os: strN(metrics.System.OS),
network_connections_out = EXCLUDED.network_connections_out, Kernel: strN(metrics.System.Kernel),
network_errors_in = EXCLUDED.network_errors_in, Architecture: strN(metrics.System.Architecture),
network_errors_out = EXCLUDED.network_errors_out, })
uptime = EXCLUDED.uptime,
processes = EXCLUDED.processes,
os = EXCLUDED.os,
kernel = EXCLUDED.kernel,
architecture = EXCLUDED.architecture
`
_, err := s.db.ExecContext(ctx, query,
metrics.NodeID, metrics.Timestamp, metrics.CPU.UsagePercent, metrics.CPU.UsageCores,
metrics.CPU.LoadAverage1, metrics.CPU.LoadAverage5, metrics.CPU.LoadAverage15,
metrics.Memory.Total, metrics.Memory.Used, metrics.Memory.Available, metrics.Memory.UsagePercent,
metrics.Storage.Total, metrics.Storage.Used, metrics.Storage.Available, metrics.Storage.UsagePercent,
metrics.Network.BytesIn, metrics.Network.BytesOut, metrics.Network.PacketsIn, metrics.Network.PacketsOut,
metrics.Network.ConnectionsIn, metrics.Network.ConnectionsOut, metrics.Network.ErrorsIn, metrics.Network.ErrorsOut,
metrics.System.Uptime, metrics.System.Processes, metrics.System.OS, metrics.System.Kernel, metrics.System.Architecture,
)
if err != nil { if err != nil {
return fmt.Errorf("failed to store node metrics: %w", err) return fmt.Errorf("failed to store node metrics: %w", err)
} }
// Store container metrics
for _, container := range metrics.Containers {
if err := s.storeContainerMetrics(ctx, metrics.NodeID, metrics.Timestamp, container); err != nil {
return fmt.Errorf("failed to store container metrics: %w", err)
}
}
return nil return nil
} }
// StoreServiceMetrics stores service metrics in the database // StoreServiceMetrics stores service metrics in the database
func (s *PostgreSQLMetricsStorage) StoreServiceMetrics(ctx context.Context, metrics *ServiceMetrics) error { func (s *PostgreSQLMetricsStorage) StoreServiceMetrics(ctx context.Context, metrics *ServiceMetrics) error {
query := ` err := s.q.UpsertServiceMetrics(ctx, sqlcdb.UpsertServiceMetricsParams{
INSERT INTO service_metrics ( ServiceID: metrics.ServiceID,
service_id, service_name, project_id, timestamp, ServiceName: metrics.ServiceName,
requests_total, requests_success, requests_errors, requests_avg_latency, ProjectID: metrics.ProjectID,
requests_p95_latency, requests_p99_latency, requests_throughput, Timestamp: metrics.Timestamp,
errors_total, errors_rate, performance_response_time, performance_throughput, RequestsTotal: int64N(metrics.Requests.Total),
performance_concurrency, performance_saturation, performance_utilization, RequestsSuccess: int64N(metrics.Requests.Success),
resource_cpu_usage, resource_memory_usage, resource_storage_usage, RequestsErrors: int64N(metrics.Requests.Errors),
resource_network_usage, resource_score RequestsAvgLatency: numStr(metrics.Requests.AvgLatency),
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23) RequestsP95Latency: numStr(metrics.Requests.P95Latency),
ON CONFLICT (service_id, timestamp) DO UPDATE SET RequestsP99Latency: numStr(metrics.Requests.P99Latency),
requests_total = EXCLUDED.requests_total, RequestsThroughput: numStr(metrics.Requests.Throughput),
requests_success = EXCLUDED.requests_success, ErrorsTotal: int64N(metrics.Errors.Total),
requests_errors = EXCLUDED.requests_errors, ErrorsRate: numStr(metrics.Errors.Rate),
requests_avg_latency = EXCLUDED.requests_avg_latency, PerformanceResponseTime: numStr(metrics.Performance.ResponseTime),
requests_p95_latency = EXCLUDED.requests_p95_latency, PerformanceThroughput: numStr(metrics.Performance.Throughput),
requests_p99_latency = EXCLUDED.requests_p99_latency, PerformanceConcurrency: int64N(metrics.Performance.Concurrency),
requests_throughput = EXCLUDED.requests_throughput, PerformanceSaturation: numStr(metrics.Performance.Saturation),
errors_total = EXCLUDED.errors_total, PerformanceUtilization: numStr(metrics.Performance.Utilization),
errors_rate = EXCLUDED.errors_rate, ResourceCpuUsage: numStr(metrics.Resources.CPUUsage),
performance_response_time = EXCLUDED.performance_response_time, ResourceMemoryUsage: int64N(metrics.Resources.MemoryUsage),
performance_throughput = EXCLUDED.performance_throughput, ResourceStorageUsage: int64N(metrics.Resources.StorageUsage),
performance_concurrency = EXCLUDED.performance_concurrency, ResourceNetworkUsage: int64N(metrics.Resources.NetworkUsage),
performance_saturation = EXCLUDED.performance_saturation, ResourceScore: numStr(metrics.Resources.ResourceScore),
performance_utilization = EXCLUDED.performance_utilization, })
resource_cpu_usage = EXCLUDED.resource_cpu_usage,
resource_memory_usage = EXCLUDED.resource_memory_usage,
resource_storage_usage = EXCLUDED.resource_storage_usage,
resource_network_usage = EXCLUDED.resource_network_usage,
resource_score = EXCLUDED.resource_score
`
_, err := s.db.ExecContext(ctx, query,
metrics.ServiceID, metrics.ServiceName, metrics.ProjectID, metrics.Timestamp,
metrics.Requests.Total, metrics.Requests.Success, metrics.Requests.Errors,
metrics.Requests.AvgLatency, metrics.Requests.P95Latency, metrics.Requests.P99Latency,
metrics.Requests.Throughput, metrics.Errors.Total, metrics.Errors.Rate,
metrics.Performance.ResponseTime, metrics.Performance.Throughput,
metrics.Performance.Concurrency, metrics.Performance.Saturation, metrics.Performance.Utilization,
metrics.Resources.CPUUsage, metrics.Resources.MemoryUsage, metrics.Resources.StorageUsage,
metrics.Resources.NetworkUsage, metrics.Resources.ResourceScore,
)
if err != nil { if err != nil {
return fmt.Errorf("failed to store service metrics: %w", err) return fmt.Errorf("failed to store service metrics: %w", err)
} }
// Store instance metrics // The service row must land first — instance_metrics references
// service_metrics(service_id, timestamp).
for _, instance := range metrics.Instances { for _, instance := range metrics.Instances {
if err := s.storeInstanceMetrics(ctx, metrics.ServiceID, metrics.Timestamp, instance); err != nil { if err := s.storeInstanceMetrics(ctx, metrics.ServiceID, metrics.Timestamp, instance); err != nil {
return fmt.Errorf("failed to store instance metrics: %w", err) return fmt.Errorf("failed to store instance metrics: %w", err)
} }
} }
return nil return nil
} }
// GetNodeMetrics retrieves node metrics from the database // GetNodeMetrics retrieves node metrics from the database
func (s *PostgreSQLMetricsStorage) GetNodeMetrics(ctx context.Context, nodeID string, from, to time.Time) ([]*NodeMetrics, error) { func (s *PostgreSQLMetricsStorage) GetNodeMetrics(ctx context.Context, nodeID string, from, to time.Time) ([]*NodeMetrics, error) {
query := ` rows, err := s.q.ListNodeMetrics(ctx, sqlcdb.ListNodeMetricsParams{
SELECT node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15, NodeID: nodeID,
memory_total, memory_used, memory_available, memory_usage_percent, Timestamp: from,
storage_total, storage_used, storage_available, storage_usage_percent, Timestamp_2: to,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out, })
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
uptime, processes, os, kernel, architecture
FROM node_metrics
WHERE node_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC
`
rows, err := s.db.QueryContext(ctx, query, nodeID, from, to)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query node metrics: %w", err) return nil, fmt.Errorf("failed to query node metrics: %w", err)
} }
defer rows.Close()
var metrics []*NodeMetrics var metrics []*NodeMetrics
for rows.Next() { for _, r := range rows {
var m NodeMetrics metrics = append(metrics, &NodeMetrics{
err := rows.Scan( NodeID: r.NodeID,
&m.NodeID, &m.Timestamp, &m.CPU.UsagePercent, &m.CPU.UsageCores, Timestamp: r.Timestamp,
&m.CPU.LoadAverage1, &m.CPU.LoadAverage5, &m.CPU.LoadAverage15, CPU: CPUMetrics{
&m.Memory.Total, &m.Memory.Used, &m.Memory.Available, &m.Memory.UsagePercent, UsagePercent: f64v(r.CpuUsage),
&m.Storage.Total, &m.Storage.Used, &m.Storage.Available, &m.Storage.UsagePercent, UsageCores: f64v(r.CpuCores),
&m.Network.BytesIn, &m.Network.BytesOut, &m.Network.PacketsIn, &m.Network.PacketsOut, LoadAverage1: f64v(r.LoadAvg1),
&m.Network.ConnectionsIn, &m.Network.ConnectionsOut, &m.Network.ErrorsIn, &m.Network.ErrorsOut, LoadAverage5: f64v(r.LoadAvg5),
&m.System.Uptime, &m.System.Processes, &m.System.OS, &m.System.Kernel, &m.System.Architecture, LoadAverage15: f64v(r.LoadAvg15),
) },
if err != nil { Memory: MemoryMetrics{
return nil, fmt.Errorf("failed to scan node metrics: %w", err) Total: i64v(r.MemoryTotal),
} Used: i64v(r.MemoryUsed),
Available: i64v(r.MemoryAvailable),
// Get container metrics for this timestamp UsagePercent: f64v(r.MemoryUsagePercent),
containers, err := s.getContainerMetrics(ctx, nodeID, m.Timestamp) },
if err != nil { Storage: StorageMetrics{
return nil, fmt.Errorf("failed to get container metrics: %w", err) Total: i64v(r.StorageTotal),
} Used: i64v(r.StorageUsed),
m.Containers = containers Available: i64v(r.StorageAvailable),
UsagePercent: f64v(r.StorageUsagePercent),
metrics = append(metrics, &m) },
Network: NetworkMetrics{
BytesIn: i64v(r.NetworkBytesIn),
BytesOut: i64v(r.NetworkBytesOut),
PacketsIn: i64v(r.NetworkPacketsIn),
PacketsOut: i64v(r.NetworkPacketsOut),
ConnectionsIn: i32v(r.NetworkConnectionsIn),
ConnectionsOut: i32v(r.NetworkConnectionsOut),
ErrorsIn: i64v(r.NetworkErrorsIn),
ErrorsOut: i64v(r.NetworkErrorsOut),
},
System: SystemMetrics{
Uptime: time.Duration(r.UptimeSeconds) * time.Second,
Processes: int(r.Processes.Int32),
OS: r.Os.String,
Kernel: r.Kernel.String,
Architecture: r.Architecture.String,
},
})
} }
return metrics, nil return metrics, nil
} }
// GetServiceMetrics retrieves service metrics from the database // GetServiceMetrics retrieves service metrics from the database
func (s *PostgreSQLMetricsStorage) GetServiceMetrics(ctx context.Context, serviceID string, from, to time.Time) ([]*ServiceMetrics, error) { func (s *PostgreSQLMetricsStorage) GetServiceMetrics(ctx context.Context, serviceID string, from, to time.Time) ([]*ServiceMetrics, error) {
query := ` rows, err := s.q.ListServiceMetrics(ctx, sqlcdb.ListServiceMetricsParams{
SELECT service_id, service_name, project_id, timestamp, ServiceID: serviceID,
requests_total, requests_success, requests_errors, requests_avg_latency, Timestamp: from,
requests_p95_latency, requests_p99_latency, requests_throughput, Timestamp_2: to,
errors_total, errors_rate, performance_response_time, performance_throughput, })
performance_concurrency, performance_saturation, performance_utilization,
resource_cpu_usage, resource_memory_usage, resource_storage_usage,
resource_network_usage, resource_score
FROM service_metrics
WHERE service_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC
`
rows, err := s.db.QueryContext(ctx, query, serviceID, from, to)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query service metrics: %w", err) return nil, fmt.Errorf("failed to query service metrics: %w", err)
} }
defer rows.Close()
var metrics []*ServiceMetrics var metrics []*ServiceMetrics
for rows.Next() { for _, r := range rows {
var m ServiceMetrics m := &ServiceMetrics{
err := rows.Scan( ServiceID: r.ServiceID,
&m.ServiceID, &m.ServiceName, &m.ProjectID, &m.Timestamp, ServiceName: r.ServiceName,
&m.Requests.Total, &m.Requests.Success, &m.Requests.Errors, ProjectID: r.ProjectID,
&m.Requests.AvgLatency, &m.Requests.P95Latency, &m.Requests.P99Latency, Timestamp: r.Timestamp,
&m.Requests.Throughput, &m.Errors.Total, &m.Errors.Rate, Requests: RequestMetrics{
&m.Performance.ResponseTime, &m.Performance.Throughput, Total: i64v(r.RequestsTotal),
&m.Performance.Concurrency, &m.Performance.Saturation, &m.Performance.Utilization, Success: i64v(r.RequestsSuccess),
&m.Resources.CPUUsage, &m.Resources.MemoryUsage, &m.Resources.StorageUsage, Errors: i64v(r.RequestsErrors),
&m.Resources.NetworkUsage, &m.Resources.ResourceScore, AvgLatency: f64v(r.RequestsAvgLatency),
) P95Latency: f64v(r.RequestsP95Latency),
if err != nil { P99Latency: f64v(r.RequestsP99Latency),
return nil, fmt.Errorf("failed to scan service metrics: %w", err) Throughput: f64v(r.RequestsThroughput),
},
Errors: ErrorMetrics{
Total: i64v(r.ErrorsTotal),
Rate: f64v(r.ErrorsRate),
},
Performance: PerformanceMetrics{
ResponseTime: f64v(r.PerformanceResponseTime),
Throughput: f64v(r.PerformanceThroughput),
Concurrency: i64v(r.PerformanceConcurrency),
Saturation: f64v(r.PerformanceSaturation),
Utilization: f64v(r.PerformanceUtilization),
},
Resources: ResourceMetrics{
CPUUsage: f64v(r.ResourceCpuUsage),
MemoryUsage: i64v(r.ResourceMemoryUsage),
StorageUsage: i64v(r.ResourceStorageUsage),
NetworkUsage: i64v(r.ResourceNetworkUsage),
ResourceScore: f64v(r.ResourceScore),
},
} }
// Get instance metrics for this timestamp instances, err := s.getInstanceMetrics(ctx, serviceID, r.Timestamp)
instances, err := s.getInstanceMetrics(ctx, serviceID, m.Timestamp)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get instance metrics: %w", err) return nil, fmt.Errorf("failed to get instance metrics: %w", err)
} }
m.Instances = instances m.Instances = instances
metrics = append(metrics, m)
metrics = append(metrics, &m)
} }
return metrics, nil return metrics, nil
} }
// GetAggregatedMetrics retrieves aggregated metrics based on a query // GetAggregatedMetrics retrieves aggregated metrics based on a query.
// Bucketing uses date_bin (PostgreSQL 14+) — no TimescaleDB needed.
func (s *PostgreSQLMetricsStorage) GetAggregatedMetrics(ctx context.Context, query MetricsQuery) (*AggregatedMetrics, error) { func (s *PostgreSQLMetricsStorage) GetAggregatedMetrics(ctx context.Context, query MetricsQuery) (*AggregatedMetrics, error) {
// This is a simplified implementation
// In a real system, you'd build dynamic SQL based on the query
var timeSeries []TimeSeriesPoint var timeSeries []TimeSeriesPoint
var summary map[string]MetricSummary var summary map[string]MetricSummary
switch query.Type { switch query.Type {
case "node": case "node":
// Aggregate node metrics seconds := int64(query.Interval.Seconds())
nodeQuery := ` if seconds <= 0 {
SELECT seconds = 300
time_bucket($1, timestamp) AS bucket, }
AVG(cpu_usage) as avg_cpu, rows, err := s.q.AggregateNodeMetrics(ctx, sqlcdb.AggregateNodeMetricsParams{
AVG(memory_usage_percent) as avg_memory, Column1: seconds,
AVG(storage_usage_percent) as avg_storage NodeID: query.ID,
FROM node_metrics Timestamp: query.From,
WHERE node_id = $2 AND timestamp BETWEEN $3 AND $4 Timestamp_2: query.To,
GROUP BY bucket })
ORDER BY bucket ASC
`
rows, err := s.db.QueryContext(ctx, nodeQuery, query.Interval, query.ID, query.From, query.To)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query aggregated node metrics: %w", err) return nil, fmt.Errorf("failed to query aggregated node metrics: %w", err)
} }
defer rows.Close() for _, r := range rows {
bucket, _ := r.Bucket.(time.Time)
for rows.Next() { timeSeries = append(timeSeries, TimeSeriesPoint{
var bucket time.Time
var avgCPU, avgMemory, avgStorage float64
if err := rows.Scan(&bucket, &avgCPU, &avgMemory, &avgStorage); err != nil {
return nil, fmt.Errorf("failed to scan aggregated metrics: %w", err)
}
point := TimeSeriesPoint{
Timestamp: bucket, Timestamp: bucket,
Values: map[string]float64{ Values: map[string]float64{
"cpu_usage": avgCPU, "cpu_usage": r.AvgCpu,
"memory_usage": avgMemory, "memory_usage": r.AvgMemory,
"storage_usage": avgStorage, "storage_usage": r.AvgStorage,
}, },
} })
timeSeries = append(timeSeries, point)
} }
// Calculate summary statistics
summary = map[string]MetricSummary{ summary = map[string]MetricSummary{
"cpu_usage": calculateSummary(timeSeries, "cpu_usage"), "cpu_usage": calculateSummary(timeSeries, "cpu_usage"),
"memory_usage": calculateSummary(timeSeries, "memory_usage"), "memory_usage": calculateSummary(timeSeries, "memory_usage"),
@@ -305,137 +298,69 @@ func (s *PostgreSQLMetricsStorage) GetAggregatedMetrics(ctx context.Context, que
}, nil }, nil
} }
// Helper methods
func (s *PostgreSQLMetricsStorage) storeContainerMetrics(ctx context.Context, nodeID string, timestamp time.Time, container ContainerMetrics) error {
query := `
INSERT INTO container_metrics (
node_id, timestamp, container_id, name, state, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out, start_time
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (node_id, timestamp, container_id) DO UPDATE SET
name = EXCLUDED.name,
state = EXCLUDED.state,
cpu = EXCLUDED.cpu,
memory = EXCLUDED.memory,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
start_time = EXCLUDED.start_time
`
_, err := s.db.ExecContext(ctx, query,
nodeID, timestamp, container.ContainerID, container.Name, container.State,
container.CPU, container.Memory, container.Network.BytesIn, container.Network.BytesOut,
container.Network.PacketsIn, container.Network.PacketsOut, container.StartTime,
)
return err
}
func (s *PostgreSQLMetricsStorage) storeInstanceMetrics(ctx context.Context, serviceID string, timestamp time.Time, instance InstanceMetrics) error { func (s *PostgreSQLMetricsStorage) storeInstanceMetrics(ctx context.Context, serviceID string, timestamp time.Time, instance InstanceMetrics) error {
query := ` return s.q.UpsertInstanceMetrics(ctx, sqlcdb.UpsertInstanceMetricsParams{
INSERT INTO instance_metrics ( ServiceID: serviceID,
service_id, timestamp, instance_id, node_id, status, cpu, memory, Timestamp: timestamp,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out, InstanceID: instance.InstanceID,
network_connections_in, network_connections_out, network_errors_in, network_errors_out, NodeID: strN(instance.NodeID),
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count Status: strN(instance.Status),
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) Cpu: numStr(instance.CPU),
ON CONFLICT (service_id, timestamp, instance_id) DO UPDATE SET Memory: int64N(instance.Memory),
node_id = EXCLUDED.node_id, NetworkBytesIn: int64N(instance.Network.BytesIn),
status = EXCLUDED.status, NetworkBytesOut: int64N(instance.Network.BytesOut),
cpu = EXCLUDED.cpu, NetworkPacketsIn: int64N(instance.Network.PacketsIn),
memory = EXCLUDED.memory, NetworkPacketsOut: int64N(instance.Network.PacketsOut),
network_bytes_in = EXCLUDED.network_bytes_in, NetworkConnectionsIn: int32N(instance.Network.ConnectionsIn),
network_bytes_out = EXCLUDED.network_bytes_out, NetworkConnectionsOut: int32N(instance.Network.ConnectionsOut),
network_packets_in = EXCLUDED.network_packets_in, NetworkErrorsIn: int64N(instance.Network.ErrorsIn),
network_packets_out = EXCLUDED.network_packets_out, NetworkErrorsOut: int64N(instance.Network.ErrorsOut),
network_connections_in = EXCLUDED.network_connections_in, StartTime: timeN(instance.StartTime),
network_connections_out = EXCLUDED.network_connections_out, LastSeen: timeN(instance.LastSeen),
network_errors_in = EXCLUDED.network_errors_in, HealthStatus: strN(instance.Health.Status),
network_errors_out = EXCLUDED.network_errors_out, HealthLastCheck: timeN(instance.Health.LastCheck),
start_time = EXCLUDED.start_time, HealthCheckCount: sql.NullInt32{Int32: int32(instance.Health.CheckCount), Valid: true},
last_seen = EXCLUDED.last_seen, HealthFailureCount: sql.NullInt32{Int32: int32(instance.Health.FailureCount), Valid: true},
health_status = EXCLUDED.health_status, })
health_last_check = EXCLUDED.health_last_check,
health_check_count = EXCLUDED.health_check_count,
health_failure_count = EXCLUDED.health_failure_count
`
_, err := s.db.ExecContext(ctx, query,
serviceID, timestamp, instance.InstanceID, instance.NodeID, instance.Status,
instance.CPU, instance.Memory, instance.Network.BytesIn, instance.Network.BytesOut,
instance.Network.PacketsIn, instance.Network.PacketsOut, instance.Network.ConnectionsIn,
instance.Network.ConnectionsOut, instance.Network.ErrorsIn, instance.Network.ErrorsOut,
instance.StartTime, instance.LastSeen, instance.Health.Status, instance.Health.LastCheck,
instance.Health.CheckCount, instance.Health.FailureCount,
)
return err
}
func (s *PostgreSQLMetricsStorage) getContainerMetrics(ctx context.Context, nodeID string, timestamp time.Time) ([]ContainerMetrics, error) {
query := `
SELECT container_id, name, state, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out, start_time
FROM container_metrics
WHERE node_id = $1 AND timestamp = $2
`
rows, err := s.db.QueryContext(ctx, query, nodeID, timestamp)
if err != nil {
return nil, err
}
defer rows.Close()
var containers []ContainerMetrics
for rows.Next() {
var c ContainerMetrics
err := rows.Scan(
&c.ContainerID, &c.Name, &c.State, &c.CPU, &c.Memory,
&c.Network.BytesIn, &c.Network.BytesOut, &c.Network.PacketsIn, &c.Network.PacketsOut, &c.StartTime,
)
if err != nil {
return nil, err
}
containers = append(containers, c)
}
return containers, nil
} }
func (s *PostgreSQLMetricsStorage) getInstanceMetrics(ctx context.Context, serviceID string, timestamp time.Time) ([]InstanceMetrics, error) { func (s *PostgreSQLMetricsStorage) getInstanceMetrics(ctx context.Context, serviceID string, timestamp time.Time) ([]InstanceMetrics, error) {
query := ` rows, err := s.q.ListInstanceMetrics(ctx, sqlcdb.ListInstanceMetricsParams{
SELECT instance_id, node_id, status, cpu, memory, ServiceID: serviceID,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out, Timestamp: timestamp,
network_connections_in, network_connections_out, network_errors_in, network_errors_out, })
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count
FROM instance_metrics
WHERE service_id = $1 AND timestamp = $2
`
rows, err := s.db.QueryContext(ctx, query, serviceID, timestamp)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var instances []InstanceMetrics var instances []InstanceMetrics
for rows.Next() { for _, r := range rows {
var i InstanceMetrics instances = append(instances, InstanceMetrics{
err := rows.Scan( InstanceID: r.InstanceID,
&i.InstanceID, &i.NodeID, &i.Status, &i.CPU, &i.Memory, NodeID: r.NodeID.String,
&i.Network.BytesIn, &i.Network.BytesOut, &i.Network.PacketsIn, &i.Network.PacketsOut, Status: r.Status.String,
&i.Network.ConnectionsIn, &i.Network.ConnectionsOut, &i.Network.ErrorsIn, &i.Network.ErrorsOut, CPU: f64v(r.Cpu),
&i.StartTime, &i.LastSeen, &i.Health.Status, &i.Health.LastCheck, &i.Health.CheckCount, &i.Health.FailureCount, Memory: i64v(r.Memory),
) Network: NetworkMetrics{
if err != nil { BytesIn: i64v(r.NetworkBytesIn),
return nil, err BytesOut: i64v(r.NetworkBytesOut),
} PacketsIn: i64v(r.NetworkPacketsIn),
instances = append(instances, i) PacketsOut: i64v(r.NetworkPacketsOut),
ConnectionsIn: i32v(r.NetworkConnectionsIn),
ConnectionsOut: i32v(r.NetworkConnectionsOut),
ErrorsIn: i64v(r.NetworkErrorsIn),
ErrorsOut: i64v(r.NetworkErrorsOut),
},
StartTime: r.StartTime.Time,
LastSeen: r.LastSeen.Time,
Health: HealthMetrics{
Status: r.HealthStatus.String,
LastCheck: r.HealthLastCheck.Time,
CheckCount: int(r.HealthCheckCount.Int32),
FailureCount: int(r.HealthFailureCount.Int32),
},
})
} }
return instances, nil return instances, nil
} }
@@ -544,7 +469,9 @@ func (s *InMemoryMetricsStorage) GetServiceMetrics(ctx context.Context, serviceI
} }
func (s *InMemoryMetricsStorage) GetAggregatedMetrics(ctx context.Context, query MetricsQuery) (*AggregatedMetrics, error) { func (s *InMemoryMetricsStorage) GetAggregatedMetrics(ctx context.Context, query MetricsQuery) (*AggregatedMetrics, error) {
// Simplified implementation s.mu.RLock()
defer s.mu.RUnlock()
return &AggregatedMetrics{ return &AggregatedMetrics{
Query: query, Query: query,
TimeSeries: []TimeSeriesPoint{}, TimeSeries: []TimeSeriesPoint{},
@@ -0,0 +1,130 @@
package metrics
import (
"context"
"database/sql"
"os"
"testing"
"time"
)
// Live round-trip against a real Postgres — set METRICS_TEST_DSN (or
// DATABASE_URL) to enable. Skipped by default so unit runs stay hermetic.
func testDSN(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("METRICS_TEST_DSN")
if dsn == "" {
dsn = os.Getenv("DATABASE_URL")
}
if dsn == "" {
t.Skip("METRICS_TEST_DSN not set")
}
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.PingContext(context.Background()); err != nil {
t.Fatalf("ping: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestPostgresStorageRoundTrip(t *testing.T) {
db := testDSN(t)
s := NewPostgreSQLMetricsStorage(db)
ctx := context.Background()
ts := time.Now().UTC().Truncate(time.Second)
nodeID := "test-node-" + ts.Format("150405")
svcID := "test-svc-" + ts.Format("150405")
// Clean slate + teardown
db.ExecContext(ctx, `DELETE FROM instance_metrics WHERE service_id = $1`, svcID)
db.ExecContext(ctx, `DELETE FROM service_metrics WHERE service_id = $1`, svcID)
db.ExecContext(ctx, `DELETE FROM node_metrics WHERE node_id = $1`, nodeID)
t.Cleanup(func() {
db.ExecContext(ctx, `DELETE FROM instance_metrics WHERE service_id = $1`, svcID)
db.ExecContext(ctx, `DELETE FROM service_metrics WHERE service_id = $1`, svcID)
db.ExecContext(ctx, `DELETE FROM node_metrics WHERE node_id = $1`, nodeID)
})
node := &NodeMetrics{
NodeID: nodeID,
Timestamp: ts,
CPU: CPUMetrics{UsagePercent: 42.5, UsageCores: 1.7, LoadAverage1: 0.5, LoadAverage5: 0.4, LoadAverage15: 0.3},
Memory: MemoryMetrics{Total: 16 << 30, Used: 8 << 30, Available: 8 << 30, UsagePercent: 50.0},
Storage: StorageMetrics{Total: 100 << 30, Used: 40 << 30, Available: 60 << 30, UsagePercent: 40.0},
Network: NetworkMetrics{BytesIn: 1000, BytesOut: 2000, PacketsIn: 10, PacketsOut: 20, ConnectionsIn: 3, ConnectionsOut: 4, ErrorsIn: 0, ErrorsOut: 1},
System: SystemMetrics{Uptime: 3600 * time.Second, Processes: 120, OS: "linux", Kernel: "6.8", Architecture: "amd64"},
}
if err := s.StoreNodeMetrics(ctx, node); err != nil {
t.Fatalf("store node: %v", err)
}
// Upsert idempotent on the same key.
if err := s.StoreNodeMetrics(ctx, node); err != nil {
t.Fatalf("store node (repeat): %v", err)
}
svc := &ServiceMetrics{
ServiceID: svcID,
ServiceName: "svc",
ProjectID: "proj",
Timestamp: ts,
Requests: RequestMetrics{Total: 100, Success: 95, Errors: 5, AvgLatency: 12.5, P95Latency: 80, P99Latency: 120, Throughput: 3.3},
Errors: ErrorMetrics{Total: 5, Rate: 0.05},
Performance: PerformanceMetrics{ResponseTime: 12.5, Throughput: 3.3, Concurrency: 2, Saturation: 0.4, Utilization: 0.55},
Resources: ResourceMetrics{CPUUsage: 42.5, MemoryUsage: 512 << 20, StorageUsage: 1 << 30, NetworkUsage: 3000, ResourceScore: 0.7},
Instances: []InstanceMetrics{{
InstanceID: "inst-1",
NodeID: nodeID,
Status: "running",
CPU: 42.5,
Memory: 512 << 20,
Network: NetworkMetrics{BytesIn: 1000, BytesOut: 2000},
StartTime: ts.Add(-time.Hour),
LastSeen: ts,
Health: HealthMetrics{Status: "healthy", LastCheck: ts, CheckCount: 10, FailureCount: 0},
}},
}
if err := s.StoreServiceMetrics(ctx, svc); err != nil {
t.Fatalf("store service: %v", err)
}
from, to := ts.Add(-time.Minute), ts.Add(time.Minute)
gotNodes, err := s.GetNodeMetrics(ctx, nodeID, from, to)
if err != nil {
t.Fatalf("get nodes: %v", err)
}
if len(gotNodes) != 1 {
t.Fatalf("want 1 node row, got %d", len(gotNodes))
}
if got := gotNodes[0]; got.CPU.UsagePercent != 42.5 || got.System.Processes != 120 || got.System.Uptime != time.Hour {
t.Errorf("node round-trip mismatch: %+v", got)
}
gotSvcs, err := s.GetServiceMetrics(ctx, svcID, from, to)
if err != nil {
t.Fatalf("get services: %v", err)
}
if len(gotSvcs) != 1 {
t.Fatalf("want 1 service row, got %d", len(gotSvcs))
}
if gotSvcs[0].Requests.P99Latency != 120 {
t.Errorf("p99 mismatch: %v", gotSvcs[0].Requests.P99Latency)
}
if len(gotSvcs[0].Instances) != 1 || gotSvcs[0].Instances[0].Health.Status != "healthy" {
t.Errorf("instance join mismatch: %+v", gotSvcs[0].Instances)
}
agg, err := s.GetAggregatedMetrics(ctx, MetricsQuery{Type: "node", ID: nodeID, From: from, To: to, Interval: 5 * time.Minute})
if err != nil {
t.Fatalf("aggregate: %v", err)
}
if len(agg.TimeSeries) != 1 || agg.TimeSeries[0].Values["cpu_usage"] != 42.5 {
t.Errorf("aggregate mismatch: %+v", agg.TimeSeries)
}
if agg.Summary["cpu_usage"].Avg != 42.5 {
t.Errorf("summary mismatch: %+v", agg.Summary)
}
}
+190
View File
@@ -0,0 +1,190 @@
// Package source materializes git checkouts for builds. Services reference
// a repository by URL (or owner/repo); deployments fetch the exact branch or
// commit into an isolated workdir, then point the builders at build_path
// inside it. Credentials travel through GIT_ASKPASS so tokens never land in
// argv, .git/config, logs, or the build context.
package source
import (
"context"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Spec describes one checkout.
type Spec struct {
CloneURL string // https URL (NormalizeCloneURL output); local paths allowed for tests
Branch string // ref to fetch when Commit is empty
Commit string // optional SHA pinned over Branch
BuildPath string // subdirectory inside the repo; "" or "." = repo root
Token string // optional http credential
User string // credential username; defaults per provider in ResolveUser
}
// NormalizeCloneURL converts the forms services store into an https clone
// URL: ssh remotes (git@host:org/repo, ssh://git@host/org/repo) map onto
// https so token auth works uniformly. A bare "owner/repo" is returned
// unchanged — the caller resolves the host through git_repositories.
func NormalizeCloneURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("empty repository reference")
}
// scp-style ssh remote: [email protected]:org/repo(.git)
if strings.HasPrefix(raw, "git@") && strings.Contains(raw, ":") {
host := raw[len("git@"):strings.Index(raw, ":")]
path := strings.TrimPrefix(raw[strings.Index(raw, ":")+1:], "/")
return "https://" + host + "/" + path, nil
}
if strings.HasPrefix(raw, "ssh://") {
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("invalid ssh remote %q", raw)
}
return "https://" + u.Host + u.Path, nil
}
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
return raw, nil
}
if strings.Contains(raw, "://") {
return "", fmt.Errorf("unsupported repository scheme in %q", raw)
}
if strings.Count(raw, "/") == 1 && !strings.ContainsAny(raw, " @:") {
return raw, nil // owner/repo — resolved against git_repositories upstream
}
return "", fmt.Errorf("unsupported repository reference %q", raw)
}
// ValidateRemoteURL rejects non-http(s) clones before a deploy accepts them.
// Local checkouts (tests, mounted workspaces) bypass this via Checkout.
func ValidateRemoteURL(cloneURL string) error {
if strings.HasPrefix(cloneURL, "http://") || strings.HasPrefix(cloneURL, "https://") {
return nil
}
return fmt.Errorf("clone URL must be http(s), got %q", cloneURL)
}
// Checkout fetches spec into destDir/repo and returns the directory the
// builders should consume (checkout root + build_path). Callers
// RemoveAll(destDir) — the checkout is a per-deployment workspace, never
// shared. The askpass helper lives outside repo so it can't leak into the
// build context.
func Checkout(ctx context.Context, destDir string, spec Spec) (string, error) {
repoDir := filepath.Join(destDir, "repo")
if err := os.MkdirAll(repoDir, 0o755); err != nil {
return "", err
}
env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
if spec.Token != "" {
// Askpass keeps the token out of argv and .git/config.
askpass := filepath.Join(destDir, ".git-askpass")
script := "#!/bin/sh\ncase \"$1\" in\n*sername*) printf '%s' \"$CONTAINR_GIT_USER\" ;;\n*) printf '%s' \"$CONTAINR_GIT_TOKEN\" ;;\nesac\n"
if err := os.WriteFile(askpass, []byte(script), 0o700); err != nil {
return "", fmt.Errorf("write askpass: %w", err)
}
user := spec.User
if user == "" {
user = "x-access-token"
}
env = append(env,
"GIT_ASKPASS="+askpass,
"CONTAINR_GIT_USER="+user,
"CONTAINR_GIT_TOKEN="+spec.Token,
)
}
run := func(args ...string) error {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = repoDir
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git %s: %s", args[0], strings.TrimSpace(string(out)))
}
return nil
}
if err := run("init", "-q"); err != nil {
return "", err
}
if err := run("remote", "add", "origin", spec.CloneURL); err != nil {
return "", err
}
ref := spec.Commit
if ref == "" {
ref = spec.Branch
}
if ref == "" {
ref = "HEAD"
}
// Cheap path first, widening on failure: dumb transports reject
// --depth outright, and some hosts refuse direct SHA fetches so a
// pinned commit may only be reachable through its branch.
attempts := [][]string{
{"fetch", "--depth", "1", "origin", ref},
{"fetch", "origin", ref},
}
if spec.Commit != "" {
branchRef := spec.Branch
if branchRef == "" {
branchRef = "HEAD"
}
attempts = append(attempts,
[]string{"fetch", "--depth", "100", "origin", branchRef},
[]string{"fetch", "origin", branchRef},
)
}
var fetchErr error
for _, args := range attempts {
if fetchErr = run(args...); fetchErr == nil {
break
}
}
if fetchErr != nil {
return "", fetchErr
}
if err := run("checkout", "-q", spec.CommitOrFetchHead()); err != nil {
return "", err
}
return ResolveBuildPath(repoDir, spec.BuildPath)
}
// CommitOrFetchHead is the checkout target: the pinned commit or FETCH_HEAD.
func (s Spec) CommitOrFetchHead() string {
if s.Commit != "" {
return s.Commit
}
return "FETCH_HEAD"
}
// ResolveBuildPath maps build_path onto the checkout and rejects escapes —
// a path like ../../etc must never leave the workspace.
func ResolveBuildPath(checkoutDir, buildPath string) (string, error) {
bp := strings.TrimSpace(buildPath)
if bp == "" || bp == "." {
return checkoutDir, nil
}
if filepath.IsAbs(bp) {
return "", fmt.Errorf("build_path must be relative, got %q", bp)
}
clean := filepath.Clean(bp)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("build_path escapes the checkout: %q", bp)
}
resolved := filepath.Join(checkoutDir, clean)
info, err := os.Stat(resolved)
if err != nil {
return "", fmt.Errorf("build_path %q not found in checkout", bp)
}
if !info.IsDir() {
return "", fmt.Errorf("build_path %q is not a directory", bp)
}
return resolved, nil
}
+152
View File
@@ -0,0 +1,152 @@
package source
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestNormalizeCloneURL(t *testing.T) {
cases := []struct {
in, want string
err bool
}{
{"https://github.com/org/repo.git", "https://github.com/org/repo.git", false},
{"[email protected]:org/repo.git", "https://github.com/org/repo.git", false},
{"ssh://[email protected]/org/repo.git", "https://gitlab.example.com/org/repo.git", false},
{"org/repo", "org/repo", false},
{" https://gitlab.com/a/b.git ", "https://gitlab.com/a/b.git", false},
{"", "", true},
{"ftp://example.com/repo", "", true},
{"weird string", "", true},
}
for _, c := range cases {
got, err := NormalizeCloneURL(c.in)
if c.err {
if err == nil {
t.Errorf("%q: expected error, got %q", c.in, got)
}
continue
}
if err != nil {
t.Errorf("%q: unexpected error %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("%q: got %q, want %q", c.in, got, c.want)
}
}
}
func TestValidateRemoteURL(t *testing.T) {
if err := ValidateRemoteURL("https://github.com/o/r.git"); err != nil {
t.Errorf("https should pass: %v", err)
}
if err := ValidateRemoteURL("file:///tmp/repo"); err == nil {
t.Error("file:// should be rejected")
}
if err := ValidateRemoteURL("/tmp/repo"); err == nil {
t.Error("local path should be rejected")
}
}
// initRepo creates a small git repo with a branch and returns its path.
func initRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
run := func(args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %s", args, out)
}
}
run("init", "-q", "-b", "main")
if err := os.WriteFile(filepath.Join(dir, "app.txt"), []byte("v1"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "sub", "inner.txt"), []byte("sub"), 0o644); err != nil {
t.Fatal(err)
}
run("add", ".")
run("commit", "-q", "-m", "first")
return dir
}
func TestCheckoutBranch(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
repo := initRepo(t)
dest := t.TempDir()
dir, err := Checkout(context.Background(), dest, Spec{CloneURL: repo, Branch: "main"})
if err != nil {
t.Fatalf("checkout: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "app.txt")); err != nil {
t.Errorf("expected app.txt in checkout: %v", err)
}
}
func TestCheckoutBuildPath(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
repo := initRepo(t)
dest := t.TempDir()
dir, err := Checkout(context.Background(), dest, Spec{CloneURL: repo, Branch: "main", BuildPath: "sub"})
if err != nil {
t.Fatalf("checkout: %v", err)
}
if filepath.Base(dir) != "sub" {
t.Errorf("expected sub build path, got %s", dir)
}
if _, err := os.Stat(filepath.Join(dir, "inner.txt")); err != nil {
t.Errorf("expected inner.txt under build path: %v", err)
}
}
func TestCheckoutCommit(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
repo := initRepo(t)
out, err := exec.Command("git", "-C", repo, "rev-parse", "HEAD").Output()
if err != nil {
t.Fatal(err)
}
sha := string(out[:len(out)-1])
dest := t.TempDir()
dir, err := Checkout(context.Background(), dest, Spec{CloneURL: repo, Commit: sha, Branch: "main"})
if err != nil {
t.Fatalf("checkout commit: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "app.txt")); err != nil {
t.Errorf("expected app.txt in pinned checkout: %v", err)
}
}
func TestResolveBuildPathEscapes(t *testing.T) {
root := t.TempDir()
if _, err := ResolveBuildPath(root, "../../etc"); err == nil {
t.Error("escape should be rejected")
}
if _, err := ResolveBuildPath(root, "/abs/path"); err == nil {
t.Error("absolute path should be rejected")
}
if _, err := ResolveBuildPath(root, "missing"); err == nil {
t.Error("missing dir should be rejected")
}
if got, err := ResolveBuildPath(root, "."); err != nil || got != root {
t.Errorf("dot should resolve to root, got %q err=%v", got, err)
}
}
@@ -0,0 +1,9 @@
-- +goose Up
-- Routing overrides: per-service Traefik middleware definitions stored as
-- { "middlewares.<name>.<type>.<field>": "value" }. Validated against an
-- allowlist; applied to the service's router and auto-attached to its
-- middleware chain at reconcile time.
ALTER TABLE services ADD COLUMN IF NOT EXISTS traefik_labels jsonb NOT NULL DEFAULT '{}';
-- +goose Down
ALTER TABLE services DROP COLUMN IF EXISTS traefik_labels;
@@ -0,0 +1,8 @@
-- +goose Up
-- Per-node base domain: services pinned/spread onto an agent with
-- default_domain get auto-generated hostnames (<service>.<domain>) when
-- they have no explicit service_domains rows.
ALTER TABLE node_agents ADD COLUMN IF NOT EXISTS default_domain VARCHAR(255) NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE node_agents DROP COLUMN IF EXISTS default_domain;
@@ -0,0 +1,11 @@
-- +goose Up
ALTER TABLE public.preview_environments
ADD COLUMN IF NOT EXISTS preview_service_id uuid REFERENCES public.services(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_preview_environments_expiry
ON public.preview_environments (expires_at)
WHERE status NOT IN ('expired', 'stopped');
-- +goose Down
DROP INDEX IF EXISTS public.idx_preview_environments_expiry;
ALTER TABLE public.preview_environments DROP COLUMN IF EXISTS preview_service_id;
@@ -0,0 +1,16 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS public.notification_channels (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
kind varchar(20) NOT NULL CHECK (kind IN ('ntfy', 'gotify')),
endpoint text NOT NULL,
token varchar(255),
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_notification_channels_user ON public.notification_channels (user_id);
-- +goose Down
DROP TABLE IF EXISTS public.notification_channels;
+12
View File
@@ -179,6 +179,18 @@ LIMIT 1;
-- name: SetAgentTags :exec -- name: SetAgentTags :exec
UPDATE node_agents SET tags = $2::jsonb, updated_at = NOW() WHERE id = $1; UPDATE node_agents SET tags = $2::jsonb, updated_at = NOW() WHERE id = $1;
-- name: SetAgentDefaultDomain :exec
UPDATE node_agents SET default_domain = $2, updated_at = NOW() WHERE id = $1;
-- name: ListSchedulableAgentDomains :many
-- Base domains of every online, schedulable agent carrying the required
-- placement tags — auto-domain candidates for spread services.
SELECT DISTINCT default_domain FROM node_agents
WHERE status = 'online' AND schedulable
AND default_domain <> ''
AND tags @> $1::jsonb
ORDER BY default_domain;
-- name: ListServiceContainers :many -- name: ListServiceContainers :many
SELECT id, name, node_agent_id, status, ports, resources FROM container_instances SELECT id, name, node_agent_id, status, ports, resources FROM container_instances
WHERE service_id = $1; WHERE service_id = $1;
+46
View File
@@ -0,0 +1,46 @@
-- name: ListServiceDomains :many
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;
-- name: CountServiceDomains :one
SELECT COUNT(*) FROM service_domains WHERE service_id = $1;
-- name: ClearServiceDomainDefault :exec
UPDATE service_domains SET is_default = false WHERE service_id = $1;
-- name: CreateServiceDomain :one
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;
-- name: DeleteServiceDomain :execrows
DELETE FROM service_domains WHERE id = $1 AND service_id = $2;
-- name: SetServiceDomainDefault :execrows
UPDATE service_domains SET is_default = (id = $1) WHERE service_id = $2;
-- name: TouchServiceDomainChecked :exec
UPDATE service_domains SET last_checked_at = NOW() WHERE id = $1;
-- name: GetServiceDomainDefault :one
SELECT domain FROM service_domains WHERE service_id = $1 AND is_default;
-- name: GetServiceDomainFirst :one
SELECT domain FROM service_domains WHERE service_id = $1 ORDER BY created_at ASC LIMIT 1;
-- name: SetServiceLegacyDomain :exec
UPDATE services SET domain = $1 WHERE id = $2;
-- name: GetServicePlacementBrief :one
SELECT name, node_id, COALESCE(spread, false) AS spread FROM services WHERE id = $1;
-- name: GetServiceAccess :one
SELECT maintenance_mode, basic_auth_users FROM services WHERE id = $1;
-- name: ServiceWriteAccess :one
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)
) AS allowed;
+147
View File
@@ -0,0 +1,147 @@
-- name: UpsertNodeMetrics :exec
INSERT INTO node_metrics (
node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15,
memory_total, memory_used, memory_available, memory_usage_percent,
storage_total, storage_used, storage_available, storage_usage_percent,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
uptime, processes, os, kernel, architecture
) 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
)
ON CONFLICT (node_id, timestamp) DO UPDATE SET
cpu_usage = EXCLUDED.cpu_usage,
cpu_cores = EXCLUDED.cpu_cores,
load_avg_1 = EXCLUDED.load_avg_1,
load_avg_5 = EXCLUDED.load_avg_5,
load_avg_15 = EXCLUDED.load_avg_15,
memory_total = EXCLUDED.memory_total,
memory_used = EXCLUDED.memory_used,
memory_available = EXCLUDED.memory_available,
memory_usage_percent = EXCLUDED.memory_usage_percent,
storage_total = EXCLUDED.storage_total,
storage_used = EXCLUDED.storage_used,
storage_available = EXCLUDED.storage_available,
storage_usage_percent = EXCLUDED.storage_usage_percent,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
network_connections_in = EXCLUDED.network_connections_in,
network_connections_out = EXCLUDED.network_connections_out,
network_errors_in = EXCLUDED.network_errors_in,
network_errors_out = EXCLUDED.network_errors_out,
uptime = EXCLUDED.uptime,
processes = EXCLUDED.processes,
os = EXCLUDED.os,
kernel = EXCLUDED.kernel,
architecture = EXCLUDED.architecture;
-- name: UpsertServiceMetrics :exec
INSERT INTO service_metrics (
service_id, service_name, project_id, timestamp,
requests_total, requests_success, requests_errors, requests_avg_latency,
requests_p95_latency, requests_p99_latency, requests_throughput,
errors_total, errors_rate, performance_response_time, performance_throughput,
performance_concurrency, performance_saturation, performance_utilization,
resource_cpu_usage, resource_memory_usage, resource_storage_usage,
resource_network_usage, resource_score
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21, $22, $23
)
ON CONFLICT (service_id, timestamp) DO UPDATE SET
requests_total = EXCLUDED.requests_total,
requests_success = EXCLUDED.requests_success,
requests_errors = EXCLUDED.requests_errors,
requests_avg_latency = EXCLUDED.requests_avg_latency,
requests_p95_latency = EXCLUDED.requests_p95_latency,
requests_p99_latency = EXCLUDED.requests_p99_latency,
requests_throughput = EXCLUDED.requests_throughput,
errors_total = EXCLUDED.errors_total,
errors_rate = EXCLUDED.errors_rate,
performance_response_time = EXCLUDED.performance_response_time,
performance_throughput = EXCLUDED.performance_throughput,
performance_concurrency = EXCLUDED.performance_concurrency,
performance_saturation = EXCLUDED.performance_saturation,
performance_utilization = EXCLUDED.performance_utilization,
resource_cpu_usage = EXCLUDED.resource_cpu_usage,
resource_memory_usage = EXCLUDED.resource_memory_usage,
resource_storage_usage = EXCLUDED.resource_storage_usage,
resource_network_usage = EXCLUDED.resource_network_usage,
resource_score = EXCLUDED.resource_score;
-- name: UpsertInstanceMetrics :exec
INSERT INTO instance_metrics (
service_id, timestamp, instance_id, node_id, status, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21
)
ON CONFLICT (service_id, timestamp, instance_id) DO UPDATE SET
node_id = EXCLUDED.node_id,
status = EXCLUDED.status,
cpu = EXCLUDED.cpu,
memory = EXCLUDED.memory,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
network_connections_in = EXCLUDED.network_connections_in,
network_connections_out = EXCLUDED.network_connections_out,
network_errors_in = EXCLUDED.network_errors_in,
network_errors_out = EXCLUDED.network_errors_out,
start_time = EXCLUDED.start_time,
last_seen = EXCLUDED.last_seen,
health_status = EXCLUDED.health_status,
health_last_check = EXCLUDED.health_last_check,
health_check_count = EXCLUDED.health_check_count,
health_failure_count = EXCLUDED.health_failure_count;
-- name: ListNodeMetrics :many
SELECT node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15,
memory_total, memory_used, memory_available, memory_usage_percent,
storage_total, storage_used, storage_available, storage_usage_percent,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
coalesce(extract(epoch from uptime), 0)::bigint AS uptime_seconds,
processes, os, kernel, architecture
FROM node_metrics
WHERE node_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC;
-- name: ListServiceMetrics :many
SELECT service_id, service_name, project_id, timestamp,
requests_total, requests_success, requests_errors, requests_avg_latency,
requests_p95_latency, requests_p99_latency, requests_throughput,
errors_total, errors_rate, performance_response_time, performance_throughput,
performance_concurrency, performance_saturation, performance_utilization,
resource_cpu_usage, resource_memory_usage, resource_storage_usage,
resource_network_usage, resource_score
FROM service_metrics
WHERE service_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC;
-- name: ListInstanceMetrics :many
SELECT instance_id, node_id, status, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count
FROM instance_metrics
WHERE service_id = $1 AND timestamp = $2;
-- name: AggregateNodeMetrics :many
-- Portable bucket bucketing (no TimescaleDB): floor epoch seconds into
-- bucket-sized buckets, then back to timestamptz.
SELECT date_bin($1::interval, timestamp, '2000-01-01'::timestamptz) AS bucket,
AVG(cpu_usage)::float8 AS avg_cpu,
AVG(memory_usage_percent)::float8 AS avg_memory,
AVG(storage_usage_percent)::float8 AS avg_storage
FROM node_metrics
WHERE node_id = $2 AND timestamp BETWEEN $3 AND $4
GROUP BY bucket
ORDER BY bucket ASC;
@@ -17,3 +17,23 @@ UPDATE notifications SET read_at = $3 WHERE id = $1 AND user_id = $2 AND read_at
-- name: MarkAllNotificationsReadByUser :exec -- name: MarkAllNotificationsReadByUser :exec
UPDATE notifications SET read_at = $2 WHERE user_id = $1 AND read_at IS NULL; UPDATE notifications SET read_at = $2 WHERE user_id = $1 AND read_at IS NULL;
-- name: ListNotificationChannelsByUser :many
SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at
FROM notification_channels
WHERE user_id = $1 AND enabled
ORDER BY created_at;
-- name: ListAllNotificationChannelsByUser :many
SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at
FROM notification_channels
WHERE user_id = $1
ORDER BY created_at;
-- name: InsertNotificationChannel :one
INSERT INTO notification_channels (user_id, kind, endpoint, token)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, kind, endpoint, token, enabled, created_at, updated_at;
-- name: DeleteNotificationChannel :execrows
DELETE FROM notification_channels WHERE id = $1 AND user_id = $2;
+18
View File
@@ -125,3 +125,21 @@ SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.u
FROM projects p FROM projects p
WHERE p.is_approved = false WHERE p.is_approved = false
ORDER BY p.created_at DESC; ORDER BY p.created_at DESC;
-- name: ListProjectEnvironments :many
SELECT e.id, e.name, e.project_id, e.created_at, e.updated_at,
(SELECT COUNT(*) FROM services s WHERE s.environment_id = e.id) AS service_count
FROM environments e
WHERE e.project_id = $1
ORDER BY e.created_at;
-- name: GetProjectEnvironmentByName :one
SELECT id, name, project_id, created_at, updated_at
FROM environments
WHERE project_id = $1 AND name = $2;
-- name: DeleteProjectEnvironment :exec
DELETE FROM environments WHERE id = $1 AND project_id = $2;
-- name: CountEnvironmentServices :one
SELECT COUNT(*) FROM services WHERE environment_id = $1;
+102 -2
View File
@@ -69,7 +69,8 @@ CREATE TABLE services (
static_dir VARCHAR(255) NOT NULL DEFAULT '', static_dir VARCHAR(255) NOT NULL DEFAULT '',
node_id VARCHAR(255), node_id VARCHAR(255),
spread BOOLEAN NOT NULL DEFAULT false, spread BOOLEAN NOT NULL DEFAULT false,
placement_tags JSONB NOT NULL DEFAULT '[]' placement_tags JSONB NOT NULL DEFAULT '[]',
traefik_labels JSONB NOT NULL DEFAULT '{}'
); );
CREATE TABLE registries ( CREATE TABLE registries (
@@ -198,7 +199,8 @@ CREATE TABLE node_agents (
metadata JSONB, metadata JSONB,
auto_prune BOOLEAN NOT NULL DEFAULT false, auto_prune BOOLEAN NOT NULL DEFAULT false,
schedulable BOOLEAN NOT NULL DEFAULT true, schedulable BOOLEAN NOT NULL DEFAULT true,
tags JSONB NOT NULL DEFAULT '[]' tags JSONB NOT NULL DEFAULT '[]',
default_domain VARCHAR(255) NOT NULL DEFAULT ''
); );
CREATE TABLE container_instances ( CREATE TABLE container_instances (
@@ -333,3 +335,101 @@ CREATE TABLE user_invites (
used_at TIMESTAMP WITH TIME ZONE, used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
CREATE TABLE notification_channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind VARCHAR(20) NOT NULL CHECK (kind IN ('ntfy', 'gotify')),
endpoint TEXT NOT NULL,
token VARCHAR(255),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE node_metrics (
node_id VARCHAR(255) NOT NULL,
timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
cpu_usage NUMERIC(5,2),
cpu_cores NUMERIC(10,2),
load_avg_1 NUMERIC(5,2),
load_avg_5 NUMERIC(5,2),
load_avg_15 NUMERIC(5,2),
memory_total BIGINT,
memory_used BIGINT,
memory_available BIGINT,
memory_usage_percent NUMERIC(5,2),
storage_total BIGINT,
storage_used BIGINT,
storage_available BIGINT,
storage_usage_percent NUMERIC(5,2),
network_bytes_in BIGINT,
network_bytes_out BIGINT,
network_packets_in BIGINT,
network_packets_out BIGINT,
network_connections_in INTEGER,
network_connections_out INTEGER,
network_errors_in BIGINT,
network_errors_out BIGINT,
uptime INTERVAL,
processes INTEGER,
os VARCHAR(50),
kernel VARCHAR(50),
architecture VARCHAR(20),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
PRIMARY KEY (node_id, timestamp)
);
CREATE TABLE service_metrics (
service_id VARCHAR(255) NOT NULL,
service_name VARCHAR(255) NOT NULL,
project_id VARCHAR(255) NOT NULL,
timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
requests_total BIGINT DEFAULT 0,
requests_success BIGINT DEFAULT 0,
requests_errors BIGINT DEFAULT 0,
requests_avg_latency NUMERIC(10,3),
requests_p95_latency NUMERIC(10,3),
requests_p99_latency NUMERIC(10,3),
requests_throughput NUMERIC(10,3),
errors_total BIGINT DEFAULT 0,
errors_rate NUMERIC(5,4),
performance_response_time NUMERIC(10,3),
performance_throughput NUMERIC(10,3),
performance_concurrency BIGINT,
performance_saturation NUMERIC(5,2),
performance_utilization NUMERIC(5,2),
resource_cpu_usage NUMERIC(5,2),
resource_memory_usage BIGINT,
resource_storage_usage BIGINT,
resource_network_usage BIGINT,
resource_score NUMERIC(5,2),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
PRIMARY KEY (service_id, timestamp)
);
CREATE TABLE instance_metrics (
service_id VARCHAR(255) NOT NULL,
timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
instance_id VARCHAR(255) NOT NULL,
node_id VARCHAR(255),
status VARCHAR(50),
cpu NUMERIC(5,2),
memory BIGINT,
network_bytes_in BIGINT,
network_bytes_out BIGINT,
network_packets_in BIGINT,
network_packets_out BIGINT,
network_connections_in INTEGER,
network_connections_out INTEGER,
network_errors_in BIGINT,
network_errors_out BIGINT,
start_time TIMESTAMP WITH TIME ZONE,
last_seen TIMESTAMP WITH TIME ZONE,
health_status VARCHAR(20),
health_last_check TIMESTAMP WITH TIME ZONE,
health_check_count INTEGER DEFAULT 0,
health_failure_count INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
PRIMARY KEY (service_id, timestamp, instance_id)
);
+4 -2
View File
@@ -1,5 +1,7 @@
# Build stage # Build stage
FROM node:22-alpine AS builder ARG NODE_IMAGE=node:22-alpine
ARG NGINX_IMAGE=nginx:alpine
FROM ${NODE_IMAGE} AS builder
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
@@ -24,7 +26,7 @@ ENV VITE_AUTH_URL=${VITE_AUTH_URL}
RUN npm run build RUN npm run build
# Production stage # Production stage
FROM nginx:alpine FROM ${NGINX_IMAGE}
# Copy custom nginx config # Copy custom nginx config
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
+54 -8
View File
@@ -31,7 +31,9 @@
"@tabler/icons-react": "^3.37.1", "@tabler/icons-react": "^3.37.1",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/query-sync-storage-persister": "^5.104.1",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-persist-client": "^5.104.1",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
@@ -3441,22 +3443,49 @@
} }
}, },
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.90.20", "version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz",
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", "integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
} }
}, },
"node_modules/@tanstack/react-query": { "node_modules/@tanstack/query-persist-client-core": {
"version": "5.90.21", "version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.104.1.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", "integrity": "sha512-ONiadXqe56iNrw6RESqKILpTIVG9ZMI/DageTQnrBDt/nZ6WiP4G13iIiMwJEsiO12gQ3Gychx8tcFIPw4k25g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.90.20" "@tanstack/query-core": "5.104.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-sync-storage-persister": {
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-5.104.1.tgz",
"integrity": "sha512-eDsBMNds48+sjXILn0dQKu+uYwfh9tGIB+Rb0cKRdoxD22HHBMzJes5DJOhJVLUhBQbxtoFq+Hpha/Vohh14NQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.104.1",
"@tanstack/query-persist-client-core": "5.104.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz",
"integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.104.1"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -3466,6 +3495,23 @@
"react": "^18 || ^19" "react": "^18 || ^19"
} }
}, },
"node_modules/@tanstack/react-query-persist-client": {
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.104.1.tgz",
"integrity": "sha512-kU3YaqjkwEUzUUfPF0JVg56d6gJKWHaFVqOlQztwc4C2IT0P5sv/23orrSoQqjxKaUe6Ejw5IIQtA/G12uNmow==",
"license": "MIT",
"dependencies": {
"@tanstack/query-persist-client-core": "5.104.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.104.1",
"react": "^18 || ^19"
}
},
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+2
View File
@@ -42,7 +42,9 @@
"@tabler/icons-react": "^3.37.1", "@tabler/icons-react": "^3.37.1",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/query-sync-storage-persister": "^5.104.1",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-persist-client": "^5.104.1",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
+2
View File
@@ -28,6 +28,7 @@ import {
} from '@/features/secondary/pages'; } from '@/features/secondary/pages';
import { NodeDetailPage } from '@/features/nodes/NodeDetailPage'; import { NodeDetailPage } from '@/features/nodes/NodeDetailPage';
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages'; import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
import { SetupWizardPage } from '@/features/setup/SetupWizardPage';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
import { getCurrentUserProfile } from '@/lib/api-client'; import { getCurrentUserProfile } from '@/lib/api-client';
@@ -138,6 +139,7 @@ export default function App() {
<Route path="/auth/sign-in" element={<SignInPage />} /> <Route path="/auth/sign-in" element={<SignInPage />} />
<Route path="/auth/sign-up" element={<SignUpPage />} /> <Route path="/auth/sign-up" element={<SignUpPage />} />
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} /> <Route path="/auth/accept-invite" element={<AcceptInvitePage />} />
<Route path="/setup" element={<SetupWizardPage />} />
{/* Public browsing — approved projects, templates, docs. */} {/* Public browsing — approved projects, templates, docs. */}
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */} {/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
+52 -1
View File
@@ -1,4 +1,4 @@
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { NavLink, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
@@ -30,10 +30,13 @@ import {
User, User,
Users, Users,
Webhook, Webhook,
WifiOff,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getCurrentUserProfile, getCurrentUserProfile,
getUpgradeStatus, getUpgradeStatus,
pingServer,
getSetupStatus,
listNotifications, listNotifications,
markAllNotificationsRead, markAllNotificationsRead,
markNotificationRead, markNotificationRead,
@@ -105,6 +108,42 @@ function getInitialSidebar(): boolean {
return readStorage('containr.sidebar.expanded') !== '0'; return readStorage('containr.sidebar.expanded') !== '0';
} }
// ConnectivityBanner pings /health every 15s and listens for browser
// online/offline events — when the instance is unreachable it shows a
// slim bar so the persisted last-known data doesn't masquerade as live.
function ConnectivityBanner() {
const [offline, setOffline] = useState(() => !navigator.onLine);
useEffect(() => {
const up = () => setOffline(false);
const down = () => setOffline(true);
window.addEventListener('online', up);
window.addEventListener('offline', down);
return () => {
window.removeEventListener('online', up);
window.removeEventListener('offline', down);
};
}, []);
const ping = useQuery({
queryKey: ['connectivity'],
queryFn: pingServer,
refetchInterval: 15_000,
retry: 0,
networkMode: 'always',
staleTime: 0,
});
if (!offline && !ping.isError) {
return null;
}
return (
<div className="flex items-center justify-center gap-2 border-b border-[var(--warning,var(--border-subtle))] bg-[var(--surface-muted)] px-4 py-1.5 text-xs text-[var(--text-secondary)]">
<WifiOff size={13} className="text-[var(--warning,#f59e0b)]" />
{offline
? 'No network connection — showing last-known data.'
: 'Cannot reach this Containr instance — showing last-known data. Retrying…'}
</div>
);
}
export function PlatformShell() { export function PlatformShell() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -147,6 +186,13 @@ export function PlatformShell() {
enabled: !isDemoMode && signedIn, enabled: !isDemoMode && signedIn,
refetchInterval: 30_000, refetchInterval: 30_000,
}); });
// Fresh installs get routed through the setup wizard until the flag lands.
const setupQuery = useQuery({
queryKey: ['setup-status'],
queryFn: getSetupStatus,
enabled: !isDemoMode && signedIn,
staleTime: 60_000,
});
const markReadMutation = useMutation({ const markReadMutation = useMutation({
mutationFn: (id: string) => markNotificationRead(id), mutationFn: (id: string) => markNotificationRead(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] }),
@@ -209,6 +255,10 @@ export function PlatformShell() {
</div> </div>
); );
if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) {
return <Navigate to="/setup" replace />;
}
return ( return (
<div className="app-shell min-h-screen"> <div className="app-shell min-h-screen">
<div className="ambient-glow" /> <div className="ambient-glow" />
@@ -557,6 +607,7 @@ export function PlatformShell() {
</nav> </nav>
</header> </header>
<ConnectivityBanner />
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto">
<Outlet /> <Outlet />
</main> </main>
@@ -48,6 +48,7 @@ export function NodeDetailPage() {
const [confirmVolumes, setConfirmVolumes] = useState(false); const [confirmVolumes, setConfirmVolumes] = useState(false);
const [confirmDrain, setConfirmDrain] = useState(false); const [confirmDrain, setConfirmDrain] = useState(false);
const [tagDraft, setTagDraft] = useState<string | null>(null); const [tagDraft, setTagDraft] = useState<string | null>(null);
const [domainDraft, setDomainDraft] = useState<string | null>(null);
const agentQuery = useQuery({ const agentQuery = useQuery({
queryKey: ['node-agent', id], queryKey: ['node-agent', id],
@@ -80,8 +81,8 @@ export function NodeDetailPage() {
queryClient.invalidateQueries({ queryKey: ['usage-agents'] }); queryClient.invalidateQueries({ queryKey: ['usage-agents'] });
}; };
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: (body: { name?: string; auto_prune?: boolean; tags?: string[] }) => updateAgent(id, body), mutationFn: (body: { name?: string; auto_prune?: boolean; tags?: string[]; default_domain?: string }) => updateAgent(id, body),
onSuccess: () => { setRename(null); setTagDraft(null); invalidate(); }, onSuccess: () => { setRename(null); setTagDraft(null); setDomainDraft(null); invalidate(); },
}); });
const pruneMutation = useMutation({ const pruneMutation = useMutation({
mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }), mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }),
@@ -218,6 +219,44 @@ export function NodeDetailPage() {
</> </>
)} )}
</p> </p>
<p className="v-mono mt-1 text-[11px] text-[var(--text-tertiary)] flex items-center gap-1.5 flex-wrap">
domain:
{domainDraft === null ? (
<>
<span className={agent.defaultDomain ? 'text-[var(--text-secondary)]' : 'text-[var(--text-muted)]'}>
{agent.defaultDomain || 'none'}
</span>
{isAdmin && (
<button
onClick={() => setDomainDraft(agent.defaultDomain || '')}
className="text-[var(--text-muted)] hover:text-[var(--text-primary)]"
title="Node base domain — domainless services placed here get <name>.<domain> hostnames"
>
<Pencil size={11} />
</button>
)}
</>
) : (
<>
<input
value={domainDraft}
onChange={(e) => setDomainDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() });
if (e.key === 'Escape') setDomainDraft(null);
}}
autoFocus
placeholder="apps.node1.example.com — empty clears"
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-64"
/>
<button
onClick={() => updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() })}
className="text-[var(--accent-primary)] hover:underline"
>save</button>
<button onClick={() => setDomainDraft(null)} className="text-[var(--text-muted)] hover:underline">cancel</button>
</>
)}
</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online <span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online
+158 -1
View File
@@ -75,6 +75,11 @@ import {
type CreateDatabaseInput, type CreateDatabaseInput,
type DatabaseEntity, type DatabaseEntity,
type FailoverPolicy, type FailoverPolicy,
listNotificationChannels,
createNotificationChannel,
deleteNotificationChannel,
testNotificationChannel,
type NotificationChannel,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { import {
demoBuilds, demoBuilds,
@@ -139,6 +144,8 @@ import {
Cloud, Cloud,
ExternalLink, ExternalLink,
Upload, Upload,
Bell,
Send,
} from 'lucide-react'; } from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) { function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
@@ -1246,7 +1253,11 @@ export function GitHubAppCallbackPage() {
mutationFn: (appCode: string) => convertGitHubAppCode(appCode), mutationFn: (appCode: string) => convertGitHubAppCode(appCode),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['github-app-status'] }); queryClient.invalidateQueries({ queryKey: ['github-app-status'] });
navigate('/settings', { replace: true }); // The setup wizard stashes a return marker before submitting the
// manifest form — resume there instead of the settings page.
const inSetup = sessionStorage.getItem('containr.setup_return') === '1';
sessionStorage.removeItem('containr.setup_return');
navigate(inSetup ? '/setup' : '/settings', { replace: true });
}, },
}); });
const firedRef = useRef(false); const firedRef = useRef(false);
@@ -1908,6 +1919,151 @@ function BackupTargetsSection() {
); );
} }
function NotificationChannelsSection() {
const queryClient = useQueryClient();
const channelsQuery = useQuery({
queryKey: ['notification-channels'],
queryFn: listNotificationChannels,
});
const [kind, setKind] = useState<'ntfy' | 'gotify'>('ntfy');
const [endpoint, setEndpoint] = useState('');
const [token, setToken] = useState('');
const [testResult, setTestResult] = useState<Record<string, 'ok' | string>>({});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['notification-channels'] });
const createMutation = useMutation({
mutationFn: () =>
createNotificationChannel({
kind,
endpoint: endpoint.trim(),
token: token.trim() || undefined,
}),
onSuccess: () => {
setEndpoint('');
setToken('');
invalidate();
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteNotificationChannel(id),
onSuccess: invalidate,
});
const testMutation = useMutation({
mutationFn: (id: string) => testNotificationChannel(id),
onSuccess: (_d, id) => setTestResult((prev) => ({ ...prev, [id]: 'ok' })),
onError: (err, id) =>
setTestResult((prev) => ({
...prev,
[id]: err instanceof Error ? err.message : 'delivery failed',
})),
});
const channels = channelsQuery.data ?? [];
return (
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Bell size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Push notifications</h2>
<p className="text-xs text-[var(--text-tertiary)]">Relay notifications to a self-hosted ntfy or Gotify server</p>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<select
value={kind}
onChange={(e) => setKind(e.target.value as 'ntfy' | 'gotify')}
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"
>
<option value="ntfy">ntfy</option>
<option value="gotify">Gotify</option>
</select>
<input
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder={kind === 'ntfy' ? 'https://ntfy.example.com/my-alerts' : 'https://gotify.example.com'}
className="h-9 flex-1 min-w-[220px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={kind === 'ntfy' ? 'Access token (optional)' : 'App token'}
className="h-9 w-48 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
<button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !endpoint.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: 'var(--accent-primary)' }}
>
{createMutation.isPending ? 'Adding…' : 'Add channel'}
</button>
</div>
{createMutation.isError ? (
<p className="text-xs text-[var(--error)]">
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add channel'}
</p>
) : null}
{channelsQuery.isLoading ? (
<div className="py-4 text-center">
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : channels.length === 0 ? (
<p className="text-xs text-[var(--text-tertiary)]">
No push channels. Notifications stay in-app until you add one.
</p>
) : (
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
{channels.map((ch: NotificationChannel) => (
<div key={ch.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0">
<span className="rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)] uppercase">
{ch.kind}
</span>
<span className="ml-2 mono text-[var(--text-primary)] break-all">{ch.endpoint}</span>
{testResult[ch.id] === 'ok' ? (
<span className="ml-2 text-[var(--success)]">delivered</span>
) : testResult[ch.id] ? (
<span className="ml-2 text-[var(--error)]">{testResult[ch.id]}</span>
) : null}
</div>
<div className="flex items-center gap-3 shrink-0">
<button
onClick={() => testMutation.mutate(ch.id)}
disabled={testMutation.isPending}
className="flex items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--accent-primary)] disabled:opacity-50"
>
<Send size={12} />
Test
</button>
<button
onClick={() => {
if (window.confirm(`Remove ${ch.kind} channel ${ch.endpoint}?`)) {
deleteMutation.mutate(ch.id);
}
}}
disabled={deleteMutation.isPending}
className="text-[var(--error)] hover:underline disabled:opacity-50"
>
Remove
</button>
</div>
</div>
))}
</div>
)}
</div>
</section>
);
}
export function SettingsPage() { export function SettingsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -2081,6 +2237,7 @@ export function SettingsPage() {
{/* Personal Access Tokens */} {/* Personal Access Tokens */}
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} /> <UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
<NotificationChannelsSection />
<RegistriesSection /> <RegistriesSection />
<BackupTargetsSection /> <BackupTargetsSection />
@@ -485,7 +485,7 @@ export function ServiceDetailPage() {
const [volumesForm, setVolumesForm] = useState<ServiceVolume[] | null>(null); const [volumesForm, setVolumesForm] = useState<ServiceVolume[] | null>(null);
const [domainInput, setDomainInput] = useState(''); const [domainInput, setDomainInput] = useState('');
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null); const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null); const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string; traefikLabels: string } | null>(null);
const [sleepDraft, setSleepDraft] = useState<{ id: string; enabled: boolean; minutes: string } | null>(null); const [sleepDraft, setSleepDraft] = useState<{ id: string; enabled: boolean; minutes: string } | null>(null);
const [nodeDraft, setNodeDraft] = useState<string | null>(null); const [nodeDraft, setNodeDraft] = useState<string | null>(null);
const nodesQuery = useQuery({ const nodesQuery = useQuery({
@@ -2490,7 +2490,13 @@ export function ServiceDetailPage() {
<button <button
type="button" type="button"
onClick={() => onClick={() =>
setAccessForm({ maintenance: Boolean(service.maintenanceMode), basicAuth: '' }) setAccessForm({
maintenance: Boolean(service.maintenanceMode),
basicAuth: '',
traefikLabels: Object.entries(service.traefikLabels ?? {})
.map(([k, v]) => `${k}=${v}`)
.join('\n'),
})
} }
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" 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"
> >
@@ -2511,6 +2517,14 @@ export function ServiceDetailPage() {
{(service.basicAuth ?? []).length > 0 ? (service.basicAuth ?? []).join(', ') : 'off'} {(service.basicAuth ?? []).length > 0 ? (service.basicAuth ?? []).join(', ') : 'off'}
</p> </p>
</div> </div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Routing overrides</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{Object.keys(service.traefikLabels ?? {}).length > 0
? `${Object.keys(service.traefikLabels ?? {}).length} label(s)`
: 'off'}
</p>
</div>
</div> </div>
) : ( ) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
@@ -2532,6 +2546,16 @@ export function ServiceDetailPage() {
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)]" className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/> />
</div> </div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Routing overrides — Traefik middleware labels, key=value per line; empty clears</label>
<textarea
value={accessForm.traefikLabels}
onChange={(e) => setAccessForm({ ...accessForm, traefikLabels: e.target.value })}
rows={3}
placeholder={"middlewares.rl.ratelimit.average=100\nmiddlewares.sec.headers.customresponseheaders.X-Frame-Options=DENY"}
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"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
@@ -2546,8 +2570,17 @@ export function ServiceDetailPage() {
return i > 0 ? { username: l.slice(0, i), password: l.slice(i + 1) } : null; return i > 0 ? { username: l.slice(0, i), password: l.slice(i + 1) } : null;
}) })
.filter((x): x is { username: string; password: string } => x !== null); .filter((x): x is { username: string; password: string } => x !== null);
const labels: Record<string, string> = {};
accessForm.traefikLabels
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.forEach((l) => {
const i = l.indexOf('=');
if (i > 0) labels[l.slice(0, i).trim()] = l.slice(i + 1).trim();
});
updateServiceMutation.mutate( updateServiceMutation.mutate(
{ maintenance_mode: accessForm.maintenance, basic_auth: creds }, { maintenance_mode: accessForm.maintenance, basic_auth: creds, traefik_labels: labels },
{ onSuccess: () => setAccessForm(null) }, { onSuccess: () => setAccessForm(null) },
); );
}} }}
@@ -0,0 +1,92 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { SetupWizardPage } from './SetupWizardPage';
import type { SetupStatus } from '@/lib/api-client';
const mockStatus = vi.fn<() => Promise<SetupStatus>>();
const mockSession = vi.fn();
vi.mock('@/lib/api-client', async (importOriginal) => {
const mod = await importOriginal<typeof import('@/lib/api-client')>();
return {
...mod,
getSetupStatus: () => mockStatus(),
completeSetup: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock('@/lib/use-auth-session', () => ({
useAuthSession: () => mockSession(),
}));
function renderWizard() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/setup']}>
<SetupWizardPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
const base: SetupStatus = {
has_users: false,
setup_completed: false,
needs_setup: true,
has_github_app: false,
has_tunnel: false,
signup_enabled: false,
};
describe('SetupWizardPage', () => {
beforeEach(() => {
mockSession.mockReturnValue({ data: null });
});
it('prompts for the admin account on a fresh install', async () => {
mockStatus.mockResolvedValue({ ...base });
renderWizard();
expect(await screen.findByText(/welcome to containr/i)).toBeInTheDocument();
expect(screen.getByText(/create admin account/i)).toBeInTheDocument();
});
it('offers sign-in when users exist but nobody is signed in', async () => {
mockStatus.mockResolvedValue({ ...base, has_users: true });
renderWizard();
expect(await screen.findByText(/sign in to continue setup/i)).toBeInTheDocument();
});
it('shows the git provider step for a signed-in admin without GitHub App', async () => {
mockStatus.mockResolvedValue({ ...base, has_users: true });
mockSession.mockReturnValue({ data: { user: { id: 'u1' } } });
renderWizard();
expect(await screen.findByText(/connect a git provider/i)).toBeInTheDocument();
expect(screen.getByText(/set up github app/i)).toBeInTheDocument();
});
it('shows the tunnel step when git is already wired', async () => {
mockStatus.mockResolvedValue({ ...base, has_users: true, has_github_app: true });
mockSession.mockReturnValue({ data: { user: { id: 'u1' } } });
renderWizard();
expect(
await screen.findByRole('heading', { name: /public access/i }),
).toBeInTheDocument();
});
it('lands on the finish step when everything is configured', async () => {
mockStatus.mockResolvedValue({
...base,
has_users: true,
has_github_app: true,
has_tunnel: true,
});
mockSession.mockReturnValue({ data: { user: { id: 'u1' } } });
renderWizard();
expect(await screen.findByText(/enter containr/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,279 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { Check, ChevronRight, Github, Loader2, Rocket, ShieldCheck } from 'lucide-react';
import { useAuthSession } from '@/lib/use-auth-session';
import {
completeSetup,
createGitHubAppManifest,
getSetupStatus,
updatePlatformSettings,
} from '@/lib/api-client';
// Marker the GitHub App callback reads so the wizard flow resumes here
// instead of the settings page.
const SETUP_RETURN_KEY = 'containr.setup_return';
type Step = 'account' | 'github' | 'tunnel' | 'done';
export function SetupWizardPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const sessionQuery = useAuthSession();
const statusQuery = useQuery({ queryKey: ['setup-status'], queryFn: getSetupStatus });
const [override, setOverride] = useState<Step | null>(null);
const status = statusQuery.data;
const signedIn = Boolean(sessionQuery.data);
const step: Step = useMemo(() => {
if (override) return override;
if (!status) return 'account';
if (!status.has_users || !signedIn) return 'account';
if (!status.has_github_app) return 'github';
if (!status.has_tunnel) return 'tunnel';
return 'done';
}, [override, status, signedIn]);
const completeMutation = useMutation({
mutationFn: completeSetup,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['setup-status'] });
navigate('/projects', { replace: true });
},
});
if (statusQuery.isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--bg-primary)]">
<Loader2 size={22} className="animate-spin text-[var(--accent-primary)]" />
</div>
);
}
// Wizard is optional once the instance is already configured.
if (status && !status.needs_setup && !override) {
return <Navigate to="/projects" replace />;
}
const steps: { key: Step; label: string }[] = [
{ key: 'account', label: 'Admin account' },
{ key: 'github', label: 'Git provider' },
{ key: 'tunnel', label: 'Public access' },
{ key: 'done', label: 'Done' },
];
const activeIdx = steps.findIndex((s) => s.key === step);
return (
<div className="min-h-screen bg-[var(--bg-primary)] flex flex-col items-center px-5 py-16">
<div className="w-full max-w-lg">
<div className="flex items-center gap-2 mb-8">
{steps.map((s, i) => (
<div key={s.key} className="flex items-center gap-2">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-semibold border ${
i < activeIdx
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-[var(--accent-on)]'
: i === activeIdx
? 'border-[var(--accent-primary)] text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] text-[var(--text-muted)]'
}`}
>
{i < activeIdx ? <Check size={12} /> : i + 1}
</div>
<span
className={`text-xs ${i === activeIdx ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]'}`}
>
{s.label}
</span>
{i < steps.length - 1 ? <div className="w-6 h-px bg-[var(--border-subtle)]" /> : null}
</div>
))}
</div>
{step === 'account' ? (
<section className="panel p-6">
<h1 className="text-xl font-bold text-[var(--text-primary)]">Welcome to Containr</h1>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
First boot detected. Create the admin account — registration closes to the public
right after, so this account owns the instance.
</p>
{status?.has_users && !signedIn ? (
<div className="mt-4">
<p className="text-sm text-[var(--text-secondary)]">
An account already exists — sign in to continue setup.
</p>
<Link
to="/auth/sign-in?redirect=/setup"
className="mt-4 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]"
style={{ background: 'var(--accent-primary)' }}
>
Sign in <ChevronRight size={14} />
</Link>
</div>
) : !status?.has_users ? (
<Link
to="/auth/sign-up?redirect=/setup"
className="mt-4 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]"
style={{ background: 'var(--accent-primary)' }}
>
Create admin account <ChevronRight size={14} />
</Link>
) : null}
</section>
) : null}
{step === 'github' ? <GitHubStep onNext={() => setOverride('tunnel')} /> : null}
{step === 'tunnel' ? <TunnelStep onNext={() => setOverride('done')} /> : null}
{step === 'done' ? (
<section className="panel p-6 text-center">
<Rocket size={28} className="mx-auto text-[var(--accent-primary)]" />
<h1 className="mt-4 text-xl font-bold text-[var(--text-primary)]">All set</h1>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
Deploy your first service from a git repo, an image, or a template — the dashboard is
ready.
</p>
{completeMutation.isError ? (
<p className="mt-3 text-xs text-[var(--error)]">
{completeMutation.error instanceof Error
? completeMutation.error.message
: 'Failed to save setup state'}
</p>
) : null}
<button
type="button"
onClick={() => completeMutation.mutate()}
disabled={completeMutation.isPending}
className="mt-5 inline-flex items-center gap-2 h-10 px-6 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
>
{completeMutation.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
<ShieldCheck size={15} />
)}
Enter Containr
</button>
</section>
) : null}
</div>
</div>
);
}
function GitHubStep({ onNext }: { onNext: () => void }) {
const queryClient = useQueryClient();
const provisionMutation = useMutation({
mutationFn: async () => {
const { url, manifest } = await createGitHubAppManifest({
base_url: window.location.origin,
});
sessionStorage.setItem(SETUP_RETURN_KEY, '1');
const form = document.createElement('form');
form.method = 'POST';
form.action = url;
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'manifest';
input.value = manifest;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['setup-status'] }),
});
return (
<section className="panel p-6">
<div className="flex items-center gap-3 mb-3">
<Github size={20} className="text-[var(--text-primary)]" />
<h1 className="text-xl font-bold text-[var(--text-primary)]">Connect a git provider</h1>
</div>
<p className="text-sm text-[var(--text-secondary)]">
Provision the instance GitHub App — repo push events then trigger deployments and preview
environments automatically. Self-hosted GitLab/Gitea can be added later in Settings.
</p>
{provisionMutation.isError ? (
<p className="mt-3 text-xs text-[var(--error)]">
{provisionMutation.error instanceof Error
? provisionMutation.error.message
: 'Could not start GitHub App provisioning'}
</p>
) : null}
<div className="mt-5 flex items-center gap-3">
<button
type="button"
onClick={() => provisionMutation.mutate()}
disabled={provisionMutation.isPending}
className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
>
{provisionMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
Set up GitHub App
</button>
<button
type="button"
onClick={onNext}
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Skip for now
</button>
</div>
</section>
);
}
function TunnelStep({ onNext }: { onNext: () => void }) {
const queryClient = useQueryClient();
const [token, setToken] = useState('');
const saveMutation = useMutation({
mutationFn: () =>
updatePlatformSettings({ cloudflareTunnelToken: token.trim() || undefined }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['setup-status'] });
onNext();
},
});
return (
<section className="panel p-6">
<h1 className="text-xl font-bold text-[var(--text-primary)]">Public access (optional)</h1>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
Paste a Cloudflare Tunnel token to expose this instance on the internet. Without one,
Containr stays reachable on this network — you can add it later in Settings.
</p>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="eyJhIjo… tunnel token"
className="mt-4 w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-colors"
/>
{saveMutation.isError ? (
<p className="mt-3 text-xs text-[var(--error)]">
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save token'}
</p>
) : null}
<div className="mt-5 flex items-center gap-3">
<button
type="button"
onClick={() => (token.trim() ? saveMutation.mutate() : onNext())}
disabled={saveMutation.isPending}
className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
>
{saveMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
{token.trim() ? 'Save tunnel token' : 'Continue'}
</button>
<button
type="button"
onClick={onNext}
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Skip
</button>
</div>
</section>
);
}
@@ -0,0 +1,467 @@
import { useCallback, useMemo, useState } from 'react';
import {
Background,
BackgroundVariant,
Handle,
MarkerType,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Connection,
type Edge,
type EdgeChange,
type Node,
type NodeChange,
type NodeProps,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Database, GitBranch, Package, Timer, Trash2, X } from 'lucide-react';
import {
canvasFromConfig,
configFromCanvas,
newServiceKey,
validateGraph,
type BuilderNode,
type BuilderService,
type BuilderServiceType,
} from './builder-model';
type ServiceBuilderNode = Node<{ svc: BuilderService }, 'tplService'>;
const TYPE_META: Record<BuilderServiceType, { label: string; icon: typeof Package; cls: string }> = {
web: { label: 'Web', icon: Package, cls: 'text-[var(--accent-primary)]' },
worker: { label: 'Worker', icon: GitBranch, cls: 'text-[var(--info,#5eb3f6)]' },
cron: { label: 'Cron', icon: Timer, cls: 'text-[var(--warning)]' },
database: { label: 'Database', icon: Database, cls: 'text-[var(--success,#7bd88a)]' },
};
const DATABASE_ENGINES = ['postgresql', 'mysql', 'mariadb', 'mongodb', 'redis', 'dragonfly', 'clickhouse'];
function ServiceBuilderNodeView({ data, selected }: NodeProps<ServiceBuilderNode>) {
const meta = TYPE_META[data.svc.type] ?? TYPE_META.web;
const Icon = meta.icon;
return (
<div
className={`relative w-[210px] rounded-[var(--radius-md)] border bg-[var(--bg-elevated)] px-3 py-2.5 shadow-sm transition-colors ${
selected ? 'border-[var(--accent-primary)]' : 'border-[var(--border-subtle)]'
}`}
>
<Handle type="target" position={Position.Left} className="!h-2.5 !w-2.5 !border-[var(--border-default)] !bg-[var(--bg-base)]" />
<Handle type="source" position={Position.Right} className="!h-2.5 !w-2.5 !border-[var(--border-default)] !bg-[var(--bg-base)]" />
<div className="flex items-center gap-2">
<Icon size={14} className={meta.cls} />
<span className="truncate text-[12.5px] font-semibold text-[var(--text-primary)]">
{data.svc.name || data.svc.key}
</span>
</div>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="text-[10.5px] uppercase tracking-wide text-[var(--text-tertiary)]">{meta.label}</span>
<span className="mono max-w-[120px] truncate text-[10.5px] text-[var(--text-secondary)]">
{data.svc.runtime || data.svc.repo || '—'}
</span>
</div>
</div>
);
}
const nodeTypes = { tplService: ServiceBuilderNodeView };
// Edges run dependency → dependent: connecting A → B means "B depends_on A".
export function TemplateBuilder(props: {
config: Record<string, unknown>;
onApply: (config: Record<string, unknown>) => void;
onCancel: () => void;
}) {
return (
<ReactFlowProvider>
<BuilderInner {...props} />
</ReactFlowProvider>
);
}
function BuilderInner({
config,
onApply,
onCancel,
}: {
config: Record<string, unknown>;
onApply: (config: Record<string, unknown>) => void;
onCancel: () => void;
}) {
const { screenToFlowPosition } = useReactFlow();
const initial = useMemo(() => canvasFromConfig(config), [config]);
const [services, setServices] = useState<Map<string, BuilderService>>(
() => new Map(initial.map((n) => [n.key, { ...n.service }])),
);
const [positions, setPositions] = useState<Record<string, { x: number; y: number }>>(
() => Object.fromEntries(initial.map((n) => [n.key, n.position])),
);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const flowNodes: ServiceBuilderNode[] = useMemo(
() =>
[...services.values()].map((svc) => ({
id: svc.key,
type: 'tplService' as const,
position: positions[svc.key] ?? { x: 0, y: 0 },
data: { svc },
selected: svc.key === selectedKey,
})),
[services, positions, selectedKey],
);
const flowEdges: Edge[] = useMemo(
() =>
[...services.values()].flatMap((svc) =>
(svc.depends_on ?? [])
.filter((dep) => services.has(dep))
.map((dep) => ({
id: `${dep}->${svc.key}`,
source: dep,
target: svc.key,
markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color: 'rgba(180,227,74,0.55)' },
style: { stroke: 'rgba(180,227,74,0.42)', strokeWidth: 1.6 },
className: 'edge-premium',
})),
),
[services],
);
const builderNodes: BuilderNode[] = useMemo(
() =>
[...services.values()].map((svc) => ({
key: svc.key,
service: svc,
position: positions[svc.key] ?? { x: 0, y: 0 },
})),
[services, positions],
);
const errors = useMemo(() => validateGraph(builderNodes), [builderNodes]);
const patchService = useCallback((key: string, patch: Partial<BuilderService>) => {
setServices((prev) => {
const svc = prev.get(key);
if (!svc) return prev;
const next = new Map(prev);
next.set(key, { ...svc, ...patch });
return next;
});
}, []);
const addService = useCallback((type: BuilderServiceType, position?: { x: number; y: number }) => {
setServices((prev) => {
const key = newServiceKey(type, new Set(prev.keys()));
const next = new Map(prev);
next.set(key, {
key,
type,
runtime: type === 'database' ? 'postgresql' : undefined,
});
setPositions((pos) => ({ ...pos, [key]: position ?? { x: 80, y: 40 + (next.size - 1) * 150 } }));
setSelectedKey(key);
return next;
});
}, []);
const removeService = useCallback((key: string) => {
setServices((prev) => {
const next = new Map(prev);
next.delete(key);
for (const svc of next.values()) {
svc.depends_on = svc.depends_on?.filter((d) => d !== key);
}
return next;
});
setPositions((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
setSelectedKey((cur) => (cur === key ? null : cur));
}, []);
const onConnect = useCallback(
(conn: Connection) => {
if (!conn.source || !conn.target || conn.source === conn.target) return;
const target = services.get(conn.target);
if (!target) return;
patchService(conn.target, {
depends_on: [...new Set([...(target.depends_on ?? []), conn.source])],
});
},
[patchService, services],
);
const onNodesChange = useCallback(
(changes: NodeChange<ServiceBuilderNode>[]) => {
for (const ch of changes) {
if (ch.type === 'position' && ch.position) {
setPositions((prev) => ({ ...prev, [ch.id]: ch.position! }));
} else if (ch.type === 'remove') {
removeService(ch.id);
} else if (ch.type === 'select') {
if (ch.selected) setSelectedKey(ch.id);
}
}
},
[removeService],
);
const onEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => {
for (const ch of changes) {
if (ch.type !== 'remove') continue;
const edge = flowEdges.find((e) => e.id === ch.id);
if (!edge) continue;
const svc = services.get(edge.target);
if (svc) {
patchService(edge.target, {
depends_on: (svc.depends_on ?? []).filter((d) => d !== edge.source),
});
}
}
},
[flowEdges, patchService, services],
);
const selected = selectedKey ? services.get(selectedKey) : null;
return (
<div className="flex min-h-0 flex-1">
<div className="relative flex-1">
<div className="absolute left-3 top-3 z-10 flex gap-1.5">
{(['web', 'database', 'cron', 'worker'] as const).map((t) => {
const meta = TYPE_META[t];
const Icon = meta.icon;
return (
<button
key={t}
type="button"
onClick={() => addService(t)}
className="canvas-pill inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
<Icon size={12} className={meta.cls} />
{meta.label}
</button>
);
})}
</div>
<ReactFlow
nodes={flowNodes}
edges={flowEdges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onPaneClick={() => setSelectedKey(null)}
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest('.react-flow__node')) return;
addService('web', screenToFlowPosition({ x: e.clientX, y: e.clientY }));
}}
fitView
deleteKeyCode={['Backspace', 'Delete']}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="rgba(255,255,255,0.07)" />
</ReactFlow>
<div className="canvas-pill absolute bottom-3 left-3 z-10 px-3 py-1.5 text-[10.5px] text-[var(--text-tertiary)]">
drag = move · connect handles = depends_on · double-click = add web · Del = remove
</div>
</div>
<div className="flex w-[290px] shrink-0 flex-col border-l border-[var(--border-subtle)]">
{selected ? (
<Inspector
key={selected.key}
svc={selected}
onPatch={(patch) => patchService(selected.key, patch)}
onRemove={() => removeService(selected.key)}
/>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
<Package size={22} className="text-[var(--text-tertiary)]" />
<p className="text-xs text-[var(--text-tertiary)]">
Select a node to edit it, or add one from the toolbar.
</p>
</div>
)}
<div className="border-t border-[var(--border-subtle)] p-3">
{errors.length > 0 ? (
<div className="mb-2 max-h-[90px] overflow-y-auto rounded-[var(--radius-sm)] bg-[var(--error-soft)] px-3 py-2">
{errors.map((e) => (
<p key={e} className="text-[11px] text-[var(--error)]">• {e}</p>
))}
</div>
) : null}
<div className="flex gap-2">
<button
type="button"
onClick={onCancel}
className="flex-1 rounded-[var(--radius-md)] px-3 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Back to JSON
</button>
<button
type="button"
disabled={errors.length > 0 || services.size === 0}
onClick={() => onApply(configFromCanvas(builderNodes, config))}
className="flex-1 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
Apply to JSON
</button>
</div>
</div>
</div>
</div>
);
}
const FIELD_CLS =
'w-full rounded-[var(--radius-sm)] border border-[var(--border-default)] bg-[var(--bg-void)] px-2.5 py-1.5 text-[12px] text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]';
const LABEL_CLS = 'mb-1 block text-[10.5px] font-medium uppercase tracking-wide text-[var(--text-tertiary)]';
function Inspector({
svc,
onPatch,
onRemove,
}: {
svc: BuilderService;
onPatch: (patch: Partial<BuilderService>) => void;
onRemove: () => void;
}) {
const envText = Object.entries(svc.environment ?? {})
.map(([k, v]) => `${k}=${v}`)
.join('\n');
const volText = (svc.volumes ?? []).map((v) => `${v.source}:${v.target}${v.read_only ? ':ro' : ''}`).join('\n');
return (
<div className="flex-1 overflow-y-auto p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-[12px] font-semibold text-[var(--text-primary)]">{svc.key}</span>
<button
type="button"
onClick={onRemove}
className="flex items-center gap-1 rounded-[var(--radius-sm)] px-2 py-1 text-[11px] text-[var(--error)] hover:bg-[var(--error-soft)]"
>
<Trash2 size={12} /> Remove
</button>
</div>
<label className={LABEL_CLS}>Key</label>
<input className={FIELD_CLS} value={svc.key} disabled title="Key is the node id — cannot be renamed" />
<label className={`${LABEL_CLS} mt-3`}>Display name</label>
<input className={FIELD_CLS} value={svc.name ?? ''} onChange={(e) => onPatch({ name: e.target.value || undefined })} placeholder="optional" />
<label className={`${LABEL_CLS} mt-3`}>Type</label>
<select
className={FIELD_CLS}
value={svc.type}
onChange={(e) => onPatch({ type: e.target.value as BuilderServiceType })}
>
<option value="web">Web</option>
<option value="worker">Worker</option>
<option value="cron">Cron</option>
<option value="database">Database</option>
</select>
{svc.type === 'database' ? (
<>
<label className={`${LABEL_CLS} mt-3`}>Engine</label>
<select className={FIELD_CLS} value={svc.runtime ?? 'postgresql'} onChange={(e) => onPatch({ runtime: e.target.value })}>
{DATABASE_ENGINES.map((eng) => (
<option key={eng} value={eng}>{eng}</option>
))}
</select>
</>
) : (
<>
<label className={`${LABEL_CLS} mt-3`}>Image (runtime)</label>
<input className={FIELD_CLS} value={svc.runtime ?? ''} onChange={(e) => onPatch({ runtime: e.target.value || undefined })} placeholder="nginx:latest" />
<label className={`${LABEL_CLS} mt-3`}>Git repo</label>
<input className={FIELD_CLS} value={svc.repo ?? ''} onChange={(e) => onPatch({ repo: e.target.value || undefined })} placeholder="owner/repo or clone URL" />
{svc.repo ? (
<>
<label className={`${LABEL_CLS} mt-3`}>Branch</label>
<input className={FIELD_CLS} value={svc.branch ?? ''} onChange={(e) => onPatch({ branch: e.target.value || undefined })} placeholder="main" />
<label className={`${LABEL_CLS} mt-3`}>Build command</label>
<input className={FIELD_CLS} value={svc.build_command ?? ''} onChange={(e) => onPatch({ build_command: e.target.value || undefined })} />
</>
) : null}
<label className={`${LABEL_CLS} mt-3`}>Start command</label>
<input className={FIELD_CLS} value={svc.start_command ?? ''} onChange={(e) => onPatch({ start_command: e.target.value || undefined })} />
</>
)}
{svc.type === 'web' ? (
<>
<label className={`${LABEL_CLS} mt-3`}>Port</label>
<input
className={FIELD_CLS}
type="number"
value={svc.port ?? ''}
onChange={(e) => onPatch({ port: e.target.value ? Number(e.target.value) : undefined })}
/>
<label className={`${LABEL_CLS} mt-3`}>Health check path</label>
<input className={FIELD_CLS} value={svc.health_check ?? ''} onChange={(e) => onPatch({ health_check: e.target.value || undefined })} placeholder="/health" />
</>
) : null}
<label className={`${LABEL_CLS} mt-3`}>Environment (KEY=value, one per line)</label>
<textarea
className={`${FIELD_CLS} mono min-h-[70px] resize-y`}
defaultValue={envText}
spellCheck={false}
onBlur={(e) => {
const env: Record<string, string> = {};
for (const line of e.target.value.split('\n')) {
const idx = line.indexOf('=');
if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
}
onPatch({ environment: Object.keys(env).length ? env : undefined });
}}
placeholder={'DATABASE_URL={{service.db.url}}\nSECRET={{secret}}'}
/>
<label className={`${LABEL_CLS} mt-3`}>Volumes (source:target[:ro], one per line)</label>
<textarea
className={`${FIELD_CLS} mono min-h-[50px] resize-y`}
defaultValue={volText}
spellCheck={false}
onBlur={(e) => {
const vols = e.target.value
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
const [source, target, ro] = l.split(':');
return { source, target, read_only: ro === 'ro' || undefined };
})
.filter((v) => v.source && v.target);
onPatch({ volumes: vols.length ? vols : undefined });
}}
placeholder={'data:/var/lib/app\n./config:/etc/app:ro'}
/>
{(svc.depends_on ?? []).length > 0 ? (
<div className="mt-3">
<span className={LABEL_CLS}>Depends on</span>
<div className="flex flex-wrap gap-1">
{svc.depends_on!.map((dep) => (
<span key={dep} className="inline-flex items-center gap-1 rounded-full bg-[var(--surface-muted)] px-2 py-0.5 text-[11px] text-[var(--text-secondary)]">
{dep}
<button
type="button"
onClick={() => onPatch({ depends_on: (svc.depends_on ?? []).filter((d) => d !== dep) })}
className="text-[var(--text-tertiary)] hover:text-[var(--error)]"
>
<X size={10} />
</button>
</span>
))}
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest';
import {
canvasFromConfig,
configFromCanvas,
layoutPositions,
newServiceKey,
validateGraph,
type BuilderNode,
} from './builder-model';
const plausibleConfig = {
version: 2,
services: [
{ key: 'db', type: 'database', runtime: 'postgresql', environment: { POSTGRES_DB: 'plausible' } },
{ key: 'events', type: 'database', runtime: 'clickhouse' },
{
key: 'web',
name: 'plausible',
type: 'web',
runtime: 'ghcr.io/plausible/community-edition:v2',
port: 8000,
health_check: '/api/health',
depends_on: ['db', 'events'],
environment: { DATABASE_URL: '{{service.db.url}}' },
},
],
};
describe('canvasFromConfig', () => {
it('parses graph services into nodes with auto-layout', () => {
const nodes = canvasFromConfig(plausibleConfig);
expect(nodes.map((n) => n.key)).toEqual(['db', 'events', 'web']);
// databases are roots (depth 0), web depends on both (depth 1)
expect(nodes[0].position.x).toBeLessThan(nodes[2].position.x);
expect(nodes[1].position.y).toBeGreaterThan(nodes[0].position.y);
});
it('restores saved canvas positions', () => {
const nodes = canvasFromConfig({
...plausibleConfig,
canvas: { positions: { db: { x: 500, y: 500 } } },
});
expect(nodes[0].position).toEqual({ x: 500, y: 500 });
});
it('migrates flat legacy configs to a single node', () => {
const nodes = canvasFromConfig({
type: 'web',
runtime: 'nginx:latest',
port: 80,
environment: { FOO: 'bar' },
});
expect(nodes).toHaveLength(1);
expect(nodes[0].service).toMatchObject({ key: 'app', type: 'web', runtime: 'nginx:latest', port: 80 });
});
it('drops entries without a key', () => {
const nodes = canvasFromConfig({ version: 2, services: [{ type: 'web', runtime: 'x' }, { key: 'ok', type: 'web', runtime: 'y' }] });
expect(nodes.map((n) => n.key)).toEqual(['ok']);
});
});
describe('configFromCanvas', () => {
it('round-trips a graph config', () => {
const nodes = canvasFromConfig(plausibleConfig);
const config = configFromCanvas(nodes, plausibleConfig);
const services = config.services as Record<string, unknown>[];
expect(config.version).toBe(2);
expect(services).toHaveLength(3);
const web = services.find((s) => s.key === 'web')!;
expect(web.depends_on).toEqual(['db', 'events']);
expect(web.port).toBe(8000);
expect((web.environment as Record<string, string>).DATABASE_URL).toBe('{{service.db.url}}');
});
it('persists node positions under config.canvas', () => {
const nodes = canvasFromConfig(plausibleConfig);
nodes[0].position = { x: 321, y: 123 };
const config = configFromCanvas(nodes, {});
expect((config.canvas as { positions: Record<string, { x: number; y: number }> }).positions.db).toEqual({ x: 321, y: 123 });
// and parses back to the same spot
expect(canvasFromConfig(config)[0].position).toEqual({ x: 321, y: 123 });
});
it('strips flat fields when converting a legacy config', () => {
const nodes = canvasFromConfig({ type: 'web', runtime: 'nginx:latest', port: 80 });
const config = configFromCanvas(nodes, { type: 'web', runtime: 'nginx:latest', port: 80 });
expect(config.type).toBeUndefined();
expect(config.runtime).toBeUndefined();
expect((config.services as unknown[]).length).toBe(1);
});
});
describe('layoutPositions', () => {
it('depths follow the depends_on chain', () => {
const pos = layoutPositions([
{ key: 'a', type: 'database', runtime: 'postgresql' },
{ key: 'b', type: 'web', runtime: 'img', depends_on: ['a'] },
{ key: 'c', type: 'web', runtime: 'img', depends_on: ['b'] },
]);
expect(pos.a.x).toBeLessThan(pos.b.x);
expect(pos.b.x).toBeLessThan(pos.c.x);
});
it('a dependency cycle does not hang', () => {
const pos = layoutPositions([
{ key: 'a', type: 'web', runtime: 'x', depends_on: ['b'] },
{ key: 'b', type: 'web', runtime: 'x', depends_on: ['a'] },
]);
expect(pos.a).toBeDefined();
expect(pos.b).toBeDefined();
});
});
describe('validateGraph', () => {
const node = (key: string, extra: Partial<BuilderNode['service']> = {}): BuilderNode => ({
key,
position: { x: 0, y: 0 },
service: { key, type: 'web', runtime: 'img:1', ...extra },
});
it('accepts a valid graph', () => {
expect(validateGraph([node('db', { type: 'database', runtime: 'postgresql' }), node('web', { depends_on: ['db'] })])).toEqual([]);
});
it('rejects duplicate keys', () => {
expect(validateGraph([node('a'), node('a')])).toContain('duplicate key "a"');
});
it('rejects bad keys and self-deps', () => {
const errs = validateGraph([node('BAD KEY'), node('x', { depends_on: ['x'] })]);
expect(errs.some((e) => e.includes('not a valid key'))).toBe(true);
expect(errs).toContain('"x" depends on itself');
});
it('rejects deps on missing services', () => {
expect(validateGraph([node('web', { depends_on: ['ghost'] })])).toContain(
'"web" depends on unknown service "ghost"',
);
});
it('rejects database without engine and web without source', () => {
const errs = validateGraph([node('db', { type: 'database', runtime: undefined }), node('web', { runtime: undefined })]);
expect(errs).toContain('"db" is a database with no engine selected');
expect(errs).toContain('"web" needs an image (runtime) or a git repo');
});
it('reports dependency cycles', () => {
const errs = validateGraph([node('a', { depends_on: ['b'] }), node('b', { depends_on: ['a'] })]);
expect(errs.some((e) => e.startsWith('dependency cycle'))).toBe(true);
});
});
describe('newServiceKey', () => {
it('picks unused keys', () => {
expect(newServiceKey('database', new Set())).toBe('db');
expect(newServiceKey('database', new Set(['db']))).toBe('db2');
expect(newServiceKey('web', new Set(['web', 'web2']))).toBe('web3');
});
});
@@ -0,0 +1,241 @@
// Pure model for the visual template builder: converts between the
// template `config` JSON (version 2 service graph, or a legacy flat
// single-service config) and canvas state (nodes + positions), and
// validates the graph before save. No React, no DOM — directly testable.
export type BuilderServiceType = 'web' | 'worker' | 'cron' | 'database';
export type BuilderService = {
key: string;
name?: string;
type: BuilderServiceType;
runtime?: string;
repo?: string;
branch?: string;
build_command?: string;
start_command?: string;
port?: number;
health_check?: string;
builder?: string;
environment?: Record<string, string>;
volumes?: { type?: string; source: string; target: string; read_only?: boolean }[];
depends_on?: string[];
};
export type BuilderCanvas = {
positions: Record<string, { x: number; y: number }>;
};
export type BuilderNode = {
key: string;
service: BuilderService;
position: { x: number; y: number };
};
const KEY_RE = /^[a-z][a-z0-9_-]*$/;
const COL_X = 60;
const ROW_Y = 200;
function asService(raw: unknown): BuilderService | null {
if (!raw || typeof raw !== 'object') return null;
const s = raw as Record<string, unknown>;
const key = typeof s.key === 'string' ? s.key : '';
if (!key) return null;
const type = typeof s.type === 'string' ? s.type : 'web';
return {
key,
name: typeof s.name === 'string' ? s.name : undefined,
type: (['web', 'worker', 'cron', 'database'] as const).includes(type as BuilderServiceType)
? (type as BuilderServiceType)
: 'web',
runtime: typeof s.runtime === 'string' ? s.runtime : undefined,
repo: typeof s.repo === 'string' ? s.repo : undefined,
branch: typeof s.branch === 'string' ? s.branch : undefined,
build_command: typeof s.build_command === 'string' ? s.build_command : undefined,
start_command: typeof s.start_command === 'string' ? s.start_command : undefined,
port: typeof s.port === 'number' ? s.port : undefined,
health_check: typeof s.health_check === 'string' ? s.health_check : undefined,
builder: typeof s.builder === 'string' ? s.builder : undefined,
environment:
s.environment && typeof s.environment === 'object' && !Array.isArray(s.environment)
? Object.fromEntries(
Object.entries(s.environment as Record<string, unknown>).map(([k, v]) => [k, String(v)]),
)
: undefined,
volumes: Array.isArray(s.volumes)
? (s.volumes
.map((v) => {
if (!v || typeof v !== 'object') return null;
const vol = v as Record<string, unknown>;
if (typeof vol.target !== 'string' || !vol.target) return null;
return {
type: typeof vol.type === 'string' ? vol.type : undefined,
source: typeof vol.source === 'string' ? vol.source : '',
target: vol.target,
read_only: typeof vol.read_only === 'boolean' ? vol.read_only : undefined,
};
})
.filter((v): v is NonNullable<typeof v> => v !== null)
)
: undefined,
depends_on: Array.isArray(s.depends_on)
? s.depends_on.filter((d): d is string => typeof d === 'string')
: undefined,
};
}
// Flat legacy configs ({type, runtime, port, …} with no services array)
// map to a single service node so the builder can edit them without
// losing fields the round-trip would drop.
function flatToService(config: Record<string, unknown>): BuilderService | null {
const type = typeof config.type === 'string' ? config.type : '';
const runtime = typeof config.runtime === 'string' ? config.runtime : '';
if (!type && !runtime) return null;
const svc = asService({
key: 'app',
type: type || 'web',
runtime,
port: config.port,
health_check: config.health_check,
build_command: config.build_command,
start_command: config.start_command,
environment: config.environment,
});
return svc;
}
// Layered auto-layout: depth = longest depends_on chain; column per
// depth, row per member. Deterministic so tests can assert positions.
export function layoutPositions(services: BuilderService[]): Record<string, { x: number; y: number }> {
const depth = new Map<string, number>();
const visiting = new Set<string>();
const byKey = new Map(services.map((s) => [s.key, s]));
const depthOf = (key: string): number => {
const memo = depth.get(key);
if (memo !== undefined) return memo;
if (visiting.has(key)) return 0; // cycle guard
visiting.add(key);
const deps = byKey.get(key)?.depends_on ?? [];
let d = 0;
for (const dep of deps) {
if (byKey.has(dep)) d = Math.max(d, depthOf(dep) + 1);
}
visiting.delete(key);
depth.set(key, d);
return d;
};
const rows = new Map<number, number>();
const positions: Record<string, { x: number; y: number }> = {};
for (const svc of services) {
const d = depthOf(svc.key);
const row = rows.get(d) ?? 0;
rows.set(d, row + 1);
positions[svc.key] = { x: COL_X + d * 300, y: 40 + row * ROW_Y };
}
return positions;
}
export function canvasFromConfig(config: Record<string, unknown>): BuilderNode[] {
const canvas = (config.canvas ?? {}) as Partial<BuilderCanvas>;
const saved = canvas.positions ?? {};
const rawServices = Array.isArray(config.services) ? config.services : null;
const services = rawServices
? rawServices.map(asService).filter((s): s is BuilderService => s !== null)
: [];
if (services.length === 0) {
const flat = flatToService(config);
if (flat) services.push(flat);
}
const auto = layoutPositions(services);
return services.map((service) => ({
key: service.key,
service,
position: saved[service.key] ?? auto[service.key] ?? { x: COL_X, y: 40 },
}));
}
export function configFromCanvas(
nodes: BuilderNode[],
rest: Record<string, unknown>,
): Record<string, unknown> {
const services = nodes.map((node) => {
const svc = node.service;
const out: Record<string, unknown> = { key: svc.key, type: svc.type };
if (svc.name) out.name = svc.name;
if (svc.runtime) out.runtime = svc.runtime;
if (svc.repo) out.repo = svc.repo;
if (svc.branch) out.branch = svc.branch;
if (svc.build_command) out.build_command = svc.build_command;
if (svc.builder) out.builder = svc.builder;
if (svc.start_command) out.start_command = svc.start_command;
if (svc.port) out.port = svc.port;
if (svc.health_check) out.health_check = svc.health_check;
if (svc.environment && Object.keys(svc.environment).length > 0) out.environment = svc.environment;
if (svc.volumes && svc.volumes.length > 0) out.volumes = svc.volumes;
if (svc.depends_on && svc.depends_on.length > 0) out.depends_on = svc.depends_on;
return out;
});
const positions: Record<string, { x: number; y: number }> = {};
for (const node of nodes) positions[node.key] = node.position;
const restConfig = { ...rest };
// Flat (v1) fields and stale graph keys are dropped — the graph wins.
for (const k of ['type', 'runtime', 'build_command', 'start_command', 'port', 'health_check', 'environment', 'dockerfile', 'nixpacks_config', 'services', 'canvas']) {
delete restConfig[k];
}
return {
version: 2,
...restConfig,
services,
canvas: { positions },
};
}
export function validateGraph(nodes: BuilderNode[]): string[] {
const errors: string[] = [];
const keys = new Set<string>();
for (const node of nodes) {
if (!KEY_RE.test(node.service.key)) {
errors.push(`"${node.service.key || '(empty)'}" is not a valid key (lowercase, digits, - or _)`);
}
if (keys.has(node.service.key)) errors.push(`duplicate key "${node.service.key}"`);
keys.add(node.service.key);
if (node.service.type === 'database' && !node.service.runtime) {
errors.push(`"${node.service.key}" is a database with no engine selected`);
}
if (node.service.type !== 'database' && !node.service.runtime && !node.service.repo) {
errors.push(`"${node.service.key}" needs an image (runtime) or a git repo`);
}
}
for (const node of nodes) {
for (const dep of node.service.depends_on ?? []) {
if (dep === node.service.key) errors.push(`"${dep}" depends on itself`);
else if (!keys.has(dep)) errors.push(`"${node.service.key}" depends on unknown service "${dep}"`);
}
}
// Cycle detection — a cycle makes topo sort fail at deploy time.
const state = new Map<string, 0 | 1 | 2>();
const byKey = new Map(nodes.map((n) => [n.service.key, n]));
const visit = (key: string, trail: string[]): void => {
const s = state.get(key) ?? 0;
if (s === 2) return;
if (s === 1) {
errors.push(`dependency cycle: ${[...trail, key].join(' → ')}`);
return;
}
state.set(key, 1);
for (const dep of byKey.get(key)?.service.depends_on ?? []) {
if (byKey.has(dep)) visit(dep, [...trail, key]);
}
state.set(key, 2);
};
for (const node of nodes) visit(node.service.key, []);
return errors;
}
export function newServiceKey(type: BuilderServiceType, existing: Set<string>): string {
const base = type === 'database' ? 'db' : type;
if (!existing.has(base)) return base;
let i = 2;
while (existing.has(`${base}${i}`)) i += 1;
return `${base}${i}`;
}
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { lazy, Suspense, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
@@ -8,6 +8,7 @@ import {
getCurrentUserProfile, getCurrentUserProfile,
getTemplateById, getTemplateById,
importComposeTemplate, importComposeTemplate,
importGitComposeTemplate,
listProjects, listProjects,
listTemplates, listTemplates,
updateTemplate, updateTemplate,
@@ -41,6 +42,10 @@ import {
Network, Network,
} from 'lucide-react'; } from 'lucide-react';
const TemplateBuilder = lazy(() =>
import('@/features/templates/builder/TemplateBuilder').then((m) => ({ default: m.TemplateBuilder })),
);
function toServiceName(value: string): string { function toServiceName(value: string): string {
const normalized = value const normalized = value
.toLowerCase() .toLowerCase()
@@ -126,6 +131,7 @@ export function TemplatesPage() {
const isAdmin = Boolean(profileQuery.data?.isAdmin); const isAdmin = Boolean(profileQuery.data?.isAdmin);
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<'json' | 'visual'>('json');
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 [editorIsPublic, setEditorIsPublic] = useState(false);
@@ -154,6 +160,10 @@ export function TemplatesPage() {
const [composeYaml, setComposeYaml] = useState(''); const [composeYaml, setComposeYaml] = useState('');
const [composeWarnings, setComposeWarnings] = useState<string[]>([]); const [composeWarnings, setComposeWarnings] = useState<string[]>([]);
const [composeError, setComposeError] = useState<string | null>(null); const [composeError, setComposeError] = useState<string | null>(null);
const [composeMode, setComposeMode] = useState<'paste' | 'git'>('paste');
const [gitRepo, setGitRepo] = useState('');
const [gitPath, setGitPath] = useState('');
const [gitRef, setGitRef] = useState('');
const [lastDeployment, setLastDeployment] = useState<{ const [lastDeployment, setLastDeployment] = useState<{
projectId: string; projectId: string;
@@ -364,7 +374,10 @@ export function TemplatesPage() {
}); });
const composeImportMutation = useMutation({ const composeImportMutation = useMutation({
mutationFn: (yaml: string) => importComposeTemplate(yaml), mutationFn: () =>
composeMode === 'git'
? importGitComposeTemplate(gitRepo.trim(), gitPath.trim(), gitRef.trim())
: importComposeTemplate(composeYaml),
onSuccess: (result) => { onSuccess: (result) => {
setComposeWarnings(result.warnings); setComposeWarnings(result.warnings);
setComposeError(null); setComposeError(null);
@@ -1014,17 +1027,50 @@ export function TemplatesPage() {
{/* Template editor — JSON only */} {/* Template editor — JSON only */}
{editorOpen ? ( {editorOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl"> <div className={`flex max-h-[92vh] w-full flex-col overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl ${editorMode === 'visual' ? 'max-w-6xl' : 'max-w-2xl'}`}>
<div className="flex items-center justify-between border-b border-[var(--border-subtle)] px-5 py-4"> <div className="flex items-center justify-between border-b border-[var(--border-subtle)] px-5 py-4">
<div> <div>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]"> <h2 className="text-[15px] font-semibold text-[var(--text-primary)]">
{editingTemplate ? `Edit ${editingTemplate.name}` : 'New template'} {editingTemplate ? `Edit ${editingTemplate.name}` : 'New template'}
</h2> </h2>
<p className="text-xs text-[var(--text-tertiary)]"> <p className="text-xs text-[var(--text-tertiary)]">
JSON definition — name, category, config and variables. {editorMode === 'visual'
? 'Visual builder — drop service nodes, wire depends_on edges, apply back to JSON.'
: 'JSON definition — name, category, config and variables.'}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="mr-1 inline-flex overflow-hidden rounded-[var(--radius-md)] border border-[var(--border-default)]">
{(['json', 'visual'] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => {
if (mode === 'visual' && editorMode === 'json') {
try {
const parsed = JSON.parse(editorJson);
if (!parsed || typeof parsed.config !== 'object') {
setEditorError('Visual mode needs a valid JSON body with a "config" object first.');
return;
}
} catch {
setEditorError('Fix the JSON before switching to visual mode.');
return;
}
setEditorError(null);
}
setEditorMode(mode);
}}
className={`px-3 py-1.5 text-[11px] font-medium capitalize ${
editorMode === mode
? 'bg-[var(--surface-muted)] text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
}`}
>
{mode}
</button>
))}
</div>
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"> <label className="inline-flex cursor-pointer items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
<Upload size={12} /> Upload .json <Upload size={12} /> Upload .json
<input <input
@@ -1047,12 +1093,49 @@ export function TemplatesPage() {
</button> </button>
</div> </div>
</div> </div>
<textarea {editorMode === 'visual' ? (
value={editorJson} <div className="flex min-h-[520px] flex-1 flex-col">
onChange={(e) => setEditorJson(e.target.value)} <Suspense
spellCheck={false} fallback={
className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none" <div className="flex flex-1 items-center justify-center text-[var(--text-tertiary)]">
/> <Loader2 size={20} className="animate-spin" />
</div>
}
>
{(() => {
let cfg: Record<string, unknown> = {};
try {
const parsed = JSON.parse(editorJson) as { config?: Record<string, unknown> };
cfg = parsed?.config ?? {};
} catch {
cfg = {};
}
return (
<TemplateBuilder
config={cfg}
onCancel={() => setEditorMode('json')}
onApply={(newConfig) => {
try {
const parsed = JSON.parse(editorJson) as Record<string, unknown>;
setEditorJson(JSON.stringify({ ...parsed, config: newConfig }, null, 2));
} catch {
setEditorJson(JSON.stringify({ config: newConfig }, null, 2));
}
setEditorMode('json');
}}
/>
);
})()}
</Suspense>
</div>
) : (
<textarea
value={editorJson}
onChange={(e) => setEditorJson(e.target.value)}
spellCheck={false}
className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/>
)}
{composeWarnings.length > 0 ? ( {composeWarnings.length > 0 ? (
<div className="border-t border-[var(--border-subtle)] bg-[var(--warning-soft)]/60 px-5 py-2.5 text-xs text-[var(--warning)]"> <div className="border-t border-[var(--border-subtle)] bg-[var(--warning-soft)]/60 px-5 py-2.5 text-xs text-[var(--warning)]">
<p className="font-medium mb-1">Compose import notes</p> <p className="font-medium mb-1">Compose import notes</p>
@@ -1066,7 +1149,7 @@ export function TemplatesPage() {
{editorError} {editorError}
</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 ${editorMode === 'visual' ? 'hidden' : ''}`}>
<label className="inline-flex cursor-pointer items-center gap-2 text-[11px] text-[var(--text-secondary)]"> <label className="inline-flex cursor-pointer items-center gap-2 text-[11px] text-[var(--text-secondary)]">
<input <input
type="checkbox" type="checkbox"
@@ -1107,7 +1190,7 @@ export function TemplatesPage() {
<div> <div>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Import docker-compose</h2> <h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Import docker-compose</h2>
<p className="text-xs text-[var(--text-tertiary)]"> <p className="text-xs text-[var(--text-tertiary)]">
Paste a compose file — known database images become managed databases, everything else becomes services. Paste a compose file or pull one from a git repo — known database images become managed databases.
</p> </p>
</div> </div>
<button <button
@@ -1118,13 +1201,67 @@ export function TemplatesPage() {
<X size={15} /> <X size={15} />
</button> </button>
</div> </div>
<textarea <div className="flex gap-1 border-b border-[var(--border-subtle)] px-5 pt-3">
value={composeYaml} {(['paste', 'git'] as const).map((m) => (
onChange={(e) => setComposeYaml(e.target.value)} <button
spellCheck={false} key={m}
placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'} type="button"
className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none" onClick={() => setComposeMode(m)}
/> className={`rounded-t-[var(--radius-md)] px-3 py-1.5 text-xs font-medium ${
composeMode === m
? 'bg-[var(--surface-muted)] text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
}`}
>
{m === 'paste' ? 'Paste YAML' : 'From git repo'}
</button>
))}
</div>
{composeMode === 'paste' ? (
<textarea
value={composeYaml}
onChange={(e) => setComposeYaml(e.target.value)}
spellCheck={false}
placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'}
className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/>
) : (
<div className="flex min-h-[320px] flex-1 flex-col gap-3 bg-[var(--bg-void)] p-4">
<label className="text-xs text-[var(--text-tertiary)]">
Repository
<input
value={gitRepo}
onChange={(e) => setGitRepo(e.target.value)}
spellCheck={false}
placeholder="owner/repo or https://github.com/owner/repo.git"
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/>
</label>
<label className="text-xs text-[var(--text-tertiary)]">
Compose file path <span className="opacity-60">(optional — compose.yaml / docker-compose.yml searched)</span>
<input
value={gitPath}
onChange={(e) => setGitPath(e.target.value)}
spellCheck={false}
placeholder="deploy/compose.yml"
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/>
</label>
<label className="text-xs text-[var(--text-tertiary)]">
Branch or commit <span className="opacity-60">(optional)</span>
<input
value={gitRef}
onChange={(e) => setGitRef(e.target.value)}
spellCheck={false}
placeholder="main"
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/>
</label>
<p className="text-[11px] text-[var(--text-tertiary)]">
Private repos use your connected git providers for credentials.
</p>
</div>
)}
{composeError ? ( {composeError ? (
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]"> <div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
{composeError} {composeError}
@@ -1140,8 +1277,8 @@ export function TemplatesPage() {
</button> </button>
<button <button
type="button" type="button"
onClick={() => composeImportMutation.mutate(composeYaml)} onClick={() => composeImportMutation.mutate()}
disabled={!composeYaml.trim() || composeImportMutation.isPending} disabled={(composeMode === 'paste' ? !composeYaml.trim() : !gitRepo.trim()) || composeImportMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
> >
{composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />} {composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />}
@@ -25,14 +25,22 @@ import {
} from '@/lib/api-client'; } from '@/lib/api-client';
import { useToast } from '@/shared/hooks/use-toast'; import { useToast } from '@/shared/hooks/use-toast';
import { inferAutoConnections, type ServiceVariable } from '../auto-connections'; import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
import { import { DEFAULT_VIEWPORT, type ProjectCanvasMetadata } from '../model';
DEFAULT_VIEWPORT,
type CanvasGroup,
type CanvasNodeLayout,
type ProjectCanvasMetadata,
} from '../model';
import { hasCanvasMetadata, loadCanvasMetadata, saveCanvasMetadata } from '../storage'; import { hasCanvasMetadata, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
import { computeLayeredLayout, layoutFromVariables, NODE_HEIGHT, NODE_WIDTH } from './auto-layout'; import { computeLayeredLayout, layoutFromVariables, NODE_HEIGHT, NODE_WIDTH } from './auto-layout';
import {
buildMetadataFromFlow,
GROUP_DEFAULT_HEIGHT,
GROUP_DEFAULT_WIDTH,
GROUP_PAD_BOTTOM,
GROUP_PAD_TOP,
GROUP_PAD_X,
GRID_STEP,
groupSlot,
resolveFreeSpot,
type CanvasNode,
type Rect,
} from './layout';
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes'; import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
import { ServiceInspector, type InspectorGroup } from './inspector'; import { ServiceInspector, type InspectorGroup } from './inspector';
import { serviceAccent } from './service-visuals'; import { serviceAccent } from './service-visuals';
@@ -65,11 +73,7 @@ type CanvasProps = {
type ServiceCanvasNode = Node<ServiceNodeData, 'serviceNode'>; type ServiceCanvasNode = Node<ServiceNodeData, 'serviceNode'>;
type GroupCanvasNode = Node<GroupNodeData, 'groupNode'>; type GroupCanvasNode = Node<GroupNodeData, 'groupNode'>;
type CanvasNode = ServiceCanvasNode | GroupCanvasNode;
type CanvasEdge = Edge<{ reasons: string[] }>; type CanvasEdge = Edge<{ reasons: string[] }>;
const GROUP_DEFAULT_WIDTH = 340;
const GROUP_DEFAULT_HEIGHT = 230;
const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']); const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']);
const nodeTypes: NodeTypes = { const nodeTypes: NodeTypes = {
@@ -176,155 +180,6 @@ function toFlowEdges(
}); });
} }
const GROUP_PAD_X = 28;
const GROUP_PAD_TOP = 56;
const GROUP_PAD_BOTTOM = 24;
const GROUP_ROW_GAP = 16;
const GRID_STEP = 26;
const NODE_GAP = 14;
type Rect = { x: number; y: number; w: number; h: number };
function rectsOverlap(a: Rect, b: Rect): boolean {
return (
a.x < b.x + b.w + NODE_GAP &&
a.x + a.w + NODE_GAP > b.x &&
a.y < b.y + b.h + NODE_GAP &&
a.y + a.h + NODE_GAP > b.y
);
}
// Nearest grid-aligned position where a node fits without touching any
// occupied rect. Spiral-searches outward on the canvas lattice; falls back to
// the given position when the neighbourhood is fully boxed in.
function resolveFreeSpot(
desired: { x: number; y: number },
occupied: Rect[],
fallback: { x: number; y: number },
bounds?: Rect,
): { x: number; y: number } {
const snap = (v: number) => Math.round(v / GRID_STEP) * GRID_STEP;
const base = { x: snap(desired.x), y: snap(desired.y) };
const fits = (x: number, y: number) => {
if (bounds && (x < bounds.x || y < bounds.y || x + NODE_WIDTH > bounds.x + bounds.w || y + NODE_HEIGHT > bounds.y + bounds.h)) {
return false;
}
return !occupied.some((rect) => rectsOverlap({ x, y, w: NODE_WIDTH, h: NODE_HEIGHT }, rect));
};
if (fits(base.x, base.y)) {
return base;
}
for (let ring = 1; ring <= 12; ring++) {
for (let dx = -ring; dx <= ring; dx++) {
for (let dy = -ring; dy <= ring; dy++) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring) {
continue;
}
const x = base.x + dx * GRID_STEP;
const y = base.y + dy * GRID_STEP;
if (fits(x, y)) {
return { x, y };
}
}
}
}
return fallback;
}
// Where an incoming service lands inside a group frame. If the point already
// fits inside the padded area, honour it. Otherwise append beneath the lowest
// member and grow the frame just enough — keeps groups compact instead of
// ballooning the frame to wherever the node used to sit.
function groupSlot(
nodes: CanvasNode[],
groupId: string,
group: CanvasNode,
desiredX: number,
desiredY: number,
ignoreServiceId?: string,
): { x: number; y: number; width: number; height: number } {
const width = typeof group.style?.width === 'number' ? group.style.width : GROUP_DEFAULT_WIDTH;
const height = typeof group.style?.height === 'number' ? group.style.height : GROUP_DEFAULT_HEIGHT;
const members = nodes.filter(
(node) => node.type === 'serviceNode' && node.parentId === groupId && node.id !== ignoreServiceId,
);
const memberRects: Rect[] = members.map((node) => ({
x: node.position.x,
y: node.position.y,
w: NODE_WIDTH,
h: NODE_HEIGHT,
}));
const snap = (v: number) => Math.round(v / GRID_STEP) * GRID_STEP;
const snappedX = snap(desiredX);
const snappedY = snap(desiredY);
const snappedFits =
snappedX >= GROUP_PAD_X &&
snappedY >= GROUP_PAD_TOP &&
snappedX + NODE_WIDTH <= width - GROUP_PAD_X &&
snappedY + NODE_HEIGHT <= height - GROUP_PAD_BOTTOM;
const collides = memberRects.some((rect) =>
rectsOverlap({ x: snappedX, y: snappedY, w: NODE_WIDTH, h: NODE_HEIGHT }, rect),
);
if (snappedFits && !collides) {
return { x: snappedX, y: snappedY, width, height };
}
let bottom = GROUP_PAD_TOP - GROUP_ROW_GAP;
for (const node of nodes) {
if (node.type === 'serviceNode' && node.parentId === groupId && node.id !== ignoreServiceId) {
bottom = Math.max(bottom, node.position.y + NODE_HEIGHT);
}
}
const x = GROUP_PAD_X;
const y = bottom + GROUP_ROW_GAP;
return {
x,
y,
width: Math.max(width, x + NODE_WIDTH + GROUP_PAD_X),
height: Math.max(height, y + NODE_HEIGHT + GROUP_PAD_BOTTOM),
};
}
function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata {
const groups: CanvasGroup[] = [];
const layouts: CanvasNodeLayout[] = [];
for (const node of nodes) {
if (node.type === 'groupNode') {
const width = typeof node.style?.width === 'number' ? node.style.width : GROUP_DEFAULT_WIDTH;
const height = typeof node.style?.height === 'number' ? node.style.height : GROUP_DEFAULT_HEIGHT;
groups.push({
id: node.id,
title: node.data.title,
position: node.position,
width,
height,
});
continue;
}
if (node.type === 'serviceNode') {
layouts.push({
serviceId: node.id,
position: node.position,
groupId: node.parentId,
});
}
}
return {
groups,
nodes: layouts,
edges: [],
viewport,
};
}
function ZoomControls({ onTidy }: { onTidy: () => void }) { function ZoomControls({ onTidy }: { onTidy: () => void }) {
const { zoom } = useViewport(); const { zoom } = useViewport();
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest';
import {
buildMetadataFromFlow,
groupSlot,
rectsOverlap,
resolveFreeSpot,
} from './layout';
import { NODE_HEIGHT, NODE_WIDTH } from './auto-layout';
import type { GroupNodeType, ServiceNodeType } from './nodes';
const GRID_STEP = 26;
const NODE_GAP = 14;
function serviceNode(
id: string,
position: { x: number; y: number },
parentId?: string,
): ServiceNodeType {
return { id, type: 'serviceNode', position, parentId } as ServiceNodeType;
}
function groupNode(
id: string,
width = 340,
height = 230,
): GroupNodeType {
return {
id,
type: 'groupNode',
position: { x: 0, y: 0 },
style: { width, height },
data: { title: `Group ${id}` },
} as GroupNodeType;
}
describe('rectsOverlap', () => {
const a = { x: 0, y: 0, w: 100, h: 100 };
it('detects intersection', () => {
expect(rectsOverlap(a, { x: 50, y: 50, w: 100, h: 100 })).toBe(true);
expect(rectsOverlap(a, { x: -50, y: -50, w: 100, h: 100 })).toBe(true);
});
it('returns false for fully disjoint rects', () => {
expect(rectsOverlap(a, { x: 500, y: 500, w: 100, h: 100 })).toBe(false);
});
it('treats the node gap as a collision zone', () => {
// b sits just inside NODE_GAP to the right of a.
expect(rectsOverlap(a, { x: 100 + NODE_GAP - 1, y: 0, w: 50, h: 50 })).toBe(true);
// Beyond the gap there is no overlap.
expect(rectsOverlap(a, { x: 100 + NODE_GAP + 1, y: 0, w: 50, h: 50 })).toBe(false);
});
});
describe('resolveFreeSpot', () => {
it('snaps the desired position to the grid when free', () => {
const spot = resolveFreeSpot({ x: 40, y: 70 }, [], { x: 0, y: 0 });
expect(spot).toEqual({ x: 52, y: 78 });
expect(spot.x % GRID_STEP).toBe(0);
expect(spot.y % GRID_STEP).toBe(0);
});
it('finds a neighbouring free grid point when occupied', () => {
const occupied = [{ x: 0, y: 0, w: NODE_WIDTH, h: NODE_HEIGHT }];
const spot = resolveFreeSpot({ x: 0, y: 0 }, occupied, { x: -1, y: -1 });
expect(spot).not.toEqual({ x: -1, y: -1 });
expect(Number.isInteger(spot.x / GRID_STEP)).toBe(true);
expect(Number.isInteger(spot.y / GRID_STEP)).toBe(true);
expect(
rectsOverlap({ x: spot.x, y: spot.y, w: NODE_WIDTH, h: NODE_HEIGHT }, occupied[0]),
).toBe(false);
});
it('falls back when bounds reject every candidate', () => {
// Node is wider than the bounds, so nothing can fit.
const bounds = { x: 0, y: 0, w: 10, h: 10 };
const fallback = { x: 999, y: 888 };
expect(resolveFreeSpot({ x: 0, y: 0 }, [], fallback, bounds)).toEqual(fallback);
});
});
describe('groupSlot', () => {
it('honours a point that fits inside the padded frame', () => {
const group = groupNode('g1');
const slot = groupSlot([group], 'g1', group, 40, 70);
expect(slot).toEqual({ x: 52, y: 78, width: 340, height: 230 });
});
it('appends below the lowest member and grows the frame on collision', () => {
const group = groupNode('g1');
const member = serviceNode('s1', { x: 52, y: 78 }, 'g1');
const slot = groupSlot([group, member], 'g1', group, 40, 70);
// bottom = 78 + 104 = 182; y = 182 + 16 = 198; grows height to 326.
expect(slot.x).toBe(28);
expect(slot.y).toBe(198);
expect(slot.height).toBe(326);
expect(slot.width).toBe(340);
});
it('appends under the padding when the desired point is outside', () => {
const group = groupNode('g1');
const slot = groupSlot([group], 'g1', group, 5000, 5000);
expect(slot).toEqual({ x: 28, y: 56, width: 340, height: 230 });
});
it('ignores the dragged node when checking collisions', () => {
const group = groupNode('g1');
const dragged = serviceNode('s1', { x: 52, y: 78 }, 'g1');
const slot = groupSlot([group, dragged], 'g1', group, 40, 70, 's1');
expect(slot).toEqual({ x: 52, y: 78, width: 340, height: 230 });
});
});
describe('buildMetadataFromFlow', () => {
it('serializes groups and service layouts', () => {
const nodes = [
{ ...groupNode('g1', 400, 300), position: { x: 10, y: 20 } },
serviceNode('s1', { x: 5, y: 6 }, 'g1'),
serviceNode('s2', { x: 100, y: 200 }),
];
const viewport = { x: 1, y: 2, zoom: 0.5 };
const meta = buildMetadataFromFlow(nodes, viewport);
expect(meta.groups).toEqual([
{ id: 'g1', title: 'Group g1', position: { x: 10, y: 20 }, width: 400, height: 300 },
]);
expect(meta.nodes).toEqual([
{ serviceId: 's1', position: { x: 5, y: 6 }, groupId: 'g1' },
{ serviceId: 's2', position: { x: 100, y: 200 }, groupId: undefined },
]);
expect(meta.edges).toEqual([]);
expect(meta.viewport).toEqual(viewport);
});
it('uses default group dimensions when style is missing', () => {
const bare = { id: 'g2', type: 'groupNode', position: { x: 0, y: 0 }, data: { title: 'bare' } } as GroupNodeType;
const meta = buildMetadataFromFlow([bare], { x: 0, y: 0, zoom: 1 });
expect(meta.groups[0].width).toBe(340);
expect(meta.groups[0].height).toBe(230);
});
});
@@ -0,0 +1,156 @@
import type { CanvasGroup, CanvasNodeLayout, ProjectCanvasMetadata } from '../model';
import { NODE_HEIGHT, NODE_WIDTH } from './auto-layout';
import type { GroupNodeType, ServiceNodeType } from './nodes';
export type CanvasNode = ServiceNodeType | GroupNodeType;
export type Rect = { x: number; y: number; w: number; h: number };
export const GROUP_DEFAULT_WIDTH = 340;
export const GROUP_DEFAULT_HEIGHT = 230;
export const GROUP_PAD_X = 28;
export const GROUP_PAD_TOP = 56;
export const GROUP_PAD_BOTTOM = 24;
export const GROUP_ROW_GAP = 16;
export const GRID_STEP = 26;
export const NODE_GAP = 14;
export function rectsOverlap(a: Rect, b: Rect): boolean {
return (
a.x < b.x + b.w + NODE_GAP &&
a.x + a.w + NODE_GAP > b.x &&
a.y < b.y + b.h + NODE_GAP &&
a.y + a.h + NODE_GAP > b.y
);
}
// Nearest grid-aligned position where a node fits without touching any
// occupied rect. Spiral-searches outward on the canvas lattice; falls back to
// the given position when the neighbourhood is fully boxed in.
export function resolveFreeSpot(
desired: { x: number; y: number },
occupied: Rect[],
fallback: { x: number; y: number },
bounds?: Rect,
): { x: number; y: number } {
const snap = (v: number) => Math.round(v / GRID_STEP) * GRID_STEP;
const base = { x: snap(desired.x), y: snap(desired.y) };
const fits = (x: number, y: number) => {
if (bounds && (x < bounds.x || y < bounds.y || x + NODE_WIDTH > bounds.x + bounds.w || y + NODE_HEIGHT > bounds.y + bounds.h)) {
return false;
}
return !occupied.some((rect) => rectsOverlap({ x, y, w: NODE_WIDTH, h: NODE_HEIGHT }, rect));
};
if (fits(base.x, base.y)) {
return base;
}
for (let ring = 1; ring <= 12; ring++) {
for (let dx = -ring; dx <= ring; dx++) {
for (let dy = -ring; dy <= ring; dy++) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring) {
continue;
}
const x = base.x + dx * GRID_STEP;
const y = base.y + dy * GRID_STEP;
if (fits(x, y)) {
return { x, y };
}
}
}
}
return fallback;
}
// Where an incoming service lands inside a group frame. If the point already
// fits inside the padded area, honour it. Otherwise append beneath the lowest
// member and grow the frame just enough — keeps groups compact instead of
// ballooning the frame to wherever the node used to sit.
export function groupSlot(
nodes: CanvasNode[],
groupId: string,
group: CanvasNode,
desiredX: number,
desiredY: number,
ignoreServiceId?: string,
): { x: number; y: number; width: number; height: number } {
const width = typeof group.style?.width === 'number' ? group.style.width : GROUP_DEFAULT_WIDTH;
const height = typeof group.style?.height === 'number' ? group.style.height : GROUP_DEFAULT_HEIGHT;
const members = nodes.filter(
(node) => node.type === 'serviceNode' && node.parentId === groupId && node.id !== ignoreServiceId,
);
const memberRects: Rect[] = members.map((node) => ({
x: node.position.x,
y: node.position.y,
w: NODE_WIDTH,
h: NODE_HEIGHT,
}));
const snap = (v: number) => Math.round(v / GRID_STEP) * GRID_STEP;
const snappedX = snap(desiredX);
const snappedY = snap(desiredY);
const snappedFits =
snappedX >= GROUP_PAD_X &&
snappedY >= GROUP_PAD_TOP &&
snappedX + NODE_WIDTH <= width - GROUP_PAD_X &&
snappedY + NODE_HEIGHT <= height - GROUP_PAD_BOTTOM;
const collides = memberRects.some((rect) =>
rectsOverlap({ x: snappedX, y: snappedY, w: NODE_WIDTH, h: NODE_HEIGHT }, rect),
);
if (snappedFits && !collides) {
return { x: snappedX, y: snappedY, width, height };
}
let bottom = GROUP_PAD_TOP - GROUP_ROW_GAP;
for (const node of nodes) {
if (node.type === 'serviceNode' && node.parentId === groupId && node.id !== ignoreServiceId) {
bottom = Math.max(bottom, node.position.y + NODE_HEIGHT);
}
}
const x = GROUP_PAD_X;
const y = bottom + GROUP_ROW_GAP;
return {
x,
y,
width: Math.max(width, x + NODE_WIDTH + GROUP_PAD_X),
height: Math.max(height, y + NODE_HEIGHT + GROUP_PAD_BOTTOM),
};
}
export function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata {
const groups: CanvasGroup[] = [];
const layouts: CanvasNodeLayout[] = [];
for (const node of nodes) {
if (node.type === 'groupNode') {
const width = typeof node.style?.width === 'number' ? node.style.width : GROUP_DEFAULT_WIDTH;
const height = typeof node.style?.height === 'number' ? node.style.height : GROUP_DEFAULT_HEIGHT;
groups.push({
id: node.id,
title: node.data.title,
position: node.position,
width,
height,
});
continue;
}
if (node.type === 'serviceNode') {
layouts.push({
serviceId: node.id,
position: node.position,
groupId: node.parentId,
});
}
}
return {
groups,
nodes: layouts,
edges: [],
viewport,
};
}
@@ -8,12 +8,14 @@ import {
createManagedDatabase, createManagedDatabase,
createService, createService,
getProjectById, getProjectById,
createProjectEnvironment,
importComposeIntoProject, importComposeIntoProject,
listConnectedGitRepositories, listConnectedGitRepositories,
listGitBranches, listGitBranches,
listGitProviders, listGitProviders,
listGitRepositories, listGitRepositories,
listProjectActivity, listProjectActivity,
listProjectEnvironments,
listServiceLogs, listServiceLogs,
listServiceVariables, listServiceVariables,
listServicesByProject, listServicesByProject,
@@ -589,6 +591,22 @@ export function ProjectWorkspacePage() {
() => (isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? []), () => (isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? []),
[isDemoMode, projectId, servicesQuery.data], [isDemoMode, projectId, servicesQuery.data],
); );
const environmentsQuery = useQuery({
queryKey: ['project-environments', projectId],
queryFn: () => listProjectEnvironments(projectId!),
enabled: !!projectId && !isDemoMode,
});
const [envFilter, setEnvFilter] = useState<string | null>(null);
const environments = environmentsQuery.data ?? [];
const visibleServices = useMemo(
() => (envFilter ? services.filter((s) => (s.environment ?? 'production') === envFilter) : services),
[services, envFilter],
);
const createEnvMutation = useMutation({
mutationFn: (name: string) => createProjectEnvironment(projectId!, name),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['project-environments', projectId] }),
onError: (err) => toast.showToast(err instanceof ApiError ? err.message : 'Failed to create environment', 'error'),
});
const serviceIdFingerprint = services.map((service) => service.id).sort().join('|'); const serviceIdFingerprint = services.map((service) => service.id).sort().join('|');
const variablesQuery = useQuery({ const variablesQuery = useQuery({
@@ -850,18 +868,50 @@ export function ProjectWorkspacePage() {
{/* Service List */} {/* Service List */}
<div className="panel p-6"> <div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Services</h3> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Services</h3>
{!isDemoMode && environments.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<button
onClick={() => setEnvFilter(null)}
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
envFilter === null
? 'bg-[var(--accent-primary)] text-white'
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
}`}
>
All
</button>
{environments.map((env) => (
<button
key={env.id}
onClick={() => setEnvFilter(envFilter === env.name ? null : env.name)}
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
envFilter === env.name
? 'bg-[var(--accent-primary)] text-white'
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
}`}
>
{env.name} ({env.service_count})
</button>
))}
<EnvCreateButton onCreate={(name) => createEnvMutation.mutate(name)} pending={createEnvMutation.isPending} />
</div>
)}
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden divide-y divide-[var(--border-subtle)]"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden divide-y divide-[var(--border-subtle)]">
{services.length === 0 ? ( {visibleServices.length === 0 ? (
<div className="p-8 text-center"> <div className="p-8 text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-[var(--surface-muted)] flex items-center justify-center"> <div className="w-12 h-12 mx-auto mb-3 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Layers size={20} className="text-[var(--text-tertiary)]" /> <Layers size={20} className="text-[var(--text-tertiary)]" />
</div> </div>
<p className="text-sm text-[var(--text-secondary)]">No services deployed yet</p> <p className="text-sm text-[var(--text-secondary)]">
<p className="text-xs text-[var(--text-muted)] mt-1">Add services from the Canvas view</p> {envFilter ? `No services in ${envFilter}` : 'No services deployed yet'}
</p>
{!envFilter && <p className="text-xs text-[var(--text-muted)] mt-1">Add services from the Canvas view</p>}
</div> </div>
) : ( ) : (
services.map((service) => ( visibleServices.map((service) => (
<button <button
key={service.id} key={service.id}
onClick={() => navigate(serviceHref(service.id))} onClick={() => navigate(serviceHref(service.id))}
@@ -1124,3 +1174,39 @@ export function ProjectWorkspacePage() {
</div> </div>
); );
} }
// EnvCreateButton expands into an inline name input for a new environment.
function EnvCreateButton({ onCreate, pending }: { onCreate: (name: string) => void; pending: boolean }) {
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="px-2.5 py-1 rounded-full text-xs font-medium border border-dashed border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
>
+ env
</button>
);
}
return (
<span className="inline-flex items-center gap-1">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && name.trim()) {
onCreate(name.trim());
setName('');
setOpen(false);
}
if (e.key === 'Escape') setOpen(false);
}}
placeholder="staging"
className="w-24 px-2 py-1 rounded-full text-xs bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-[var(--text-primary)] outline-none"
/>
{pending && <Loader2 size={12} className="animate-spin text-[var(--text-muted)]" />}
</span>
);
}
+494 -2
View File
@@ -3243,6 +3243,80 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/templates/import/git": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Import a compose file from a git repository
* @description Fetches a compose file out of a git repository and runs it through
* the same conversion as POST /templates/import/compose. `repo`
* accepts a clone URL, ssh remote, or `owner/repo` shorthand —
* connected git providers (including GitHub App installs) supply
* credentials, so private repositories work. `path` defaults to
* `compose.yaml`, `compose.yml`, `docker-compose.yml`, then
* `docker-compose.yaml`; `ref` selects a branch or commit SHA.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description Clone URL, ssh remote, or owner/repo shorthand */
repo: string;
/** @description Repo-relative compose file path (searched by convention when empty) */
path?: string;
/** @description Branch name or 40-char commit SHA */
ref?: string;
};
};
};
responses: {
/** @description Converted config */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
config?: components["schemas"]["TemplateConfig"];
variables?: Record<string, never>[];
warnings?: string[];
};
};
};
/** @description Invalid repo reference, compose file missing, or conversion error */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Git fetch failed */
502: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/templates/deploy": { "/templates/deploy": {
parameters: { parameters: {
query?: never; query?: never;
@@ -3624,6 +3698,167 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/notifications/channels": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List push notification channels (ntfy/Gotify) */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Channel list */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
channels?: components["schemas"]["NotificationChannel"][];
};
};
};
};
};
put?: never;
/**
* Add a push notification channel
* @description ntfy endpoint should include the topic (e.g. https://ntfy.example.com/my-alerts); Gotify endpoint is the server base URL with the app token in `token`.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @enum {string} */
kind: "ntfy" | "gotify";
/** Format: uri */
endpoint: string;
/** @description ntfy access token (Bearer) or Gotify app token */
token?: string;
};
};
};
responses: {
/** @description Channel created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
channel?: components["schemas"]["NotificationChannel"];
};
};
};
400: components["schemas"]["Error"];
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/notifications/channels/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete a push notification channel */
delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Deleted */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageResponse"];
};
};
404: components["schemas"]["Error"];
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/notifications/channels/{id}/test": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Send a test notification through a push channel */
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Test delivered */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageResponse"];
};
};
/** @description Push delivery failed */
502: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/webhooks": { "/webhooks": {
parameters: { parameters: {
query?: never; query?: never;
@@ -5398,6 +5633,90 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/setup/status": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* First-run setup status
* @description Public. Drives the setup wizard — needs_setup is true until setup_completed is recorded. Instances with users and projects that predate the wizard report completed automatically.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Setup state */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
has_users?: boolean;
setup_completed?: boolean;
needs_setup?: boolean;
/** @description GitHub App credentials configured (manifest flow or env) */
has_github_app?: boolean;
/** @description Cloudflare tunnel token configured */
has_tunnel?: boolean;
signup_enabled?: boolean;
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/setup/complete": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Mark the setup wizard complete */
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Setup completed */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageResponse"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/settings": { "/settings": {
parameters: { parameters: {
query?: never; query?: never;
@@ -8105,7 +8424,7 @@ export interface paths {
}; };
/** /**
* Update node agent * Update node agent
* @description Partial update — accepts any subset of agent fields (name, status, auto_prune, tags). `tags` sets the node's placement labels; services with placement_tags only schedule onto agents carrying every required tag. * @description Partial update — accepts any subset of agent fields (name, status, auto_prune, tags, default_domain). `tags` sets the node's placement labels; services with placement_tags only schedule onto agents carrying every required tag. `default_domain` gives the node a base domain — domainless services placed there get `<name>.<default_domain>` auto-hostnames; empty string clears it.
*/ */
put: { put: {
parameters: { parameters: {
@@ -9317,6 +9636,137 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/projects/{id}/environments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List project environments
* @description Returns each environment lane with a live service count — the data behind the environment switcher.
*/
get: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project ID */
id: components["parameters"]["ProjectIdParam"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Project environments */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
environments?: components["schemas"]["ProjectEnvironment"][];
};
};
};
};
};
put?: never;
/**
* Create an environment
* @description Adds a named environment lane. Names are lowercase alphanumeric with dashes, 1-50 chars, unique per project.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project ID */
id: components["parameters"]["ProjectIdParam"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
name: string;
};
};
};
responses: {
/** @description Created environment */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
environment?: components["schemas"]["ProjectEnvironment"];
};
};
};
/** @description Environment already exists */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/environments/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/**
* Delete an empty environment
* @description Removes an environment lane; fails with 409 while it still holds services.
*/
delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Environment deleted */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Environment still contains services */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{id}/preview-environments": { "/projects/{id}/preview-environments": {
parameters: { parameters: {
query?: never; query?: never;
@@ -10939,7 +11389,7 @@ export interface components {
sleep_enabled?: boolean; sleep_enabled?: boolean;
/** @description Idle timeout in minutes before sleeping (1-1440, default 15) */ /** @description Idle timeout in minutes before sleeping (1-1440, default 15) */
sleep_idle_minutes?: number; sleep_idle_minutes?: number;
/** @description Node agent id this service is pinned to; empty/null = local Docker host. 'auto' on create/update resolves to the least-loaded online agent. Remote nodes run registry-pulled images only (no git builds, no Traefik domains). */ /** @description Node agent id this service is pinned to; empty/null = local Docker host. 'auto' on create/update resolves to the least-loaded online agent. Git-sourced services build on the node itself (build context ships as an agent-token artifact); rollbacks of locally-built tags ship the image via docker save/load. */
node_id?: string; node_id?: string;
/** @description Display name of the pinned node agent (read-only) */ /** @description Display name of the pinned node agent (read-only) */
node_name?: string; node_name?: string;
@@ -10947,6 +11397,10 @@ export interface components {
spread?: boolean; spread?: boolean;
/** @description Node tags required for remote placement — auto/spread only consider agents carrying every tag; re-resolved each reconcile. Mutually exclusive with an explicit node_id pin (read-only on responses when a pin is set). */ /** @description Node tags required for remote placement — auto/spread only consider agents carrying every tag; re-resolved each reconcile. Mutually exclusive with an explicit node_id pin (read-only on responses when a pin is set). */
placement_tags?: string[]; placement_tags?: string[];
/** @description Extra Traefik middlewares defined for this service's router — keys 'middlewares.<name>.<type>[.<field>]' (e.g. 'middlewares.rl.ratelimit.average'), allowlist-validated; each defined middleware attaches to the router after builtins */
traefik_labels?: {
[key: string]: string;
};
/** @description Computed public URL (published port or domain) */ /** @description Computed public URL (published port or domain) */
public_url?: string; public_url?: string;
/** /**
@@ -11023,6 +11477,10 @@ export interface components {
spread?: boolean; spread?: boolean;
/** @description Restrict remote placement to agents carrying every listed tag; mutually exclusive with an explicit pin or 'local' */ /** @description Restrict remote placement to agents carrying every listed tag; mutually exclusive with an explicit pin or 'local' */
placement_tags?: string[]; placement_tags?: string[];
/** @description Extra Traefik middlewares on the service router — keys 'middlewares.<name>.<type>[.<field>]'; allowlist-validated */
traefik_labels?: {
[key: string]: string;
};
}; };
UpdateServiceRequest: { UpdateServiceRequest: {
/** @description Service name */ /** @description Service name */
@@ -11090,6 +11548,10 @@ export interface components {
spread?: boolean; spread?: boolean;
/** @description Restrict remote placement to agents carrying every listed tag; [] clears. Mutually exclusive with a pin or 'local' — clear the pin first */ /** @description Restrict remote placement to agents carrying every listed tag; [] clears. Mutually exclusive with a pin or 'local' — clear the pin first */
placement_tags?: string[]; placement_tags?: string[];
/** @description Replace the service's Traefik middleware overrides — keys 'middlewares.<name>.<type>[.<field>]'; {} clears */
traefik_labels?: {
[key: string]: string;
};
}; };
ServiceDomain: { ServiceDomain: {
/** Format: uuid */ /** Format: uuid */
@@ -11607,6 +12069,17 @@ export interface components {
notifications?: components["schemas"]["Notification"][]; notifications?: components["schemas"]["Notification"][];
unread?: number; unread?: number;
}; };
NotificationChannel: {
/** Format: uuid */
id?: string;
/** @enum {string} */
kind?: "ntfy" | "gotify";
/** Format: uri */
endpoint?: string;
enabled?: boolean;
/** Format: date-time */
created_at?: string;
};
OutboundWebhook: { OutboundWebhook: {
/** Format: uuid */ /** Format: uuid */
id?: string; id?: string;
@@ -12211,6 +12684,8 @@ export interface components {
schedulable?: boolean; schedulable?: boolean;
/** @description Operator-set placement labels; services with placement_tags require all of them on the target node. */ /** @description Operator-set placement labels; services with placement_tags require all of them on the target node. */
tags?: string[]; tags?: string[];
/** @description Node base domain — services placed on this node (pin or spread) with no explicit service_domains get `<name>.<default_domain>` hostnames automatically. Wildcard DNS for `*.<default_domain>` must resolve to the Containr edge. */
default_domain?: string;
/** Format: date-time */ /** Format: date-time */
last_heartbeat?: string; last_heartbeat?: string;
/** Format: date-time */ /** Format: date-time */
@@ -12400,6 +12875,18 @@ export interface components {
restart_policy?: Record<string, never>; restart_policy?: Record<string, never>;
health_check?: Record<string, never>; health_check?: Record<string, never>;
}; };
ProjectEnvironment: {
/** Format: uuid */
id?: string;
name?: string;
/** Format: uuid */
project_id?: string;
service_count?: number;
/** Format: date-time */
created_at?: string;
/** Format: date-time */
updated_at?: string;
};
PreviewEnvironment: { PreviewEnvironment: {
/** Format: uuid */ /** Format: uuid */
id?: string; id?: string;
@@ -12407,6 +12894,11 @@ export interface components {
project_id?: string; project_id?: string;
/** Format: uuid */ /** Format: uuid */
service_id?: string; service_id?: string;
/**
* Format: uuid
* @description The cloned service the preview actually deploys; its live status drives the preview status.
*/
preview_service_id?: string;
branch_name?: string; branch_name?: string;
pr_number?: number; pr_number?: number;
/** @description Generated name, e.g. preview-{branch}-{ts} */ /** @description Generated name, e.g. preview-{branch}-{ts} */
+110 -1
View File
@@ -88,6 +88,7 @@ export type ServiceEntity = {
nodeName?: string; nodeName?: string;
spread?: boolean; spread?: boolean;
placementTags?: string[]; placementTags?: string[];
traefikLabels?: Record<string, string>;
publicUrl?: string; publicUrl?: string;
}; };
@@ -361,6 +362,7 @@ export type NodeAgentEntity = {
autoPrune: boolean; autoPrune: boolean;
schedulable: boolean; schedulable: boolean;
tags: string[]; tags: string[];
defaultDomain?: string;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
lastHeartbeat?: string; lastHeartbeat?: string;
createdAt?: string; createdAt?: string;
@@ -488,6 +490,32 @@ export function getApiBaseUrl(): string {
return API_BASE; return API_BASE;
} }
// pingServer hits the public /health route — any HTTP response proves
// reachability, and no auth means no 401 noise in the connectivity probe.
export async function pingServer(): Promise<void> {
const res = await fetch(`${API_ROOT}/health`);
if (!res.ok) {
throw new Error(`Server returned ${res.status}`);
}
}
export type SetupStatus = {
has_users: boolean;
setup_completed: boolean;
needs_setup: boolean;
has_github_app: boolean;
has_tunnel: boolean;
signup_enabled: boolean;
};
export async function getSetupStatus(): Promise<SetupStatus> {
return requestJson<SetupStatus>('/setup/status');
}
export async function completeSetup(): Promise<void> {
await requestJson('/setup/complete', { method: 'POST', body: JSON.stringify({}) });
}
export function getAgentPublicBaseUrl(): string { export function getAgentPublicBaseUrl(): string {
return `${API_ROOT}/api/agents`; return `${API_ROOT}/api/agents`;
} }
@@ -702,6 +730,7 @@ function normalizeService(service: RawService): ServiceEntity | null {
nodeName: service.node_name, nodeName: service.node_name,
spread: service.spread, spread: service.spread,
placementTags: service.placement_tags, placementTags: service.placement_tags,
traefikLabels: service.traefik_labels,
publicUrl: service.public_url, publicUrl: service.public_url,
}; };
} }
@@ -1105,6 +1134,7 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
autoPrune: agent.auto_prune ?? false, autoPrune: agent.auto_prune ?? false,
schedulable: agent.schedulable ?? true, schedulable: agent.schedulable ?? true,
tags: Array.isArray(agent.tags) ? agent.tags : [], tags: Array.isArray(agent.tags) ? agent.tags : [],
defaultDomain: agent.default_domain || '',
metadata: (agent.metadata ?? {}) as Record<string, unknown>, metadata: (agent.metadata ?? {}) as Record<string, unknown>,
createdAt: agent.created_at, createdAt: agent.created_at,
updatedAt: agent.updated_at, updatedAt: agent.updated_at,
@@ -1144,7 +1174,7 @@ export async function listAgents(): Promise<NodeAgentEntity[]> {
return normalizeAgentArray(payload.agents); return normalizeAgentArray(payload.agents);
} }
export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean; tags?: string[] }): Promise<NodeAgentEntity | null> { export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean; tags?: string[]; default_domain?: string }): Promise<NodeAgentEntity | null> {
const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`, { const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -1317,6 +1347,34 @@ export async function listServicesByProject(projectId: string): Promise<ServiceE
return normalizeServiceArray(rows); return normalizeServiceArray(rows);
} }
export interface ProjectEnvironment {
id: string;
name: string;
project_id: string;
service_count: number;
created_at: string;
updated_at: string;
}
export async function listProjectEnvironments(projectId: string): Promise<ProjectEnvironment[]> {
const payload = await requestJson<{ environments?: ProjectEnvironment[] }>(
`/projects/${projectId}/environments`,
);
return payload.environments ?? [];
}
export async function createProjectEnvironment(projectId: string, name: string): Promise<ProjectEnvironment> {
const payload = await requestJson<{ environment: ProjectEnvironment }>(
`/projects/${projectId}/environments`,
{ method: 'POST', body: JSON.stringify({ name }) },
);
return payload.environment;
}
export async function deleteProjectEnvironment(environmentId: string): Promise<void> {
await requestJson(`/environments/${encodeURIComponent(environmentId)}`, { method: 'DELETE' });
}
export async function getServiceById(serviceId: string): Promise<ServiceEntity> { export async function getServiceById(serviceId: string): Promise<ServiceEntity> {
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`); const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`);
const row = (payload as { service?: RawService }).service ?? (payload as RawService); const row = (payload as { service?: RawService }).service ?? (payload as RawService);
@@ -1924,6 +1982,21 @@ export async function importComposeTemplate(composeYaml: string): Promise<Compos
return { config: payload.config, warnings: payload.warnings ?? [] }; return { config: payload.config, warnings: payload.warnings ?? [] };
} }
export async function importGitComposeTemplate(
repo: string,
path: string,
ref: string,
): Promise<ComposeImportResult> {
const payload = await requestJson<{ config?: Record<string, unknown>; warnings?: string[] }>(
'/templates/import/git',
{ method: 'POST', body: JSON.stringify({ repo, path: path || undefined, ref: ref || undefined }) },
);
if (!payload.config || typeof payload.config !== 'object') {
throw new ApiError('Git import response is invalid', 500);
}
return { config: payload.config, warnings: payload.warnings ?? [] };
}
export type ProjectComposeImportResult = { export type ProjectComposeImportResult = {
created: Array<{ key: string; kind: string; id: string; name: string; deployment_id?: string }>; created: Array<{ key: string; kind: string; id: string; name: string; deployment_id?: string }>;
warnings: string[]; warnings: string[];
@@ -2549,6 +2622,42 @@ export async function markAllNotificationsRead(): Promise<void> {
await requestJson('/notifications/read-all', { method: 'POST' }); await requestJson('/notifications/read-all', { method: 'POST' });
} }
export type NotificationChannel = {
id: string;
kind: 'ntfy' | 'gotify';
endpoint: string;
enabled: boolean;
created_at: string;
};
export async function listNotificationChannels(): Promise<NotificationChannel[]> {
const payload = await requestJson<{ channels?: NotificationChannel[] }>('/notifications/channels');
return payload.channels ?? [];
}
export async function createNotificationChannel(input: {
kind: 'ntfy' | 'gotify';
endpoint: string;
token?: string;
}): Promise<NotificationChannel> {
const payload = await requestJson<{ channel: NotificationChannel }>('/notifications/channels', {
method: 'POST',
body: JSON.stringify(input),
});
return payload.channel;
}
export async function deleteNotificationChannel(id: string): Promise<void> {
await requestJson(`/notifications/channels/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
export async function testNotificationChannel(id: string): Promise<void> {
await requestJson(`/notifications/channels/${encodeURIComponent(id)}/test`, {
method: 'POST',
body: JSON.stringify({}),
});
}
export type PreviewEnvironmentEntity = components['schemas']['PreviewEnvironment']; export type PreviewEnvironmentEntity = components['schemas']['PreviewEnvironment'];
export async function listPreviewEnvironments(projectId: string): Promise<PreviewEnvironmentEntity[]> { export async function listPreviewEnvironments(projectId: string): Promise<PreviewEnvironmentEntity[]> {
+36 -3
View File
@@ -1,6 +1,8 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import App from './app/App'; import App from './app/App';
import { ToastProvider } from './shared/components'; import { ToastProvider } from './shared/components';
@@ -12,18 +14,49 @@ const queryClient = new QueryClient({
staleTime: 30_000, staleTime: 30_000,
retry: 1, retry: 1,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
// Serve the persisted snapshot while offline instead of erroring.
networkMode: 'offlineFirst',
}, },
}, },
}); });
// High-churn or bulky keys never hit localStorage — a stale log tail is
// worse than none.
const NEVER_PERSIST = new Set([
'shell-notifications',
'notifications',
'service-logs',
'logs',
'activity',
'audit-logs',
'audit',
'metrics',
'connectivity',
]);
const persister = createSyncStoragePersister({
storage: window.localStorage,
key: 'containr-query-cache-v1',
});
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 24 * 60 * 60 * 1000,
dehydrateOptions: {
shouldDehydrateQuery: (q) =>
q.state.status === 'success' && !NEVER_PERSIST.has(String(q.queryKey[0])),
},
}}
>
<BrowserRouter> <BrowserRouter>
<ToastProvider> <ToastProvider>
<App /> <App />
</ToastProvider> </ToastProvider>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </PersistQueryClientProvider>
</StrictMode>, </StrictMode>,
); );
+91
View File
@@ -9,11 +9,15 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@tanstack/query-async-storage-persister": "^5.104.1",
"@tanstack/react-query": "^5.104.1", "@tanstack/react-query": "^5.104.1",
"@tanstack/react-query-persist-client": "^5.104.1",
"expo": "~57.0.27", "expo": "~57.0.27",
"expo-constants": "~57.0.21", "expo-constants": "~57.0.21",
"expo-font": "~57.0.4", "expo-font": "~57.0.4",
"expo-linking": "~57.0.12", "expo-linking": "~57.0.12",
"expo-network": "~57.0.2",
"expo-router": "~57.0.25", "expo-router": "~57.0.25",
"expo-secure-store": "~57.0.4", "expo-secure-store": "~57.0.4",
"expo-status-bar": "~57.0.1", "expo-status-bar": "~57.0.1",
@@ -2165,6 +2169,18 @@
} }
} }
}, },
"node_modules/@react-native-async-storage/async-storage": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
"license": "MIT",
"dependencies": {
"merge-options": "^3.0.4"
},
"peerDependencies": {
"react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
"node_modules/@react-native-masked-view/masked-view": { "node_modules/@react-native-masked-view/masked-view": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz",
@@ -2469,6 +2485,20 @@
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tanstack/query-async-storage-persister": {
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.104.1.tgz",
"integrity": "sha512-wSs/HrangoSMVW3zLifawRT9lA+fbiilPQmaY+YhxBOM/VS9oqx+MQvROHpW+hKwDpdvFmhyBjWiyDhTHL1YXg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.104.1",
"@tanstack/query-persist-client-core": "5.104.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.104.1", "version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz",
@@ -2479,6 +2509,19 @@
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
} }
}, },
"node_modules/@tanstack/query-persist-client-core": {
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.104.1.tgz",
"integrity": "sha512-ONiadXqe56iNrw6RESqKILpTIVG9ZMI/DageTQnrBDt/nZ6WiP4G13iIiMwJEsiO12gQ3Gychx8tcFIPw4k25g==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.104.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": { "node_modules/@tanstack/react-query": {
"version": "5.104.1", "version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz",
@@ -2495,6 +2538,23 @@
"react": "^18 || ^19" "react": "^18 || ^19"
} }
}, },
"node_modules/@tanstack/react-query-persist-client": {
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.104.1.tgz",
"integrity": "sha512-kU3YaqjkwEUzUUfPF0JVg56d6gJKWHaFVqOlQztwc4C2IT0P5sv/23orrSoQqjxKaUe6Ejw5IIQtA/G12uNmow==",
"license": "MIT",
"dependencies": {
"@tanstack/query-persist-client-core": "5.104.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.104.1",
"react": "^18 || ^19"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.4", "version": "0.10.4",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
@@ -5416,6 +5476,16 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-network": {
"version": "57.0.2",
"resolved": "https://registry.npmjs.org/expo-network/-/expo-network-57.0.2.tgz",
"integrity": "sha512-BfK1kdftZcM6o9o1FAG7/y0l6vWX0n8td0M5lwkvaMeRXZ9LkRwOi9CQXwvsMTQGQiPwtQXgwiAze13cqjt75g==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*"
}
},
"node_modules/expo-router": { "node_modules/expo-router": {
"version": "57.0.25", "version": "57.0.25",
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.25.tgz", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.25.tgz",
@@ -7084,6 +7154,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-plain-obj": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-regex": { "node_modules/is-regex": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -7968,6 +8047,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/merge-options": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
"license": "MIT",
"dependencies": {
"is-plain-obj": "^2.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/merge-stream": { "node_modules/merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+4
View File
@@ -4,11 +4,15 @@
"main": "expo-router/entry", "main": "expo-router/entry",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@tanstack/query-async-storage-persister": "^5.104.1",
"@tanstack/react-query": "^5.104.1", "@tanstack/react-query": "^5.104.1",
"@tanstack/react-query-persist-client": "^5.104.1",
"expo": "~57.0.27", "expo": "~57.0.27",
"expo-constants": "~57.0.21", "expo-constants": "~57.0.21",
"expo-font": "~57.0.4", "expo-font": "~57.0.4",
"expo-linking": "~57.0.12", "expo-linking": "~57.0.12",
"expo-network": "~57.0.2",
"expo-router": "~57.0.25", "expo-router": "~57.0.25",
"expo-secure-store": "~57.0.4", "expo-secure-store": "~57.0.4",
"expo-status-bar": "~57.0.1", "expo-status-bar": "~57.0.1",
+170
View File
@@ -68,6 +68,68 @@ export interface Template {
icon?: string; icon?: string;
} }
export interface Variable {
id?: string;
key: string;
value: string;
is_secret: boolean;
}
export interface ServiceDomain {
id: string;
domain: string;
is_default: boolean;
cert_type?: string;
cert_status?: string;
}
export interface AppNotification {
id: string;
kind: string;
title: string;
body?: string;
resource_type?: string;
resource_id?: string;
read_at?: string | null;
created_at?: string;
}
export interface NotificationChannel {
id: string;
kind: 'ntfy' | 'gotify';
endpoint: string;
enabled: boolean;
created_at?: string;
}
export interface CronJob {
id: string;
service_id?: string;
name: string;
schedule: string;
command: string;
timezone?: string;
enabled?: boolean;
last_run_at?: string;
next_run_at?: string;
last_status?: string;
last_output?: string;
}
export interface CronExecution {
id: string;
cron_job_id: string;
started_at?: string;
finished_at?: string;
status?: string;
output?: string;
error?: string;
}
// Masked value the API returns for secret variables; echoing it back on
// update preserves the stored ciphertext.
export const MASKED_SECRET = '********';
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
public status: number, public status: number,
@@ -145,6 +207,114 @@ export class Api {
return this.req<EnvCheck>('GET', `/services/${id}/env-check`); return this.req<EnvCheck>('GET', `/services/${id}/env-check`);
} }
serviceVariables(id: string) {
return this.req<{ variables: Variable[] }>('GET', `/services/${id}/variables`).then(
(r) => r.variables ?? [],
);
}
updateVariables(id: string, variables: { key: string; value: string; is_secret: boolean }[]) {
return this.req<unknown>('PUT', `/services/${id}/variables`, { variables });
}
serviceDomains(id: string) {
return this.req<{ domains: ServiceDomain[] }>('GET', `/services/${id}/domains`).then(
(r) => r.domains ?? [],
);
}
addDomain(id: string, domain: string) {
return this.req<unknown>('POST', `/services/${id}/domains`, { domain });
}
deleteDomain(id: string, domainId: string) {
return this.req<unknown>('DELETE', `/services/${id}/domains/${domainId}`);
}
setDefaultDomain(id: string, domainId: string) {
return this.req<unknown>('POST', `/services/${id}/domains/${domainId}/default`);
}
updateService(
id: string,
patch: Partial<{
name: string;
command: string;
replicas: number;
port: number;
domain: string;
restart_policy: string;
healthcheck_path: string;
cpu: string;
memory: string;
sleep_enabled: boolean;
sleep_idle_minutes: number;
maintenance_mode: boolean;
}>,
) {
return this.req<unknown>('PUT', `/services/${id}`, patch);
}
notifications() {
return this.req<{ notifications: AppNotification[]; unread: number }>(
'GET',
'/notifications?limit=50',
);
}
markNotificationRead(id: string) {
return this.req<unknown>('POST', `/notifications/${id}/read`);
}
markAllNotificationsRead() {
return this.req<unknown>('POST', '/notifications/read-all');
}
notificationChannels() {
return this.req<{ channels: NotificationChannel[] }>(
'GET',
'/notifications/channels',
).then((r) => r.channels ?? []);
}
addNotificationChannel(input: {
kind: 'ntfy' | 'gotify';
endpoint: string;
token?: string;
}) {
return this.req<{ channel: NotificationChannel }>(
'POST',
'/notifications/channels',
input,
);
}
deleteNotificationChannel(id: string) {
return this.req<unknown>('DELETE', `/notifications/channels/${id}`);
}
testNotificationChannel(id: string) {
return this.req<unknown>('POST', `/notifications/channels/${id}/test`, {});
}
cronJobs(serviceId: string) {
return this.req<{ cron_jobs: CronJob[] }>(
'GET',
`/cron-jobs?service_id=${serviceId}`,
).then((r) => r.cron_jobs ?? []);
}
cronExecutions(jobId: string) {
return this.req<{ executions: CronExecution[] }>(
'GET',
`/cron-jobs/${jobId}/executions`,
).then((r) => r.executions ?? []);
}
triggerCronJob(jobId: string) {
return this.req<unknown>('POST', `/cron-jobs/${jobId}/trigger`, {});
}
serviceDeployments(id: string) { serviceDeployments(id: string) {
return this.req<{ deployments: Deployment[] }>( return this.req<{ deployments: Deployment[] }>(
'GET', 'GET',
+69 -3
View File
@@ -1,11 +1,52 @@
import React from 'react'; import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Tabs } from 'expo-router'; import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useQuery } from '@tanstack/react-query';
import * as Network from 'expo-network';
import { useApi } from '../../session';
import { colors } from '../../theme'; import { colors } from '../../theme';
export default function TabsLayout() { // OfflineBanner pings /health every 15s; expo-network gives a fast local
// signal, the ping catches "device online but instance down".
function OfflineBanner() {
const api = useApi();
const net = useQuery({
queryKey: ['connectivity', 'net'],
queryFn: async () => (await Network.getNetworkStateAsync()).isConnected !== false,
refetchInterval: 15_000,
});
const ping = useQuery({
queryKey: ['connectivity', 'ping'],
queryFn: () => api.ping(),
refetchInterval: 15_000,
retry: 0,
networkMode: 'always',
});
if (net.data !== false && ping.data !== false && !ping.isError) return null;
return ( return (
<Tabs <View style={styles.banner}>
<Ionicons name="cloud-offline-outline" size={13} color={colors.warn} />
<Text style={styles.bannerText}>
{net.data === false ? 'No connection' : 'Instance unreachable'} — showing last-known data
</Text>
</View>
);
}
export default function TabsLayout() {
const api = useApi();
const notifs = useQuery({
queryKey: ['notifications'],
queryFn: () => api.notifications(),
refetchInterval: 60_000,
});
const unread = notifs.data?.unread ?? 0;
return (
<View style={{ flex: 1 }}>
<OfflineBanner />
<Tabs
screenOptions={{ screenOptions={{
headerStyle: { backgroundColor: colors.bg }, headerStyle: { backgroundColor: colors.bg },
headerTintColor: colors.text, headerTintColor: colors.text,
@@ -41,6 +82,16 @@ export default function TabsLayout() {
), ),
}} }}
/> />
<Tabs.Screen
name="notifications"
options={{
title: 'Alerts',
tabBarBadge: unread > 0 ? unread : undefined,
tabBarIcon: ({ color, size }) => (
<Ionicons name="notifications-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen <Tabs.Screen
name="settings" name="settings"
options={{ options={{
@@ -50,6 +101,21 @@ export default function TabsLayout() {
), ),
}} }}
/> />
</Tabs> </Tabs>
</View>
); );
} }
const styles = StyleSheet.create({
banner: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 6,
backgroundColor: colors.surface,
borderBottomWidth: 1,
borderBottomColor: colors.warn,
},
bannerText: { color: colors.textDim, fontSize: 11 },
});
+233
View File
@@ -0,0 +1,233 @@
import React, { useState } from 'react';
import { FlatList, RefreshControl, StyleSheet, Text, TextInput, View } from 'react-native';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
import { relTime } from '../../time';
import { ActionButton, Card, Empty, ErrorBox, Loading, SectionTitle } from '../../components';
const KIND_COLOR: Record<string, string> = {
deployment_failed: colors.err,
deployment: colors.accent,
alert: colors.warn,
scale: colors.info,
};
export default function Notifications() {
const api = useApi();
const qc = useQueryClient();
const notifs = useQuery({
queryKey: ['notifications'],
queryFn: () => api.notifications(),
refetchInterval: 30_000,
});
const markAll = useMutation({
mutationFn: () => api.markAllNotificationsRead(),
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
});
const markRead = useMutation({
mutationFn: (id: string) => api.markNotificationRead(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
});
if (notifs.isLoading) return <Loading />;
if (notifs.error) return <ErrorBox error={notifs.error} />;
const items = notifs.data?.notifications ?? [];
const unread = notifs.data?.unread ?? 0;
return (
<FlatList
style={styles.root}
contentContainerStyle={{ padding: 16 }}
refreshControl={
<RefreshControl
refreshing={notifs.isRefetching}
onRefresh={() => notifs.refetch()}
tintColor={colors.accent}
/>
}
ListHeaderComponent={
unread > 0 ? (
<ActionButton
label={`Mark all read (${unread})`}
disabled={markAll.isPending}
onPress={() => markAll.mutate()}
/>
) : null
}
ListEmptyComponent={<Empty text="No notifications" />}
ListFooterComponent={<PushChannelsCard />}
data={items}
keyExtractor={(n) => n.id}
renderItem={({ item }) => {
const isUnread = !item.read_at;
const dot = KIND_COLOR[item.kind] ?? colors.accent;
return (
<Card
style={isUnread ? { borderColor: colors.accent } : undefined}
>
<View style={styles.row}>
<View style={[styles.dot, { backgroundColor: isUnread ? dot : colors.border }]} />
<View style={{ flex: 1 }}>
<Text style={[styles.title, !isUnread && { color: colors.textDim }]}>
{item.title}
</Text>
{item.body ? <Text style={styles.body}>{item.body}</Text> : null}
<Text style={styles.meta}>
{item.kind}
{item.created_at ? ` · ${relTime(item.created_at)}` : ''}
</Text>
</View>
{isUnread ? (
<Text
style={styles.markRead}
onPress={() => markRead.mutate(item.id)}
>
Mark read
</Text>
) : null}
</View>
</Card>
);
}}
/>
);
}
function PushChannelsCard() {
const api = useApi();
const qc = useQueryClient();
const channels = useQuery({
queryKey: ['notification-channels'],
queryFn: () => api.notificationChannels(),
});
const [kind, setKind] = useState<'ntfy' | 'gotify'>('ntfy');
const [endpoint, setEndpoint] = useState('');
const [token, setToken] = useState('');
const [adding, setAdding] = useState(false);
const invalidate = () => qc.invalidateQueries({ queryKey: ['notification-channels'] });
const add = useMutation({
mutationFn: () =>
api.addNotificationChannel({
kind,
endpoint: endpoint.trim(),
token: token.trim() || undefined,
}),
onSuccess: () => {
setEndpoint('');
setToken('');
setAdding(false);
invalidate();
},
});
const remove = useMutation({
mutationFn: (id: string) => api.deleteNotificationChannel(id),
onSuccess: invalidate,
});
const test = useMutation({
mutationFn: (id: string) => api.testNotificationChannel(id),
});
const list = channels.data ?? [];
return (
<View style={{ marginTop: 24 }}>
<SectionTitle>Push channels</SectionTitle>
<Card>
<Text style={styles.hint}>
Relay alerts to a self-hosted ntfy or Gotify server — your phone gets pushes via the ntfy app, no account required here.
</Text>
{list.map((ch) => (
<View key={ch.id} style={styles.channelRow}>
<View style={{ flex: 1 }}>
<Text style={styles.channelKind}>{ch.kind.toUpperCase()}</Text>
<Text style={styles.channelEndpoint} numberOfLines={1}>
{ch.endpoint}
</Text>
</View>
<Text
style={styles.channelAction}
onPress={() => test.mutate(ch.id)}
>
{test.isPending ? '…' : 'Test'}
</Text>
<Text
style={[styles.channelAction, { color: colors.err }]}
onPress={() => remove.mutate(ch.id)}
>
Remove
</Text>
</View>
))}
{adding ? (
<View style={{ marginTop: 8 }}>
<View style={styles.kindRow}>
{(['ntfy', 'gotify'] as const).map((k) => (
<Text
key={k}
style={[styles.kindChip, kind === k && styles.kindChipActive]}
onPress={() => setKind(k)}
>
{k}
</Text>
))}
</View>
<TextInput
style={styles.input}
value={endpoint}
onChangeText={setEndpoint}
placeholder={kind === 'ntfy' ? 'https://ntfy.example.com/my-alerts' : 'https://gotify.example.com'}
placeholderTextColor={colors.textDim}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<TextInput
style={styles.input}
value={token}
onChangeText={setToken}
placeholder={kind === 'ntfy' ? 'Access token (optional)' : 'App token'}
placeholderTextColor={colors.textDim}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
/>
{add.error ? <ErrorBox error={add.error} /> : null}
<ActionButton
label={add.isPending ? 'Adding…' : 'Add channel'}
disabled={add.isPending || !endpoint.trim()}
onPress={() => add.mutate()}
/>
</View>
) : (
<Text style={styles.channelAction} onPress={() => setAdding(true)}>
+ Add channel
</Text>
)}
</Card>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
row: { flexDirection: 'row', alignItems: 'flex-start' },
dot: { width: 8, height: 8, borderRadius: 4, marginTop: 5, marginRight: 10 },
title: { color: colors.text, fontSize: 15, fontWeight: '600' },
body: { color: colors.textDim, fontSize: 13, marginTop: 4, lineHeight: 18 },
meta: { color: colors.textDim, fontSize: 11, marginTop: 6 },
markRead: { color: colors.accent, fontSize: 12, fontWeight: '600', marginLeft: 8 },
hint: { color: colors.textDim, fontSize: 12, lineHeight: 17, marginBottom: 8 },
channelRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border },
channelKind: { color: colors.text, fontSize: 12, fontWeight: '700' },
channelEndpoint: { color: colors.textDim, fontSize: 12, marginTop: 2 },
channelAction: { color: colors.accent, fontSize: 12, fontWeight: '600', marginLeft: 12 },
kindRow: { flexDirection: 'row', gap: 8, marginBottom: 8 },
kindChip: { color: colors.textDim, fontSize: 13, paddingVertical: 4, paddingHorizontal: 12, borderRadius: 12, borderWidth: 1, borderColor: colors.border },
kindChipActive: { color: colors.accent, borderColor: colors.accent },
input: { color: colors.text, fontSize: 14, borderWidth: 1, borderColor: colors.border, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 8, marginBottom: 8 },
});
+30 -4
View File
@@ -1,16 +1,28 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Stack, useRouter, useSegments } from 'expo-router'; import { Stack, useRouter, useSegments } from 'expo-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { SessionProvider, useSession } from '../session'; import { SessionProvider, useSession } from '../session';
import { colors } from '../theme'; import { colors } from '../theme';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { staleTime: 15_000, retry: 1 }, queries: { staleTime: 15_000, retry: 1, networkMode: 'offlineFirst' },
}, },
}); });
// High-churn keys never hit disk — a stale notification count or log
// tail is worse than none.
const NEVER_PERSIST = new Set(['notifications', 'notification-channels', 'logs', 'connectivity']);
const persister = createAsyncStoragePersister({
storage: AsyncStorage,
key: 'containr-query-cache-v1',
});
function AuthGate() { function AuthGate() {
const { ready, api } = useSession(); const { ready, api } = useSession();
const segments = useSegments(); const segments = useSegments();
@@ -37,6 +49,10 @@ function AuthGate() {
<Stack.Screen name="project/[id]" options={{ title: 'Project' }} /> <Stack.Screen name="project/[id]" options={{ title: 'Project' }} />
<Stack.Screen name="service/[id]" options={{ title: 'Service' }} /> <Stack.Screen name="service/[id]" options={{ title: 'Service' }} />
<Stack.Screen name="service/logs" options={{ title: 'Logs' }} /> <Stack.Screen name="service/logs" options={{ title: 'Logs' }} />
<Stack.Screen name="service/variables" options={{ title: 'Variables' }} />
<Stack.Screen name="service/domains" options={{ title: 'Domains' }} />
<Stack.Screen name="service/cron" options={{ title: 'Cron jobs' }} />
<Stack.Screen name="service/settings" options={{ title: 'Service settings' }} />
</Stack.Protected> </Stack.Protected>
<Stack.Screen name="login" options={{ headerShown: false }} /> <Stack.Screen name="login" options={{ headerShown: false }} />
</Stack> </Stack>
@@ -45,11 +61,21 @@ function AuthGate() {
export default function RootLayout() { export default function RootLayout() {
return ( return (
<QueryClientProvider client={queryClient}> <PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 7 * 24 * 60 * 60 * 1000,
dehydrateOptions: {
shouldDehydrateQuery: (q) =>
q.state.status === 'success' && !NEVER_PERSIST.has(String(q.queryKey[0])),
},
}}
>
<SessionProvider> <SessionProvider>
<StatusBar style="light" /> <StatusBar style="light" />
<AuthGate /> <AuthGate />
</SessionProvider> </SessionProvider>
</QueryClientProvider> </PersistQueryClientProvider>
); );
} }
+28
View File
@@ -11,6 +11,7 @@ import {
Empty, Empty,
ErrorBox, ErrorBox,
Loading, Loading,
Row,
SectionTitle, SectionTitle,
StatusBadge, StatusBadge,
} from '../../components'; } from '../../components';
@@ -142,6 +143,33 @@ export default function ServiceDetail() {
/> />
</View> </View>
<SectionTitle>Manage</SectionTitle>
<Card>
<Row
title="Variables"
subtitle="Environment and secrets"
onPress={() =>
router.push(`/service/variables?id=${s.id}&name=${encodeURIComponent(s.name)}`)
}
/>
<Row
title="Domains"
subtitle={s.domain ?? 'Hostnames routed to this service'}
onPress={() => router.push(`/service/domains?id=${s.id}`)}
/>
<Row
title="Cron jobs"
subtitle="Schedules, run now, history"
onPress={() => router.push(`/service/cron?id=${s.id}`)}
/>
<Row
title="Scale & sleep"
subtitle={`${s.replicas ?? 1} replica${(s.replicas ?? 1) === 1 ? '' : 's'}` +
(s.sleep_enabled ? ` · sleeps after ${s.sleep_idle_minutes}m idle` : '')}
onPress={() => router.push(`/service/settings?id=${s.id}`)}
/>
</Card>
{s.sleep_enabled ? ( {s.sleep_enabled ? (
<Text style={styles.hint}> <Text style={styles.hint}>
Sleep enabled — scales to zero after {s.sleep_idle_minutes}m idle, wakes on traffic. Sleep enabled — scales to zero after {s.sleep_idle_minutes}m idle, wakes on traffic.
+144
View File
@@ -0,0 +1,144 @@
import React, { useState } from 'react';
import { Alert, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
import { relTime } from '../../time';
import {
ActionButton,
Card,
Empty,
ErrorBox,
Loading,
StatusBadge,
} from '../../components';
export default function ServiceCron() {
const { id } = useLocalSearchParams<{ id: string }>();
const api = useApi();
const qc = useQueryClient();
const [historyFor, setHistoryFor] = useState<string | null>(null);
const jobs = useQuery({
queryKey: ['service-cron', id],
queryFn: () => api.cronJobs(id!),
enabled: !!id,
refetchInterval: 15_000,
});
const executions = useQuery({
queryKey: ['cron-executions', historyFor],
queryFn: () => api.cronExecutions(historyFor!),
enabled: !!historyFor,
});
const trigger = useMutation({
mutationFn: (jobId: string) => api.triggerCronJob(jobId),
onSuccess: (_d, jobId) => {
qc.invalidateQueries({ queryKey: ['service-cron', id] });
setHistoryFor(jobId);
setTimeout(
() => qc.invalidateQueries({ queryKey: ['cron-executions', jobId] }),
2000,
);
},
onError: (e) =>
Alert.alert('Trigger failed', e instanceof Error ? e.message : String(e)),
});
if (jobs.isLoading) return <Loading />;
if (jobs.error) return <ErrorBox error={jobs.error} />;
const list = jobs.data ?? [];
return (
<ScrollView
style={styles.root}
contentContainerStyle={{ padding: 16 }}
refreshControl={
<RefreshControl
refreshing={jobs.isRefetching}
onRefresh={() => jobs.refetch()}
tintColor={colors.accent}
/>
}
>
{list.length === 0 ? <Empty text="No cron jobs on this service" /> : null}
{list.map((job) => (
<Card key={job.id}>
<View style={styles.jobHead}>
<View style={{ flex: 1 }}>
<Text style={styles.jobName}>{job.name}</Text>
<Text style={styles.meta}>
{job.schedule}
{job.timezone ? ` · ${job.timezone}` : ''}
{job.enabled === false ? ' · disabled' : ''}
</Text>
<Text style={styles.cmd} numberOfLines={1}>
{job.command}
</Text>
<Text style={styles.meta}>
{job.last_run_at ? `last ${relTime(job.last_run_at)}` : 'never run'}
{job.next_run_at ? ` · next ${relTime(job.next_run_at)}` : ''}
</Text>
</View>
{job.last_status ? <StatusBadge status={job.last_status} /> : null}
</View>
<View style={styles.jobActions}>
<ActionButton
label="Run now"
kind="primary"
disabled={trigger.isPending}
onPress={() => trigger.mutate(job.id)}
/>
<ActionButton
label={historyFor === job.id ? 'Hide history' : 'History'}
onPress={() => setHistoryFor(historyFor === job.id ? null : job.id)}
/>
</View>
{historyFor === job.id ? (
<View style={styles.history}>
{executions.isLoading ? (
<Loading />
) : (executions.data ?? []).length === 0 ? (
<Text style={styles.meta}>No executions yet</Text>
) : (
(executions.data ?? []).slice(0, 10).map((ex) => (
<View key={ex.id} style={styles.execRow}>
<View style={{ flex: 1 }}>
<Text style={styles.execTime}>{relTime(ex.started_at)}</Text>
{ex.error ? (
<Text style={styles.execErr} numberOfLines={2}>
{ex.error}
</Text>
) : ex.output ? (
<Text style={styles.execOut} numberOfLines={2}>
{ex.output}
</Text>
) : null}
</View>
<StatusBadge status={ex.status ?? 'unknown'} />
</View>
))
)}
</View>
) : null}
</Card>
))}
</ScrollView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
jobHead: { flexDirection: 'row', alignItems: 'flex-start' },
jobName: { color: colors.text, fontSize: 15, fontWeight: '600' },
meta: { color: colors.textDim, fontSize: 11, marginTop: 3 },
cmd: { color: colors.textDim, fontSize: 12, fontFamily: 'monospace', marginTop: 4 },
jobActions: { flexDirection: 'row', gap: 8, marginTop: 10 },
history: { marginTop: 10, borderTopWidth: 1, borderTopColor: colors.border, paddingTop: 8 },
execRow: { flexDirection: 'row', alignItems: 'flex-start', paddingVertical: 6 },
execTime: { color: colors.text, fontSize: 12 },
execErr: { color: colors.err, fontSize: 11, marginTop: 2 },
execOut: { color: colors.textDim, fontSize: 11, marginTop: 2 },
});
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more