mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7191ad834 | ||
|
|
a6b8397dd8 | ||
|
|
c4307034ec | ||
|
|
985d22798c | ||
|
|
26f495ac8e | ||
|
|
9243d020cc | ||
|
|
60145f7b21 | ||
|
|
dd7b735b38 | ||
|
|
d5909172ae | ||
|
|
1409186a1f | ||
|
|
a4bb7ad559 | ||
|
|
4071ed2991 | ||
|
|
c5562109bf | ||
|
|
604319ee06 | ||
|
|
3cc84553f4 | ||
|
|
d57ac72aa5 | ||
|
|
59dfd35583 | ||
|
|
4028639239 | ||
|
|
c3a35081d0 | ||
|
|
4458de56b0 | ||
|
|
9de586f177 | ||
|
|
dd5ce80ce2 | ||
|
|
bc93c83cf9 | ||
|
|
401daf69aa | ||
|
|
ae7e6b4991 | ||
|
|
ee43064c5c | ||
|
|
e054457156 | ||
|
|
3f066b3b1e |
No files matched your search
@@ -49,6 +49,10 @@ CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
|
||||
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
|
||||
# Bundled agent binaries dir served at /api/agents/download/:platform.
|
||||
# CONTAINR_AGENT_BIN_DIR=/agents
|
||||
# Directory Traefik's file provider watches. The backend writes one
|
||||
# config per remote service here so domains route to node agents. The
|
||||
# infra compose wires a shared volume — set only for custom layouts.
|
||||
# TRAEFIK_DYNAMIC_DIR=/traefik-dynamic
|
||||
# Optional remote template catalog (another Containr instance). Its
|
||||
# templates merge into GET /templates as source:"registry" and
|
||||
# materialize locally on deploy. The bundled catalog works without it.
|
||||
|
||||
@@ -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
|
||||
@@ -163,11 +163,13 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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: |
|
||||
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
|
||||
BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
|
||||
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
|
||||
run: |
|
||||
@@ -204,7 +206,13 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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
|
||||
with:
|
||||
config-inline: |
|
||||
[registry."docker.io"]
|
||||
mirrors = ["mirror.gcr.io"]
|
||||
|
||||
- name: Login to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
@@ -49,7 +49,7 @@ dedicated console.
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
|
||||
+186
-64
@@ -192,8 +192,11 @@ goroutines; this fixes correctness *and* unlocks the ops surface.
|
||||
compares vs edge IP → `ok|wrong-target|pending`.
|
||||
- [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
|
||||
no proxy code needed.
|
||||
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with
|
||||
allowlist validation (middlewares, headers, redirects).
|
||||
- [x] **Routing overrides** — `services.traefik_labels` jsonb with
|
||||
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
|
||||
`auto|railpack|nixpacks|dockerfile|static`; static builder = build +
|
||||
nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug.
|
||||
@@ -223,58 +226,147 @@ goroutines; this fixes correctness *and* unlocks the ops surface.
|
||||
{target_node_id}`: dump → stream transfer → provision → restore →
|
||||
re-point bound services. Deferred until Phase F lands real second
|
||||
nodes — nothing to migrate between today.
|
||||
- [ ] **Backup import** — upload archive → new restore point (pairs with
|
||||
- [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).
|
||||
|
||||
## 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}`;
|
||||
deploy order from dependency refs.
|
||||
- [ ] **Expressions** — `${{service.KEY}}` (already the runtime ref
|
||||
syntax — reuse), `{{secret(len,"charset")}}`, `{{random:int}}`,
|
||||
`{{VAR:-default}}`; `POST /templates/:id/plan` returns the resolved
|
||||
graph without deploying (agents love this).
|
||||
- [ ] **Compose import** — `POST /templates/import/compose` (compose-go
|
||||
parser → graph), `POST /templates/import/github` (compose file from
|
||||
repo path). Implements `docs/superpowers/specs/2026-04-14-*`.
|
||||
- [ ] **Graph deploy pipeline** — creates project + services in
|
||||
dependency order, resolves vars, triggers builds; defined
|
||||
partial-failure policy.
|
||||
- [ ] **Visual template builder** — canvas mode: drop git/image/db
|
||||
nodes, wire refs, set order, save/publish. Reuses existing canvas
|
||||
primitives (dflow's `templates/compose` is exactly this).
|
||||
- [ ] **Catalog growth** — convert `templates/*.md` (20 app guides) into
|
||||
real graph templates; screenshots + badges per the old spec.
|
||||
deploy order from dependency refs. `templategraph.go`,
|
||||
`topoSortServices`.
|
||||
- [x] **Expressions** — `{{service.KEY.prop}}` (host/port/user/password/
|
||||
database/url), `{{secret}}`/`{{secret(N)}}`, `{{random:N}}`,
|
||||
`{{VAR:-default}}` (incl. explicitly-empty defaults); `POST
|
||||
/templates/:id/plan` returns the resolved graph without deploying.
|
||||
- [x] **Compose import** — `POST /templates/import/compose` (yaml →
|
||||
graph, known DB images become managed databases), `POST
|
||||
/templates/import/git` (compose file fetched from any git repo — clone
|
||||
URL, ssh remote, or owner/repo via connected providers, `ref` selects
|
||||
branch/commit); `templates import-compose|import-git` CLI, MCP tools,
|
||||
paste-or-git toggle in the template UI.
|
||||
- [x] **Graph deploy pipeline** — creates services in dependency order,
|
||||
resolves vars/secrets, triggers builds. `deployTemplateGraph`,
|
||||
`POST /templates/deploy` (inline), `POST /projects/:id/import-compose`.
|
||||
- [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
|
||||
and wired refs, one click or one CLI call.
|
||||
|
||||
## 7. Phase F — Nodes & infrastructure
|
||||
|
||||
- [ ] **SSH bootstrap install** — `containr nodes add --ssh user@host` +
|
||||
UI flow: one SSH session runs agent install (binary + systemd +
|
||||
enroll token), SSH closed forever. dflow onboarding UX, agent-model
|
||||
security.
|
||||
- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/
|
||||
zt0 IPs at register; backend stores + displays. Optional
|
||||
`TAILSCALE_AUTH_KEY` enroll-at-provision later.
|
||||
- [ ] **Node detail page** — placed services, telemetry history, agent
|
||||
version + self-upgrade, **drain/cordon**, remove.
|
||||
- [ ] **Node housekeeping** — agent `prune` command (docker system
|
||||
prune, bounded) + optional schedule (`setServerAutoCleanup` analog —
|
||||
disk pressure is the #1 small-VPS killer).
|
||||
- [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists):
|
||||
pin/spread/affinity, prefer-lowest-usage; deploy engine routes to
|
||||
`services.node_id` via agent commands (agent already runs containers).
|
||||
- [ ] **Build-on-node** — agents currently `docker run` prebuilt images
|
||||
only; extend agent to pull registry images (build stays central) —
|
||||
private registry creds flow through Phase C.
|
||||
- [x] **SSH bootstrap install** — `containr nodes add <user@host>` mints an
|
||||
enroll token, SSHes once (agent/key/default keys), runs
|
||||
`GET /api/agents/install.sh | bash` remotely, closes the session.
|
||||
`--print` emits the one-liner for manual runs; the Usage page shows the
|
||||
same command. Agent binaries ship in the image under `/agents` and are
|
||||
served at `/api/agents/download/{linux-amd64,linux-arm64}` (install
|
||||
script falls back to GH releases). `cli/commands/nodes_add.go`,
|
||||
`api/assets/install-agent.sh`, `agents.go:ServeInstallScript`/`ServeAgentBinary`.
|
||||
- [x] **Mesh awareness** — agent reports tailscale/netbird/wireguard/
|
||||
zerotier interface IPs at register (`mesh` field, authoritative on
|
||||
re-register), stored in `node_agents.metadata.mesh`, shown on the node
|
||||
detail page. `TAILSCALE_AUTH_KEY` enroll-at-provision still open.
|
||||
- [x] **Node detail page** — `/nodes/:id` (admin): heartbeat telemetry
|
||||
(cpu/mem sparklines over 1h/24h/7d via `GET /agents/:id/metrics`),
|
||||
containers, command history, rename, auto-prune toggle, prune-now,
|
||||
remove. Drain/cordon and self-upgrade deferred — they need
|
||||
`services.node_id` routing which lands with multi-node scheduling.
|
||||
- [x] **Node housekeeping** — agent `prune` + `system_df` commands
|
||||
(`docker system prune -af`, volumes excluded unless requested, optional
|
||||
`--until`). `node_agents.auto_prune` + the health sweep enqueue a prune
|
||||
at most once per 24h per node. Surfaces: `POST /agents/:id/prune`,
|
||||
`nodes update --auto-prune` / `nodes prune [--volumes --until]` /
|
||||
`nodes commands`, MCP `containr_nodes_update|prune|commands`
|
||||
(confirm-gated), Usage-page per-node Prune button + auto toggle.
|
||||
`cmd/agent/main.go`, `notification_producers.go:scheduleAutoPrune`.
|
||||
- [🟡] **Multi-node scheduling** — phase 1 shipped: `services.node_id`
|
||||
pin (explicit / `auto` = least-loaded online / `local` = clear),
|
||||
`NodeRunner` seam in the deploy engine routes reconcile to the agent's
|
||||
command queue (`create_container` with image/env/cmd/ports/volumes/
|
||||
labels/limits), `container_instances` inventory per (agent, service,
|
||||
replica), remote stop/start/restart/delete fan-out, `GET
|
||||
/nodes/options`, `--node` on services create/update + MCP + OpenAPI +
|
||||
service-detail Placement section. Drain/cordon shipped:
|
||||
`node_agents.schedulable`, `POST /agents/:id/cordon|uncordon|drain`
|
||||
(drain evicts remote containers via the agent queue + unpins services
|
||||
so they redeploy locally), `nodes cordon|uncordon|drain` CLI +
|
||||
MCP (drain confirm-gated) + node-detail page controls; `auto` and
|
||||
explicit pins skip cordoned nodes. Spread placement shipped:
|
||||
`services.spread` distributes replicas deterministically across every
|
||||
online schedulable agent (replica i → agents[i mod n]), mutually
|
||||
exclusive with a pin; `auto` is now resource-aware — least memory
|
||||
utilisation, then cpu, then container count, with uninstrumented
|
||||
agents sorting last. Lifecycle and `GET /runtime` are
|
||||
inventory-driven, so remote replicas show up and tear down wherever
|
||||
they run. Remote ingress shipped: the agent reports assigned host
|
||||
ports in the `create_container` result, the backend renders a
|
||||
Traefik file-provider config per remote service into
|
||||
`TRAEFIK_DYNAMIC_DIR` (shared volume, `watch=true`) pointing the
|
||||
loadbalancer at each replica's mesh IP + published port; route files
|
||||
are rewritten on every remote reconcile and removed on teardown or
|
||||
local placement. Agents report managed-container states at every
|
||||
heartbeat — exits, OOMs, and crash loops land in inventory between
|
||||
reconciles (tombstones never resurrect). Remote sleep/wake shipped:
|
||||
heartbeats carry per-container net counters, idle remote services
|
||||
lose replicas via agent remove + tombstones, and the route file
|
||||
repoints at `/internal/wake-page/:id` on the backend — first hit
|
||||
retriggers the wake reconcile, which recreates replicas and restores
|
||||
real upstreams. Remote nodes run
|
||||
registry-pulled images only — git builds are the remaining
|
||||
documented ceiling. Tag affinity shipped: `node_agents.tags`
|
||||
(operator-set via `PUT /agents/:id` / `nodes update --tags`) +
|
||||
`services.placement_tags` — `auto` and `spread` only consider
|
||||
online schedulable agents carrying every required tag, tags are
|
||||
re-evaluated each reconcile (a tag change re-spreads replicas),
|
||||
unsatisfiable requirements fail with a clear error, pin + tags
|
||||
and local + tags are rejected, empty array clears. Surfaces:
|
||||
`--placement-tags` on services create/update, `--tags` on
|
||||
`nodes update`, MCP args, OpenAPI, node-detail tag editor +
|
||||
service-detail placement-tags input. Remaining: build-on-node,
|
||||
remote sleep/wake, spread weighting by real capacity.
|
||||
- [x] **Agent self-upgrade** — `POST /agents/:id/upgrade` enqueues a
|
||||
`self_upgrade` command carrying platform + sha256 + server version;
|
||||
the agent downloads its replacement from `GET /agents/download/
|
||||
:platform`, verifies the checksum, sanity-checks `--version`,
|
||||
atomically swaps its own executable, reports the result, then
|
||||
re-execs (`syscall.Exec`) — no supervisor needed. Agents now stamp
|
||||
`main.version` via ldflags at image build and report it at
|
||||
register/heartbeat (`node_agents.version` refreshes on re-register;
|
||||
older agents keep their stored value). Surfaces: `nodes upgrade`
|
||||
CLI, `containr_nodes_upgrade` MCP (confirm-gated), node-detail page
|
||||
button (online-gated). Ceilings: linux amd64/arm64 only; agents on
|
||||
other platforms get a `NO_BINARY` 409.
|
||||
- [x] **Build-on-node** — private registry pulls + real builds shipped.
|
||||
Git sources materialize via `internal/source` checkout (branch/commit,
|
||||
`GIT_ASKPASS` credential injection, dumb-transport fallback), package
|
||||
through `BuildManager.PackageContext`, ship as agent-token artifacts,
|
||||
and `docker build` on the node (`build_image` command). Remote rollbacks
|
||||
`docker save` → artifact → `load_image` on the node. Verified end-to-end
|
||||
on a live agent (PR #60).
|
||||
- [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/
|
||||
OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module
|
||||
(dflow's second provider), cloud-init → auto-enroll.
|
||||
- [ ] **Global/wildcard domain per node** (`nodes.default_domain`) —
|
||||
auto-domains for services on that node.
|
||||
- [x] **Global/wildcard domain per node** (`node_agents.default_domain`) —
|
||||
`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
|
||||
|
||||
@@ -347,8 +439,11 @@ and wired refs, one click or one CLI call.
|
||||
filters. Applied: audit logs (server-backed), builds (server + client
|
||||
date-range), activity (severity server + client category/search).
|
||||
Notifications table has no list surface yet — apply when one lands.
|
||||
- [ ] **Notification producers round-out** — backups, agent offline,
|
||||
upgrade available, security findings.
|
||||
- [x] **Notification producers round-out** — all shipped: deployments
|
||||
(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) —
|
||||
`services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper
|
||||
polls container NetworkIO deltas; idle past threshold → workload
|
||||
@@ -380,12 +475,17 @@ and wired refs, one click or one CLI call.
|
||||
|
||||
## 9. Phase H — Migration & ecosystem
|
||||
|
||||
- [ ] **Railway migrator** — `containr migrate railway --token --project
|
||||
--dry-run`: Railway GraphQL → services/vars/domains → recreate via
|
||||
PAT. Landing-page pitch: "off Railway in one command."
|
||||
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST
|
||||
export or Mongo dump: servers→nodes, projects, services(+vars/
|
||||
domains/volumes), databases, templates. Onboards dflow refugees.
|
||||
- [x] **Railway migrator** — `containr migrate railway --token --project
|
||||
--environment --into --dry-run`: Railway GraphQL (backboard) →
|
||||
services (git-repo/image + command + replicas), variables, custom +
|
||||
service domains → recreates via the Containr API. Project tokens and
|
||||
account tokens both supported. `cli/commands/migrate.go`.
|
||||
- [x] **dflow importer** — `containr migrate dflow --url --api-key
|
||||
--project --into --dry-run`: pages the Payload REST collections
|
||||
(users API-Key auth) → services (app→git/web, docker→image,
|
||||
database→typed image + DATABASE_URL for external providers),
|
||||
variables (empty encrypted values flagged), bind volumes, domains.
|
||||
Soft-deleted services excluded. `cli/commands/migrate_dflow.go`.
|
||||
- [x] **`containr import compose`** — `POST /projects/:id/import-compose`
|
||||
deploys a compose file straight into a project; CLI `import compose`,
|
||||
MCP tool, workspace import modal; project delete cascades to
|
||||
@@ -408,28 +508,50 @@ and wired refs, one click or one CLI call.
|
||||
a prior deployment, database start/stop/restart/backup-now.
|
||||
- **Observe** — polling log tail (300 lines, 5s refresh), per-service
|
||||
deployment history, env-doctor issues surfaced on the service view.
|
||||
- [ ] **env var edit + apply-redeploy, domain management, replica
|
||||
scaling, cron execution history** — next iteration.
|
||||
- [ ] **Push notifications** — zero-infra path = self-hosted
|
||||
ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional.
|
||||
- [ ] **Offline resilience** — cached last-known state + unreachable
|
||||
banner.
|
||||
- [ ] **Status/offline resilience** — app + CLI degrade gracefully when
|
||||
the instance is unreachable (cached last-known state, clear banner).
|
||||
- [x] **env var edit + domain management + replica scaling** (PR #61 —
|
||||
mobile v1.5: variables editor, domains screen, scale + sleep
|
||||
settings, notifications tab, cron jobs screen with run-now and
|
||||
execution history).
|
||||
- [x] **Push notifications** — per-user `notification_channels`
|
||||
(ntfy + Gotify): every `insertUserNotification` fans out best-effort
|
||||
to enabled channels; CRUD + live test-ping via web settings, CLI
|
||||
`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)
|
||||
|
||||
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit →
|
||||
`gateway/proxy.go` → metrics. Management API exists; proxy unwired.
|
||||
- [ ] **Preview environments runtime** — real build+deploy+subdomain+TTL
|
||||
(bookkeeping only now). Pairs with clone + multi-domain.
|
||||
- [ ] **Environments** — env switcher, per-env vars/domains, promote.
|
||||
- [x] **Gateway traffic path** — `/g/:slug/*` mounted → api_key auth →
|
||||
rpm + monthly quota → `gateway/proxy.go` → usage_counters /
|
||||
metrics_timeseries / incident_events.
|
||||
- [x] **Preview environments runtime** — previews are real service clones:
|
||||
branch override, queued deployment, node-domain hostnames, TTL sweeper,
|
||||
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** — ~140 raw `database/sql` sites → `sqlc/queries/`.
|
||||
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
|
||||
webhook table tests, Playwright e2e golden path.
|
||||
- [ ] **First-run wizard** — admin → GitHub App (manifest flow from
|
||||
Phase G makes this self-serve) → Tunnel → done.
|
||||
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release.
|
||||
- [x] **First-run wizard** — public `GET /setup/status` + authed
|
||||
`POST /setup/complete`; `/setup` page steps admin account → GitHub App
|
||||
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
-4
@@ -1,5 +1,7 @@
|
||||
# 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
|
||||
|
||||
@@ -13,8 +15,12 @@ ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \
|
||||
-ldflags "-X containr/internal/api.Version=${VERSION}" -o main cmd/server/main.go
|
||||
|
||||
# Node-agent binaries served by the API for SSH/bootstrap installs.
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X main.version=${VERSION}" -o /agents/containr-agent-linux-amd64 ./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
|
||||
FROM node:22-alpine AS auth-builder
|
||||
FROM ${NODE_IMAGE} AS auth-builder
|
||||
|
||||
WORKDIR /auth
|
||||
|
||||
@@ -24,9 +30,9 @@ RUN npm ci --omit=dev
|
||||
COPY auth/src ./src
|
||||
|
||||
# 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 && \
|
||||
adduser -u 1001 -S appuser -G appgroup
|
||||
@@ -35,6 +41,7 @@ WORKDIR /app
|
||||
|
||||
COPY --from=go-builder /app/main ./main
|
||||
COPY --from=go-builder /app/migrations_goose ./migrations_goose
|
||||
COPY --from=go-builder /agents /agents
|
||||
COPY --from=auth-builder /auth ./auth
|
||||
|
||||
RUN chown -R appuser:appgroup /app
|
||||
|
||||
@@ -3,14 +3,18 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -104,7 +108,15 @@ type registerResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// version is stamped at build time via -ldflags "-X main.version=…".
|
||||
// The server reports it on upgrades so agents can show what they run.
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "version") {
|
||||
fmt.Printf("containr-agent %s\n", version)
|
||||
return
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -167,6 +179,9 @@ func register(ctx context.Context, client *http.Client, cfg agentConfig) (string
|
||||
"ip_address": cfg.IP,
|
||||
"port": cfg.Port,
|
||||
"capabilities": detectCapabilities(),
|
||||
"version": firstEnv("CONTAINR_AGENT_VERSION", version),
|
||||
// Always send mesh so a removed interface clears stored state.
|
||||
"mesh": detectMeshInterfaces(),
|
||||
}
|
||||
var response registerResponse
|
||||
if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil {
|
||||
@@ -188,11 +203,93 @@ func sendHeartbeat(ctx context.Context, client *http.Client, cfg agentConfig, ag
|
||||
"container_count": dockerContainerCount(),
|
||||
"system_load": readSystemLoad(),
|
||||
"uptime": readSystemUptime(),
|
||||
"version": firstEnv("CONTAINR_AGENT_VERSION", "dev"),
|
||||
"version": firstEnv("CONTAINR_AGENT_VERSION", version),
|
||||
"containers": managedContainerStates(),
|
||||
}
|
||||
return postJSON(ctx, client, cfg, "/api/agents/heartbeat", payload, nil)
|
||||
}
|
||||
|
||||
// managedContainerStates lists every containr-managed container's docker
|
||||
// state plus cumulative network counters — state keeps runtime honest,
|
||||
// net bytes let the sleeper detect idle remote services.
|
||||
func managedContainerStates() []map[string]interface{} {
|
||||
out, err := runDocker("ps", "-a",
|
||||
"--filter", "label=containr.managed=true",
|
||||
"--format", "{{.Names}}\t{{.State}}")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
netIO := containerNetIO()
|
||||
var states []map[string]interface{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
parts := strings.SplitN(line, "\t", 2)
|
||||
if len(parts) != 2 || parts[0] == "" {
|
||||
continue
|
||||
}
|
||||
entry := map[string]interface{}{"name": parts[0], "state": parts[1]}
|
||||
if io, ok := netIO[parts[0]]; ok {
|
||||
entry["rx_bytes"], entry["tx_bytes"] = io[0], io[1]
|
||||
}
|
||||
states = append(states, entry)
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// containerNetIO snapshots cumulative rx/tx per running container. Stats
|
||||
// only covers running containers — stopped replicas report state without
|
||||
// counters, which is all the sleeper needs.
|
||||
func containerNetIO() map[string][2]int64 {
|
||||
out, err := runDocker("stats", "--no-stream",
|
||||
"--format", "{{.Name}}\t{{.NetIO}}")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
result := map[string][2]int64{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
parts := strings.SplitN(line, "\t", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
io := strings.Split(parts[1], "/")
|
||||
if len(io) != 2 {
|
||||
continue
|
||||
}
|
||||
result[parts[0]] = [2]int64{parseHumanBytes(io[0]), parseHumanBytes(io[1])}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseHumanBytes converts docker's human units ("1.2kB", "3.4MB") to bytes.
|
||||
func parseHumanBytes(s string) int64 {
|
||||
s = strings.TrimSpace(s)
|
||||
var num float64
|
||||
var unit string
|
||||
if _, err := fmt.Sscanf(s, "%f%s", &num, &unit); err != nil {
|
||||
return 0
|
||||
}
|
||||
switch strings.ToUpper(unit) {
|
||||
case "B":
|
||||
return int64(num)
|
||||
case "KB":
|
||||
return int64(num * 1e3)
|
||||
case "MB":
|
||||
return int64(num * 1e6)
|
||||
case "GB":
|
||||
return int64(num * 1e9)
|
||||
case "TB":
|
||||
return int64(num * 1e12)
|
||||
case "KIB":
|
||||
return int64(num * 1024)
|
||||
case "MIB":
|
||||
return int64(num * 1024 * 1024)
|
||||
case "GIB":
|
||||
return int64(num * 1024 * 1024 * 1024)
|
||||
case "TIB":
|
||||
return int64(num * 1024 * 1024 * 1024 * 1024)
|
||||
}
|
||||
return int64(num)
|
||||
}
|
||||
|
||||
func processCommands(ctx context.Context, client *http.Client, cfg agentConfig, agentID string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/agents/%s/commands", cfg.APIURL, agentID), nil)
|
||||
if err != nil {
|
||||
@@ -214,7 +311,13 @@ func processCommands(ctx context.Context, client *http.Client, cfg agentConfig,
|
||||
return err
|
||||
}
|
||||
for _, item := range list.Commands {
|
||||
result, runErr := executeCommand(item)
|
||||
var result, reexecPath string
|
||||
var runErr error
|
||||
if item.Type == "self_upgrade" {
|
||||
result, reexecPath, runErr = selfUpgrade(cfg, item.Payload)
|
||||
} else {
|
||||
result, runErr = executeCommand(cfg, item)
|
||||
}
|
||||
status := "completed"
|
||||
errText := ""
|
||||
if runErr != nil {
|
||||
@@ -230,11 +333,17 @@ func processCommands(ctx context.Context, client *http.Client, cfg agentConfig,
|
||||
if err := postJSON(ctx, client, cfg, path, payload, nil); err != nil {
|
||||
log.Printf("report command result failed: command=%s err=%v", item.ID, err)
|
||||
}
|
||||
// Re-exec AFTER the result posts — the new binary picks up the loop.
|
||||
if reexecPath != "" {
|
||||
if err := syscall.Exec(reexecPath, os.Args, os.Environ()); err != nil {
|
||||
log.Fatalf("re-exec after upgrade failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeCommand(item command) (string, error) {
|
||||
func executeCommand(cfg agentConfig, item command) (string, error) {
|
||||
switch item.Type {
|
||||
case "create_container":
|
||||
return createContainer(item.Payload)
|
||||
@@ -245,12 +354,100 @@ func executeCommand(item command) (string, error) {
|
||||
case "restart_container":
|
||||
return runDocker("restart", commandTarget(item.Payload))
|
||||
case "remove_container":
|
||||
return runDocker("rm", "-f", commandTarget(item.Payload))
|
||||
out, err := runDocker("rm", "-f", commandTarget(item.Payload))
|
||||
// A missing container is the desired end state — report success.
|
||||
if err != nil && strings.Contains(out, "No such container") {
|
||||
return out, nil
|
||||
}
|
||||
return out, err
|
||||
case "build_image":
|
||||
return buildImage(cfg, item.Payload)
|
||||
case "load_image":
|
||||
return loadImage(cfg, item.Payload)
|
||||
case "prune":
|
||||
return dockerPrune(item.Payload)
|
||||
case "system_df":
|
||||
return runDocker("system", "df", "--format", "json")
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported command type %q", item.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// selfUpgrade downloads the agent binary the server bundles for this
|
||||
// platform, verifies it, swaps it in atomically, and returns the path to
|
||||
// re-exec. On any failure the running binary is left untouched.
|
||||
func selfUpgrade(cfg agentConfig, payload map[string]interface{}) (string, string, error) {
|
||||
platform := firstString(stringValue(payload["platform"]), runtime.GOOS+"-"+runtime.GOARCH)
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolve own path: %w", err)
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/agents/download/%s", cfg.APIURL, platform)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 2 * time.Minute}).Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("download %s: %w", platform, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("download %s: server returned %d", platform, resp.StatusCode)
|
||||
}
|
||||
|
||||
newPath := exe + ".new"
|
||||
out, err := os.OpenFile(newPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("write %s: %w", newPath, err)
|
||||
}
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(io.MultiWriter(out, h), resp.Body); err != nil {
|
||||
out.Close()
|
||||
os.Remove(newPath)
|
||||
return "", "", fmt.Errorf("download write: %w", err)
|
||||
}
|
||||
out.Close()
|
||||
|
||||
if want := stringValue(payload["sha256"]); want != "" {
|
||||
if got := hex.EncodeToString(h.Sum(nil)); got != want {
|
||||
os.Remove(newPath)
|
||||
return "", "", fmt.Errorf("sha256 mismatch: got %s want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: the new binary must at least report its version.
|
||||
if verOut, err := exec.Command(newPath, "--version").CombinedOutput(); err != nil {
|
||||
os.Remove(newPath)
|
||||
return "", "", fmt.Errorf("new binary failed sanity check: %s", strings.TrimSpace(string(verOut)))
|
||||
}
|
||||
|
||||
if err := os.Rename(newPath, exe); err != nil {
|
||||
os.Remove(newPath)
|
||||
return "", "", fmt.Errorf("swap binary: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("upgraded to %s (%s)", firstString(stringValue(payload["version"]), "latest"), platform), exe, nil
|
||||
}
|
||||
|
||||
// dockerPrune runs a bounded docker system prune. Volumes are excluded
|
||||
// unless the payload explicitly opts in — a stray volume prune destroys
|
||||
// database data.
|
||||
func dockerPrune(payload map[string]interface{}) (string, error) {
|
||||
args := []string{"system", "prune", "-af"}
|
||||
if boolValue(payload["volumes"]) {
|
||||
args = append(args, "--volumes")
|
||||
}
|
||||
if until := stringValue(payload["until"]); until != "" {
|
||||
args = append(args, "--filter", fmt.Sprintf("until=%s", until))
|
||||
}
|
||||
return runDocker(args...)
|
||||
}
|
||||
|
||||
func createContainer(payload map[string]interface{}) (string, error) {
|
||||
container, ok := payload["container"].(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -263,7 +460,38 @@ func createContainer(payload map[string]interface{}) (string, error) {
|
||||
return "", errors.New("container name and image are required")
|
||||
}
|
||||
|
||||
args := []string{"run", "-d", "--name", name, "--restart", "unless-stopped"}
|
||||
// Private registry: log in for the pull, then log back out so the
|
||||
// credential doesn't linger in the node's docker config.
|
||||
if reg, ok := container["registry"].(map[string]interface{}); ok {
|
||||
if user := stringValue(reg["username"]); user != "" {
|
||||
server := firstString(stringValue(reg["server"]), registryHostOf(image))
|
||||
login := exec.Command("docker", "login", server, "-u", user, "--password-stdin")
|
||||
login.Stdin = strings.NewReader(stringValue(reg["password"]))
|
||||
if out, err := login.CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("registry login %s: %s", server, strings.TrimSpace(string(out)))
|
||||
}
|
||||
defer func() { _, _ = runDocker("logout", server) }()
|
||||
}
|
||||
}
|
||||
|
||||
// Redeploys reuse the deterministic name — clear the stale container
|
||||
// first so `run` never fails on a name conflict.
|
||||
_, _ = runDocker("rm", "-f", name)
|
||||
|
||||
args := []string{"run", "-d", "--name", name}
|
||||
restart := firstString(stringValue(container["restart_policy"]), "unless-stopped")
|
||||
args = append(args, "--restart", restart)
|
||||
if labels, ok := container["labels"].(map[string]interface{}); ok {
|
||||
for key, value := range labels {
|
||||
args = append(args, "--label", fmt.Sprintf("%s=%v", key, value))
|
||||
}
|
||||
}
|
||||
if mem := int64Value(container["memory"]); mem > 0 {
|
||||
args = append(args, "--memory", fmt.Sprintf("%d", mem))
|
||||
}
|
||||
if cpus := floatValue(container["cpus"]); cpus > 0 {
|
||||
args = append(args, "--cpus", fmt.Sprintf("%g", cpus))
|
||||
}
|
||||
if env, ok := container["environment"].(map[string]interface{}); ok {
|
||||
for key, value := range env {
|
||||
args = append(args, "-e", fmt.Sprintf("%s=%v", key, value))
|
||||
@@ -278,8 +506,14 @@ func createContainer(payload map[string]interface{}) (string, error) {
|
||||
hostPort := intValue(port["host_port"])
|
||||
containerPort := intValue(port["container_port"])
|
||||
protocol := firstString(stringValue(port["protocol"]), "tcp")
|
||||
if hostPort > 0 && containerPort > 0 {
|
||||
if containerPort <= 0 {
|
||||
continue
|
||||
}
|
||||
if hostPort > 0 {
|
||||
args = append(args, "-p", fmt.Sprintf("%d:%d/%s", hostPort, containerPort, protocol))
|
||||
} else {
|
||||
// Ephemeral host port — docker picks the binding.
|
||||
args = append(args, "-p", fmt.Sprintf("%d/%s", containerPort, protocol))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,7 +535,165 @@ func createContainer(payload map[string]interface{}) (string, error) {
|
||||
}
|
||||
}
|
||||
args = append(args, image)
|
||||
return runDocker(args...)
|
||||
if cmd, ok := container["command"].([]interface{}); ok {
|
||||
for _, arg := range cmd {
|
||||
args = append(args, stringValue(arg))
|
||||
}
|
||||
}
|
||||
if _, err := runDocker(args...); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// docker run -d returns before the entrypoint fails — a crash-looping
|
||||
// service would be reported healthy. Give it a moment, then verify.
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
state, _ := runDocker("inspect", "-f", "{{.State.Status}}", name)
|
||||
if state != "running" {
|
||||
logs, _ := runDocker("logs", "--tail", "5", name)
|
||||
return "", fmt.Errorf("container %s is %q after start: %s", name, state, logs)
|
||||
}
|
||||
// Report the real host port bindings — ephemeral publishes pick a port
|
||||
// the server can't guess, and remote ingress needs the actual mapping.
|
||||
hostPorts := map[string]string{}
|
||||
portsJSON, _ := runDocker("inspect", "-f", "{{json .NetworkSettings.Ports}}", name)
|
||||
var bindings map[string][]struct {
|
||||
HostIP string `json:"HostIp"`
|
||||
HostPort string `json:"HostPort"`
|
||||
}
|
||||
if json.Unmarshal([]byte(portsJSON), &bindings) == nil {
|
||||
for cport, addrs := range bindings {
|
||||
for _, a := range addrs {
|
||||
if a.HostPort != "" && a.HostPort != "0" {
|
||||
hostPorts[cport] = a.HostPort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out, _ := json.Marshal(map[string]interface{}{"name": name, "host_ports": hostPorts})
|
||||
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
|
||||
// a "." or ":" is a registry host; bare names mean Docker Hub.
|
||||
func registryHostOf(imageRef string) string {
|
||||
parts := strings.Split(imageRef, "/")
|
||||
if len(parts) > 1 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
|
||||
return parts[0]
|
||||
}
|
||||
return "docker.io"
|
||||
}
|
||||
|
||||
func commandTarget(payload map[string]interface{}) string {
|
||||
@@ -354,6 +746,42 @@ func detectCapabilities() capabilities {
|
||||
}
|
||||
}
|
||||
|
||||
// detectMeshInterfaces reports overlay-network interface IPs (tailscale,
|
||||
// netbird, zerotier, wireguard) so the backend can display reachable
|
||||
// mesh addresses alongside the primary IP.
|
||||
func detectMeshInterfaces() map[string]string {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
mesh := map[string]string{}
|
||||
for _, iface := range ifaces {
|
||||
name := strings.ToLower(iface.Name)
|
||||
isMesh := strings.HasPrefix(name, "tailscale") || strings.HasPrefix(name, "ts.") ||
|
||||
strings.HasPrefix(name, "netbird") || strings.HasPrefix(name, "wt") ||
|
||||
strings.HasPrefix(name, "zt") || strings.HasPrefix(name, "wg")
|
||||
if !isMesh || iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
ip, _, err := net.ParseCIDR(addr.String())
|
||||
if err != nil || ip.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
mesh[iface.Name] = v4.String()
|
||||
} else if _, exists := mesh[iface.Name]; !exists {
|
||||
mesh[iface.Name] = ip.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return mesh
|
||||
}
|
||||
|
||||
func collectResources(ip string) nodeResources {
|
||||
mem := readMemory()
|
||||
storage := readStorage("/")
|
||||
@@ -469,6 +897,15 @@ func runDocker(args ...string) (string, error) {
|
||||
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 {
|
||||
_, err := exec.LookPath("docker")
|
||||
if err != nil {
|
||||
@@ -564,3 +1001,35 @@ func boolValue(value interface{}) bool {
|
||||
typed, _ := value.(bool)
|
||||
return typed
|
||||
}
|
||||
|
||||
func int64Value(value interface{}) int64 {
|
||||
switch typed := value.(type) {
|
||||
case int64:
|
||||
return typed
|
||||
case int:
|
||||
return int64(typed)
|
||||
case float64:
|
||||
return int64(typed)
|
||||
case json.Number:
|
||||
out, _ := typed.Int64()
|
||||
return out
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func floatValue(value interface{}) float64 {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed
|
||||
case int:
|
||||
return float64(typed)
|
||||
case int64:
|
||||
return float64(typed)
|
||||
case json.Number:
|
||||
out, _ := typed.Float64()
|
||||
return out
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,17 @@ import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -26,16 +30,24 @@ import (
|
||||
|
||||
// NodeAgent represents a container orchestration agent
|
||||
type NodeAgent struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Port int `json:"port"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Capabilities AgentCapabilities `json:"capabilities"`
|
||||
Resources NodeResources `json:"resources"`
|
||||
LastHeartbeat time.Time `json:"last_heartbeat"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Port int `json:"port"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Capabilities AgentCapabilities `json:"capabilities"`
|
||||
Resources NodeResources `json:"resources"`
|
||||
LastHeartbeat time.Time `json:"last_heartbeat"`
|
||||
AutoPrune bool `json:"auto_prune"`
|
||||
Schedulable bool `json:"schedulable"`
|
||||
// Tags are operator-set placement labels; services with placement_tags
|
||||
// only schedule onto agents carrying every required tag.
|
||||
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"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
@@ -189,6 +201,17 @@ type AgentHeartbeat struct {
|
||||
SystemLoad SystemLoad `json:"system_load"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
Version string `json:"version"`
|
||||
// Containers reports managed-container liveness — {name, state} per
|
||||
// containr.managed container on the node — so remote replica status
|
||||
// reflects reality between reconciles (exits, OOMs, crash loops).
|
||||
Containers []AgentContainerState `json:"containers,omitempty"`
|
||||
}
|
||||
|
||||
type AgentContainerState struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
RxBytes int64 `json:"rx_bytes,omitempty"`
|
||||
TxBytes int64 `json:"tx_bytes,omitempty"`
|
||||
}
|
||||
|
||||
type SystemLoad struct {
|
||||
@@ -225,16 +248,45 @@ func unmarshalRaw(raw pqtype.NullRawMessage, dst interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// ListNodeOptions returns the minimal id/name/status list used by the service
|
||||
// placement picker — available to any authenticated user (no hostnames, IPs,
|
||||
// or telemetry).
|
||||
func (h *NodeAgentHandler) ListNodeOptions(c *gin.Context) {
|
||||
rows, err := h.q.ListAgents(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list nodes"})
|
||||
return
|
||||
}
|
||||
options := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
var tags []string
|
||||
if len(row.Tags) > 0 {
|
||||
_ = json.Unmarshal(row.Tags, &tags)
|
||||
}
|
||||
options = append(options, gin.H{
|
||||
"id": row.ID,
|
||||
"name": row.Name,
|
||||
"status": row.Status.String,
|
||||
"tags": tags,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"nodes": options})
|
||||
}
|
||||
|
||||
func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
|
||||
agent := NodeAgent{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Hostname: row.Hostname,
|
||||
IPAddress: row.IpAddress,
|
||||
Port: int(row.Port),
|
||||
Status: row.Status.String,
|
||||
Version: row.Version.String,
|
||||
Metadata: map[string]interface{}{},
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Hostname: row.Hostname,
|
||||
IPAddress: row.IpAddress,
|
||||
Port: int(row.Port),
|
||||
Status: row.Status.String,
|
||||
Version: row.Version.String,
|
||||
AutoPrune: row.AutoPrune,
|
||||
Schedulable: row.Schedulable,
|
||||
Tags: []string{},
|
||||
DefaultDomain: row.DefaultDomain,
|
||||
Metadata: map[string]interface{}{},
|
||||
}
|
||||
if row.LastHeartbeat.Valid {
|
||||
agent.LastHeartbeat = row.LastHeartbeat.Time
|
||||
@@ -248,6 +300,9 @@ func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
|
||||
unmarshalRaw(row.Capabilities, &agent.Capabilities)
|
||||
unmarshalRaw(row.Resources, &agent.Resources)
|
||||
unmarshalRaw(row.Metadata, &agent.Metadata)
|
||||
if len(row.Tags) > 0 {
|
||||
_ = json.Unmarshal(row.Tags, &agent.Tags)
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
@@ -326,7 +381,9 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
IPAddress string `json:"ip_address" binding:"required"`
|
||||
Port int `json:"port" binding:"required"`
|
||||
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
|
||||
Version string `json:"version"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
Mesh map[string]string `json:"mesh"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -341,24 +398,43 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
metadata := map[string]interface{}{}
|
||||
// req.Mesh is authoritative when present (newer agents always send it,
|
||||
// possibly empty); nil means an older agent — keep any stored value.
|
||||
if req.Mesh != nil {
|
||||
metadata["mesh"] = req.Mesh
|
||||
}
|
||||
|
||||
// Re-registering an existing host updates it in place.
|
||||
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
|
||||
Hostname: req.Hostname,
|
||||
IpAddress: req.IPAddress,
|
||||
}); err == nil {
|
||||
if existing.Metadata.Valid && len(existing.Metadata.RawMessage) > 0 {
|
||||
var prev map[string]interface{}
|
||||
if err := json.Unmarshal(existing.Metadata.RawMessage, &prev); err == nil {
|
||||
for k, v := range prev {
|
||||
if _, ok := metadata[k]; !ok {
|
||||
metadata[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
|
||||
ID: existing.ID,
|
||||
Name: req.Name,
|
||||
Hostname: existing.Hostname,
|
||||
IpAddress: existing.IpAddress,
|
||||
Port: int32(req.Port),
|
||||
Status: sql.NullString{String: "connecting", Valid: true},
|
||||
Version: existing.Version,
|
||||
ID: existing.ID,
|
||||
Name: req.Name,
|
||||
Hostname: existing.Hostname,
|
||||
IpAddress: existing.IpAddress,
|
||||
Port: int32(req.Port),
|
||||
Status: sql.NullString{String: "connecting", Valid: true},
|
||||
// Older agents don't report a version — keep whatever's stored.
|
||||
Version: sql.NullString{String: firstNonEmpty(req.Version, existing.Version.String), Valid: true},
|
||||
Capabilities: rawJSON(req.Capabilities),
|
||||
Resources: existing.Resources,
|
||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
Metadata: existing.Metadata,
|
||||
Metadata: rawJSON(metadata),
|
||||
AutoPrune: existing.AutoPrune,
|
||||
Schedulable: existing.Schedulable,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
||||
@@ -404,11 +480,11 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
IpAddress: req.IPAddress,
|
||||
Port: int32(req.Port),
|
||||
Status: sql.NullString{String: "connecting", Valid: true},
|
||||
Version: sql.NullString{},
|
||||
Version: sql.NullString{String: req.Version, Valid: req.Version != ""},
|
||||
Capabilities: rawJSON(req.Capabilities),
|
||||
Resources: rawJSON(resources),
|
||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
Metadata: rawJSON(map[string]interface{}{}),
|
||||
Metadata: rawJSON(metadata),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
|
||||
@@ -489,13 +565,46 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
|
||||
Resources: rawJSON(agent.Resources),
|
||||
LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()},
|
||||
Metadata: rawJSON(agent.Metadata),
|
||||
AutoPrune: agent.AutoPrune,
|
||||
Schedulable: agent.Schedulable,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(updated)})
|
||||
// Tags persist through their own column write — UpdateAgent keeps the
|
||||
// register-path signature untouched.
|
||||
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 {
|
||||
var tags []string
|
||||
if b, mErr := json.Marshal(raw); mErr == nil {
|
||||
_ = json.Unmarshal(b, &tags)
|
||||
}
|
||||
tags = normalizePlacementTags(tags)
|
||||
if err := h.q.SetAgentTags(ctx, sqlcdb.SetAgentTagsParams{
|
||||
ID: id,
|
||||
Column2: json.RawMessage(tagsJSON(tags)),
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent tags"})
|
||||
return
|
||||
}
|
||||
resp.Tags = tags
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"agent": resp})
|
||||
}
|
||||
|
||||
// applyAgentUpdates merges whitelisted keys from an arbitrary update map.
|
||||
@@ -516,6 +625,8 @@ func applyAgentUpdates(agent *NodeAgent, updates map[string]interface{}) {
|
||||
remarshal("capabilities", &agent.Capabilities)
|
||||
remarshal("resources", &agent.Resources)
|
||||
remarshal("metadata", &agent.Metadata)
|
||||
remarshal("auto_prune", &agent.AutoPrune)
|
||||
remarshal("schedulable", &agent.Schedulable)
|
||||
}
|
||||
|
||||
// DeleteAgent removes an agent
|
||||
@@ -581,6 +692,22 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Reconcile remote container liveness — exits and crash loops surface
|
||||
// in inventory between reconciles. Best-effort, never fatal.
|
||||
for _, rc := range heartbeat.Containers {
|
||||
if rc.Name == "" || rc.State == "" {
|
||||
continue
|
||||
}
|
||||
stateJSON, _ := json.Marshal(rc.State)
|
||||
bytesJSON, _ := json.Marshal(rc.RxBytes + rc.TxBytes)
|
||||
_ = h.q.UpdateContainerStateByName(ctx, sqlcdb.UpdateContainerStateByNameParams{
|
||||
Name: rc.Name,
|
||||
NodeAgentID: heartbeat.NodeAgentID,
|
||||
Column3: stateJSON,
|
||||
Column4: bytesJSON,
|
||||
})
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -650,9 +777,51 @@ func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
|
||||
h.updateContainerStatusAfterCommand(ctx, out)
|
||||
}
|
||||
|
||||
// Command payloads can carry registry credentials — scrub them once the
|
||||
// command is settled so secrets don't linger in the queue rows.
|
||||
scrubCommandSecrets(ctx, h.q, commandID, command.Payload)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"command": out})
|
||||
}
|
||||
|
||||
// scrubCommandSecrets rewrites a completed command's payload with any
|
||||
// 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) {
|
||||
if !payload.Valid || len(payload.RawMessage) == 0 {
|
||||
return
|
||||
}
|
||||
var doc map[string]interface{}
|
||||
if err := json.Unmarshal(payload.RawMessage, &doc); err != nil {
|
||||
return
|
||||
}
|
||||
dirty := false
|
||||
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
|
||||
}
|
||||
if err := q.ScrubCommandPayload(ctx, sqlcdb.ScrubCommandPayloadParams{
|
||||
ID: commandID,
|
||||
Payload: rawJSON(doc),
|
||||
}); err != nil {
|
||||
log.Printf("agents: scrub command %s payload: %v", commandID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(ctx context.Context, command AgentCommand) {
|
||||
row, err := h.q.GetContainer(ctx, *command.ContainerID)
|
||||
if err != nil {
|
||||
@@ -803,6 +972,103 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{"container": container})
|
||||
}
|
||||
|
||||
// PruneAgent enqueues a bounded `docker system prune -af` command. Volumes
|
||||
// stay unless the caller explicitly opts in.
|
||||
func (h *NodeAgentHandler) PruneAgent(c *gin.Context) {
|
||||
agentID := c.Param("id")
|
||||
if _, err := h.q.GetAgent(c.Request.Context(), agentID); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Until string `json:"until"` // e.g. "168h" — only prune objects older than this
|
||||
Volumes bool `json:"volumes"` // destructive — off by default
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
command, err := h.q.CreateCommand(c.Request.Context(), sqlcdb.CreateCommandParams{
|
||||
ID: uuid.New().String(),
|
||||
Type: "prune",
|
||||
NodeAgentID: agentID,
|
||||
Payload: rawJSON(map[string]interface{}{"until": req.Until, "volumes": req.Volumes}),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enqueue prune"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"command": command})
|
||||
}
|
||||
|
||||
// UpgradeAgent enqueues a self_upgrade command: the agent downloads its
|
||||
// replacement binary from this server's bundled /download endpoint,
|
||||
// verifies the sha256, swaps it in, and re-execs. Fails fast when no
|
||||
// bundled binary exists for the agent's architecture.
|
||||
func (h *NodeAgentHandler) UpgradeAgent(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
id := c.Param("id")
|
||||
agent, err := h.q.GetAgent(ctx, id)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "Agent not found")
|
||||
return
|
||||
}
|
||||
if agent.Status.String != "online" {
|
||||
respondError(c, http.StatusConflict, "NODE_OFFLINE", "node is offline — reconnect it before upgrading")
|
||||
return
|
||||
}
|
||||
|
||||
arch := "amd64"
|
||||
var caps struct {
|
||||
SupportedArchitectures []string `json:"supported_architectures"`
|
||||
}
|
||||
unmarshalRaw(agent.Capabilities, &caps)
|
||||
if len(caps.SupportedArchitectures) > 0 && caps.SupportedArchitectures[0] != "" {
|
||||
arch = caps.SupportedArchitectures[0]
|
||||
}
|
||||
platform := "linux-" + arch // agents only ship for linux today
|
||||
|
||||
dir := os.Getenv("CONTAINR_AGENT_BIN_DIR")
|
||||
if dir == "" {
|
||||
dir = "/agents"
|
||||
}
|
||||
path := filepath.Join(dir, "containr-agent-"+platform)
|
||||
sum, err := sha256File(path)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusConflict, "NO_BINARY",
|
||||
fmt.Sprintf("no bundled agent binary for %s on this server", platform))
|
||||
return
|
||||
}
|
||||
|
||||
command, err := h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
|
||||
ID: uuid.New().String(),
|
||||
Type: "self_upgrade",
|
||||
NodeAgentID: id,
|
||||
Payload: rawJSON(map[string]interface{}{
|
||||
"platform": platform,
|
||||
"sha256": sum,
|
||||
"version": Version,
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to enqueue upgrade")
|
||||
return
|
||||
}
|
||||
LogAuditWithRequest(c, "node", id, "upgrade", map[string]interface{}{"platform": platform, "version": Version})
|
||||
c.JSON(http.StatusAccepted, gin.H{"command": command, "platform": platform, "version": Version})
|
||||
}
|
||||
|
||||
func sha256File(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// ExecuteCommand executes a command on an agent
|
||||
func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
|
||||
agentID := c.Param("id")
|
||||
@@ -1214,9 +1480,49 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
|
||||
agents.POST("/heartbeat", h.SendHeartbeat)
|
||||
agents.GET("/:id/commands", h.GetPendingCommandsForAgent)
|
||||
agents.POST("/:id/commands/:commandId/result", h.CompleteCommand)
|
||||
|
||||
// Bootstrap surface — the install script and agent binaries are
|
||||
// public; the enroll token is the actual secret.
|
||||
agents.GET("/install.sh", h.ServeInstallScript)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
//go:embed assets/install-agent.sh
|
||||
var agentInstallScript string
|
||||
|
||||
// ServeInstallScript returns the agent bootstrap script. The script
|
||||
// downloads the binary from this instance first, GH releases second.
|
||||
func (h *NodeAgentHandler) ServeInstallScript(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "text/x-sh", []byte(agentInstallScript))
|
||||
}
|
||||
|
||||
// ServeAgentBinary streams a bundled agent binary. Images ship the
|
||||
// linux binaries under CONTAINR_AGENT_BIN_DIR (default /agents).
|
||||
func (h *NodeAgentHandler) ServeAgentBinary(c *gin.Context) {
|
||||
platform := c.Param("platform")
|
||||
if platform != "linux-amd64" && platform != "linux-arm64" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported platform"})
|
||||
return
|
||||
}
|
||||
dir := os.Getenv("CONTAINR_AGENT_BIN_DIR")
|
||||
if dir == "" {
|
||||
dir = "/agents"
|
||||
}
|
||||
path := filepath.Join(dir, "containr-agent-"+platform)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no bundled binary for " + platform})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", `attachment; filename="containr-agent"`)
|
||||
c.File(path)
|
||||
}
|
||||
|
||||
// SetupAdminRoutes registers dashboard/control agent routes. Command
|
||||
// history and container details can leak sensitive output, so these stay
|
||||
// admin-only rather than joining the public read group.
|
||||
@@ -1243,8 +1549,95 @@ func (h *NodeAgentHandler) SetupAdminRoutes(router *gin.RouterGroup) {
|
||||
agents.DELETE("/:id/containers/:containerId", h.ContainerAction)
|
||||
|
||||
agents.GET("/:id/metrics", h.GetAgentMetrics)
|
||||
agents.POST("/:id/cordon", h.CordonAgent)
|
||||
agents.POST("/:id/uncordon", h.UncordonAgent)
|
||||
agents.POST("/:id/drain", h.DrainAgent)
|
||||
agents.POST("/:id/upgrade", h.UpgradeAgent)
|
||||
agents.POST("/:id/prune", h.PruneAgent)
|
||||
agents.POST("/:id/commands", h.ExecuteCommand)
|
||||
agents.GET("/:id/commands", h.GetAgentCommands)
|
||||
agents.GET("/:id/commands/:commandId", h.GetCommandStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// setSchedulable flips the node's scheduling gate. Cordoned nodes keep
|
||||
// running their placed replicas but take no new pins or `auto` placements.
|
||||
func (h *NodeAgentHandler) setSchedulable(c *gin.Context, schedulable bool) {
|
||||
id := c.Param("id")
|
||||
if _, err := h.q.GetAgent(c.Request.Context(), id); err != nil {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "Agent not found")
|
||||
return
|
||||
}
|
||||
if err := h.q.SetAgentSchedulable(c.Request.Context(), sqlcdb.SetAgentSchedulableParams{
|
||||
ID: id, Schedulable: schedulable,
|
||||
}); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to update node")
|
||||
return
|
||||
}
|
||||
LogAuditWithRequest(c, "node", id, map[bool]string{true: "uncordon", false: "cordon"}[schedulable], nil)
|
||||
c.JSON(http.StatusOK, gin.H{"id": id, "schedulable": schedulable})
|
||||
}
|
||||
|
||||
// CordonAgent blocks new placements on the node (explicit pins and `auto`).
|
||||
func (h *NodeAgentHandler) CordonAgent(c *gin.Context) { h.setSchedulable(c, false) }
|
||||
|
||||
// UncordonAgent re-opens the node for placement.
|
||||
func (h *NodeAgentHandler) UncordonAgent(c *gin.Context) { h.setSchedulable(c, true) }
|
||||
|
||||
// DrainAgent cordons the node, removes every service container via the agent
|
||||
// command queue, and clears the affected services' pins — they fall back to
|
||||
// the local host on next deploy and can be re-pinned to another node.
|
||||
// Requires the node to be online; an offline node can't acknowledge the
|
||||
// removals.
|
||||
func (h *NodeAgentHandler) DrainAgent(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
id := c.Param("id")
|
||||
agent, err := h.q.GetAgent(ctx, id)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "Agent not found")
|
||||
return
|
||||
}
|
||||
if agent.Status.String != "online" {
|
||||
respondError(c, http.StatusConflict, "NODE_OFFLINE",
|
||||
"node is not online — reconnect it, or remove the containers manually")
|
||||
return
|
||||
}
|
||||
if err := h.q.SetAgentSchedulable(ctx, sqlcdb.SetAgentSchedulableParams{
|
||||
ID: id, Schedulable: false,
|
||||
}); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to cordon node")
|
||||
return
|
||||
}
|
||||
|
||||
runner := &agentNodeRunner{q: h.q}
|
||||
rows, err := h.q.ListContainersForAgent(ctx, id)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list node containers")
|
||||
return
|
||||
}
|
||||
services := map[string]bool{}
|
||||
for _, row := range rows {
|
||||
services[row.ServiceID] = true
|
||||
}
|
||||
var drained int
|
||||
var firstErr error
|
||||
for serviceID := range services {
|
||||
if err := runner.RemoveService(ctx, serviceID, id); err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
drained += 1
|
||||
}
|
||||
unpinned, _ := h.q.ClearServiceNodePins(ctx, sql.NullString{String: id, Valid: true})
|
||||
if firstErr != nil {
|
||||
respondError(c, http.StatusBadGateway, "DRAIN_PARTIAL",
|
||||
fmt.Sprintf("drained %d service(s), unpinned %d — one failed: %v", drained, unpinned, firstErr))
|
||||
return
|
||||
}
|
||||
LogAuditWithRequest(c, "node", id, "drain", map[string]interface{}{
|
||||
"services": drained, "unpinned": unpinned,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"id": id, "schedulable": false, "services_drained": drained, "services_unpinned": unpinned})
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# Containr node agent installer.
|
||||
# curl -fsSL <api>/api/agents/install.sh | bash -s -- --url <api> --token <enroll-token>
|
||||
#
|
||||
# One-shot: downloads the agent binary from the Containr instance (or GH
|
||||
# releases as fallback), writes a systemd unit, starts it. SSH is never
|
||||
# needed again — the agent heartbeats and polls commands outbound.
|
||||
set -euo pipefail
|
||||
|
||||
API_URL=""
|
||||
TOKEN=""
|
||||
AGENT_NAME=""
|
||||
VERSION=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--url) API_URL="$2"; shift 2 ;;
|
||||
--token) TOKEN="$2"; shift 2 ;;
|
||||
--name) AGENT_NAME="$2"; shift 2 ;;
|
||||
--version) VERSION="$2"; shift 2 ;;
|
||||
*) echo "unknown flag: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$API_URL" ] || { echo "--url is required" >&2; exit 2; }
|
||||
[ -n "$TOKEN" ] || { echo "--token is required (create one: containr nodes tokens create)" >&2; exit 2; }
|
||||
API_URL="${API_URL%/}"
|
||||
API_URL="${API_URL%/api/v1}"
|
||||
API_URL="${API_URL%/api}"
|
||||
|
||||
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||
err() { printf '\033[1;31mError:\033[0m %s\n' "$*" >&2; }
|
||||
|
||||
[ "$(id -u)" = "0" ] || { err "run as root (sudo bash -s -- --url … --token …)"; exit 1; }
|
||||
command -v docker >/dev/null 2>&1 || { err "docker not found"; exit 1; }
|
||||
command -v curl >/dev/null 2>&1 || { err "curl not found"; exit 1; }
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64|amd64) ARCH=amd64 ;;
|
||||
aarch64|arm64) ARCH=arm64 ;;
|
||||
*) err "unsupported architecture: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
|
||||
BIN=/usr/local/bin/containr-agent
|
||||
info "downloading containr-agent (linux/$ARCH)"
|
||||
if ! curl -fsSL "$API_URL/api/agents/download/linux-$ARCH" -o "$BIN" 2>/dev/null; then
|
||||
info "instance has no bundled binary — trying GitHub releases"
|
||||
REF="${VERSION:-latest}"
|
||||
if [ "$REF" = "latest" ]; then
|
||||
DL="https://github.com/Dvorinka/Containr/releases/latest/download/containr-agent-linux-$ARCH"
|
||||
else
|
||||
DL="https://github.com/Dvorinka/Containr/releases/download/$REF/containr-agent-linux-$ARCH"
|
||||
fi
|
||||
curl -fsSL "$DL" -o "$BIN" || { err "agent download failed"; exit 1; }
|
||||
fi
|
||||
chmod +x "$BIN"
|
||||
|
||||
UNIT=/etc/systemd/system/containr-agent.service
|
||||
info "writing systemd unit"
|
||||
cat > "$UNIT" <<UNIT
|
||||
[Unit]
|
||||
Description=Containr node agent
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Environment=CONTAINR_API_URL=$API_URL
|
||||
Environment=CONTAINR_AGENT_AUTH_TOKEN=$TOKEN
|
||||
Environment=CONTAINR_AGENT_NAME=${AGENT_NAME:-$(hostname)}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
ExecStart=$BIN
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now containr-agent
|
||||
info "agent installed and started — it should appear in Containr within ~15s"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,15 @@ import (
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
"containr/internal/docker"
|
||||
"containr/internal/source"
|
||||
"containr/internal/types"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -377,6 +381,23 @@ func runDeploymentAndSyncWithImage(
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
|
||||
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)
|
||||
if sourcePath == "" {
|
||||
sourcePath = "."
|
||||
@@ -421,6 +442,33 @@ func runDeploymentAndSyncWithImage(
|
||||
maintURL = maintenanceURL(db)
|
||||
}
|
||||
|
||||
// Node-pinned and spread services dispatch to agent command queues.
|
||||
nodeID := serviceNodeID(db, service.ID)
|
||||
spread := serviceSpread(db, service.ID)
|
||||
remote := nodeID != "" || spread
|
||||
|
||||
// Materialize the git checkout — local builds consume it directly;
|
||||
// remote builds package it into a context artifact the node builds.
|
||||
if service.GitRepo != "" && imageOverride == "" {
|
||||
var ownerID string
|
||||
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
|
||||
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{
|
||||
ProjectID: service.ProjectID.String(),
|
||||
ServiceID: service.ID.String(),
|
||||
@@ -447,6 +495,8 @@ func runDeploymentAndSyncWithImage(
|
||||
Maintenance: maintenanceMode,
|
||||
MaintenanceURL: maintURL,
|
||||
BasicAuthUsers: basicAuthUsers,
|
||||
NodeID: nodeID,
|
||||
Spread: spread,
|
||||
},
|
||||
Trigger: deployment.TriggerConfig{
|
||||
Type: req.Trigger,
|
||||
@@ -463,14 +513,36 @@ func runDeploymentAndSyncWithImage(
|
||||
if image == "" {
|
||||
image = service.Image
|
||||
}
|
||||
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,
|
||||
if remote && strings.HasPrefix(image, "containr-") {
|
||||
// Locally-built tag — no registry serves it. Ship the image
|
||||
// itself as a docker-save artifact; the node loads it.
|
||||
rc, err := engine.DockerClient().SaveImage(ctx, image)
|
||||
if err != nil {
|
||||
failDeploy("export rollback image: " + err.Error())
|
||||
return
|
||||
}
|
||||
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 {
|
||||
// "auto" leaves BuildType empty so the manager detects it; the static
|
||||
@@ -480,16 +552,54 @@ func runDeploymentAndSyncWithImage(
|
||||
if service.Builder != "" && service.Builder != "auto" {
|
||||
buildType = service.Builder
|
||||
}
|
||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||
BuildType: buildType,
|
||||
SourcePath: sourcePath,
|
||||
Branch: req.Branch,
|
||||
Commit: req.CommitHash,
|
||||
NoCache: req.NoCache,
|
||||
}
|
||||
buildArgs := map[string]string{}
|
||||
buildCommand := ""
|
||||
if buildType == "static" {
|
||||
deployReq.BuildConfig.BuildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build")
|
||||
deployReq.BuildConfig.BuildArgs = map[string]string{"STATIC_DIR": firstNonEmpty(service.StaticDir, "dist")}
|
||||
buildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build")
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,9 +803,15 @@ func splitImageReference(image, fallbackTag string) (string, string) {
|
||||
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, "/")
|
||||
lastColon := strings.LastIndex(image, ":")
|
||||
if lastColon > lastSlash && !strings.Contains(image[lastColon:], "@") {
|
||||
if lastColon > lastSlash {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -68,6 +70,72 @@ func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) [
|
||||
if len(out) == 0 && 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 {
|
||||
var name string
|
||||
var nodeID sql.NullString
|
||||
var spread bool
|
||||
if err := db.QueryRow(
|
||||
`SELECT name, node_id, COALESCE(spread, false) FROM services WHERE id = $1`, serviceID,
|
||||
).Scan(&name, &nodeID, &spread); err != nil {
|
||||
return nil
|
||||
}
|
||||
label := dnsLabel(name)
|
||||
if label == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var bases []string
|
||||
ctx := context.Background()
|
||||
q := sqlcdb.New(db.DB)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
)
|
||||
|
||||
// Remote ingress: replicas on node agents aren't on the hub's
|
||||
// containr-network, so Traefik's Docker provider can't see them. Instead
|
||||
// the backend renders one file-provider config per service into
|
||||
// TRAEFIK_DYNAMIC_DIR (a directory Traefik watches), pointing the
|
||||
// loadbalancer at the node's reachable IP + the ephemeral host port the
|
||||
// agent reported back after `create_container`.
|
||||
//
|
||||
// Empty dir → feature off (bare-metal dev). Files are best-effort: a
|
||||
// failed write never fails a reconcile.
|
||||
|
||||
func ingressDir() string { return os.Getenv("TRAEFIK_DYNAMIC_DIR") }
|
||||
|
||||
func ingressFile(serviceID string) string {
|
||||
return filepath.Join(ingressDir(), "containr-"+serviceID+".yml")
|
||||
}
|
||||
|
||||
// jsonInt coerces JSON-ish numbers (int/int32/int64/float64) to int —
|
||||
// spec fields arrive as int32, agent payloads unmarshal as float64.
|
||||
func jsonInt(v interface{}) int {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int32:
|
||||
return int(n)
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// reportedHostPorts parses the agent's create_container result —
|
||||
// {"name": ..., "host_ports": {"80/tcp": "32768"}}. Older agents return a
|
||||
// bare name string; JSON parse failure means no port info.
|
||||
func reportedHostPorts(result string) map[string]string {
|
||||
var out struct {
|
||||
HostPorts map[string]string `json:"host_ports"`
|
||||
}
|
||||
if json.Unmarshal([]byte(result), &out) != nil {
|
||||
return nil
|
||||
}
|
||||
return out.HostPorts
|
||||
}
|
||||
|
||||
// nodeIngressIP picks the address the hub can reach the node on. Mesh
|
||||
// overlays first — the agent operator joined a mesh precisely so the hub
|
||||
// can reach it — then the reported primary IP as a LAN fallback.
|
||||
func nodeIngressIP(agent sqlcdb.NodeAgent) string {
|
||||
var meta struct {
|
||||
Mesh map[string]string `json:"mesh"`
|
||||
}
|
||||
_ = json.Unmarshal(agent.Metadata.RawMessage, &meta)
|
||||
for _, prefix := range []string{"tailscale", "netbird", "wg", "zt"} {
|
||||
var keys []string
|
||||
for name := range meta.Mesh {
|
||||
keys = append(keys, name)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, name := range keys {
|
||||
if strings.HasPrefix(name, prefix) && meta.Mesh[name] != "" {
|
||||
return meta.Mesh[name]
|
||||
}
|
||||
}
|
||||
}
|
||||
return agent.IpAddress
|
||||
}
|
||||
|
||||
// syncRemoteIngress rewrites the service's file-provider config from the
|
||||
// live remote replica set. Called after every remote reconcile so replica
|
||||
// adds/removals and node retargets converge the upstream list.
|
||||
func syncRemoteIngress(ctx context.Context, db *database.DB, spec deployment.RuntimeSpec) {
|
||||
if ingressDir() == "" {
|
||||
return
|
||||
}
|
||||
domains := spec.Domains
|
||||
if len(domains) == 0 && spec.Domain != "" {
|
||||
domains = []string{spec.Domain}
|
||||
}
|
||||
if len(domains) == 0 || spec.Port <= 0 {
|
||||
removeServiceIngress(spec.ServiceID)
|
||||
return
|
||||
}
|
||||
|
||||
q := sqlcdb.New(db.DB)
|
||||
rows, err := q.ListServiceContainers(ctx, spec.ServiceID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
type upstream struct{ ip, port string }
|
||||
var servers []upstream
|
||||
for _, row := range rows {
|
||||
var st struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
_ = json.Unmarshal(row.Status.RawMessage, &st)
|
||||
if st.State != "running" {
|
||||
continue
|
||||
}
|
||||
var port string
|
||||
var plist []map[string]interface{}
|
||||
if err := json.Unmarshal(row.Ports.RawMessage, &plist); err == nil {
|
||||
for _, p := range plist {
|
||||
if hp, ok := p["host_port"].(float64); ok && hp > 0 {
|
||||
port = fmt.Sprintf("%d", int(hp))
|
||||
}
|
||||
}
|
||||
}
|
||||
if port == "" {
|
||||
continue
|
||||
}
|
||||
agent, err := q.GetAgent(ctx, row.NodeAgentID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ip := nodeIngressIP(agent); ip != "" {
|
||||
servers = append(servers, upstream{ip, port})
|
||||
}
|
||||
}
|
||||
if len(servers) == 0 {
|
||||
removeServiceIngress(spec.ServiceID)
|
||||
return
|
||||
}
|
||||
|
||||
router := "svc-" + spec.ServiceID[:8]
|
||||
rules := make([]string, 0, len(domains))
|
||||
for _, d := range domains {
|
||||
rules = append(rules, "Host(`"+d+"`)")
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("http:\n routers:\n " + router + ":\n")
|
||||
b.WriteString(" rule: \"" + strings.Join(rules, " || ") + "\"\n")
|
||||
b.WriteString(" entrypoints: [web]\n")
|
||||
b.WriteString(" service: " + router + "\n")
|
||||
b.WriteString(" services:\n " + router + ":\n loadBalancer:\n servers:\n")
|
||||
for _, s := range servers {
|
||||
b.WriteString(" - url: \"http://" + s.ip + ":" + s.port + "\"\n")
|
||||
}
|
||||
// Atomic-ish: write temp then rename so Traefik's watcher never reads a
|
||||
// truncated file.
|
||||
tmp := ingressFile(spec.ServiceID) + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Rename(tmp, ingressFile(spec.ServiceID))
|
||||
}
|
||||
|
||||
// removeServiceIngress deletes the service's remote route. Called on
|
||||
// remote teardown and when a service returns to local placement.
|
||||
func removeServiceIngress(serviceID string) {
|
||||
if ingressDir() == "" {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(ingressFile(serviceID))
|
||||
}
|
||||
|
||||
// wakeUpstreamURL is where a sleeping remote service's route points —
|
||||
// the backend's wake-page endpoint. Defaults to the compose service name;
|
||||
// TRAEFIK_BACKEND_URL overrides for custom layouts.
|
||||
func wakeUpstreamURL(apiPort int) string {
|
||||
if v := strings.TrimSpace(os.Getenv("TRAEFIK_BACKEND_URL")); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return fmt.Sprintf("http://containr-backend:%d", apiPort)
|
||||
}
|
||||
|
||||
// writeWakeIngress routes the service's domains at the wake-page endpoint —
|
||||
// first hit triggers the wake reconcile and serves a reloading page.
|
||||
func writeWakeIngress(serviceID string, domains []string, apiPort int) {
|
||||
if ingressDir() == "" {
|
||||
return
|
||||
}
|
||||
sort.Strings(domains)
|
||||
router := "svc-" + serviceID[:8] + "-wake"
|
||||
rules := make([]string, 0, len(domains))
|
||||
for _, d := range domains {
|
||||
rules = append(rules, "Host(`"+d+"`)")
|
||||
}
|
||||
var b strings.Builder
|
||||
mw := router + "-rw"
|
||||
b.WriteString("http:\n routers:\n " + router + ":\n")
|
||||
b.WriteString(" rule: \"" + strings.Join(rules, " || ") + "\"\n")
|
||||
b.WriteString(" entrypoints: [web]\n")
|
||||
b.WriteString(" service: " + router + "\n")
|
||||
b.WriteString(" priority: 100\n")
|
||||
b.WriteString(" middlewares: [" + mw + "]\n")
|
||||
b.WriteString(" middlewares:\n " + mw + ":\n")
|
||||
b.WriteString(" replacePath:\n path: \"/api/v1/internal/wake-page/" + serviceID + "\"\n")
|
||||
b.WriteString(" services:\n " + router + ":\n loadBalancer:\n servers:\n")
|
||||
b.WriteString(" - url: \"" + wakeUpstreamURL(apiPort) + "\"\n")
|
||||
tmp := ingressFile(serviceID) + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Rename(tmp, ingressFile(serviceID))
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
)
|
||||
|
||||
// agentNodeRunner implements deployment.NodeRunner by dispatching container
|
||||
// lifecycle commands to a node agent's command queue and waiting for the
|
||||
// reported result. Domains route through the file provider —
|
||||
// syncRemoteIngress renders per-service configs from live inventory.
|
||||
type agentNodeRunner struct {
|
||||
q *sqlcdb.Queries
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
func newAgentNodeRunner(db *database.DB) *agentNodeRunner {
|
||||
return &agentNodeRunner{q: sqlcdb.New(db.DB), db: db}
|
||||
}
|
||||
|
||||
// remoteContainerID is deterministic per (agent, service, replica) so redeploys
|
||||
// update the same inventory row instead of accumulating stale ones.
|
||||
func remoteContainerID(agentID, serviceID string, replica int) string {
|
||||
sum := sha256.Sum256([]byte(agentID + "|" + serviceID + "|" + fmt.Sprint(replica)))
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16])
|
||||
}
|
||||
|
||||
func remoteContainerName(serviceID string, replica int) string {
|
||||
return fmt.Sprintf("containr-%s-%d", serviceID, replica)
|
||||
}
|
||||
|
||||
func (r *agentNodeRunner) ReconcileOnNode(ctx context.Context, spec deployment.RuntimeSpec) (*deployment.RuntimeState, error) {
|
||||
agent, err := r.q.GetAgent(ctx, spec.NodeID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("node %s not found", spec.NodeID)
|
||||
}
|
||||
if !agent.Status.Valid || agent.Status.String != "online" {
|
||||
return nil, fmt.Errorf("node %s is %s — heartbeats resume scheduling when it reconnects", agent.Name, agent.Status.String)
|
||||
}
|
||||
return r.reconcileOnAgents(ctx, spec, func(int) string { return spec.NodeID })
|
||||
}
|
||||
|
||||
// ReconcileSpread distributes replicas deterministically across online,
|
||||
// schedulable agents: replica i lands on agents[i % len(agents)], agents
|
||||
// sorted by id. Replicas wrap when they outnumber nodes. The local host is
|
||||
// not a spread target — pin "local" or leave unset for local execution.
|
||||
func (r *agentNodeRunner) ReconcileSpread(ctx context.Context, spec deployment.RuntimeSpec) (*deployment.RuntimeState, error) {
|
||||
tagsRaw, _ := json.Marshal(spec.PlacementTags)
|
||||
if len(spec.PlacementTags) == 0 {
|
||||
tagsRaw = []byte("[]")
|
||||
}
|
||||
agents, err := r.q.ListSchedulableAgentsMatching(ctx, json.RawMessage(tagsRaw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list schedulable nodes: %w", err)
|
||||
}
|
||||
if len(agents) == 0 {
|
||||
if len(spec.PlacementTags) > 0 {
|
||||
return nil, fmt.Errorf("no online schedulable nodes carry placement tags %v — tag a node or relax the requirement", spec.PlacementTags)
|
||||
}
|
||||
return nil, fmt.Errorf("no online schedulable nodes — connect an agent or unset spread")
|
||||
}
|
||||
return r.reconcileOnAgents(ctx, spec, func(i int) string {
|
||||
return agents[i%len(agents)].ID
|
||||
})
|
||||
}
|
||||
|
||||
// ReconcileAuto resolves the least-loaded online agent matching the
|
||||
// service's placement tags and reconciles every replica onto it. Stale
|
||||
// replicas on other agents retire through reconcileOnAgents — a service
|
||||
// follows its tags when the matching set changes.
|
||||
func (r *agentNodeRunner) ReconcileAuto(ctx context.Context, spec deployment.RuntimeSpec) (*deployment.RuntimeState, error) {
|
||||
tagsRaw, _ := json.Marshal(spec.PlacementTags)
|
||||
if len(spec.PlacementTags) == 0 {
|
||||
tagsRaw = []byte("[]")
|
||||
}
|
||||
agentID, err := r.q.PickLeastLoadedAgentMatching(ctx, json.RawMessage(tagsRaw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no online nodes carry placement tags %v", spec.PlacementTags)
|
||||
}
|
||||
return r.reconcileOnAgents(ctx, spec, func(int) string { return agentID })
|
||||
}
|
||||
|
||||
// RemoteRuntimeState reads the service's remote replicas from inventory.
|
||||
// Rows only carry the last dispatched state — the agent doesn't stream
|
||||
// container events — so "running" means "last create succeeded".
|
||||
func (r *agentNodeRunner) RemoteRuntimeState(ctx context.Context, serviceID string) (*deployment.RuntimeState, error) {
|
||||
rows, err := r.q.ListServiceContainers(ctx, serviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state := &deployment.RuntimeState{Status: "stopped"}
|
||||
running := 0
|
||||
for _, row := range rows {
|
||||
var status struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
unmarshalRaw(row.Status, &status)
|
||||
if status.State == "" || status.State == "removed" {
|
||||
continue
|
||||
}
|
||||
replica := -1
|
||||
if idx, err := strconv.Atoi(strings.TrimPrefix(row.Name, fmt.Sprintf("containr-%s-", serviceID))); err == nil {
|
||||
replica = idx
|
||||
}
|
||||
state.Containers = append(state.Containers, deployment.RuntimeContainer{
|
||||
ID: row.ID, Name: row.Name, State: status.State, Replica: replica,
|
||||
})
|
||||
if status.State == "running" {
|
||||
running++
|
||||
}
|
||||
}
|
||||
if running > 0 {
|
||||
state.Status = "running"
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// reconcileOnAgents creates the service's replicas on the agents chosen by
|
||||
// targetFor and retires stale replicas on any agent — including leftovers
|
||||
// from a previous pin or a different spread assignment.
|
||||
func (r *agentNodeRunner) reconcileOnAgents(ctx context.Context, spec deployment.RuntimeSpec, targetFor func(replica int) string) (*deployment.RuntimeState, error) {
|
||||
replicas := spec.Replicas
|
||||
if replicas < 1 {
|
||||
replicas = 1
|
||||
}
|
||||
|
||||
prefix := fmt.Sprintf("containr-%s-", spec.ServiceID)
|
||||
existing, _ := r.q.ListServiceContainers(ctx, spec.ServiceID)
|
||||
for _, row := range existing {
|
||||
if !strings.HasPrefix(row.Name, prefix) {
|
||||
continue
|
||||
}
|
||||
replica, err := strconv.Atoi(strings.TrimPrefix(row.Name, prefix))
|
||||
keep := err == nil && replica < replicas && row.NodeAgentID == targetFor(replica)
|
||||
if keep {
|
||||
continue
|
||||
}
|
||||
if _, cmdErr := r.enqueueAndWait(ctx, row.NodeAgentID, "", "remove_container",
|
||||
map[string]interface{}{"container_name": row.Name}); cmdErr != nil {
|
||||
return nil, fmt.Errorf("remove stale replica %s on node %s: %w", row.Name, row.NodeAgentID, cmdErr)
|
||||
}
|
||||
_ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
|
||||
ID: row.ID,
|
||||
Status: rawJSON(map[string]interface{}{"state": "removed"}),
|
||||
})
|
||||
}
|
||||
|
||||
// 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"}
|
||||
for i := 0; i < replicas; i++ {
|
||||
agentID := targetFor(i)
|
||||
name := remoteContainerName(spec.ServiceID, i)
|
||||
containerID := remoteContainerID(agentID, spec.ServiceID, i)
|
||||
container, env, ports, volumes, restart := r.replicaPayload(spec, agentID, i, name)
|
||||
|
||||
_ = r.q.UpsertServiceContainer(ctx, sqlcdb.UpsertServiceContainerParams{
|
||||
ID: containerID,
|
||||
Name: name,
|
||||
Image: spec.Image,
|
||||
ProjectID: spec.ProjectID,
|
||||
ServiceID: spec.ServiceID,
|
||||
NodeAgentID: agentID,
|
||||
Status: rawJSON(map[string]interface{}{"state": "created", "health": "none"}),
|
||||
Resources: rawJSON(map[string]interface{}{"memory": spec.MemoryBytes, "cpus": float64(spec.NanoCPUs) / 1e9}),
|
||||
Ports: rawJSON(ports),
|
||||
Environment: rawJSON(env),
|
||||
Volumes: rawJSON(volumes),
|
||||
Networks: rawJSON([]string{}),
|
||||
RestartPolicy: rawJSON(map[string]interface{}{"name": restart}),
|
||||
HealthCheck: rawJSON(map[string]interface{}{"path": spec.HealthPath}),
|
||||
})
|
||||
|
||||
result, err := r.enqueueAndWait(ctx, agentID, containerID, "create_container",
|
||||
map[string]interface{}{"container": container})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("replica %d on node %s: %w", i, agentID, err)
|
||||
}
|
||||
if assigned := reportedHostPorts(result); len(assigned) > 0 {
|
||||
for _, p := range ports {
|
||||
pm, ok := p.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cport := jsonInt(pm["container_port"])
|
||||
proto, _ := pm["protocol"].(string)
|
||||
if hp, ok := assigned[fmt.Sprintf("%d/%s", cport, proto)]; ok {
|
||||
if n, err := strconv.Atoi(hp); err == nil {
|
||||
pm["host_port"] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = r.q.UpdateContainerPorts(ctx, sqlcdb.UpdateContainerPortsParams{
|
||||
ID: containerID,
|
||||
Status: rawJSON(map[string]interface{}{"state": "running", "health": "none"}),
|
||||
Ports: rawJSON(ports),
|
||||
})
|
||||
} else {
|
||||
_ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
|
||||
ID: containerID,
|
||||
Status: rawJSON(map[string]interface{}{"state": "running", "health": "none"}),
|
||||
})
|
||||
}
|
||||
state.Containers = append(state.Containers, deployment.RuntimeContainer{
|
||||
ID: containerID, Name: name, State: "running", Replica: i,
|
||||
})
|
||||
}
|
||||
syncRemoteIngress(ctx, r.db, spec)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// replicaPayload builds the agent create_container payload for one replica.
|
||||
func (r *agentNodeRunner) replicaPayload(spec deployment.RuntimeSpec, agentID string, i int, name string) (map[string]interface{}, map[string]interface{}, []interface{}, []interface{}, string) {
|
||||
ports := []interface{}{}
|
||||
if spec.Port > 0 {
|
||||
hostPort := spec.PublishedPort // 0 → ephemeral on the node
|
||||
if i > 0 {
|
||||
hostPort = 0 // only replica 0 claims the preferred host port
|
||||
}
|
||||
ports = append(ports, map[string]interface{}{
|
||||
"published": true,
|
||||
"host_port": hostPort,
|
||||
"container_port": spec.Port,
|
||||
"protocol": "tcp",
|
||||
})
|
||||
}
|
||||
volumes := make([]interface{}, 0, len(spec.Volumes))
|
||||
for _, v := range spec.Volumes {
|
||||
volumes = append(volumes, map[string]interface{}{
|
||||
"source": v.Source, "target": v.Destination, "read_only": v.ReadOnly,
|
||||
})
|
||||
}
|
||||
env := map[string]interface{}{}
|
||||
for k, v := range spec.Env {
|
||||
env[k] = v
|
||||
}
|
||||
restart := spec.RestartPolicy
|
||||
if restart == "" {
|
||||
restart = "unless-stopped"
|
||||
}
|
||||
cmd := make([]interface{}, 0, len(spec.Command))
|
||||
for _, arg := range spec.Command {
|
||||
cmd = append(cmd, arg)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"name": name,
|
||||
"image": spec.Image,
|
||||
"command": cmd,
|
||||
"environment": env,
|
||||
"registry": r.pullCredentials(spec.ProjectID, spec.Image),
|
||||
"ports": ports,
|
||||
"volumes": volumes,
|
||||
"restart_policy": restart,
|
||||
"memory": spec.MemoryBytes,
|
||||
"cpus": float64(spec.NanoCPUs) / 1e9,
|
||||
"labels": map[string]interface{}{
|
||||
"containr.managed": "true",
|
||||
"containr.project": spec.ProjectID,
|
||||
"containr.service": spec.ServiceID,
|
||||
"containr.replica": fmt.Sprint(i),
|
||||
"containr.node": agentID,
|
||||
},
|
||||
}, env, ports, volumes, restart
|
||||
}
|
||||
|
||||
// pullCredentials resolves the project owner's registry auth for the image
|
||||
// host so private images pull on remote nodes. Empty map → anonymous pull.
|
||||
func (r *agentNodeRunner) pullCredentials(projectID, imageRef string) map[string]interface{} {
|
||||
var ownerID string
|
||||
if err := r.db.QueryRow(
|
||||
`SELECT owner_id FROM projects WHERE id = $1`, projectID,
|
||||
).Scan(&ownerID); err != nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
auth := registryAuthFor(r.db, ownerID, imageRef)
|
||||
if auth.Username == "" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"server": auth.ServerAddress,
|
||||
"username": auth.Username,
|
||||
"password": auth.Password,
|
||||
}
|
||||
}
|
||||
|
||||
// ControlService fans a lifecycle action out to every replica row the
|
||||
// service has on the agent. Actions map onto the agent's container commands;
|
||||
// "remove" also tombstones the inventory row.
|
||||
func (r *agentNodeRunner) ControlService(ctx context.Context, serviceID, agentID, action string) error {
|
||||
cmdType := map[string]string{
|
||||
"start": "start_container",
|
||||
"stop": "stop_container",
|
||||
"restart": "restart_container",
|
||||
"remove": "remove_container",
|
||||
}[action]
|
||||
if cmdType == "" {
|
||||
return fmt.Errorf("unsupported action %q", action)
|
||||
}
|
||||
rows, err := r.q.ListContainersForAgent(ctx, agentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removed := map[string]interface{}{"state": "removed"}
|
||||
for _, row := range rows {
|
||||
if row.ServiceID != serviceID {
|
||||
continue
|
||||
}
|
||||
// Tombstoned replicas are already gone remotely — skip the dispatch
|
||||
// or a fresh docker rm on a missing name reports a false failure.
|
||||
if action == "remove" {
|
||||
var st struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(row.Status.RawMessage, &st); err == nil && st.State == "removed" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, err := r.enqueueAndWait(ctx, agentID, row.ID, cmdType,
|
||||
map[string]interface{}{"container_name": row.Name}); err != nil {
|
||||
return fmt.Errorf("%s %s on node: %w", action, row.Name, err)
|
||||
}
|
||||
if action == "remove" {
|
||||
_ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
|
||||
ID: row.ID, Status: rawJSON(removed)})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveService tears down every replica of the service on the agent and
|
||||
// clears the inventory rows.
|
||||
func (r *agentNodeRunner) RemoveService(ctx context.Context, serviceID, agentID string) error {
|
||||
if err := r.ControlService(ctx, serviceID, agentID, "remove"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.q.DeleteServiceContainersOnAgent(ctx, sqlcdb.DeleteServiceContainersOnAgentParams{
|
||||
NodeAgentID: agentID,
|
||||
ServiceID: serviceID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
removeServiceIngress(serviceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetireService removes the service's remote replicas wherever they run —
|
||||
// called when the service converges to local placement so a cleared pin or
|
||||
// removed tag requirement doesn't leave containers behind.
|
||||
func (r *agentNodeRunner) RetireService(ctx context.Context, serviceID string) error {
|
||||
agentIDs, err := r.q.ListServiceAgents(ctx, serviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, agentID := range agentIDs {
|
||||
if err := r.RemoveService(ctx, serviceID, agentID); err != nil {
|
||||
return fmt.Errorf("retire on node %s: %w", agentID, err)
|
||||
}
|
||||
}
|
||||
removeServiceIngress(serviceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// enqueueAndWait pushes a command to the agent's queue and polls for the
|
||||
// reported result, returning the agent's result string on success. Agents
|
||||
// poll on their own interval (≥5s), so this blocks — it runs inside the
|
||||
// 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) {
|
||||
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
|
||||
if containerID != "" {
|
||||
cid = sql.NullString{String: containerID, Valid: true}
|
||||
}
|
||||
cmd, err := r.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
|
||||
ID: uuid.New().String(),
|
||||
Type: cmdType,
|
||||
NodeAgentID: agentID,
|
||||
ContainerID: cid,
|
||||
Payload: rawJSON(payload),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(timeout) // image pulls/builds on a cold node
|
||||
for {
|
||||
row, err := r.q.GetCommandForAgent(ctx, sqlcdb.GetCommandForAgentParams{
|
||||
ID: cmd.ID,
|
||||
NodeAgentID: agentID,
|
||||
})
|
||||
if err == nil {
|
||||
switch row.Status.String {
|
||||
case "completed":
|
||||
return row.Result.String, nil
|
||||
case "failed":
|
||||
if row.Error.Valid && row.Error.String != "" {
|
||||
return "", fmt.Errorf("agent: %s", row.Error.String)
|
||||
}
|
||||
return "", fmt.Errorf("agent reported failure")
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("command %s timed out waiting for the agent", cmdType)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveNodePin validates or resolves a node_id request value. "auto" picks
|
||||
// the least-loaded online agent that carries every required placement tag;
|
||||
// "" / "local" clears the pin. An explicit pin bypasses tag filtering —
|
||||
// the operator is overriding placement on purpose.
|
||||
func resolveNodePin(ctx context.Context, db *database.DB, requested string, requiredTags []string) (string, error) {
|
||||
requested = strings.TrimSpace(requested)
|
||||
if requested == "" || requested == "local" {
|
||||
return "", nil
|
||||
}
|
||||
q := sqlcdb.New(db.DB)
|
||||
if requested == "auto" {
|
||||
tagsRaw, _ := json.Marshal(requiredTags)
|
||||
if len(requiredTags) == 0 {
|
||||
tagsRaw = []byte("[]")
|
||||
}
|
||||
id, err := q.PickLeastLoadedAgentMatching(ctx, json.RawMessage(tagsRaw))
|
||||
if err != nil {
|
||||
if len(requiredTags) > 0 {
|
||||
return "", fmt.Errorf("no online nodes carry placement tags %v", requiredTags)
|
||||
}
|
||||
return "", fmt.Errorf("no online nodes available")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
agent, err := q.GetAgent(ctx, requested)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("node not found")
|
||||
}
|
||||
if !agent.Schedulable {
|
||||
return "", fmt.Errorf("node %s is cordoned — uncordon it before pinning", agent.Name)
|
||||
}
|
||||
return requested, nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -61,26 +62,28 @@ func StartAgentHealthSweep(ctx context.Context, db *database.DB) {
|
||||
}()
|
||||
}
|
||||
|
||||
type agentHealthRow struct {
|
||||
id string
|
||||
name string
|
||||
status string
|
||||
stale bool
|
||||
autoPrune bool
|
||||
}
|
||||
|
||||
func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
|
||||
cutoff := time.Now().Add(-staleAfter)
|
||||
type agentRow struct {
|
||||
id string
|
||||
name string
|
||||
status string
|
||||
stale bool
|
||||
}
|
||||
var agents []agentRow
|
||||
var agents []agentHealthRow
|
||||
rows, err := db.Query(
|
||||
`SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''),
|
||||
COALESCE(last_heartbeat < $1, TRUE)
|
||||
COALESCE(last_heartbeat < $1, TRUE), auto_prune
|
||||
FROM node_agents`, cutoff)
|
||||
if err != nil {
|
||||
log.Printf("containr: agent health sweep: %v", err)
|
||||
return
|
||||
}
|
||||
for rows.Next() {
|
||||
var a agentRow
|
||||
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale); err == nil {
|
||||
var a agentHealthRow
|
||||
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale, &a.autoPrune); err == nil {
|
||||
agents = append(agents, a)
|
||||
}
|
||||
}
|
||||
@@ -104,6 +107,32 @@ func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
|
||||
fmt.Sprintf("Agent %s resumed heartbeating.", a.name), "agent", a.id)
|
||||
}
|
||||
}
|
||||
|
||||
scheduleAutoPrune(db, agents)
|
||||
}
|
||||
|
||||
// scheduleAutoPrune enqueues a `prune` command once per day for agents
|
||||
// with auto_prune enabled. Disk pressure is the most common small-VPS
|
||||
// failure — this is the dflow setServerAutoCleanup analog.
|
||||
func scheduleAutoPrune(db *database.DB, agents []agentHealthRow) {
|
||||
q := sqlcdb.New(db.DB)
|
||||
for _, a := range agents {
|
||||
if !a.autoPrune || a.stale {
|
||||
continue
|
||||
}
|
||||
last, err := q.GetLastAgentCommandByType(context.Background(),
|
||||
sqlcdb.GetLastAgentCommandByTypeParams{NodeAgentID: a.id, Type: "prune"})
|
||||
if err == nil && last.CreatedAt.Valid && time.Since(last.CreatedAt.Time) < 24*time.Hour {
|
||||
continue
|
||||
}
|
||||
if _, err := q.CreateCommand(context.Background(), sqlcdb.CreateCommandParams{
|
||||
ID: uuid.New().String(),
|
||||
Type: "prune",
|
||||
NodeAgentID: a.id,
|
||||
}); err != nil {
|
||||
log.Printf("containr: auto-prune enqueue failed for agent %s: %v", a.id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StartUpgradeCheck polls GitHub for the latest release once a day and
|
||||
|
||||
@@ -41,6 +41,7 @@ func insertUserNotification(db *database.DB, userID, kind, title, body, resource
|
||||
ResourceID: sql.NullString{String: resourceID, Valid: resourceID != ""},
|
||||
CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
})
|
||||
go fanoutUserPush(db, userID, kind, title, body)
|
||||
}
|
||||
|
||||
func mapNotification(row sqlcdb.Notification) NotificationItem {
|
||||
|
||||
@@ -2,7 +2,12 @@ package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -26,6 +31,10 @@ type PreviewEnvironment struct {
|
||||
CreatedAt time.Time `json:"created_at" db:"created_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
|
||||
Service *Service `json:"service,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
|
||||
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,
|
||||
s.id as service_id, s.name as service_name, s.type as service_type
|
||||
FROM preview_environments pe
|
||||
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`,
|
||||
projectID,
|
||||
)
|
||||
@@ -101,7 +110,7 @@ func handleGetPreviewEnvironments(c *gin.Context) {
|
||||
var serviceType sql.NullString
|
||||
|
||||
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,
|
||||
&serviceID, &serviceName, &serviceType,
|
||||
)
|
||||
@@ -215,87 +224,111 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
|
||||
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{
|
||||
ID: uuid.New(),
|
||||
ProjectID: req.ProjectID,
|
||||
ServiceID: req.ServiceID,
|
||||
BranchName: req.BranchName,
|
||||
PRNumber: req.PRNumber,
|
||||
Environment: generatePreviewEnvironmentName(req.BranchName),
|
||||
Environment: envName,
|
||||
Status: "building",
|
||||
ExpiresAt: &[]time.Time{time.Now().Add(time.Duration(ttlHours) * time.Hour)}[0],
|
||||
ExpiresAt: &expiresAt,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if previewHost != "" {
|
||||
env.URL = "https://" + previewHost
|
||||
}
|
||||
|
||||
// Insert preview environment into database
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO preview_environments
|
||||
(id, project_id, service_id, branch_name, pr_number, environment,
|
||||
`INSERT INTO preview_environments
|
||||
(id, project_id, service_id, preview_service_id, branch_name, pr_number, environment,
|
||||
status, url, expires_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
env.ID, env.ProjectID, env.ServiceID, env.BranchName, env.PRNumber,
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
env.ID, env.ProjectID, env.ServiceID, cloneID, env.BranchName, env.PRNumber,
|
||||
env.Environment, env.Status, env.URL, env.ExpiresAt, env.CreatedAt, env.UpdatedAt,
|
||||
)
|
||||
|
||||
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"})
|
||||
return
|
||||
}
|
||||
|
||||
deploymentID := uuid.New()
|
||||
sanitizedBranch := strings.ReplaceAll(req.BranchName, "/", "-")
|
||||
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(
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO deployments
|
||||
(id, service_id, version, commit_hash, status, started_at, completed_at, deployment_log, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())`,
|
||||
deploymentID,
|
||||
req.ServiceID,
|
||||
version,
|
||||
req.BranchName,
|
||||
"running",
|
||||
startedAt,
|
||||
completedAt,
|
||||
fmt.Sprintf("Preview environment %s activated for branch %s", env.Environment, req.BranchName),
|
||||
(id, service_id, version, commit_hash, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW())`,
|
||||
deploymentID, cloneID, "preview-"+env.Environment, req.BranchName,
|
||||
)
|
||||
if deployErr != nil {
|
||||
_, _ = db.(*database.DB).Exec(
|
||||
`UPDATE preview_environments
|
||||
SET status = 'failed', updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
env.ID,
|
||||
)
|
||||
if err != nil {
|
||||
_, _ = db.(*database.DB).Exec(`DELETE FROM preview_environments WHERE id = $1`, env.ID)
|
||||
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to provision preview deployment"})
|
||||
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
|
||||
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`UPDATE preview_environments
|
||||
SET status = $1, url = $2, updated_at = $3
|
||||
WHERE id = $4`,
|
||||
env.Status,
|
||||
env.URL,
|
||||
env.UpdatedAt,
|
||||
env.ID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to finalize preview environment"})
|
||||
return
|
||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
||||
cloneService, loadErr := loadServiceForDeploy(db.(*database.DB), cloneID)
|
||||
if loadErr == nil {
|
||||
engineInstance := engine
|
||||
d := DeploymentModel{ID: deploymentID, ServiceID: cloneID, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||||
getDeployQueue(c).Enqueue(cloneID, deployqueue.Job{
|
||||
DeploymentID: deploymentID,
|
||||
Run: func(jctx context.Context) {
|
||||
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, cloneService, CreateDeploymentRequest{Branch: req.BranchName, Trigger: "preview"}, userID.(string))
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -321,7 +354,7 @@ func handleGetPreviewEnvironment(c *gin.Context) {
|
||||
var serviceName sql.NullString
|
||||
var serviceType sql.NullString
|
||||
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,
|
||||
s.id as service_id, s.name as service_name, s.type as service_type
|
||||
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))`,
|
||||
envID, userID, contextIsAdmin(c),
|
||||
).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,
|
||||
&serviceID, &serviceName, &serviceType,
|
||||
)
|
||||
@@ -480,22 +513,19 @@ func handleDeletePreviewEnvironment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
cleanupID := uuid.New()
|
||||
cleanupStartedAt := time.Now().UTC()
|
||||
cleanupCompletedAt := cleanupStartedAt
|
||||
|
||||
_, _ = db.(*database.DB).Exec(
|
||||
`INSERT INTO deployments
|
||||
(id, service_id, version, status, started_at, completed_at, deployment_log, created_at, updated_at)
|
||||
VALUES ($1, (SELECT service_id FROM preview_environments WHERE id = $2), $3, $4, $5, $6, $7, NOW(), NOW())`,
|
||||
cleanupID,
|
||||
envID,
|
||||
"preview-cleanup",
|
||||
"rolled_back",
|
||||
cleanupStartedAt,
|
||||
cleanupCompletedAt,
|
||||
"Preview environment resources cleaned up",
|
||||
)
|
||||
// Tear down the clone's runtime and row — the preview is the clone.
|
||||
var cloneID *uuid.UUID
|
||||
_ = db.(*database.DB).QueryRow(
|
||||
`SELECT preview_service_id FROM preview_environments WHERE id = $1`, envID,
|
||||
).Scan(&cloneID)
|
||||
if cloneID != 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, *cloneID)
|
||||
}
|
||||
}
|
||||
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *cloneID)
|
||||
}
|
||||
|
||||
// Delete preview environment
|
||||
_, err = db.(*database.DB).Exec(
|
||||
@@ -542,13 +572,13 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
|
||||
// Get preview environment details
|
||||
var env PreviewEnvironment
|
||||
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
|
||||
JOIN projects p ON pe.project_id = p.id
|
||||
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
|
||||
envID, userID, contextIsAdmin(c),
|
||||
).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 {
|
||||
@@ -562,38 +592,53 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Promote is a real redeploy: the source service builds the preview's
|
||||
// branch onto its own deployment track.
|
||||
deploymentID := uuid.New()
|
||||
promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix())
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO deployments (id, service_id, version, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())`,
|
||||
deploymentID, env.ServiceID, promotionVersion, "pending",
|
||||
`INSERT INTO deployments (id, service_id, version, commit_hash, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW())`,
|
||||
deploymentID, env.ServiceID, promotionVersion, env.BranchName,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := db.(*database.DB).Exec(
|
||||
`UPDATE services
|
||||
SET environment = $1, updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
req.TargetEnvironment, env.ServiceID,
|
||||
); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service target environment"})
|
||||
sourceService, loadErr := loadServiceForDeploy(db.(*database.DB), env.ServiceID)
|
||||
if loadErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load promotion target service"})
|
||||
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"
|
||||
if req.CreateBackup {
|
||||
previewStatus = "expired"
|
||||
// Promotion retires the preview — the clone's containers and row go.
|
||||
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)
|
||||
}
|
||||
|
||||
if _, err := db.(*database.DB).Exec(
|
||||
`UPDATE preview_environments
|
||||
SET status = $1, updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
previewStatus, env.ID,
|
||||
SET status = 'stopped', updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
env.ID,
|
||||
); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment status"})
|
||||
return
|
||||
@@ -608,7 +653,7 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
|
||||
"create_backup": req.CreateBackup,
|
||||
"deployment_id": deploymentID,
|
||||
"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
|
||||
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
|
||||
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'`,
|
||||
@@ -655,7 +700,7 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
|
||||
for rows.Next() {
|
||||
var env PreviewEnvironment
|
||||
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 {
|
||||
continue
|
||||
@@ -663,10 +708,9 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
|
||||
expiredEnvs = append(expiredEnvs, env)
|
||||
}
|
||||
|
||||
// Mark expired environments as expired and trigger cleanup
|
||||
// Mark expired environments as expired and tear down their clones
|
||||
cleanupCount := 0
|
||||
for _, env := range expiredEnvs {
|
||||
// Update status to expired
|
||||
_, err := db.(*database.DB).Exec(
|
||||
"UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1",
|
||||
env.ID,
|
||||
@@ -674,7 +718,14 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
|
||||
if err != nil {
|
||||
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++
|
||||
}
|
||||
|
||||
@@ -684,3 +735,157 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"containr/internal/build"
|
||||
@@ -43,6 +44,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
dockerClient = client
|
||||
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
||||
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
|
||||
initArtifacts(filepath.Join("/tmp/containr-builds", "artifacts"))
|
||||
purgeStaleArtifacts()
|
||||
// Node-pinned services dispatch container lifecycle to the agent's
|
||||
// command queue instead of the local Docker socket.
|
||||
deploymentEngine.SetNodeRunner(newAgentNodeRunner(db))
|
||||
// Restore managed sidecars (e.g. cloudflared) from saved settings.
|
||||
syncManagedContainers(dockerClient, db)
|
||||
}
|
||||
@@ -105,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.
|
||||
StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port)
|
||||
StartPreviewSweeper(context.Background(), db, deploymentEngine)
|
||||
|
||||
// Audit retention — prunes rows past AUDIT_RETENTION_DAYS (default 90).
|
||||
StartAuditRetention(context.Background(), db)
|
||||
@@ -263,6 +270,12 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// endpoint, verified against the provisioned webhook secret.
|
||||
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")
|
||||
{
|
||||
// Public routes (no authentication required)
|
||||
@@ -273,6 +286,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
public.POST("/auth/register", handleRegister)
|
||||
public.GET("/maintenance", handleMaintenancePage)
|
||||
public.GET("/branding", handleGetBranding)
|
||||
public.GET("/setup/status", handleSetupStatus)
|
||||
|
||||
// Invite accept flow — validates the link, then registers
|
||||
// even while public signup is closed.
|
||||
@@ -282,6 +296,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// Wake callback for sleep-mode placeholders. Unauthenticated but
|
||||
// non-destructive: it only reconciles the service back to running.
|
||||
public.Any("/internal/wake/:id", handleServiceWake)
|
||||
public.Any("/internal/wake-page/:id", handleServiceWakePage)
|
||||
}
|
||||
|
||||
// Read routes — public browsing. OptionalAuth resolves a session when
|
||||
@@ -293,6 +308,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
read.GET("/projects", handleGetProjects)
|
||||
read.GET("/projects/:id", handleGetProject)
|
||||
read.GET("/projects/:id/services", handleGetServices)
|
||||
read.GET("/projects/:id/environments", handleListEnvironments)
|
||||
read.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
|
||||
read.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
|
||||
read.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
|
||||
@@ -340,6 +356,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
{
|
||||
authed.GET("/user/profile", handleGetProfile)
|
||||
authed.PUT("/user/profile", handleUpdateProfile)
|
||||
authed.POST("/setup/complete", handleSetupComplete)
|
||||
|
||||
// Personal access tokens for CLI/MCP/agent auth.
|
||||
authed.GET("/user/tokens", handleListUserTokens)
|
||||
@@ -349,12 +366,17 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
authed.GET("/notifications", handleListNotifications)
|
||||
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
|
||||
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
|
||||
authed.GET("/ws", handleWebSocket)
|
||||
|
||||
// Cross-cutting ops view — active/queued/failed work
|
||||
authed.GET("/operations", handleGetOperations)
|
||||
authed.GET("/nodes/options", agentHandler.ListNodeOptions)
|
||||
authed.GET("/activity", handleGetActivity)
|
||||
authed.GET("/projects/:id/activity", handleGetProjectActivity)
|
||||
|
||||
@@ -461,6 +483,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
authed.POST("/databases/:id/backups/import", databaseHandler.ImportBackup)
|
||||
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.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
|
||||
authed.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
|
||||
@@ -479,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/plan", handlePlanTemplate)
|
||||
authed.POST("/templates/import/compose", handleImportCompose)
|
||||
authed.POST("/templates/import/git", handleImportGitCompose)
|
||||
authed.POST("/templates/deploy", handleDeployInlineGraph)
|
||||
|
||||
authed.POST("/cron-jobs", handleCreateCronJob)
|
||||
|
||||
@@ -18,6 +18,61 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// remoteAgentsForService lists every agent holding inventory rows for the
|
||||
// service — covers pinned placements, spread replicas, and leftovers from a
|
||||
// since-cleared pin.
|
||||
func remoteAgentsForService(ctx context.Context, db *database.DB, serviceID uuid.UUID) []string {
|
||||
rows, err := db.Query(
|
||||
`SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1`,
|
||||
serviceID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil && id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// serviceLifecycle routes a start/stop/restart action to every agent holding
|
||||
// the service's replicas, then to the local Docker engine. Both run — a
|
||||
// service can't be pinned and local at once, but stale containers from a
|
||||
// cleared pin shouldn't survive a stop.
|
||||
func serviceLifecycle(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, serviceID uuid.UUID, action string) error {
|
||||
for _, agentID := range remoteAgentsForService(ctx, db, serviceID) {
|
||||
if err := engine.ControlServiceOnNode(ctx, serviceID.String(), agentID, action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
switch action {
|
||||
case "start":
|
||||
return engine.StartService(ctx, serviceID.String())
|
||||
case "stop":
|
||||
return engine.StopService(ctx, serviceID.String())
|
||||
case "restart":
|
||||
if err := engine.StopService(ctx, serviceID.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
return engine.StartService(ctx, serviceID.String())
|
||||
}
|
||||
return fmt.Errorf("unsupported lifecycle action %q", action)
|
||||
}
|
||||
|
||||
// removeServiceRuntime tears down replicas wherever they run — remote nodes
|
||||
// (pinned or spread) and the local Docker host.
|
||||
func removeServiceRuntime(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, serviceID uuid.UUID) {
|
||||
for _, agentID := range remoteAgentsForService(ctx, db, serviceID) {
|
||||
_ = engine.RemoveServiceContainersOnNode(ctx, serviceID.String(), agentID)
|
||||
}
|
||||
_ = engine.RemoveServiceContainers(ctx, serviceID.String())
|
||||
}
|
||||
|
||||
// serviceRuntimeSpec builds the container spec for a service: stored env vars
|
||||
// merged with ${{Service.KEY}} references resolved across the project.
|
||||
func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpec, error) {
|
||||
@@ -37,6 +92,9 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
|
||||
Domain: service.Domain,
|
||||
HealthPath: service.HealthCheckPath,
|
||||
RestartPolicy: service.RestartPolicy,
|
||||
NodeID: serviceNodeID(db, service.ID),
|
||||
Spread: serviceSpread(db, service.ID),
|
||||
PlacementTags: servicePlacementTags(db, service.ID),
|
||||
}
|
||||
// Best effort: reuse the host port from the last live deployment so the
|
||||
// public URL survives restarts. Column exists post-migration; older DBs
|
||||
@@ -63,6 +121,7 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
|
||||
spec.Volumes = loadServiceVolumes(db, service.ID)
|
||||
spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
|
||||
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
|
||||
spec.TraefikLabels = serviceTraefikLabels(db, service.ID)
|
||||
if spec.Maintenance {
|
||||
spec.MaintenanceURL = maintenanceURL(db)
|
||||
}
|
||||
@@ -341,6 +400,7 @@ func handleGetServiceRuntime(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
mergeRemoteRuntime(c.Request.Context(), engine, service.ID.String(), state)
|
||||
engine.ProbeServiceHealth(c.Request.Context(), state, service.ProjectID.String(), service.Name, service.Port, service.HealthCheckPath)
|
||||
persistPublishedPort(db, service.ID, state.Ports)
|
||||
if host := requestHostname(c); host != "" {
|
||||
@@ -384,7 +444,7 @@ func handleServiceStart(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := engine.StartService(c.Request.Context(), service.ID.String()); err != nil {
|
||||
if err := serviceLifecycle(c.Request.Context(), db, engine, service.ID, "start"); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -402,7 +462,7 @@ func handleServiceStop(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := engine.StopService(c.Request.Context(), service.ID.String()); err != nil {
|
||||
if err := serviceLifecycle(c.Request.Context(), db, engine, service.ID, "stop"); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -421,11 +481,7 @@ func handleServiceRestart(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if err := engine.StopService(ctx, service.ID.String()); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := engine.StartService(ctx, service.ID.String()); err != nil {
|
||||
if err := serviceLifecycle(ctx, db, engine, service.ID, "restart"); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -535,6 +591,37 @@ func enqueueServiceRedeploy(c *gin.Context, db *database.DB, service Service) bo
|
||||
return true
|
||||
}
|
||||
|
||||
// mergeRemoteRuntime folds inventory-backed remote replica state into a
|
||||
// local runtime view so pinned and spread services report their containers.
|
||||
// Remote rows carry deterministic UUIDs, local ones Docker IDs — no overlap.
|
||||
func mergeRemoteRuntime(ctx context.Context, engine *deployment.DeploymentEngine, serviceID string, state *deployment.RuntimeState) {
|
||||
remote, err := engine.RemoteRuntimeState(ctx, serviceID)
|
||||
if err != nil || remote == nil {
|
||||
return
|
||||
}
|
||||
running := 0
|
||||
for _, c := range state.Containers {
|
||||
if c.State == "running" {
|
||||
running++
|
||||
}
|
||||
}
|
||||
for _, rc := range remote.Containers {
|
||||
state.Containers = append(state.Containers, rc)
|
||||
if rc.State == "running" {
|
||||
running++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(state.Containers) == 0:
|
||||
case running == 0:
|
||||
state.Status = "stopped"
|
||||
case running < state.Desired:
|
||||
state.Status = "degraded"
|
||||
default:
|
||||
state.Status = "running"
|
||||
}
|
||||
}
|
||||
|
||||
// persistPublishedPort records the host port Docker actually bound so later
|
||||
// redeploys can reuse it. Idempotent — only writes when the value changed.
|
||||
func persistPublishedPort(db *database.DB, serviceID uuid.UUID, ports []uint16) {
|
||||
@@ -558,7 +645,11 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
state, err := engine.RuntimeState(ctx, service.ID.String(), service.Replicas)
|
||||
if err != nil || state == nil || len(state.Containers) == 0 {
|
||||
if err != nil || state == nil {
|
||||
return
|
||||
}
|
||||
mergeRemoteRuntime(ctx, engine, service.ID.String(), state)
|
||||
if len(state.Containers) == 0 {
|
||||
return
|
||||
}
|
||||
if state.Status != service.Status {
|
||||
@@ -568,6 +659,8 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
|
||||
persistPublishedPort(db, service.ID, state.Ports)
|
||||
if 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 {
|
||||
service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c))
|
||||
}
|
||||
|
||||
@@ -67,22 +67,45 @@ func handleCloneService(c *gin.Context) {
|
||||
if req.Environment != "" {
|
||||
environment = req.Environment
|
||||
}
|
||||
if environment == "" {
|
||||
environment = "production"
|
||||
}
|
||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), targetProjectID, environment)
|
||||
|
||||
newID, err := cloneServiceRow(db.(*database.DB), source.ID, targetProjectID, name, environment)
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
now := time.Now()
|
||||
// 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
|
||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||
build_command, start_command, type, status, image, command, environment,
|
||||
@@ -98,35 +121,30 @@ func handleCloneService(c *gin.Context) {
|
||||
$9, $9
|
||||
FROM services WHERE id = $10`,
|
||||
newID, targetProjectID, name, environmentID, environment, volumesJSON,
|
||||
maintenance, basicAuth, now, source.ID,
|
||||
maintenance, basicAuth, now, sourceID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
|
||||
return
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
if _, err := db.(*database.DB).Exec(
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO service_domains (service_id, domain, is_default, cert_type)
|
||||
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
|
||||
newID, source.ID,
|
||||
newID, sourceID,
|
||||
); err != nil {
|
||||
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service domains"})
|
||||
return
|
||||
_, _ = db.Exec(`DELETE FROM services WHERE id = $1`, newID)
|
||||
return uuid.Nil, err
|
||||
}
|
||||
// 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)
|
||||
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
|
||||
newID, source.ID,
|
||||
newID, sourceID,
|
||||
); err != nil {
|
||||
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service variables"})
|
||||
return
|
||||
_, _ = db.Exec(`DELETE FROM services WHERE id = $1`, newID)
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
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"})
|
||||
return newID, nil
|
||||
}
|
||||
|
||||
type MoveServiceRequest struct {
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -48,8 +50,21 @@ type Service struct {
|
||||
StaticDir string `json:"static_dir,omitempty" db:"static_dir"`
|
||||
SleepEnabled bool `json:"sleep_enabled" db:"-"`
|
||||
SleepIdleMinutes int `json:"sleep_idle_minutes" db:"-"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
// NodeID pins the service to a node agent ("" = local Docker host,
|
||||
// resolved "auto" picks the least-loaded online agent at write time).
|
||||
NodeID string `json:"node_id,omitempty" db:"-"`
|
||||
NodeName string `json:"node_name,omitempty" db:"-"`
|
||||
// Spread distributes replicas across every online, schedulable agent.
|
||||
// Mutually exclusive with an explicit NodeID pin.
|
||||
Spread bool `json:"spread" db:"-"`
|
||||
// PlacementTags restrict auto/spread candidates to agents carrying all
|
||||
// listed tags. An explicit pin overrides them — pin + tags is rejected.
|
||||
PlacementTags []string `json:"placement_tags,omitempty" db:"-"`
|
||||
// TraefikLabels holds per-service middleware overrides —
|
||||
// "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
|
||||
@@ -64,6 +79,151 @@ func loadServiceSleep(db *database.DB, s *Service) {
|
||||
}
|
||||
}
|
||||
|
||||
// serviceNodeID reads the pin column directly — kept out of the wide service
|
||||
// SELECTs so pre-migration schemas keep working.
|
||||
func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
|
||||
var id sql.NullString
|
||||
if err := db.QueryRow(`SELECT node_id FROM services WHERE id = $1`, serviceID).Scan(&id); err == nil {
|
||||
return id.String
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// serviceSpread reads the spread column with the same lazy pattern.
|
||||
func serviceSpread(db *database.DB, serviceID uuid.UUID) bool {
|
||||
var spread bool
|
||||
if err := db.QueryRow(`SELECT COALESCE(spread, false) FROM services WHERE id = $1`, serviceID).Scan(&spread); err == nil {
|
||||
return spread
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// servicePlacementTags reads the placement_tags column with the lazy pattern.
|
||||
func servicePlacementTags(db *database.DB, serviceID uuid.UUID) []string {
|
||||
var raw []byte
|
||||
if err := db.QueryRow(`SELECT placement_tags FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
|
||||
return nil
|
||||
}
|
||||
var tags []string
|
||||
if err := json.Unmarshal(raw, &tags); err != nil {
|
||||
return nil
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// normalizePlacementTags trims, lowercases, and dedupes tag input. Tags are
|
||||
// matched verbatim against node_agents.tags.
|
||||
func normalizePlacementTags(tags []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(tags))
|
||||
for _, t := range tags {
|
||||
t = strings.ToLower(strings.TrimSpace(t))
|
||||
if t == "" || seen[t] {
|
||||
continue
|
||||
}
|
||||
seen[t] = true
|
||||
out = append(out, t)
|
||||
}
|
||||
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.
|
||||
func tagsJSON(tags []string) []byte {
|
||||
if len(tags) == 0 {
|
||||
return []byte("[]")
|
||||
}
|
||||
raw, _ := json.Marshal(tags)
|
||||
return raw
|
||||
}
|
||||
|
||||
// loadServiceNode fills the pin + spread + placement tags + the agent's
|
||||
// display name for responses.
|
||||
func loadServiceNode(db *database.DB, s *Service) {
|
||||
var name sql.NullString
|
||||
s.NodeID = serviceNodeID(db, s.ID)
|
||||
s.Spread = serviceSpread(db, s.ID)
|
||||
s.PlacementTags = servicePlacementTags(db, s.ID)
|
||||
s.TraefikLabels = serviceTraefikLabels(db, s.ID)
|
||||
if s.NodeID == "" {
|
||||
return
|
||||
}
|
||||
if err := db.QueryRow(`SELECT name FROM node_agents WHERE id = $1`, s.NodeID).Scan(&name); err == nil {
|
||||
s.NodeName = name.String
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceVolume is a volume/bind mount applied to every replica.
|
||||
type ServiceVolume struct {
|
||||
Type string `json:"type"` // volume | bind (empty = volume)
|
||||
@@ -141,6 +301,23 @@ type CreateServiceRequest struct {
|
||||
MemoryReserve string `json:"memory_reserve"`
|
||||
StaticBuildCmd string `json:"static_build_cmd"`
|
||||
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
|
||||
// online agent, "local"/"" runs on the Containr host.
|
||||
NodeID string `json:"node_id"`
|
||||
// Spread distributes replicas across every online, schedulable agent.
|
||||
// Mutually exclusive with an explicit node pin.
|
||||
Spread *bool `json:"spread"`
|
||||
// PlacementTags restrict auto/spread candidates to agents carrying all
|
||||
// listed tags. Mutually exclusive with an explicit node pin.
|
||||
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
|
||||
@@ -170,6 +347,18 @@ type UpdateServiceRequest struct {
|
||||
StaticDir *string `json:"static_dir"`
|
||||
SleepEnabled *bool `json:"sleep_enabled"`
|
||||
SleepIdleMinutes *int `json:"sleep_idle_minutes"`
|
||||
// NodeID pins placement; pointer so "local"/"" explicitly clears the pin.
|
||||
NodeID *string `json:"node_id"`
|
||||
// Spread toggles multi-node replica distribution; pointer so false is
|
||||
// a real update, not a missing field.
|
||||
Spread *bool `json:"spread"`
|
||||
// PlacementTags restrict auto/spread candidates to agents carrying all
|
||||
// listed tags; pointer so [] clears the requirement. Mutually exclusive
|
||||
// with an explicit node pin.
|
||||
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
|
||||
@@ -253,6 +442,7 @@ func handleGetServices(c *gin.Context) {
|
||||
// Reconcile stored status with live container state (no-op without Docker).
|
||||
for i := range services {
|
||||
liveServiceStatus(c, db.(*database.DB), &services[i])
|
||||
loadServiceNode(db.(*database.DB), &services[i])
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"services": services})
|
||||
@@ -372,6 +562,14 @@ func handleCreateService(c *gin.Context) {
|
||||
if service.Builder == "static" && service.StaticDir == "" {
|
||||
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{
|
||||
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
||||
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
||||
@@ -409,6 +607,39 @@ func handleCreateService(c *gin.Context) {
|
||||
|
||||
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 {
|
||||
service.Spread = *req.Spread
|
||||
}
|
||||
service.PlacementTags = normalizePlacementTags(req.PlacementTags)
|
||||
if req.NodeID != "" {
|
||||
if service.Spread && req.NodeID != "auto" && req.NodeID != "local" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "spread and an explicit node pin are mutually exclusive", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
if len(service.PlacementTags) > 0 && req.NodeID != "auto" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "placement tags only apply to remote placement — set node_id=\"auto\" or omit it", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
nodeID, nodeErr := resolveNodePin(c.Request.Context(), db.(*database.DB), req.NodeID, service.PlacementTags)
|
||||
if nodeErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": nodeErr.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
if !service.Spread {
|
||||
service.NodeID = nodeID
|
||||
}
|
||||
}
|
||||
|
||||
// Insert service into database
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO services
|
||||
@@ -417,16 +648,24 @@ func handleCreateService(c *gin.Context) {
|
||||
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||
healthcheck_path, restart_policy, volumes,
|
||||
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
||||
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,
|
||||
$27, $28, $29, $30, $31, $32, $33)`,
|
||||
$27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39)`,
|
||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
||||
volumesJSON, service.Builder, service.CPUReserve, service.MemoryReserve,
|
||||
service.StaticBuildCmd, service.StaticDir, service.CreatedAt, service.UpdatedAt,
|
||||
service.StaticBuildCmd, service.StaticDir,
|
||||
sql.NullString{String: service.NodeID, Valid: service.NodeID != ""},
|
||||
service.Spread,
|
||||
tagsJSON(service.PlacementTags),
|
||||
service.SleepEnabled,
|
||||
sql.NullInt32{Int32: int32(service.SleepIdleMinutes), Valid: service.SleepIdleMinutes > 0},
|
||||
traefikLabelsJSON(service.TraefikLabels),
|
||||
service.CreatedAt, service.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -456,7 +695,9 @@ func handleCreateService(c *gin.Context) {
|
||||
|
||||
LogAuditWithRequest(c, "service", service.ID.String(), "create", map[string]interface{}{
|
||||
"name": service.Name, "type": service.Type, "project_id": service.ProjectID,
|
||||
"node_id": service.NodeID,
|
||||
})
|
||||
loadServiceNode(db.(*database.DB), &service)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"service": service})
|
||||
}
|
||||
@@ -547,6 +788,7 @@ func handleGetService(c *gin.Context) {
|
||||
}
|
||||
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
|
||||
loadServiceSleep(db.(*database.DB), &service)
|
||||
loadServiceNode(db.(*database.DB), &service)
|
||||
maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
|
||||
service.MaintenanceMode = maintenance
|
||||
service.BasicAuth = basicAuthUsernames(basicAuth)
|
||||
@@ -699,6 +941,16 @@ func handleUpdateService(c *gin.Context) {
|
||||
}
|
||||
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{
|
||||
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
||||
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
||||
@@ -725,6 +977,44 @@ func handleUpdateService(c *gin.Context) {
|
||||
volumesArg = volumesJSON
|
||||
}
|
||||
|
||||
if req.Spread != nil {
|
||||
existingService.Spread = *req.Spread
|
||||
} else {
|
||||
existingService.Spread = serviceSpread(db.(*database.DB), serviceID)
|
||||
}
|
||||
if req.PlacementTags != nil {
|
||||
existingService.PlacementTags = normalizePlacementTags(*req.PlacementTags)
|
||||
} else {
|
||||
existingService.PlacementTags = servicePlacementTags(db.(*database.DB), serviceID)
|
||||
}
|
||||
if req.NodeID != nil {
|
||||
nodeValue := strings.TrimSpace(*req.NodeID)
|
||||
if existingService.Spread && nodeValue != "" && nodeValue != "auto" && nodeValue != "local" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "spread and an explicit node pin are mutually exclusive — set spread=false first", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
if len(existingService.PlacementTags) > 0 && nodeValue != "" && nodeValue != "auto" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "service has placement tags — clear them (placement_tags=[]) before pinning or going local", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
nodeID, nodeErr := resolveNodePin(c.Request.Context(), db.(*database.DB), nodeValue, existingService.PlacementTags)
|
||||
if nodeErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": nodeErr.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
existingService.NodeID = nodeID
|
||||
} else {
|
||||
existingService.NodeID = serviceNodeID(db.(*database.DB), serviceID)
|
||||
}
|
||||
if existingService.Spread && existingService.NodeID != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "service already has an explicit node pin — clear it (node_id=\"local\") before enabling spread", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
if len(existingService.PlacementTags) > 0 && existingService.NodeID != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "service already has an explicit node pin — clear it (node_id=\"local\") before setting placement tags", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
|
||||
existingService.UpdatedAt = time.Now()
|
||||
|
||||
// Update service in database
|
||||
@@ -736,7 +1026,8 @@ func handleUpdateService(c *gin.Context) {
|
||||
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
|
||||
builder = $19, cpu_reserve = $20, memory_reserve = $21,
|
||||
static_build_cmd = $22, static_dir = $23,
|
||||
sleep_enabled = $24, sleep_idle_minutes = $25
|
||||
sleep_enabled = $24, sleep_idle_minutes = $25, node_id = $26, spread = $27,
|
||||
placement_tags = $28::jsonb, traefik_labels = $29::jsonb
|
||||
WHERE id = $18`,
|
||||
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
||||
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
||||
@@ -747,6 +1038,10 @@ func handleUpdateService(c *gin.Context) {
|
||||
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
|
||||
existingService.StaticBuildCmd, existingService.StaticDir,
|
||||
existingService.SleepEnabled, existingService.SleepIdleMinutes,
|
||||
sql.NullString{String: existingService.NodeID, Valid: existingService.NodeID != ""},
|
||||
existingService.Spread,
|
||||
tagsJSON(existingService.PlacementTags),
|
||||
traefikLabelsJSON(existingService.TraefikLabels),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -816,6 +1111,7 @@ func handleUpdateService(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
loadServiceNode(db.(*database.DB), &existingService)
|
||||
c.JSON(http.StatusOK, gin.H{"service": existingService})
|
||||
}
|
||||
|
||||
@@ -865,7 +1161,7 @@ func handleDeleteService(c *gin.Context) {
|
||||
// Remove runtime containers before dropping the row.
|
||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
||||
_ = engine.RemoveServiceContainers(c.Request.Context(), serviceID.String())
|
||||
removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, serviceID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/docker"
|
||||
|
||||
@@ -100,7 +101,20 @@ func sweepSleeping(ctx context.Context, db *database.DB, dc *docker.Client, engi
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
q := sqlcdb.New(db.DB)
|
||||
for _, svc := range candidates {
|
||||
// Remote replicas live in inventory, not the local docker host —
|
||||
// idleness comes from heartbeat-reported net counters instead.
|
||||
if remote := remoteLiveContainers(ctx, q, svc.ID.String()); len(remote) > 0 {
|
||||
var total uint64
|
||||
for _, row := range remote {
|
||||
total += rowNetBytes(row)
|
||||
}
|
||||
if markIdle(svc.ID, total, time.Duration(svc.IdleMinutes)*time.Minute) {
|
||||
putToSleepRemote(ctx, db, q, svc, apiPort)
|
||||
}
|
||||
continue
|
||||
}
|
||||
containers, err := engine.ListServiceContainers(ctx, svc.ID.String())
|
||||
if err != nil || len(containers) == 0 {
|
||||
continue
|
||||
@@ -125,6 +139,65 @@ func sweepSleeping(ctx context.Context, db *database.DB, dc *docker.Client, engi
|
||||
}
|
||||
}
|
||||
|
||||
// remoteLiveContainers returns inventory rows whose replicas are alive on
|
||||
// node agents — the signal that idleness must be measured remotely.
|
||||
func remoteLiveContainers(ctx context.Context, q *sqlcdb.Queries, serviceID string) []sqlcdb.ListServiceContainersRow {
|
||||
rows, err := q.ListServiceContainers(ctx, serviceID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
live := rows[:0]
|
||||
for _, row := range rows {
|
||||
var st struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(row.Status.RawMessage, &st); err == nil &&
|
||||
st.State != "" && st.State != "removed" && st.State != "sleeping" {
|
||||
live = append(live, row)
|
||||
}
|
||||
}
|
||||
return live
|
||||
}
|
||||
|
||||
// rowNetBytes reads the cumulative rx+tx the agent reported at its last
|
||||
// heartbeat — 0 for rows written before the counter existed.
|
||||
func rowNetBytes(row sqlcdb.ListServiceContainersRow) uint64 {
|
||||
var res struct {
|
||||
NetBytes uint64 `json:"net_bytes"`
|
||||
}
|
||||
_ = json.Unmarshal(row.Resources.RawMessage, &res)
|
||||
return res.NetBytes
|
||||
}
|
||||
|
||||
// putToSleepRemote removes the service's remote replicas via their agents,
|
||||
// marks the service sleeping, and repoints the domain route at the wake
|
||||
// endpoint so inbound traffic brings it back.
|
||||
func putToSleepRemote(ctx context.Context, db *database.DB, q *sqlcdb.Queries, svc sleepCandidate, apiPort int) {
|
||||
runner := newAgentNodeRunner(db)
|
||||
for _, agentID := range remoteAgentsForService(ctx, db, svc.ID) {
|
||||
if err := runner.ControlService(ctx, svc.ID.String(), agentID, "remove"); err != nil {
|
||||
log.Printf("sleep: remote remove on %s failed: %v", agentID, err)
|
||||
continue
|
||||
}
|
||||
_ = q.DeleteServiceContainersOnAgent(ctx, sqlcdb.DeleteServiceContainersOnAgentParams{
|
||||
NodeAgentID: agentID,
|
||||
ServiceID: svc.ID.String(),
|
||||
})
|
||||
}
|
||||
tracker.mu.Lock()
|
||||
delete(tracker.activity, svc.ID)
|
||||
tracker.mu.Unlock()
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'sleeping', updated_at = $1 WHERE id = $2`, time.Now(), svc.ID)
|
||||
log.Printf("sleep: %s (%s) remote replicas removed after idle timeout", svc.Name, svc.ID)
|
||||
|
||||
var domain string
|
||||
_ = db.QueryRow(`SELECT COALESCE(domain,'') FROM services WHERE id = $1`, svc.ID).Scan(&domain)
|
||||
domains := serviceDomainNames(db, svc.ID, domain)
|
||||
if len(domains) > 0 {
|
||||
writeWakeIngress(svc.ID.String(), domains, apiPort)
|
||||
}
|
||||
}
|
||||
|
||||
// markIdle returns true when the network counter stopped growing past the
|
||||
// timeout. First sighting only sets the baseline — no idle without history.
|
||||
func markIdle(id uuid.UUID, bytes uint64, timeout time.Duration) bool {
|
||||
@@ -191,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
|
||||
// host rule at higher priority. On boot it calls the wake endpoint, then
|
||||
// keeps serving a reloading page until the next reconcile removes it.
|
||||
// host rule at higher priority. Inbound hits redirect to /cgi-bin/wake,
|
||||
// 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 {
|
||||
edgeNet := ""
|
||||
for _, n := range networkList(ctx, dc) {
|
||||
@@ -223,19 +307,25 @@ func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client,
|
||||
"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>` +
|
||||
`<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>` +
|
||||
`<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
|
||||
// httpd, fire the wake callback, then hold PID1 on sleep infinity.
|
||||
// The wake callback must fire when a request arrives, not at boot —
|
||||
// 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(
|
||||
`mkdir -p /www && printf '%%s' '%s' > /www/index.html && `+
|
||||
`httpd -p 80 -h /www && `+
|
||||
`(sleep 2; wget -q -O /dev/null --timeout=20 '%s' 2>/dev/null || true) && `+
|
||||
`sleep infinity`,
|
||||
strings.ReplaceAll(page, "'", "'\\''"), wakeURL)
|
||||
`mkdir -p /www/cgi-bin && printf '%%s' '%s' > /www/index.html && `+
|
||||
`printf '%%s' '%s' > /www/cgi-bin/wake && chmod +x /www/cgi-bin/wake && `+
|
||||
`printf '%%s\n' 'E404:/cgi-bin/wake' > /www/httpd.conf && `+
|
||||
`httpd -p 80 -h /www && sleep infinity`,
|
||||
strings.ReplaceAll(indexPage, "'", "'\\''"),
|
||||
strings.ReplaceAll(wakeCGIScript, "'", "'\\''"))
|
||||
|
||||
endpoints := map[string]*network.EndpointSettings{edgeNet: {}}
|
||||
if ownNet := dc.OwnNetworkName(ctx); ownNet != "" && ownNet != edgeNet {
|
||||
@@ -256,9 +346,13 @@ func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client,
|
||||
}
|
||||
|
||||
id, err := dc.CreateContainer(ctx, docker.ContainerConfig{
|
||||
Name: name,
|
||||
Image: wakeImage,
|
||||
Cmd: []string{"sh", "-c", cmd},
|
||||
Name: name,
|
||||
Image: wakeImage,
|
||||
Cmd: []string{"sh", "-c", cmd},
|
||||
Env: []string{
|
||||
"WAKE_URLS=" + strings.Join(wakeURLs, " "),
|
||||
"WAKE_PAGE=" + page,
|
||||
},
|
||||
Labels: labels,
|
||||
Networks: endpoints,
|
||||
})
|
||||
@@ -280,25 +374,41 @@ func networkList(ctx context.Context, dc *docker.Client) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// wakeEndpointURL builds the internal URL the wake container calls. The
|
||||
// edge network's gateway reaches the host's published API port; when the
|
||||
// API itself is containerized its own name resolves on the shared network.
|
||||
func wakeEndpointURL(ctx context.Context, dc *docker.Client, apiPort int, serviceID uuid.UUID, edgeNet string) string {
|
||||
// wakeEndpointURLs builds ordered candidate URLs for the wake container to
|
||||
// call, deduplicated. No single address covers every topology: a containerized
|
||||
// API resolves by name on the shared network; Docker Desktop containers reach
|
||||
// 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 self, err := dc.GetContainer(ctx, selfID()); err == nil {
|
||||
name := strings.TrimPrefix(self.Name, "/")
|
||||
if name != "" {
|
||||
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", name, apiPort, serviceID)
|
||||
if name := strings.TrimPrefix(self.Name, "/"); name != "" {
|
||||
hosts = append(hosts, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
hosts = append(hosts, "host.docker.internal")
|
||||
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 != "" {
|
||||
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 {
|
||||
@@ -346,18 +456,40 @@ func pruneWakeContainers(ctx context.Context, db *database.DB, dc *docker.Client
|
||||
// idempotent and non-destructive, so no token is required; the endpoint
|
||||
// never mutates configuration.
|
||||
func handleServiceWake(c *gin.Context) {
|
||||
code, body := triggerServiceWake(c)
|
||||
if code != 0 {
|
||||
c.JSON(code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// handleServiceWakePage is the remote ingress wake target — Traefik routes
|
||||
// a sleeping remote service's domain here via the file provider. Every hit
|
||||
// (re)triggers the wake reconcile and returns a reloading page.
|
||||
func handleServiceWakePage(c *gin.Context) {
|
||||
triggerServiceWake(c)
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, wakePageHTML)
|
||||
}
|
||||
|
||||
const wakePageHTML = `<!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}` +
|
||||
`.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>`
|
||||
|
||||
// triggerServiceWake loads the service and reconciles it back to running
|
||||
// in the background. Returns (0, nil) when a response was already written
|
||||
// by an embedded handler path, else the JSON status to send.
|
||||
func triggerServiceWake(c *gin.Context) (int, gin.H) {
|
||||
dbVal, _ := c.Get("db")
|
||||
db := dbVal.(*database.DB)
|
||||
engineVal, _ := c.Get("deployment_engine")
|
||||
engine, _ := engineVal.(*deployment.DeploymentEngine)
|
||||
if engine == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "deployment engine unavailable"})
|
||||
return
|
||||
return http.StatusServiceUnavailable, gin.H{"error": "deployment engine unavailable"}
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid service id"})
|
||||
return
|
||||
return http.StatusBadRequest, gin.H{"error": "invalid service id"}
|
||||
}
|
||||
var service Service
|
||||
err = db.QueryRow(
|
||||
@@ -385,22 +517,23 @@ func handleServiceWake(c *gin.Context) {
|
||||
&service.CreatedAt, &service.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "service not found"})
|
||||
return
|
||||
return http.StatusNotFound, gin.H{"error": "service not found"}
|
||||
}
|
||||
if service.Status == "running" || service.Status == "deployed" {
|
||||
c.JSON(http.StatusOK, gin.H{"status": service.Status})
|
||||
return
|
||||
return http.StatusOK, gin.H{"status": service.Status}
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
spec, err := serviceRuntimeSpec(db, service)
|
||||
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
|
||||
}
|
||||
state, err := engine.ReconcileService(ctx, spec)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -411,7 +544,7 @@ func handleServiceWake(c *gin.Context) {
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
|
||||
}()
|
||||
c.JSON(http.StatusAccepted, gin.H{"status": "waking"})
|
||||
return http.StatusAccepted, gin.H{"status": "waking"}
|
||||
}
|
||||
|
||||
// handleServiceSleep puts a service to sleep immediately (owner+ only).
|
||||
@@ -438,7 +571,12 @@ func handleServiceSleep(c *gin.Context) {
|
||||
Name: service.Name,
|
||||
Status: service.Status,
|
||||
}
|
||||
go putToSleep(context.Background(), db, dc, engine, svc, apiPortFromContext(c))
|
||||
q := sqlcdb.New(db.DB)
|
||||
if len(remoteLiveContainers(c.Request.Context(), q, service.ID.String())) > 0 {
|
||||
go putToSleepRemote(context.Background(), db, q, svc, apiPortFromContext(c))
|
||||
} else {
|
||||
go putToSleep(context.Background(), db, dc, engine, svc, apiPortFromContext(c))
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"status": "sleeping"})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"log"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -22,6 +24,7 @@ import (
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
"containr/internal/secrets"
|
||||
"containr/internal/source"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -227,7 +230,9 @@ func resolveStaticExpr(value string, userVars map[string]string, unresolved *[]s
|
||||
if val, ok := userVars[key]; ok {
|
||||
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]
|
||||
}
|
||||
if unresolved != nil {
|
||||
@@ -637,7 +642,7 @@ func deployTemplateGraph(
|
||||
cleanup := func() {
|
||||
for _, sid := range createdServiceIDs {
|
||||
if engine != nil {
|
||||
_ = engine.RemoveServiceContainers(context.Background(), sid.String())
|
||||
removeServiceRuntime(context.Background(), db, engine, sid)
|
||||
}
|
||||
_, _ = db.Exec(`DELETE FROM environment_variables WHERE service_id = $1`, sid)
|
||||
_, _ = db.Exec(`DELETE FROM services WHERE id = $1`, sid)
|
||||
@@ -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.
|
||||
// Compose service names become keys; declaration order follows YAML document
|
||||
// order — yaml.v3 maps lose order, so sort keys for stability.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -124,3 +126,42 @@ func TestComposeToSpecsWarnings(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1102,6 +1102,98 @@ func SeedTemplates() []ServiceTemplate {
|
||||
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeTemplateServiceType(t *testing.T) {
|
||||
valid := []string{"web", "worker", "database", "cron"}
|
||||
@@ -71,3 +74,32 @@ func TestResolveTemplateRuntimeImage(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -539,12 +539,16 @@ func createBuildContext(sourcePath, dockerfileContent string) (io.ReadCloser, er
|
||||
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 {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fi.IsDir() {
|
||||
if fi.Name() == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package build
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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
|
||||
func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
|
||||
if req.PrebuiltImage == "" {
|
||||
|
||||
@@ -476,17 +476,22 @@ func (n *NixpacksBuilder) Build(ctx context.Context, req *types.BuildRequest) (*
|
||||
}
|
||||
|
||||
// Create temporary Dockerfile
|
||||
dockerfile, err := n.generateDockerfile(plan)
|
||||
dockerfilePath, err := n.generateDockerfile(plan)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate Dockerfile: %w", err)
|
||||
}
|
||||
defer os.Remove(dockerfile)
|
||||
defer os.Remove(dockerfilePath)
|
||||
|
||||
// Build Docker image
|
||||
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
||||
|
||||
// Create build context tar
|
||||
buildCtx, err := createBuildContext(req.SourcePath, dockerfile)
|
||||
// Create build context tar — createBuildContext takes the 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 {
|
||||
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)
|
||||
}
|
||||
@@ -4,9 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -44,20 +42,13 @@ var ShellCmd = &cobra.Command{
|
||||
if err == nil {
|
||||
defer term.Restore(fd, oldState)
|
||||
}
|
||||
if w, h, err := term.GetSize(fd); err == nil {
|
||||
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
|
||||
}
|
||||
// Forward SIGWINCH resizes.
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGWINCH)
|
||||
defer signal.Stop(sigCh)
|
||||
go func() {
|
||||
for range sigCh {
|
||||
if w, h, err := term.GetSize(fd); err == nil {
|
||||
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
|
||||
}
|
||||
sendSize := func() {
|
||||
if w, h, err := term.GetSize(fd); err == nil {
|
||||
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
|
||||
}
|
||||
}()
|
||||
}
|
||||
sendSize()
|
||||
defer watchResize(sendSize)()
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build !windows
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// watchResize forwards SIGWINCH events to send. Returns a stop func.
|
||||
func watchResize(send func()) func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGWINCH)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-sigCh:
|
||||
send()
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
signal.Stop(sigCh)
|
||||
close(done)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build windows
|
||||
|
||||
package commands
|
||||
|
||||
// watchResize is a no-op on Windows — console resize events don't map to a
|
||||
// signal. The initial size is still sent, and Windows Terminal resize is rare
|
||||
// enough to accept stale dimensions for now.
|
||||
func watchResize(send func()) func() { return func() {} }
|
||||
@@ -0,0 +1,374 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const railwayAPI = "https://backboard.railway.com/graphql/v2"
|
||||
|
||||
// railwaySource mirrors the Railway serviceInstance.source union.
|
||||
type railwaySource struct {
|
||||
Repo string `json:"repo"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
type railwayServicePlan struct {
|
||||
ID string
|
||||
Name string
|
||||
Source railwaySource
|
||||
Command string
|
||||
Replicas int
|
||||
Domains []string
|
||||
Variables map[string]string
|
||||
}
|
||||
|
||||
// MigrateCmd imports workloads from other platforms.
|
||||
var MigrateCmd = &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Import workloads from other platforms",
|
||||
}
|
||||
|
||||
var migrateRailwayCmd = &cobra.Command{
|
||||
Use: "railway",
|
||||
Short: "Migrate a Railway project into Containr",
|
||||
Long: `Pulls a Railway project's services, variables, and domains via the
|
||||
public GraphQL API and recreates them in Containr.
|
||||
|
||||
containr migrate railway --token <railway-token> --project <id> --dry-run
|
||||
containr migrate railway --token <railway-token> --project <id> --into <project-id>
|
||||
|
||||
Token: account/workspace token (Authorization: Bearer) or a project token.
|
||||
--into defaults to creating a project named after the Railway project.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
token, _ := cmd.Flags().GetString("token")
|
||||
if token == "" {
|
||||
token = os.Getenv("RAILWAY_TOKEN")
|
||||
}
|
||||
if token == "" {
|
||||
return &APIError{Message: "pass --token or set RAILWAY_TOKEN", ExitCode: ExitError}
|
||||
}
|
||||
projectID, _ := cmd.Flags().GetString("project")
|
||||
envName, _ := cmd.Flags().GetString("environment")
|
||||
into, _ := cmd.Flags().GetString("into")
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
|
||||
projectName, envID, services, err := railwayProject(projectID, envName, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(services) == 0 {
|
||||
return &APIError{Message: "no services found in that Railway project/environment", ExitCode: ExitError}
|
||||
}
|
||||
|
||||
plans := make([]railwayServicePlan, 0, len(services))
|
||||
for _, svc := range services {
|
||||
plan, err := railwayServiceDetail(svc, envID, token)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: %v — skipping\n", svc["name"], err)
|
||||
continue
|
||||
}
|
||||
plans = append(plans, plan)
|
||||
}
|
||||
if len(plans) == 0 {
|
||||
return &APIError{Message: "no migratable services", ExitCode: ExitError}
|
||||
}
|
||||
|
||||
fmt.Printf("Railway project %q → %d service(s)\n", projectName, len(plans))
|
||||
for _, p := range plans {
|
||||
src := p.Source.Image
|
||||
if src == "" {
|
||||
src = railwayRepoURL(p.Source.Repo)
|
||||
}
|
||||
fmt.Printf(" %s src=%s replicas=%d vars=%d domains=%d\n",
|
||||
p.Name, src, max(p.Replicas, 1), len(p.Variables), len(p.Domains))
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Println("\nDry run — pass without --dry-run to create these resources.")
|
||||
return nil
|
||||
}
|
||||
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target := into
|
||||
if target == "" {
|
||||
data, err := c.Do("POST", "/projects", map[string]interface{}{
|
||||
"name": projectName,
|
||||
"description": "Imported from Railway",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, _ := unwrapObject(data, "project")
|
||||
target = str(p, "id")
|
||||
if target == "" || target == "-" {
|
||||
return &APIError{Message: "project created but id missing from response", ExitCode: ExitError}
|
||||
}
|
||||
fmt.Printf("Created project %s (%s)\n", projectName, target)
|
||||
}
|
||||
|
||||
for _, p := range plans {
|
||||
body := map[string]interface{}{
|
||||
"name": p.Name,
|
||||
"type": "web",
|
||||
"environment": "production",
|
||||
}
|
||||
if p.Source.Image != "" {
|
||||
body["image"] = p.Source.Image
|
||||
} else if repo := railwayRepoURL(p.Source.Repo); repo != "" {
|
||||
body["git_repo"] = repo
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s has no source — skipping\n", p.Name)
|
||||
continue
|
||||
}
|
||||
if p.Command != "" {
|
||||
body["command"] = p.Command
|
||||
}
|
||||
if p.Replicas > 1 {
|
||||
body["replicas"] = p.Replicas
|
||||
}
|
||||
data, err := c.Do("POST", "/projects/"+target+"/services", body)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: create %s: %v\n", p.Name, err)
|
||||
continue
|
||||
}
|
||||
svc, _ := unwrapObject(data, "service")
|
||||
svcID := str(svc, "id")
|
||||
fmt.Printf(" created service %s (%s)\n", p.Name, svcID)
|
||||
|
||||
if len(p.Variables) > 0 && svcID != "" && svcID != "-" {
|
||||
vars := make([]map[string]interface{}, 0, len(p.Variables))
|
||||
for k, v := range p.Variables {
|
||||
vars = append(vars, map[string]interface{}{"key": k, "value": v, "is_secret": false})
|
||||
}
|
||||
if _, err := c.Do("PUT", "/services/"+svcID+"/variables",
|
||||
map[string]interface{}{"variables": vars}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " warning: variables for %s: %v\n", p.Name, err)
|
||||
}
|
||||
}
|
||||
for _, domain := range p.Domains {
|
||||
if svcID == "" || svcID == "-" {
|
||||
break
|
||||
}
|
||||
if _, err := c.Do("POST", "/services/"+svcID+"/domains",
|
||||
map[string]interface{}{"domain": domain}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " warning: domain %s: %v\n", domain, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println("Done. Railway deploy triggers are not migrated — redeploy each service when ready.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// railwayGraphQL posts a query; Railway returns 200 with an errors array on
|
||||
// partial failures — treat errors as fatal, the migration needs clean data.
|
||||
func railwayGraphQL(token, query string, variables map[string]interface{}) (map[string]interface{}, error) {
|
||||
body, _ := json.Marshal(map[string]interface{}{"query": query, "variables": variables})
|
||||
req, err := http.NewRequest(http.MethodPost, railwayAPI, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// Project tokens use a dedicated header; account/workspace tokens use Bearer.
|
||||
if strings.HasPrefix(token, "rw_pat") || strings.HasPrefix(token, "rw_pt") {
|
||||
req.Header.Set("Project-Access-Token", token)
|
||||
} else {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
var out struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("railway response not JSON (status %d)", resp.StatusCode)
|
||||
}
|
||||
if len(out.Errors) > 0 {
|
||||
return nil, fmt.Errorf("railway: %s", out.Errors[0].Message)
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("railway status %d", resp.StatusCode)
|
||||
}
|
||||
return out.Data, nil
|
||||
}
|
||||
|
||||
// railwayProject resolves the project (by id, or sole accessible project) and
|
||||
// returns its name, the target environment id, and its services.
|
||||
func railwayProject(projectID, envName, token string) (string, string, []map[string]interface{}, error) {
|
||||
if projectID == "" {
|
||||
data, err := railwayGraphQL(token,
|
||||
`query { projects(first: 100) { edges { node { id name } } } }`, nil)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
projects := edgesOf(data, "projects")
|
||||
if len(projects) == 1 {
|
||||
projectID = str(projects[0], "id")
|
||||
} else {
|
||||
for _, p := range projects {
|
||||
fmt.Fprintf(os.Stderr, " %s %s\n", str(p, "id"), str(p, "name"))
|
||||
}
|
||||
return "", "", nil, &APIError{
|
||||
Message: "multiple projects — pass --project <id> (listed above)",
|
||||
ExitCode: ExitError,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, err := railwayGraphQL(token, `query ($id: String!) {
|
||||
project(id: $id) {
|
||||
name
|
||||
environments { edges { node { id name } } }
|
||||
services { edges { node { id name } } }
|
||||
}
|
||||
}`, map[string]interface{}{"id": projectID})
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
project, _ := data["project"].(map[string]interface{})
|
||||
if project == nil {
|
||||
return "", "", nil, &APIError{Message: "railway project not found", ExitCode: ExitNotFound}
|
||||
}
|
||||
envs := edgesOf(project, "environments")
|
||||
envID := ""
|
||||
for _, e := range envs {
|
||||
if str(e, "name") == envName {
|
||||
envID = str(e, "id")
|
||||
break
|
||||
}
|
||||
}
|
||||
if envID == "" && len(envs) == 1 {
|
||||
envID = str(envs[0], "id")
|
||||
}
|
||||
if envID == "" {
|
||||
names := make([]string, 0, len(envs))
|
||||
for _, e := range envs {
|
||||
names = append(names, str(e, "name"))
|
||||
}
|
||||
return "", "", nil, &APIError{
|
||||
Message: fmt.Sprintf("environment %q not found (have: %s)", envName, strings.Join(names, ", ")),
|
||||
ExitCode: ExitError,
|
||||
}
|
||||
}
|
||||
return str(project, "name"), envID, edgesOf(project, "services"), nil
|
||||
}
|
||||
|
||||
// railwayServiceDetail pulls source, command, replicas, domains, and
|
||||
// variables for one service in the target environment.
|
||||
func railwayServiceDetail(svc map[string]interface{}, envID, token string) (railwayServicePlan, error) {
|
||||
plan := railwayServicePlan{
|
||||
ID: str(svc, "id"),
|
||||
Name: str(svc, "name"),
|
||||
}
|
||||
data, err := railwayGraphQL(token, `query ($sid: String!, $eid: String!) {
|
||||
serviceInstance(serviceId: $sid, environmentId: $eid) {
|
||||
startCommand
|
||||
numReplicas
|
||||
source { repo image }
|
||||
domains { serviceDomains { domain } customDomains { domain } }
|
||||
}
|
||||
}`, map[string]interface{}{"sid": plan.ID, "eid": envID})
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
inst, _ := data["serviceInstance"].(map[string]interface{})
|
||||
if inst == nil {
|
||||
return plan, fmt.Errorf("no service instance in this environment")
|
||||
}
|
||||
if src, ok := inst["source"].(map[string]interface{}); ok {
|
||||
plan.Source = railwaySource{Repo: str(src, "repo"), Image: str(src, "image")}
|
||||
}
|
||||
if cmd, ok := inst["startCommand"].(string); ok {
|
||||
plan.Command = cmd
|
||||
}
|
||||
if n, ok := inst["numReplicas"].(float64); ok {
|
||||
plan.Replicas = int(n)
|
||||
}
|
||||
if domains, ok := inst["domains"].(map[string]interface{}); ok {
|
||||
for _, group := range []string{"serviceDomains", "customDomains"} {
|
||||
if list, ok := domains[group].([]interface{}); ok {
|
||||
for _, d := range list {
|
||||
if m, ok := d.(map[string]interface{}); ok {
|
||||
if dom := str(m, "domain"); dom != "" && dom != "-" {
|
||||
plan.Domains = append(plan.Domains, dom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vdata, err := railwayGraphQL(token, `query ($sid: String!, $eid: String!) {
|
||||
variables(serviceId: $sid, environmentId: $eid)
|
||||
}`, map[string]interface{}{"sid": plan.ID, "eid": envID})
|
||||
if err == nil {
|
||||
if vars, ok := vdata["variables"].(map[string]interface{}); ok {
|
||||
plan.Variables = map[string]string{}
|
||||
for k, v := range vars {
|
||||
if s, ok := v.(string); ok {
|
||||
// Railway reference variables (${{…}}) resolve at deploy
|
||||
// time — carry them literally so they stay visible.
|
||||
plan.Variables[k] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// railwayRepoURL normalizes Railway's "owner/repo" to a cloneable https URL.
|
||||
func railwayRepoURL(repo string) string {
|
||||
repo = strings.TrimSpace(repo)
|
||||
if repo == "" || repo == "-" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(repo, "://") || strings.HasPrefix(repo, "git@") {
|
||||
return repo
|
||||
}
|
||||
return "https://github.com/" + repo
|
||||
}
|
||||
|
||||
func edgesOf(obj map[string]interface{}, key string) []map[string]interface{} {
|
||||
conn, _ := obj[key].(map[string]interface{})
|
||||
edges, _ := conn["edges"].([]interface{})
|
||||
out := make([]map[string]interface{}, 0, len(edges))
|
||||
for _, e := range edges {
|
||||
if m, ok := e.(map[string]interface{}); ok {
|
||||
if node, ok := m["node"].(map[string]interface{}); ok {
|
||||
out = append(out, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrateRailwayCmd.Flags().String("token", "", "Railway API token (or RAILWAY_TOKEN env)")
|
||||
migrateRailwayCmd.Flags().String("project", "", "Railway project id (lists yours when omitted and >1 exists)")
|
||||
migrateRailwayCmd.Flags().String("environment", "production", "Railway environment name")
|
||||
migrateRailwayCmd.Flags().String("into", "", "existing Containr project id (default: create one)")
|
||||
migrateRailwayCmd.Flags().Bool("dry-run", false, "print the plan without creating anything")
|
||||
MigrateCmd.AddCommand(migrateRailwayCmd)
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// dflow runs on Payload CMS — every collection is a plain REST resource under
|
||||
// /api/<slug> returning {docs, page, totalPages, hasNextPage}. Auth is a
|
||||
// Payload API key on the users collection: "Authorization: users API-Key <k>".
|
||||
|
||||
type dflowServicePlan struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
Type string // web | database
|
||||
Image string
|
||||
GitRepo string
|
||||
GitBranch string
|
||||
BuildPath string
|
||||
Port int
|
||||
Variables map[string]string
|
||||
Domains []string
|
||||
Volumes []map[string]string
|
||||
}
|
||||
|
||||
var migrateDflowCmd = &cobra.Command{
|
||||
Use: "dflow",
|
||||
Short: "Migrate a dflow (Payload CMS) project into Containr",
|
||||
Long: `Pulls a dflow project's services, variables, volumes, and domains via the
|
||||
Payload REST API and recreates them in Containr.
|
||||
|
||||
containr migrate dflow --url https://dflow.example.com --api-key <key> --dry-run
|
||||
containr migrate dflow --url https://dflow.example.com --api-key <key> --project <id>
|
||||
|
||||
Auth: a Payload API key from a dflow admin user (users API-Key). Encrypted
|
||||
fields (variables, git tokens) only come back decrypted if the API key belongs
|
||||
to a user with access — empty values are flagged so nothing is silently lost.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
base, _ := cmd.Flags().GetString("url")
|
||||
if base == "" {
|
||||
base = os.Getenv("DFLOW_URL")
|
||||
}
|
||||
if base == "" {
|
||||
return &APIError{Message: "pass --url or set DFLOW_URL", ExitCode: ExitError}
|
||||
}
|
||||
base = strings.TrimSuffix(base, "/")
|
||||
key, _ := cmd.Flags().GetString("api-key")
|
||||
if key == "" {
|
||||
key = os.Getenv("DFLOW_API_KEY")
|
||||
}
|
||||
if key == "" {
|
||||
return &APIError{Message: "pass --api-key or set DFLOW_API_KEY", ExitCode: ExitError}
|
||||
}
|
||||
projectID, _ := cmd.Flags().GetString("project")
|
||||
into, _ := cmd.Flags().GetString("into")
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
|
||||
projectName, services, err := dflowProject(base, key, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(services) == 0 {
|
||||
return &APIError{Message: "no services found in that dflow project", ExitCode: ExitError}
|
||||
}
|
||||
|
||||
plans := make([]dflowServicePlan, 0, len(services))
|
||||
for _, svc := range services {
|
||||
plan, warn := dflowServicePlanOf(svc)
|
||||
for _, w := range warn {
|
||||
fmt.Fprintf(os.Stderr, " warning: %s: %s\n", plan.Name, w)
|
||||
}
|
||||
plans = append(plans, plan)
|
||||
}
|
||||
|
||||
fmt.Printf("dflow project %q → %d service(s)\n", projectName, len(plans))
|
||||
for _, p := range plans {
|
||||
src := p.Image
|
||||
if src == "" {
|
||||
src = p.GitRepo
|
||||
}
|
||||
fmt.Printf(" %s type=%s src=%s vars=%d domains=%d volumes=%d\n",
|
||||
p.Name, p.Type, src, len(p.Variables), len(p.Domains), len(p.Volumes))
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Println("\nDry run — pass without --dry-run to create these resources.")
|
||||
return nil
|
||||
}
|
||||
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target := into
|
||||
if target == "" {
|
||||
data, err := c.Do("POST", "/projects", map[string]interface{}{
|
||||
"name": projectName,
|
||||
"description": "Imported from dflow",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, _ := unwrapObject(data, "project")
|
||||
target = str(p, "id")
|
||||
if target == "" || target == "-" {
|
||||
return &APIError{Message: "project created but id missing from response", ExitCode: ExitError}
|
||||
}
|
||||
fmt.Printf("Created project %s (%s)\n", projectName, target)
|
||||
}
|
||||
|
||||
for _, p := range plans {
|
||||
body := map[string]interface{}{
|
||||
"name": p.Name,
|
||||
"type": p.Type,
|
||||
"environment": "production",
|
||||
}
|
||||
if p.Image != "" {
|
||||
body["image"] = p.Image
|
||||
}
|
||||
if p.GitRepo != "" {
|
||||
body["git_repo"] = p.GitRepo
|
||||
}
|
||||
if p.GitBranch != "" {
|
||||
body["git_branch"] = p.GitBranch
|
||||
}
|
||||
if p.BuildPath != "" {
|
||||
body["build_path"] = p.BuildPath
|
||||
}
|
||||
if p.Port > 0 {
|
||||
body["port"] = p.Port
|
||||
}
|
||||
if len(p.Volumes) > 0 {
|
||||
vols := make([]map[string]interface{}, 0, len(p.Volumes))
|
||||
for _, v := range p.Volumes {
|
||||
vols = append(vols, map[string]interface{}{
|
||||
"type": "bind", "source": v["source"], "target": v["target"],
|
||||
})
|
||||
}
|
||||
body["volumes"] = vols
|
||||
}
|
||||
data, err := c.Do("POST", "/projects/"+target+"/services", body)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: create %s: %v\n", p.Name, err)
|
||||
continue
|
||||
}
|
||||
svc, _ := unwrapObject(data, "service")
|
||||
svcID := str(svc, "id")
|
||||
fmt.Printf(" created service %s (%s)\n", p.Name, svcID)
|
||||
|
||||
if len(p.Variables) > 0 && svcID != "" && svcID != "-" {
|
||||
vars := make([]map[string]interface{}, 0, len(p.Variables))
|
||||
for k, v := range p.Variables {
|
||||
vars = append(vars, map[string]interface{}{"key": k, "value": v, "is_secret": false})
|
||||
}
|
||||
if _, err := c.Do("PUT", "/services/"+svcID+"/variables",
|
||||
map[string]interface{}{"variables": vars}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " warning: variables for %s: %v\n", p.Name, err)
|
||||
}
|
||||
}
|
||||
for _, domain := range p.Domains {
|
||||
if svcID == "" || svcID == "-" {
|
||||
break
|
||||
}
|
||||
if _, err := c.Do("POST", "/services/"+svcID+"/domains",
|
||||
map[string]interface{}{"domain": domain}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " warning: domain %s: %v\n", domain, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println("Done. Git credentials, deployment history, and server topology are not migrated — reconnect repos and redeploy each service when ready.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// dflowGet performs an authenticated Payload REST call.
|
||||
func dflowGet(base, apiKey, path string, q url.Values) (map[string]interface{}, error) {
|
||||
u := base + "/api/" + path
|
||||
if len(q) > 0 {
|
||||
u += "?" + q.Encode()
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "users API-Key "+apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return nil, &APIError{Message: "dflow rejected the API key — needs an admin user's users API-Key", ExitCode: ExitAuth}
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("dflow response not JSON (status %d)", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("dflow status %d", resp.StatusCode)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// dflowCollection pages through a Payload collection.
|
||||
func dflowCollection(base, apiKey, collection string, q url.Values) ([]map[string]interface{}, error) {
|
||||
var docs []map[string]interface{}
|
||||
for page := 1; ; page++ {
|
||||
params := url.Values{}
|
||||
for k, v := range q {
|
||||
params[k] = v
|
||||
}
|
||||
params.Set("limit", "100")
|
||||
params.Set("depth", "0")
|
||||
params.Set("page", fmt.Sprint(page))
|
||||
data, err := dflowGet(base, apiKey, collection, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, _ := data["docs"].([]interface{})
|
||||
for _, d := range list {
|
||||
if m, ok := d.(map[string]interface{}); ok {
|
||||
docs = append(docs, m)
|
||||
}
|
||||
}
|
||||
next, _ := data["hasNextPage"].(bool)
|
||||
if !next {
|
||||
return docs, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dflowProject resolves the project (by id, or sole project) and returns its
|
||||
// name plus its services.
|
||||
func dflowProject(base, apiKey, projectID string) (string, []map[string]interface{}, error) {
|
||||
if projectID == "" {
|
||||
projects, err := dflowCollection(base, apiKey, "projects", nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(projects) == 1 {
|
||||
projectID = str(projects[0], "id")
|
||||
} else {
|
||||
for _, p := range projects {
|
||||
fmt.Fprintf(os.Stderr, " %s %s\n", str(p, "id"), str(p, "name"))
|
||||
}
|
||||
return "", nil, &APIError{
|
||||
Message: "multiple projects — pass --project <id> (listed above)",
|
||||
ExitCode: ExitError,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
projects, err := dflowCollection(base, apiKey, "projects",
|
||||
url.Values{"where[id][equals]": {projectID}})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(projects) == 0 {
|
||||
return "", nil, &APIError{Message: "dflow project not found", ExitCode: ExitNotFound}
|
||||
}
|
||||
name := str(projects[0], "name")
|
||||
|
||||
q := url.Values{
|
||||
"where[project][equals]": {projectID},
|
||||
"where[deletedAt][exists]": {"false"},
|
||||
"where[_status][not_equals]": {"draft"},
|
||||
}
|
||||
services, err := dflowCollection(base, apiKey, "services", q)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return name, services, nil
|
||||
}
|
||||
|
||||
// dflowServicePlanOf maps a Payload service doc onto a Containr create plan.
|
||||
func dflowServicePlanOf(svc map[string]interface{}) (dflowServicePlan, []string) {
|
||||
var warn []string
|
||||
plan := dflowServicePlan{
|
||||
ID: str(svc, "id"),
|
||||
Name: str(svc, "name"),
|
||||
Description: str(svc, "description"),
|
||||
Variables: map[string]string{},
|
||||
}
|
||||
switch str(svc, "type") {
|
||||
case "app":
|
||||
plan.Type = "web"
|
||||
settings := dflowProviderSettings(svc)
|
||||
plan.GitRepo = dflowRepoURL(str(svc, "providerType"), str(settings, "owner"), str(settings, "repository"))
|
||||
plan.GitBranch = str(settings, "branch")
|
||||
plan.BuildPath = str(settings, "buildPath")
|
||||
plan.Port = int(num(settings, "port"))
|
||||
if plan.GitRepo == "" {
|
||||
warn = append(warn, "no repository settings — importing with no source")
|
||||
}
|
||||
case "docker":
|
||||
plan.Type = "web"
|
||||
d, _ := svc["dockerDetails"].(map[string]interface{})
|
||||
plan.Image = normalizeDockerImage(str(d, "url"))
|
||||
for _, p := range listOf(d, "ports") {
|
||||
if plan.Port == 0 {
|
||||
plan.Port = int(num(p, "containerPort"))
|
||||
}
|
||||
}
|
||||
if plan.Image == "" {
|
||||
warn = append(warn, "no docker image url — importing with no source")
|
||||
}
|
||||
case "database":
|
||||
plan.Type = "database"
|
||||
d, _ := svc["databaseDetails"].(map[string]interface{})
|
||||
dbType := str(d, "type")
|
||||
version := str(d, "version")
|
||||
if dbType != "" {
|
||||
plan.Image = dbType + ":" + firstOf(version, "latest")
|
||||
}
|
||||
if str(d, "provider") == "external" {
|
||||
if conn := str(d, "connectionUrl"); conn != "" {
|
||||
plan.Variables["DATABASE_URL"] = conn
|
||||
} else if str(d, "host") != "" {
|
||||
warn = append(warn, "external database has host but no connectionUrl — set DATABASE_URL manually")
|
||||
}
|
||||
}
|
||||
default:
|
||||
plan.Type = "web"
|
||||
warn = append(warn, fmt.Sprintf("unknown type %q — imported as web", str(svc, "type")))
|
||||
}
|
||||
|
||||
for _, v := range listOf(svc, "variables") {
|
||||
k, val := str(v, "key"), str(v, "value")
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if val == "" {
|
||||
warn = append(warn, fmt.Sprintf("variable %s came back empty (encrypted field?)", k))
|
||||
}
|
||||
plan.Variables[k] = val
|
||||
}
|
||||
for _, d := range listOf(svc, "domains") {
|
||||
if dom := str(d, "domain"); dom != "" && dom != "-" {
|
||||
plan.Domains = append(plan.Domains, dom)
|
||||
}
|
||||
}
|
||||
for _, v := range listOf(svc, "volumes") {
|
||||
host, container := str(v, "hostPath"), str(v, "containerPath")
|
||||
if host != "" && container != "" {
|
||||
plan.Volumes = append(plan.Volumes, map[string]string{"source": host, "target": container})
|
||||
}
|
||||
}
|
||||
return plan, warn
|
||||
}
|
||||
|
||||
// dflowProviderSettings picks the populated <provider>Settings group.
|
||||
func dflowProviderSettings(svc map[string]interface{}) map[string]interface{} {
|
||||
for _, key := range []string{"githubSettings", "gitlabSettings", "bitbucketSettings", "giteaSettings", "azureSettings"} {
|
||||
if g, ok := svc[key].(map[string]interface{}); ok && str(g, "repository") != "" {
|
||||
return g
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dflowRepoURL builds a cloneable https URL for the known git providers.
|
||||
func dflowRepoURL(providerType, owner, repo string) string {
|
||||
repo = strings.TrimSuffix(strings.TrimSpace(repo), ".git")
|
||||
owner = strings.TrimSpace(owner)
|
||||
if repo == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(repo, "://") || strings.HasPrefix(repo, "git@") {
|
||||
return repo
|
||||
}
|
||||
switch providerType {
|
||||
case "gitlab":
|
||||
return "https://gitlab.com/" + owner + "/" + repo
|
||||
case "bitbucket":
|
||||
return "https://bitbucket.org/" + owner + "/" + repo
|
||||
case "azureDevOps":
|
||||
return "https://dev.azure.com/" + owner + "/_git/" + repo
|
||||
case "gitea":
|
||||
return "" // host unknown — user must reconnect the repo
|
||||
default:
|
||||
if owner == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://github.com/" + owner + "/" + repo
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeDockerImage fixes common dflow registry URL typos (ghrc://).
|
||||
func normalizeDockerImage(image string) string {
|
||||
image = strings.TrimSpace(image)
|
||||
if strings.HasPrefix(image, "ghrc://") {
|
||||
return "ghcr.io/" + strings.TrimPrefix(image, "ghrc://")
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
func listOf(m map[string]interface{}, key string) []map[string]interface{} {
|
||||
raw, _ := m[key].([]interface{})
|
||||
out := make([]map[string]interface{}, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
if v, ok := item.(map[string]interface{}); ok {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func num(m map[string]interface{}, key string) float64 {
|
||||
v, _ := m[key].(float64)
|
||||
return v
|
||||
}
|
||||
|
||||
func firstOf(v, fallback string) string {
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrateDflowCmd.Flags().String("url", "", "dflow base URL (or DFLOW_URL env)")
|
||||
migrateDflowCmd.Flags().String("api-key", "", "dflow admin API key (or DFLOW_API_KEY env)")
|
||||
migrateDflowCmd.Flags().String("project", "", "dflow project id (lists yours when omitted and >1 exists)")
|
||||
migrateDflowCmd.Flags().String("into", "", "existing Containr project id (default: create one)")
|
||||
migrateDflowCmd.Flags().Bool("dry-run", false, "print the plan without creating anything")
|
||||
MigrateCmd.AddCommand(migrateDflowCmd)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package commands
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRailwayRepoURL(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"owner/repo": "https://github.com/owner/repo",
|
||||
"https://gitlab.com/a/b": "https://gitlab.com/a/b",
|
||||
"[email protected]:a/b.git": "[email protected]:a/b.git",
|
||||
"": "",
|
||||
"-": "",
|
||||
" spaced/x ": "https://github.com/spaced/x",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := railwayRepoURL(in); got != want {
|
||||
t.Errorf("railwayRepoURL(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEdgesOf(t *testing.T) {
|
||||
obj := map[string]interface{}{
|
||||
"services": map[string]interface{}{
|
||||
"edges": []interface{}{
|
||||
map[string]interface{}{"node": map[string]interface{}{"id": "a", "name": "web"}},
|
||||
map[string]interface{}{"node": map[string]interface{}{"id": "b", "name": "api"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
nodes := edgesOf(obj, "services")
|
||||
if len(nodes) != 2 || str(nodes[0], "name") != "web" {
|
||||
t.Fatalf("edgesOf returned %+v", nodes)
|
||||
}
|
||||
if got := edgesOf(obj, "missing"); len(got) != 0 {
|
||||
t.Fatalf("edgesOf(missing) = %+v, want empty", got)
|
||||
}
|
||||
if got := edgesOf(map[string]interface{}{"services": nil}, "services"); len(got) != 0 {
|
||||
t.Fatalf("edgesOf(nil conn) = %+v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDflowServicePlanOf(t *testing.T) {
|
||||
appSvc := map[string]interface{}{
|
||||
"id": "s1",
|
||||
"name": "api",
|
||||
"type": "app",
|
||||
"providerType": "github",
|
||||
"githubSettings": map[string]interface{}{
|
||||
"owner": "acme", "repository": "api", "branch": "main",
|
||||
"buildPath": "/", "port": float64(8080),
|
||||
},
|
||||
"variables": []interface{}{
|
||||
map[string]interface{}{"key": "FOO", "value": "bar"},
|
||||
map[string]interface{}{"key": "EMPTY", "value": ""},
|
||||
},
|
||||
"domains": []interface{}{
|
||||
map[string]interface{}{"domain": "api.example.com"},
|
||||
},
|
||||
"volumes": []interface{}{
|
||||
map[string]interface{}{"hostPath": "/data", "containerPath": "/app/data"},
|
||||
},
|
||||
}
|
||||
plan, warn := dflowServicePlanOf(appSvc)
|
||||
if plan.Type != "web" || plan.GitRepo != "https://github.com/acme/api" || plan.Port != 8080 {
|
||||
t.Fatalf("app plan = %+v", plan)
|
||||
}
|
||||
if plan.Variables["FOO"] != "bar" || len(plan.Domains) != 1 || len(plan.Volumes) != 1 {
|
||||
t.Fatalf("plan detail = %+v", plan)
|
||||
}
|
||||
if len(warn) != 1 { // EMPTY value flagged
|
||||
t.Fatalf("warnings = %v", warn)
|
||||
}
|
||||
|
||||
dbSvc := map[string]interface{}{
|
||||
"id": "s2", "name": "pg", "type": "database",
|
||||
"databaseDetails": map[string]interface{}{
|
||||
"type": "postgres", "version": "16", "provider": "external",
|
||||
"connectionUrl": "postgres://u:p@h:5432/db",
|
||||
},
|
||||
}
|
||||
dplan, _ := dflowServicePlanOf(dbSvc)
|
||||
if dplan.Type != "database" || dplan.Image != "postgres:16" {
|
||||
t.Fatalf("db plan = %+v", dplan)
|
||||
}
|
||||
if dplan.Variables["DATABASE_URL"] != "postgres://u:p@h:5432/db" {
|
||||
t.Fatalf("db vars = %+v", dplan.Variables)
|
||||
}
|
||||
|
||||
dockerSvc := map[string]interface{}{
|
||||
"id": "s3", "name": "img", "type": "docker",
|
||||
"dockerDetails": map[string]interface{}{
|
||||
"url": "ghrc://acme/web:v1",
|
||||
"ports": []interface{}{
|
||||
map[string]interface{}{"hostPort": float64(80), "containerPort": float64(3000), "scheme": "http"},
|
||||
},
|
||||
},
|
||||
}
|
||||
iplan, _ := dflowServicePlanOf(dockerSvc)
|
||||
if iplan.Image != "ghcr.io/acme/web:v1" || iplan.Port != 3000 {
|
||||
t.Fatalf("docker plan = %+v", iplan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDflowRepoURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
provider, owner, repo, want string
|
||||
}{
|
||||
{"github", "acme", "api", "https://github.com/acme/api"},
|
||||
{"gitlab", "acme", "api", "https://gitlab.com/acme/api"},
|
||||
{"bitbucket", "acme", "api", "https://bitbucket.org/acme/api"},
|
||||
{"gitea", "acme", "api", ""},
|
||||
{"github", "acme", "api.git", "https://github.com/acme/api"},
|
||||
{"github", "", "api", ""},
|
||||
{"github", "acme", "", ""},
|
||||
{"github", "acme", "https://git.example.com/a/b", "https://git.example.com/a/b"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := dflowRepoURL(c.provider, c.owner, c.repo); got != c.want {
|
||||
t.Errorf("dflowRepoURL(%q,%q,%q) = %q, want %q", c.provider, c.owner, c.repo, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -81,6 +82,164 @@ var nodesDeleteCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var nodesUpdateCmd = &cobra.Command{
|
||||
Use: "update <id>",
|
||||
Short: "Update a node agent (--name, --auto-prune, --tags, --domain)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if cmd.Flags().Changed("name") {
|
||||
body["name"], _ = cmd.Flags().GetString("name")
|
||||
}
|
||||
if cmd.Flags().Changed("auto-prune") {
|
||||
body["auto_prune"], _ = cmd.Flags().GetBool("auto-prune")
|
||||
}
|
||||
if cmd.Flags().Changed("tags") {
|
||||
raw, _ := cmd.Flags().GetString("tags")
|
||||
tags := []string{}
|
||||
for _, t := range strings.Split(raw, ",") {
|
||||
if t = strings.TrimSpace(t); t != "" {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
}
|
||||
body["tags"] = tags
|
||||
}
|
||||
if cmd.Flags().Changed("domain") {
|
||||
body["default_domain"], _ = cmd.Flags().GetString("domain")
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return &APIError{Message: "nothing to update — pass --name, --auto-prune, --tags or --domain", ExitCode: ExitError}
|
||||
}
|
||||
data, err := c.Do("PUT", "/agents/"+args[0], body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nodesPruneCmd = &cobra.Command{
|
||||
Use: "prune <id>",
|
||||
Short: "Enqueue a bounded docker system prune on a node",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
volumes, _ := cmd.Flags().GetBool("volumes")
|
||||
if volumes && !Confirm("Prune INCLUDING volumes? This can delete database data.") {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
until, _ := cmd.Flags().GetString("until")
|
||||
data, err := c.Do("POST", "/agents/"+args[0]+"/prune",
|
||||
map[string]interface{}{"until": until, "volumes": volumes})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nodesCordonCmd = &cobra.Command{
|
||||
Use: "cordon <id>",
|
||||
Short: "Stop new placements on a node (running replicas stay)",
|
||||
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", "/agents/"+args[0]+"/cordon", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nodesUncordonCmd = &cobra.Command{
|
||||
Use: "uncordon <id>",
|
||||
Short: "Re-open a node for placement",
|
||||
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", "/agents/"+args[0]+"/uncordon", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nodesDrainCmd = &cobra.Command{
|
||||
Use: "drain <id>",
|
||||
Short: "Cordon a node, remove its service containers, unpin affected services",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Confirm(fmt.Sprintf("Drain node %s? Its service containers are removed and services unpinned (they redeploy locally).", args[0])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
data, err := c.Do("POST", "/agents/"+args[0]+"/drain", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nodesUpgradeCmd = &cobra.Command{
|
||||
Use: "upgrade <id>",
|
||||
Short: "Upgrade the node agent to the server's bundled binary",
|
||||
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", "/agents/"+args[0]+"/upgrade", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nodesCommandsCmd = &cobra.Command{
|
||||
Use: "commands <id>",
|
||||
Short: "List recent commands sent to a node agent",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/agents/"+args[0]+"/commands", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Agent tokens (cagt_) onboard new nodes.
|
||||
var nodeTokensCmd = &cobra.Command{
|
||||
Use: "tokens",
|
||||
@@ -145,6 +304,12 @@ var nodeTokensRevokeCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func init() {
|
||||
nodesUpdateCmd.Flags().String("name", "", "node display name")
|
||||
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("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().Bool("volumes", false, "also prune volumes (destructive — asks twice)")
|
||||
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
|
||||
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesDeleteCmd, nodeTokensCmd)
|
||||
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesUpdateCmd, nodesCordonCmd, nodesUncordonCmd, nodesDrainCmd, nodesUpgradeCmd, nodesPruneCmd, nodesCommandsCmd, nodesDeleteCmd, nodeTokensCmd)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package commands
|
||||
|
||||
// SSH node bootstrap — one SSH session installs the agent (binary +
|
||||
// systemd + enroll token), then SSH is never needed again. SSH keys
|
||||
// stay client-side; the server only sees the resulting agent.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/agent"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var (
|
||||
nodeSSHKey string
|
||||
nodeSSHName string
|
||||
nodeSSHPrint bool
|
||||
)
|
||||
|
||||
var nodesAddCmd = &cobra.Command{
|
||||
Use: "add <[user@]host[:port]>",
|
||||
Short: "Bootstrap a node agent over SSH (one session, then closed)",
|
||||
Long: `Installs the containr agent on a remote host:
|
||||
|
||||
1. mints a single-use enroll token via the API
|
||||
2. SSHes in (agent socket, --key, or default key files)
|
||||
3. runs the instance's install script remotely
|
||||
4. closes the session — the agent heartbeats outbound from then on
|
||||
|
||||
Use --print to skip SSH and emit the remote one-liner for manual runs.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Mint the enroll token first — SSH failures then don't leave
|
||||
// partial state on the remote.
|
||||
body := map[string]interface{}{"label": "bootstrap"}
|
||||
if nodeSSHName != "" {
|
||||
body["label"] = "bootstrap:" + nodeSSHName
|
||||
}
|
||||
tokenData, err := c.Do("POST", "/agent-tokens", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tokenObj, err := unwrapObject(tokenData, "token")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token := str(tokenObj, "token")
|
||||
if token == "" {
|
||||
return &APIError{Message: "enroll token missing from response", ExitCode: ExitError}
|
||||
}
|
||||
|
||||
// Agent bootstrap endpoints live at /api/agents (no /v1) — strip
|
||||
// the API version suffix off the client base.
|
||||
apiRoot := strings.TrimSuffix(c.BaseURL, "/api/v1")
|
||||
installCmd := fmt.Sprintf(
|
||||
`curl -fsSL %q | bash -s -- --url %q --token %q --name %q`,
|
||||
apiRoot+"/api/agents/install.sh", apiRoot, token, firstNonEmptyStr(nodeSSHName, nodeSSHTarget(args[0]).host),
|
||||
)
|
||||
|
||||
if nodeSSHPrint {
|
||||
fmt.Println(installCmd)
|
||||
return nil
|
||||
}
|
||||
|
||||
target := nodeSSHTarget(args[0])
|
||||
if err := sshBootstrap(target, installCmd); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("agent installing on %s — it should appear in `containr nodes list` within ~15s\n", target.host)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type sshTarget struct {
|
||||
user string
|
||||
host string
|
||||
port string
|
||||
}
|
||||
|
||||
func nodeSSHTarget(raw string) sshTarget {
|
||||
t := sshTarget{port: "22"}
|
||||
rest := raw
|
||||
if at := strings.LastIndex(rest, "@"); at >= 0 {
|
||||
t.user = rest[:at]
|
||||
rest = rest[at+1:]
|
||||
}
|
||||
if colon := strings.LastIndex(rest, ":"); colon >= 0 && !strings.Contains(rest, "]") {
|
||||
t.host, t.port = rest[:colon], rest[colon+1:]
|
||||
} else {
|
||||
t.host = rest
|
||||
}
|
||||
if t.user == "" {
|
||||
if u, err := user.Current(); err == nil {
|
||||
t.user = u.Username
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func sshAuthMethods(keyPath string) []ssh.AuthMethod {
|
||||
var methods []ssh.AuthMethod
|
||||
|
||||
// ssh-agent first — matches how people actually manage keys.
|
||||
if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
|
||||
if conn, err := net.Dial("unix", sock); err == nil {
|
||||
defer conn.Close()
|
||||
methods = append(methods, ssh.PublicKeysCallback(agent.NewClient(conn).Signers))
|
||||
}
|
||||
}
|
||||
|
||||
var keyFiles []string
|
||||
if keyPath != "" {
|
||||
keyFiles = []string{keyPath}
|
||||
} else if home, err := os.UserHomeDir(); err == nil {
|
||||
for _, name := range []string{"id_ed25519", "id_rsa", "id_ecdsa"} {
|
||||
p := filepath.Join(home, ".ssh", name)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
keyFiles = append(keyFiles, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range keyFiles {
|
||||
if signer, err := sshPrivateKey(p); err == nil {
|
||||
methods = append(methods, ssh.PublicKeys(signer))
|
||||
}
|
||||
}
|
||||
return methods
|
||||
}
|
||||
|
||||
func sshPrivateKey(path string) (ssh.Signer, error) {
|
||||
pem, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signer, err := ssh.ParsePrivateKey(pem)
|
||||
if err != nil {
|
||||
var missing *ssh.PassphraseMissingError
|
||||
if errors.As(err, &missing) {
|
||||
fmt.Fprintf(os.Stderr, "passphrase for %s: ", path)
|
||||
pass, readErr := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
return ssh.ParsePrivateKeyWithPassphrase(pem, pass)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
func sshBootstrap(t sshTarget, remoteCmd string) error {
|
||||
config := &ssh.ClientConfig{
|
||||
User: t.user,
|
||||
Auth: sshAuthMethods(nodeSSHKey),
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // jarvis: TOFU would be better; ssh_known_hosts parsing is the upgrade path
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
if len(config.Auth) == 0 {
|
||||
return &APIError{Message: "no SSH auth methods (ssh-agent empty, no usable keys)", ExitCode: ExitError}
|
||||
}
|
||||
|
||||
conn, err := ssh.Dial("tcp", net.JoinHostPort(t.host, t.port), config)
|
||||
if err != nil {
|
||||
return &APIError{Message: fmt.Sprintf("ssh connect: %v", err), ExitCode: ExitError}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
session, err := conn.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
session.Stdout = os.Stdout
|
||||
session.Stderr = os.Stderr
|
||||
|
||||
// Root shells run directly; otherwise escalate via sudo. NOPASSWD
|
||||
// sudo is assumed — a password prompt here means the operator should
|
||||
// use --print and run the command themselves.
|
||||
if t.user == "root" {
|
||||
return session.Run(remoteCmd)
|
||||
}
|
||||
return session.Run("sudo -n " + remoteCmd)
|
||||
}
|
||||
|
||||
func firstNonEmptyStr(values ...string) string {
|
||||
for _, v := range values {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
nodesAddCmd.Flags().StringVar(&nodeSSHKey, "key", "", "SSH private key path (defaults: agent, ~/.ssh/id_*)")
|
||||
nodesAddCmd.Flags().StringVar(&nodeSSHName, "name", "", "node display name (defaults to remote hostname)")
|
||||
nodesAddCmd.Flags().BoolVar(&nodeSSHPrint, "print", false, "print the remote install command instead of running SSH")
|
||||
NodesCmd.AddCommand(nodesAddCmd)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"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() {
|
||||
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)
|
||||
}
|
||||
@@ -60,7 +60,7 @@ var servicesGetCmd = &cobra.Command{
|
||||
return nil
|
||||
}
|
||||
s, _ := unwrapObject(data, "service")
|
||||
for _, k := range []string{"id", "name", "type", "status", "environment", "image", "git_repo", "git_branch", "domain", "port"} {
|
||||
for _, k := range []string{"id", "name", "type", "status", "environment", "image", "git_repo", "git_branch", "domain", "port", "node_name"} {
|
||||
fmt.Printf("%-14s %s\n", k+":", str(s, k))
|
||||
}
|
||||
return nil
|
||||
@@ -79,7 +79,7 @@ var servicesCreateCmd = &cobra.Command{
|
||||
body := map[string]interface{}{
|
||||
"name": args[1],
|
||||
}
|
||||
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
|
||||
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir", "node"} {
|
||||
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||
key := f
|
||||
// JSON uses snake_case keys matching the API schema.
|
||||
@@ -102,6 +102,8 @@ var servicesCreateCmd = &cobra.Command{
|
||||
key = "static_build_cmd"
|
||||
case "static-dir":
|
||||
key = "static_dir"
|
||||
case "node":
|
||||
key = "node_id"
|
||||
}
|
||||
body[key] = v
|
||||
}
|
||||
@@ -112,6 +114,31 @@ var servicesCreateCmd = &cobra.Command{
|
||||
if v, _ := cmd.Flags().GetInt("replicas"); v > 0 {
|
||||
body["replicas"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("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 != "" {
|
||||
tags := []string{}
|
||||
for _, t := range strings.Split(v, ",") {
|
||||
if t = strings.TrimSpace(t); t != "" {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -126,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{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a service",
|
||||
@@ -159,7 +208,7 @@ var servicesUpdateCmd = &cobra.Command{
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
|
||||
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir", "node"} {
|
||||
if cmd.Flags().Changed(f) {
|
||||
key := f
|
||||
switch f {
|
||||
@@ -175,6 +224,8 @@ var servicesUpdateCmd = &cobra.Command{
|
||||
key = "static_build_cmd"
|
||||
case "static-dir":
|
||||
key = "static_dir"
|
||||
case "node":
|
||||
key = "node_id"
|
||||
}
|
||||
v, _ := cmd.Flags().GetString(f)
|
||||
body[key] = v
|
||||
@@ -199,10 +250,29 @@ var servicesUpdateCmd = &cobra.Command{
|
||||
v, _ := cmd.Flags().GetBool("sleep")
|
||||
body["sleep_enabled"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("spread") {
|
||||
v, _ := cmd.Flags().GetBool("spread")
|
||||
body["spread"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("placement-tags") {
|
||||
v, _ := cmd.Flags().GetString("placement-tags")
|
||||
tags := []string{}
|
||||
for _, t := range strings.Split(v, ",") {
|
||||
if t = strings.TrimSpace(t); t != "" {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
}
|
||||
body["placement_tags"] = tags
|
||||
}
|
||||
if cmd.Flags().Changed("sleep-idle") {
|
||||
v, _ := cmd.Flags().GetInt("sleep-idle")
|
||||
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") {
|
||||
v, _ := cmd.Flags().GetString("maintenance")
|
||||
body["maintenance_mode"] = v == "on" || v == "true"
|
||||
@@ -490,6 +560,12 @@ func init() {
|
||||
f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)")
|
||||
f.String("static-cmd", "", "build command for the static builder")
|
||||
f.String("static-dir", "", "output dir for the static builder (default dist)")
|
||||
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("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.StringArray("traefik-label", nil, "Traefik middleware override, key=value (e.g. middlewares.rl.ratelimit.average=100); repeatable")
|
||||
|
||||
uf := servicesUpdateCmd.Flags()
|
||||
uf.String("name", "", "service name")
|
||||
@@ -514,6 +590,10 @@ func init() {
|
||||
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.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.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)")
|
||||
|
||||
for _, a := range []string{"start", "stop", "restart", "redeploy", "sleep", "wake"} {
|
||||
verb := a
|
||||
|
||||
@@ -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{
|
||||
Use: "deploy-graph <project-id> <config.json|->",
|
||||
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)")
|
||||
templatesPlanCmd.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)
|
||||
}
|
||||
@@ -94,6 +94,8 @@ func init() {
|
||||
rootCmd.AddCommand(commands.BannersCmd)
|
||||
rootCmd.AddCommand(commands.InvitesCmd)
|
||||
rootCmd.AddCommand(commands.UpCmd)
|
||||
rootCmd.AddCommand(commands.MigrateCmd)
|
||||
rootCmd.AddCommand(commands.EnvironmentsCmd)
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
|
||||
@@ -14,6 +14,18 @@ import (
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
)
|
||||
|
||||
const clearServiceNodePins = `-- name: ClearServiceNodePins :execrows
|
||||
UPDATE services SET node_id = NULL, updated_at = NOW() WHERE node_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ClearServiceNodePins(ctx context.Context, nodeID sql.NullString) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, clearServiceNodePins, nodeID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const completeCommand = `-- name: CompleteCommand :one
|
||||
UPDATE agent_commands SET
|
||||
status = $3,
|
||||
@@ -63,7 +75,7 @@ INSERT INTO node_agents (
|
||||
id, name, hostname, ip_address, port, status, version,
|
||||
capabilities, resources, last_heartbeat, metadata
|
||||
) 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
|
||||
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 {
|
||||
@@ -109,6 +121,10 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeA
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Metadata,
|
||||
&i.AutoPrune,
|
||||
&i.Schedulable,
|
||||
&i.Tags,
|
||||
&i.DefaultDomain,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -228,8 +244,22 @@ func (q *Queries) DeleteAgent(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteServiceContainersOnAgent = `-- name: DeleteServiceContainersOnAgent :exec
|
||||
DELETE FROM container_instances WHERE node_agent_id = $1 AND service_id = $2
|
||||
`
|
||||
|
||||
type DeleteServiceContainersOnAgentParams struct {
|
||||
NodeAgentID string `json:"node_agent_id"`
|
||||
ServiceID string `json:"service_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteServiceContainersOnAgent, arg.NodeAgentID, arg.ServiceID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getAgent = `-- name: GetAgent :one
|
||||
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata 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) {
|
||||
@@ -249,12 +279,16 @@ func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Metadata,
|
||||
&i.AutoPrune,
|
||||
&i.Schedulable,
|
||||
&i.Tags,
|
||||
&i.DefaultDomain,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one
|
||||
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata 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
|
||||
LIMIT 1
|
||||
`
|
||||
@@ -281,6 +315,10 @@ func (q *Queries) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAnd
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Metadata,
|
||||
&i.AutoPrune,
|
||||
&i.Schedulable,
|
||||
&i.Tags,
|
||||
&i.DefaultDomain,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -378,6 +416,36 @@ func (q *Queries) GetContainerForAgent(ctx context.Context, arg GetContainerForA
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getLastAgentCommandByType = `-- name: GetLastAgentCommandByType :one
|
||||
SELECT id, type, node_agent_id, container_id, payload, status, result, error, created_at, updated_at, completed_at FROM agent_commands
|
||||
WHERE node_agent_id = $1 AND type = $2
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`
|
||||
|
||||
type GetLastAgentCommandByTypeParams struct {
|
||||
NodeAgentID string `json:"node_agent_id"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetLastAgentCommandByType(ctx context.Context, arg GetLastAgentCommandByTypeParams) (AgentCommand, error) {
|
||||
row := q.db.QueryRowContext(ctx, getLastAgentCommandByType, arg.NodeAgentID, arg.Type)
|
||||
var i AgentCommand
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Type,
|
||||
&i.NodeAgentID,
|
||||
&i.ContainerID,
|
||||
&i.Payload,
|
||||
&i.Status,
|
||||
&i.Result,
|
||||
&i.Error,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.CompletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertAgentHeartbeat = `-- name: InsertAgentHeartbeat :exec
|
||||
INSERT INTO agent_heartbeats (
|
||||
id, node_agent_id, timestamp, status, resources,
|
||||
@@ -458,7 +526,7 @@ func (q *Queries) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHea
|
||||
}
|
||||
|
||||
const listAgents = `-- name: ListAgents :many
|
||||
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata 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) {
|
||||
@@ -484,6 +552,10 @@ func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Metadata,
|
||||
&i.AutoPrune,
|
||||
&i.Schedulable,
|
||||
&i.Tags,
|
||||
&i.DefaultDomain,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -627,6 +699,296 @@ func (q *Queries) ListPendingCommands(ctx context.Context, nodeAgentID string) (
|
||||
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
|
||||
SELECT id, name FROM node_agents
|
||||
WHERE status = 'online' AND schedulable
|
||||
ORDER BY id
|
||||
`
|
||||
|
||||
type ListSchedulableAgentsRow struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listSchedulableAgents)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListSchedulableAgentsRow{}
|
||||
for rows.Next() {
|
||||
var i ListSchedulableAgentsRow
|
||||
if err := rows.Scan(&i.ID, &i.Name); 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 listSchedulableAgentsMatching = `-- name: ListSchedulableAgentsMatching :many
|
||||
SELECT id, name FROM node_agents
|
||||
WHERE status = 'online' AND schedulable
|
||||
AND tags @> $1::jsonb
|
||||
ORDER BY id
|
||||
`
|
||||
|
||||
type ListSchedulableAgentsMatchingRow struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Only agents carrying every required placement tag (jsonb array containment).
|
||||
func (q *Queries) ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listSchedulableAgentsMatching, dollar_1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListSchedulableAgentsMatchingRow{}
|
||||
for rows.Next() {
|
||||
var i ListSchedulableAgentsMatchingRow
|
||||
if err := rows.Scan(&i.ID, &i.Name); 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 listServiceAgents = `-- name: ListServiceAgents :many
|
||||
SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1
|
||||
`
|
||||
|
||||
// Distinct agents holding inventory rows for a service.
|
||||
func (q *Queries) ListServiceAgents(ctx context.Context, serviceID string) ([]string, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listServiceAgents, serviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []string{}
|
||||
for rows.Next() {
|
||||
var node_agent_id string
|
||||
if err := rows.Scan(&node_agent_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, node_agent_id)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listServiceContainers = `-- name: ListServiceContainers :many
|
||||
SELECT id, name, node_agent_id, status, ports, resources FROM container_instances
|
||||
WHERE service_id = $1
|
||||
`
|
||||
|
||||
type ListServiceContainersRow struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
NodeAgentID string `json:"node_agent_id"`
|
||||
Status pqtype.NullRawMessage `json:"status"`
|
||||
Ports pqtype.NullRawMessage `json:"ports"`
|
||||
Resources pqtype.NullRawMessage `json:"resources"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listServiceContainers, serviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListServiceContainersRow{}
|
||||
for rows.Next() {
|
||||
var i ListServiceContainersRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NodeAgentID,
|
||||
&i.Status,
|
||||
&i.Ports,
|
||||
&i.Resources,
|
||||
); 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 pickLeastLoadedAgent = `-- name: PickLeastLoadedAgent :one
|
||||
SELECT a.id FROM node_agents a
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT h.container_count FROM agent_heartbeats h
|
||||
WHERE h.node_agent_id = a.id
|
||||
ORDER BY h.timestamp DESC LIMIT 1
|
||||
) h ON true
|
||||
WHERE a.status = 'online' AND a.schedulable
|
||||
ORDER BY
|
||||
COALESCE(
|
||||
(a.resources->'memory'->>'used')::double precision
|
||||
/ NULLIF((a.resources->'memory'->>'total')::double precision, 0),
|
||||
1.0
|
||||
) ASC,
|
||||
COALESCE((a.resources->'cpu'->>'usage')::double precision, 100.0) ASC,
|
||||
COALESCE(h.container_count, 0) ASC,
|
||||
a.created_at ASC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
// Resource-aware: lowest memory utilisation first, then cpu, then container
|
||||
// count. Agents without telemetry sort last (NULL → worst score).
|
||||
func (q *Queries) PickLeastLoadedAgent(ctx context.Context) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, pickLeastLoadedAgent)
|
||||
var id string
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const pickLeastLoadedAgentMatching = `-- name: PickLeastLoadedAgentMatching :one
|
||||
SELECT a.id FROM node_agents a
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT h.container_count FROM agent_heartbeats h
|
||||
WHERE h.node_agent_id = a.id
|
||||
ORDER BY h.timestamp DESC LIMIT 1
|
||||
) h ON true
|
||||
WHERE a.status = 'online' AND a.schedulable
|
||||
AND a.tags @> $1::jsonb
|
||||
ORDER BY
|
||||
COALESCE(
|
||||
(a.resources->'memory'->>'used')::double precision
|
||||
/ NULLIF((a.resources->'memory'->>'total')::double precision, 0),
|
||||
1.0
|
||||
) ASC,
|
||||
COALESCE((a.resources->'cpu'->>'usage')::double precision, 100.0) ASC,
|
||||
COALESCE(h.container_count, 0) ASC,
|
||||
a.created_at ASC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
// Same resource-aware ordering as PickLeastLoadedAgent, restricted to agents
|
||||
// carrying every required placement tag.
|
||||
func (q *Queries) PickLeastLoadedAgentMatching(ctx context.Context, dollar_1 json.RawMessage) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, pickLeastLoadedAgentMatching, dollar_1)
|
||||
var id string
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const scrubCommandPayload = `-- name: ScrubCommandPayload :exec
|
||||
UPDATE agent_commands SET payload = $2, updated_at = NOW() WHERE id = $1
|
||||
`
|
||||
|
||||
type ScrubCommandPayloadParams struct {
|
||||
ID string `json:"id"`
|
||||
Payload pqtype.NullRawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
func (q *Queries) ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error {
|
||||
_, err := q.db.ExecContext(ctx, scrubCommandPayload, arg.ID, arg.Payload)
|
||||
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
|
||||
UPDATE node_agents SET schedulable = $2, updated_at = NOW() WHERE id = $1
|
||||
`
|
||||
|
||||
type SetAgentSchedulableParams struct {
|
||||
ID string `json:"id"`
|
||||
Schedulable bool `json:"schedulable"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setAgentSchedulable, arg.ID, arg.Schedulable)
|
||||
return err
|
||||
}
|
||||
|
||||
const setAgentTags = `-- name: SetAgentTags :exec
|
||||
UPDATE node_agents SET tags = $2::jsonb, updated_at = NOW() WHERE id = $1
|
||||
`
|
||||
|
||||
type SetAgentTagsParams struct {
|
||||
ID string `json:"id"`
|
||||
Column2 json.RawMessage `json:"column_2"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setAgentTags, arg.ID, arg.Column2)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateAgent = `-- name: UpdateAgent :one
|
||||
UPDATE node_agents SET
|
||||
name = $2,
|
||||
@@ -639,9 +1001,11 @@ UPDATE node_agents SET
|
||||
resources = $9,
|
||||
last_heartbeat = $10,
|
||||
metadata = $11,
|
||||
auto_prune = $12,
|
||||
schedulable = $13,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata
|
||||
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 {
|
||||
@@ -656,6 +1020,8 @@ type UpdateAgentParams struct {
|
||||
Resources pqtype.NullRawMessage `json:"resources"`
|
||||
LastHeartbeat sql.NullTime `json:"last_heartbeat"`
|
||||
Metadata pqtype.NullRawMessage `json:"metadata"`
|
||||
AutoPrune bool `json:"auto_prune"`
|
||||
Schedulable bool `json:"schedulable"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) {
|
||||
@@ -671,6 +1037,8 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
|
||||
arg.Resources,
|
||||
arg.LastHeartbeat,
|
||||
arg.Metadata,
|
||||
arg.AutoPrune,
|
||||
arg.Schedulable,
|
||||
)
|
||||
var i NodeAgent
|
||||
err := row.Scan(
|
||||
@@ -687,6 +1055,10 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Metadata,
|
||||
&i.AutoPrune,
|
||||
&i.Schedulable,
|
||||
&i.Tags,
|
||||
&i.DefaultDomain,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -717,6 +1089,53 @@ func (q *Queries) UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeart
|
||||
return err
|
||||
}
|
||||
|
||||
const updateContainerPorts = `-- name: UpdateContainerPorts :exec
|
||||
UPDATE container_instances SET status = $2, ports = $3, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
type UpdateContainerPortsParams struct {
|
||||
ID string `json:"id"`
|
||||
Status pqtype.NullRawMessage `json:"status"`
|
||||
Ports pqtype.NullRawMessage `json:"ports"`
|
||||
}
|
||||
|
||||
// Agent reports the real host bindings after create — persist them with
|
||||
// the status flip so ingress can route to the assigned port.
|
||||
func (q *Queries) UpdateContainerPorts(ctx context.Context, arg UpdateContainerPortsParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateContainerPorts, arg.ID, arg.Status, arg.Ports)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateContainerStateByName = `-- name: UpdateContainerStateByName :exec
|
||||
UPDATE container_instances SET
|
||||
status = jsonb_set(status, '{state}', $3::jsonb),
|
||||
resources = jsonb_set(COALESCE(resources, '{}'::jsonb), '{net_bytes}', $4::jsonb),
|
||||
updated_at = NOW()
|
||||
WHERE name = $1 AND node_agent_id = $2
|
||||
AND status->>'state' IS DISTINCT FROM 'removed'
|
||||
`
|
||||
|
||||
type UpdateContainerStateByNameParams struct {
|
||||
Name string `json:"name"`
|
||||
NodeAgentID string `json:"node_agent_id"`
|
||||
Column3 json.RawMessage `json:"column_3"`
|
||||
Column4 json.RawMessage `json:"column_4"`
|
||||
}
|
||||
|
||||
// Agent heartbeat reports real docker state + net counters; reconcile
|
||||
// inventory for rows this node owns. Tombstones win — never resurrect a
|
||||
// removed replica. $4 is the cumulative rx+tx byte counter.
|
||||
func (q *Queries) UpdateContainerStateByName(ctx context.Context, arg UpdateContainerStateByNameParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateContainerStateByName,
|
||||
arg.Name,
|
||||
arg.NodeAgentID,
|
||||
arg.Column3,
|
||||
arg.Column4,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateContainerStatus = `-- name: UpdateContainerStatus :exec
|
||||
UPDATE container_instances SET status = $2, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
@@ -731,3 +1150,52 @@ func (q *Queries) UpdateContainerStatus(ctx context.Context, arg UpdateContainer
|
||||
_, err := q.db.ExecContext(ctx, updateContainerStatus, arg.ID, arg.Status)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertServiceContainer = `-- name: UpsertServiceContainer :exec
|
||||
INSERT INTO container_instances (
|
||||
id, name, image, project_id, service_id, node_agent_id,
|
||||
status, resources, ports, environment, volumes, networks,
|
||||
restart_policy, health_check
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
image = EXCLUDED.image,
|
||||
status = EXCLUDED.status,
|
||||
updated_at = NOW()
|
||||
`
|
||||
|
||||
type UpsertServiceContainerParams struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
ProjectID string `json:"project_id"`
|
||||
ServiceID string `json:"service_id"`
|
||||
NodeAgentID string `json:"node_agent_id"`
|
||||
Status pqtype.NullRawMessage `json:"status"`
|
||||
Resources pqtype.NullRawMessage `json:"resources"`
|
||||
Ports pqtype.NullRawMessage `json:"ports"`
|
||||
Environment pqtype.NullRawMessage `json:"environment"`
|
||||
Volumes pqtype.NullRawMessage `json:"volumes"`
|
||||
Networks pqtype.NullRawMessage `json:"networks"`
|
||||
RestartPolicy pqtype.NullRawMessage `json:"restart_policy"`
|
||||
HealthCheck pqtype.NullRawMessage `json:"health_check"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertServiceContainer,
|
||||
arg.ID,
|
||||
arg.Name,
|
||||
arg.Image,
|
||||
arg.ProjectID,
|
||||
arg.ServiceID,
|
||||
arg.NodeAgentID,
|
||||
arg.Status,
|
||||
arg.Resources,
|
||||
arg.Ports,
|
||||
arg.Environment,
|
||||
arg.Volumes,
|
||||
arg.Networks,
|
||||
arg.RestartPolicy,
|
||||
arg.HealthCheck,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -175,6 +175,10 @@ type NodeAgent struct {
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
Metadata pqtype.NullRawMessage `json:"metadata"`
|
||||
AutoPrune bool `json:"auto_prune"`
|
||||
Schedulable bool `json:"schedulable"`
|
||||
Tags json.RawMessage `json:"tags"`
|
||||
DefaultDomain string `json:"default_domain"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
@@ -189,6 +193,17 @@ type Notification struct {
|
||||
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 {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
@@ -273,6 +288,10 @@ type Service struct {
|
||||
MemoryReserve string `json:"memory_reserve"`
|
||||
StaticBuildCmd string `json:"static_build_cmd"`
|
||||
StaticDir string `json:"static_dir"`
|
||||
NodeID sql.NullString `json:"node_id"`
|
||||
Spread bool `json:"spread"`
|
||||
PlacementTags json.RawMessage `json:"placement_tags"`
|
||||
TraefikLabels json.RawMessage `json:"traefik_labels"`
|
||||
}
|
||||
|
||||
type ServiceDomain struct {
|
||||
|
||||
@@ -53,6 +53,135 @@ func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotification
|
||||
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
|
||||
SELECT id, user_id, kind, title, body, resource_type, resource_id, read_at, created_at
|
||||
FROM notifications
|
||||
|
||||
@@ -12,6 +12,17 @@ import (
|
||||
"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
|
||||
SELECT COUNT(*)::bigint AS total
|
||||
FROM projects p
|
||||
@@ -86,6 +97,20 @@ func (q *Queries) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (i
|
||||
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
|
||||
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
|
||||
FROM projects p
|
||||
@@ -123,6 +148,30 @@ func (q *Queries) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDF
|
||||
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
|
||||
SELECT owner_id
|
||||
FROM projects
|
||||
@@ -212,6 +261,53 @@ func (q *Queries) ListPendingProjects(ctx context.Context) ([]Project, error) {
|
||||
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
|
||||
|
||||
SELECT
|
||||
|
||||
@@ -7,14 +7,17 @@ package sqlcdb
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
ClearServiceNodePins(ctx context.Context, nodeID sql.NullString) (int64, error)
|
||||
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
|
||||
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (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)
|
||||
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
|
||||
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
|
||||
@@ -41,8 +44,11 @@ type Querier interface {
|
||||
DeleteBanner(ctx context.Context, id uuid.UUID) error
|
||||
DeleteDatabaseServiceByID(ctx context.Context, id string) error
|
||||
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
||||
DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error)
|
||||
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
|
||||
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||
DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error
|
||||
DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error
|
||||
DeleteUserInvite(ctx context.Context, id uuid.UUID) error
|
||||
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
|
||||
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
|
||||
@@ -58,9 +64,11 @@ type Querier interface {
|
||||
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
|
||||
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
|
||||
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
|
||||
GetLastAgentCommandByType(ctx context.Context, arg GetLastAgentCommandByTypeParams) (AgentCommand, error)
|
||||
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
|
||||
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, 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)
|
||||
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
||||
@@ -68,12 +76,14 @@ type Querier interface {
|
||||
GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error)
|
||||
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
|
||||
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
|
||||
InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error)
|
||||
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
|
||||
ListActiveBanners(ctx context.Context) ([]Banner, error)
|
||||
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
||||
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
|
||||
ListAgents(ctx context.Context) ([]NodeAgent, error)
|
||||
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
|
||||
ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
|
||||
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
|
||||
ListBanners(ctx context.Context) ([]Banner, error)
|
||||
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||
@@ -83,14 +93,25 @@ type Querier interface {
|
||||
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
|
||||
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
||||
ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error)
|
||||
ListNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
|
||||
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
|
||||
ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error)
|
||||
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, 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
|
||||
// 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.
|
||||
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)
|
||||
// Only agents carrying every required placement tag (jsonb array containment).
|
||||
ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error)
|
||||
// Distinct agents holding inventory rows for a service.
|
||||
ListServiceAgents(ctx context.Context, serviceID string) ([]string, error)
|
||||
ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error)
|
||||
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
||||
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
|
||||
ListUserInvites(ctx context.Context) ([]UserInvite, error)
|
||||
@@ -99,8 +120,18 @@ type Querier interface {
|
||||
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
||||
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
||||
MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error
|
||||
// Resource-aware: lowest memory utilisation first, then cpu, then container
|
||||
// count. Agents without telemetry sort last (NULL → worst score).
|
||||
PickLeastLoadedAgent(ctx context.Context) (string, error)
|
||||
// Same resource-aware ordering as PickLeastLoadedAgent, restricted to agents
|
||||
// carrying every required placement tag.
|
||||
PickLeastLoadedAgentMatching(ctx context.Context, dollar_1 json.RawMessage) (string, error)
|
||||
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
||||
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
|
||||
ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error
|
||||
SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error
|
||||
SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error
|
||||
SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error
|
||||
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
|
||||
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
||||
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
||||
@@ -117,6 +148,13 @@ type Querier interface {
|
||||
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
||||
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
|
||||
UpdateBanner(ctx context.Context, arg UpdateBannerParams) (Banner, error)
|
||||
// Agent reports the real host bindings after create — persist them with
|
||||
// the status flip so ingress can route to the assigned port.
|
||||
UpdateContainerPorts(ctx context.Context, arg UpdateContainerPortsParams) error
|
||||
// Agent heartbeat reports real docker state + net counters; reconcile
|
||||
// inventory for rows this node owns. Tombstones win — never resurrect a
|
||||
// removed replica. $4 is the cumulative rx+tx byte counter.
|
||||
UpdateContainerStateByName(ctx context.Context, arg UpdateContainerStateByNameParams) error
|
||||
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
||||
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
|
||||
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
||||
@@ -126,6 +164,7 @@ type Querier interface {
|
||||
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
|
||||
UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error
|
||||
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
|
||||
UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) 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")
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,37 @@ import (
|
||||
"containr/internal/types"
|
||||
)
|
||||
|
||||
// NodeRunner deploys a service's containers onto a remote node agent.
|
||||
// Wired at boot; nil means every service runs on the local Docker host.
|
||||
type NodeRunner interface {
|
||||
// ReconcileOnNode brings the node's containers in line with the spec and
|
||||
// returns the resulting runtime state.
|
||||
ReconcileOnNode(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error)
|
||||
// ReconcileSpread distributes the service's replicas across every online,
|
||||
// schedulable agent — replica i lands on agents[i % len].
|
||||
ReconcileSpread(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error)
|
||||
// ReconcileAuto picks the least-loaded online agent carrying every tag in
|
||||
// spec.PlacementTags and reconciles all replicas onto it. Re-resolved on
|
||||
// every reconcile — the service follows the tag, not a stored node.
|
||||
ReconcileAuto(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error)
|
||||
// ControlService fans a lifecycle action ("start"|"stop"|"restart"|"remove")
|
||||
// out to every replica of the service on the agent.
|
||||
ControlService(ctx context.Context, serviceID, agentID, action string) error
|
||||
// RemoveService tears down every replica of the service on the agent.
|
||||
RemoveService(ctx context.Context, serviceID, agentID string) error
|
||||
// RemoteRuntimeState reads the service's remote replicas from inventory
|
||||
// across all agents. nil/empty means nothing runs remotely.
|
||||
RemoteRuntimeState(ctx context.Context, serviceID string) (*RuntimeState, error)
|
||||
// RetireService removes every remote replica of the service across all
|
||||
// agents — used when a service returns to local placement.
|
||||
RetireService(ctx context.Context, serviceID string) error
|
||||
}
|
||||
|
||||
type DeploymentEngine struct {
|
||||
buildManager *build.BuildManager
|
||||
dockerClient *docker.Client
|
||||
scheduler *Scheduler
|
||||
nodeRunner NodeRunner
|
||||
deployments map[string]*Deployment
|
||||
deploymentLog chan *DeploymentEvent
|
||||
}
|
||||
@@ -67,6 +94,30 @@ type ServiceConfig struct {
|
||||
// PublishedPort is the host port bound by the previous deployment; replica 0
|
||||
// reuses it so public URLs survive restarts. 0 = pick ephemeral.
|
||||
PublishedPort int32 `json:"published_port,omitempty"`
|
||||
// NodeID pins the service to a registered node agent; "" = local Docker
|
||||
// host.
|
||||
NodeID string `json:"node_id,omitempty"`
|
||||
// Spread distributes replicas across every online, schedulable agent.
|
||||
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 {
|
||||
@@ -177,6 +228,17 @@ func (de *DeploymentEngine) DockerClient() *docker.Client {
|
||||
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.
|
||||
func (de *DeploymentEngine) SetNodeRunner(r NodeRunner) {
|
||||
de.nodeRunner = r
|
||||
}
|
||||
|
||||
func NewDeploymentEngine(buildManager *build.BuildManager, dockerClient *docker.Client) *DeploymentEngine {
|
||||
return &DeploymentEngine{
|
||||
buildManager: buildManager,
|
||||
@@ -287,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) {
|
||||
if buildConfig == nil {
|
||||
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{
|
||||
BuildType: buildConfig.BuildType,
|
||||
@@ -352,6 +422,10 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
||||
Maintenance: cfg.Maintenance,
|
||||
MaintenanceURL: cfg.MaintenanceURL,
|
||||
BasicAuthUsers: cfg.BasicAuthUsers,
|
||||
NodeID: cfg.NodeID,
|
||||
Spread: cfg.Spread,
|
||||
RemoteBuild: cfg.RemoteBuild,
|
||||
RemoteLoad: cfg.RemoteLoad,
|
||||
}
|
||||
for _, pm := range cfg.PortMappings {
|
||||
if spec.Port == 0 {
|
||||
|
||||
@@ -55,6 +55,24 @@ type RuntimeSpec struct {
|
||||
Maintenance bool // redirect traffic to MaintenanceURL
|
||||
MaintenanceURL string // absolute URL; empty = serve empty response
|
||||
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 string
|
||||
// Spread distributes replicas across all online, schedulable agents
|
||||
// instead of a single node. Mutually exclusive with NodeID.
|
||||
Spread bool
|
||||
// PlacementTags restricts auto/spread candidates to agents carrying
|
||||
// every listed tag. An explicit NodeID pin bypasses tag filtering.
|
||||
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.
|
||||
@@ -151,8 +169,35 @@ func (de *DeploymentEngine) ListServiceContainers(ctx context.Context, serviceID
|
||||
|
||||
// ReconcileService brings live containers in line with the spec. When the
|
||||
// spec hash changed (image/env/ports/etc.) all replicas are recreated;
|
||||
// otherwise only the replica delta is applied.
|
||||
// otherwise only the replica delta is applied. A pinned NodeID routes the
|
||||
// whole reconcile to that agent's command queue instead of local Docker.
|
||||
func (de *DeploymentEngine) ReconcileService(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error) {
|
||||
if spec.Spread && spec.NodeID == "" {
|
||||
if de.nodeRunner == nil {
|
||||
return nil, fmt.Errorf("service has spread placement but remote dispatch is unavailable")
|
||||
}
|
||||
return de.nodeRunner.ReconcileSpread(ctx, spec)
|
||||
}
|
||||
if spec.NodeID != "" {
|
||||
if de.nodeRunner == nil {
|
||||
return nil, fmt.Errorf("service is pinned to node %s but remote dispatch is unavailable", spec.NodeID)
|
||||
}
|
||||
return de.nodeRunner.ReconcileOnNode(ctx, spec)
|
||||
}
|
||||
// Placement tags without a pin/spread resolve to the least-loaded
|
||||
// matching agent on every reconcile — soft affinity, re-evaluated.
|
||||
if len(spec.PlacementTags) > 0 {
|
||||
if de.nodeRunner == nil {
|
||||
return nil, fmt.Errorf("service has placement tags but remote dispatch is unavailable")
|
||||
}
|
||||
return de.nodeRunner.ReconcileAuto(ctx, spec)
|
||||
}
|
||||
// Local placement: retire any remote leftovers from a previous pin,
|
||||
// spread, or tag affinity before reconciling local replicas.
|
||||
if de.nodeRunner != nil {
|
||||
_ = de.nodeRunner.RetireService(ctx, spec.ServiceID)
|
||||
}
|
||||
|
||||
if spec.Replicas < 1 {
|
||||
spec.Replicas = 1
|
||||
}
|
||||
@@ -233,44 +278,9 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
|
||||
projectNet: {Aliases: []string{spec.Name}},
|
||||
}
|
||||
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{}
|
||||
labels["traefik.enable"] = "true"
|
||||
labels["traefik.docker.network"] = edgeNet
|
||||
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, ",")
|
||||
for k, v := range edgeLabels(spec, edgeNet) {
|
||||
labels[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,6 +368,81 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
|
||||
// vpnkit drops a conflicting binding).
|
||||
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
|
||||
// requested host port was already bound.
|
||||
func isPortConflict(err error) bool {
|
||||
@@ -498,6 +583,34 @@ func (de *DeploymentEngine) RemoveServiceContainers(ctx context.Context, service
|
||||
return nil
|
||||
}
|
||||
|
||||
// ControlServiceOnNode fans a lifecycle action out to the service's replicas
|
||||
// on a remote node agent. Callers resolve the pin; nil runner means the
|
||||
// platform was booted without node support.
|
||||
func (de *DeploymentEngine) ControlServiceOnNode(ctx context.Context, serviceID, nodeID, action string) error {
|
||||
if de.nodeRunner == nil {
|
||||
return fmt.Errorf("remote node control is not configured")
|
||||
}
|
||||
return de.nodeRunner.ControlService(ctx, serviceID, nodeID, action)
|
||||
}
|
||||
|
||||
// RemoveServiceContainersOnNode tears down the service's replicas on a remote
|
||||
// node agent.
|
||||
func (de *DeploymentEngine) RemoveServiceContainersOnNode(ctx context.Context, serviceID, nodeID string) error {
|
||||
if de.nodeRunner == nil {
|
||||
return fmt.Errorf("remote node control is not configured")
|
||||
}
|
||||
return de.nodeRunner.RemoveService(ctx, serviceID, nodeID)
|
||||
}
|
||||
|
||||
// RemoteRuntimeState exposes inventory-backed remote container state; nil
|
||||
// runner means no node support was wired at boot.
|
||||
func (de *DeploymentEngine) RemoteRuntimeState(ctx context.Context, serviceID string) (*RuntimeState, error) {
|
||||
if de.nodeRunner == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return de.nodeRunner.RemoteRuntimeState(ctx, serviceID)
|
||||
}
|
||||
|
||||
// RemoveProjectContainers deletes every managed container in a project.
|
||||
func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, projectID string) error {
|
||||
containers, err := de.dockerClient.ListContainersFiltered(ctx, filters.NewArgs(
|
||||
|
||||
@@ -336,6 +336,13 @@ func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options
|
||||
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
|
||||
func (c *Client) RemoveImage(ctx context.Context, imageID string, force bool) ([]image.DeleteResponse, error) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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_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
|
||||
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir.", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||
register(spec{"containr_services_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_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})
|
||||
@@ -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_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
|
||||
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
|
||||
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. 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.", "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
|
||||
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_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_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_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
|
||||
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_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_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,6 +183,13 @@ func init() {
|
||||
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
|
||||
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
|
||||
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
|
||||
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_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_prune", "Enqueue a bounded docker system prune on a node (admin). Args: id; optional until (e.g. 168h), volumes (danger, off by default).", "POST", "/agents/{id}/prune", []string{"id"}, []string{"until", "volumes"}, true})
|
||||
register(spec{"containr_nodes_upgrade", "Upgrade a node's agent to the server's bundled binary: downloads over the API, verifies sha256, swaps atomically, re-execs. Node must be online (admin). Args: id.", "POST", "/agents/{id}/upgrade", []string{"id"}, nil, true})
|
||||
register(spec{"containr_nodes_commands", "List recent commands sent to a node agent (admin). Args: id.", "GET", "/agents/{id}/commands", []string{"id"}, nil, false})
|
||||
|
||||
// Scaling / HA / security
|
||||
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,5 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE node_agents ADD COLUMN IF NOT EXISTS auto_prune BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE node_agents DROP COLUMN IF EXISTS auto_prune;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS node_id VARCHAR(255);
|
||||
|
||||
-- +goose StatementBegin
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'services_node_id_fkey') THEN
|
||||
ALTER TABLE services
|
||||
ADD CONSTRAINT services_node_id_fkey
|
||||
FOREIGN KEY (node_id) REFERENCES node_agents(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
-- +goose StatementEnd
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_services_node_id ON services (node_id) WHERE node_id IS NOT NULL;
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS idx_services_node_id;
|
||||
ALTER TABLE services DROP COLUMN IF EXISTS node_id;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE node_agents ADD COLUMN IF NOT EXISTS schedulable BOOLEAN NOT NULL DEFAULT true;
|
||||
CREATE INDEX IF NOT EXISTS idx_node_agents_schedulable ON node_agents(schedulable) WHERE NOT schedulable;
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS idx_node_agents_schedulable;
|
||||
ALTER TABLE node_agents DROP COLUMN IF EXISTS schedulable;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS spread BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE services DROP COLUMN IF EXISTS spread;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- Placement tags: operators label node_agents (tags jsonb array of strings),
|
||||
-- services require a tag set (placement_tags). "auto" and spread placement
|
||||
-- only consider online schedulable agents carrying every required tag.
|
||||
-- Explicit node_id pins bypass tag filtering — the pin is an operator override.
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS placement_tags jsonb NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE node_agents ADD COLUMN IF NOT EXISTS tags jsonb NOT NULL DEFAULT '[]';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE services DROP COLUMN IF EXISTS placement_tags;
|
||||
ALTER TABLE node_agents DROP COLUMN IF EXISTS tags;
|
||||
@@ -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;
|
||||
@@ -28,6 +28,8 @@ UPDATE node_agents SET
|
||||
resources = $9,
|
||||
last_heartbeat = $10,
|
||||
metadata = $11,
|
||||
auto_prune = $12,
|
||||
schedulable = $13,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
@@ -107,3 +109,126 @@ RETURNING *;
|
||||
-- name: UpdateContainerStatus :exec
|
||||
UPDATE container_instances SET status = $2, updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UpdateContainerPorts :exec
|
||||
-- Agent reports the real host bindings after create — persist them with
|
||||
-- the status flip so ingress can route to the assigned port.
|
||||
UPDATE container_instances SET status = $2, ports = $3, updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetLastAgentCommandByType :one
|
||||
SELECT * FROM agent_commands
|
||||
WHERE node_agent_id = $1 AND type = $2
|
||||
ORDER BY created_at DESC LIMIT 1;
|
||||
|
||||
-- name: PickLeastLoadedAgent :one
|
||||
-- Resource-aware: lowest memory utilisation first, then cpu, then container
|
||||
-- count. Agents without telemetry sort last (NULL → worst score).
|
||||
SELECT a.id FROM node_agents a
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT h.container_count FROM agent_heartbeats h
|
||||
WHERE h.node_agent_id = a.id
|
||||
ORDER BY h.timestamp DESC LIMIT 1
|
||||
) h ON true
|
||||
WHERE a.status = 'online' AND a.schedulable
|
||||
ORDER BY
|
||||
COALESCE(
|
||||
(a.resources->'memory'->>'used')::double precision
|
||||
/ NULLIF((a.resources->'memory'->>'total')::double precision, 0),
|
||||
1.0
|
||||
) ASC,
|
||||
COALESCE((a.resources->'cpu'->>'usage')::double precision, 100.0) ASC,
|
||||
COALESCE(h.container_count, 0) ASC,
|
||||
a.created_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: ListSchedulableAgents :many
|
||||
SELECT id, name FROM node_agents
|
||||
WHERE status = 'online' AND schedulable
|
||||
ORDER BY id;
|
||||
|
||||
-- name: ListSchedulableAgentsMatching :many
|
||||
-- Only agents carrying every required placement tag (jsonb array containment).
|
||||
SELECT id, name FROM node_agents
|
||||
WHERE status = 'online' AND schedulable
|
||||
AND tags @> $1::jsonb
|
||||
ORDER BY id;
|
||||
|
||||
-- name: PickLeastLoadedAgentMatching :one
|
||||
-- Same resource-aware ordering as PickLeastLoadedAgent, restricted to agents
|
||||
-- carrying every required placement tag.
|
||||
SELECT a.id FROM node_agents a
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT h.container_count FROM agent_heartbeats h
|
||||
WHERE h.node_agent_id = a.id
|
||||
ORDER BY h.timestamp DESC LIMIT 1
|
||||
) h ON true
|
||||
WHERE a.status = 'online' AND a.schedulable
|
||||
AND a.tags @> $1::jsonb
|
||||
ORDER BY
|
||||
COALESCE(
|
||||
(a.resources->'memory'->>'used')::double precision
|
||||
/ NULLIF((a.resources->'memory'->>'total')::double precision, 0),
|
||||
1.0
|
||||
) ASC,
|
||||
COALESCE((a.resources->'cpu'->>'usage')::double precision, 100.0) ASC,
|
||||
COALESCE(h.container_count, 0) ASC,
|
||||
a.created_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: SetAgentTags :exec
|
||||
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
|
||||
SELECT id, name, node_agent_id, status, ports, resources FROM container_instances
|
||||
WHERE service_id = $1;
|
||||
|
||||
-- name: SetAgentSchedulable :exec
|
||||
UPDATE node_agents SET schedulable = $2, updated_at = NOW() WHERE id = $1;
|
||||
|
||||
-- name: ClearServiceNodePins :execrows
|
||||
UPDATE services SET node_id = NULL, updated_at = NOW() WHERE node_id = $1;
|
||||
|
||||
-- name: UpsertServiceContainer :exec
|
||||
INSERT INTO container_instances (
|
||||
id, name, image, project_id, service_id, node_agent_id,
|
||||
status, resources, ports, environment, volumes, networks,
|
||||
restart_policy, health_check
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
image = EXCLUDED.image,
|
||||
status = EXCLUDED.status,
|
||||
updated_at = NOW();
|
||||
|
||||
-- name: DeleteServiceContainersOnAgent :exec
|
||||
DELETE FROM container_instances WHERE node_agent_id = $1 AND service_id = $2;
|
||||
|
||||
-- name: UpdateContainerStateByName :exec
|
||||
-- Agent heartbeat reports real docker state + net counters; reconcile
|
||||
-- inventory for rows this node owns. Tombstones win — never resurrect a
|
||||
-- removed replica. $4 is the cumulative rx+tx byte counter.
|
||||
UPDATE container_instances SET
|
||||
status = jsonb_set(status, '{state}', $3::jsonb),
|
||||
resources = jsonb_set(COALESCE(resources, '{}'::jsonb), '{net_bytes}', $4::jsonb),
|
||||
updated_at = NOW()
|
||||
WHERE name = $1 AND node_agent_id = $2
|
||||
AND status->>'state' IS DISTINCT FROM 'removed';
|
||||
|
||||
-- name: ListServiceAgents :many
|
||||
-- Distinct agents holding inventory rows for a service.
|
||||
SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1;
|
||||
|
||||
-- name: ScrubCommandPayload :exec
|
||||
UPDATE agent_commands SET payload = $2, updated_at = NOW() WHERE id = $1;
|
||||
@@ -17,3 +17,23 @@ UPDATE notifications SET read_at = $3 WHERE id = $1 AND user_id = $2 AND read_at
|
||||
|
||||
-- name: MarkAllNotificationsReadByUser :exec
|
||||
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;
|
||||
@@ -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
|
||||
WHERE p.is_approved = false
|
||||
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;
|
||||
@@ -66,7 +66,11 @@ CREATE TABLE services (
|
||||
cpu_reserve VARCHAR(20) NOT NULL DEFAULT '',
|
||||
memory_reserve VARCHAR(20) NOT NULL DEFAULT '',
|
||||
static_build_cmd VARCHAR(255) NOT NULL DEFAULT '',
|
||||
static_dir VARCHAR(255) NOT NULL DEFAULT ''
|
||||
static_dir VARCHAR(255) NOT NULL DEFAULT '',
|
||||
node_id VARCHAR(255),
|
||||
spread BOOLEAN NOT NULL DEFAULT false,
|
||||
placement_tags JSONB NOT NULL DEFAULT '[]',
|
||||
traefik_labels JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE registries (
|
||||
@@ -192,7 +196,11 @@ CREATE TABLE node_agents (
|
||||
last_heartbeat TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
metadata JSONB
|
||||
metadata JSONB,
|
||||
auto_prune BOOLEAN NOT NULL DEFAULT false,
|
||||
schedulable BOOLEAN NOT NULL DEFAULT true,
|
||||
tags JSONB NOT NULL DEFAULT '[]',
|
||||
default_domain VARCHAR(255) NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE container_instances (
|
||||
@@ -327,3 +335,14 @@ CREATE TABLE user_invites (
|
||||
used_at TIMESTAMP WITH TIME ZONE,
|
||||
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()
|
||||
);
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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
|
||||
WORKDIR /app
|
||||
@@ -24,7 +26,7 @@ ENV VITE_AUTH_URL=${VITE_AUTH_URL}
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
FROM ${NGINX_IMAGE}
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
Generated
+54
-8
@@ -31,7 +31,9 @@
|
||||
"@tabler/icons-react": "^3.37.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/query-sync-storage-persister": "^5.104.1",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@tanstack/react-query-persist-client": "^5.104.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
@@ -3441,22 +3443,49 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.20",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
|
||||
"version": "5.104.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz",
|
||||
"integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.90.21",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
||||
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
||||
"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.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": {
|
||||
"type": "github",
|
||||
@@ -3466,6 +3495,23 @@
|
||||
"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": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
|
||||
@@ -42,7 +42,9 @@
|
||||
"@tabler/icons-react": "^3.37.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/query-sync-storage-persister": "^5.104.1",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@tanstack/react-query-persist-client": "^5.104.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
ComponentShowcase,
|
||||
GitHubAppCallbackPage,
|
||||
} from '@/features/secondary/pages';
|
||||
import { NodeDetailPage } from '@/features/nodes/NodeDetailPage';
|
||||
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
|
||||
import { SetupWizardPage } from '@/features/setup/SetupWizardPage';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { getCurrentUserProfile } from '@/lib/api-client';
|
||||
@@ -137,6 +139,7 @@ export default function App() {
|
||||
<Route path="/auth/sign-in" element={<SignInPage />} />
|
||||
<Route path="/auth/sign-up" element={<SignUpPage />} />
|
||||
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} />
|
||||
<Route path="/setup" element={<SetupWizardPage />} />
|
||||
|
||||
{/* Public browsing — approved projects, templates, docs. */}
|
||||
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
|
||||
@@ -168,6 +171,7 @@ export default function App() {
|
||||
{/* Platform owner only */}
|
||||
<Route element={<AdminRequired />}>
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/nodes/:id" element={<NodeDetailPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
|
||||
@@ -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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
@@ -30,10 +30,13 @@ import {
|
||||
User,
|
||||
Users,
|
||||
Webhook,
|
||||
WifiOff,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getCurrentUserProfile,
|
||||
getUpgradeStatus,
|
||||
pingServer,
|
||||
getSetupStatus,
|
||||
listNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
@@ -105,6 +108,42 @@ function getInitialSidebar(): boolean {
|
||||
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() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -147,6 +186,13 @@ export function PlatformShell() {
|
||||
enabled: !isDemoMode && signedIn,
|
||||
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({
|
||||
mutationFn: (id: string) => markNotificationRead(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] }),
|
||||
@@ -209,6 +255,10 @@ export function PlatformShell() {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) {
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell min-h-screen">
|
||||
<div className="ambient-glow" />
|
||||
@@ -557,6 +607,7 @@ export function PlatformShell() {
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<ConnectivityBanner />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
cordonAgent,
|
||||
deleteAgent,
|
||||
drainAgent,
|
||||
getAgent,
|
||||
getAgentMetrics,
|
||||
listAgentCommands,
|
||||
listAgentContainers,
|
||||
pruneAgent,
|
||||
updateAgent,
|
||||
upgradeAgent,
|
||||
type AgentMetricPoint,
|
||||
} from '@/lib/api-client';
|
||||
import { getCurrentUserProfile } from '@/lib/api-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { formatBytes, formatRelative } from '@/lib/time';
|
||||
import { DemoRestricted } from '@/shared/components';
|
||||
import {
|
||||
ArrowLeft, CheckCircle2, ChevronRight, Loader2, Pencil, XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ label: '1h', value: '1h' },
|
||||
{ label: '24h', value: '24h' },
|
||||
{ label: '7d', value: '168h' },
|
||||
];
|
||||
|
||||
export function NodeDetailPage() {
|
||||
const { id = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const isDemoMode = useDemoMode();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['user-profile'],
|
||||
queryFn: getCurrentUserProfile,
|
||||
enabled: !isDemoMode && Boolean(sessionQuery.data),
|
||||
retry: false,
|
||||
});
|
||||
const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
|
||||
const [timeRange, setTimeRange] = useState('24h');
|
||||
const [rename, setRename] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [confirmVolumes, setConfirmVolumes] = useState(false);
|
||||
const [confirmDrain, setConfirmDrain] = useState(false);
|
||||
const [tagDraft, setTagDraft] = useState<string | null>(null);
|
||||
const [domainDraft, setDomainDraft] = useState<string | null>(null);
|
||||
|
||||
const agentQuery = useQuery({
|
||||
queryKey: ['node-agent', id],
|
||||
queryFn: () => getAgent(id),
|
||||
enabled: !isDemoMode && id !== '',
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
const metricsQuery = useQuery({
|
||||
queryKey: ['node-metrics', id, timeRange],
|
||||
queryFn: () => getAgentMetrics(id, timeRange),
|
||||
enabled: !isDemoMode && id !== '',
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
const containersQuery = useQuery({
|
||||
queryKey: ['node-containers', id],
|
||||
queryFn: () => listAgentContainers(id),
|
||||
enabled: !isDemoMode && id !== '',
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
const commandsQuery = useQuery({
|
||||
queryKey: ['node-commands', id],
|
||||
queryFn: () => listAgentCommands(id),
|
||||
enabled: !isDemoMode && id !== '',
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['node-agent', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['node-commands', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['usage-agents'] });
|
||||
};
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (body: { name?: string; auto_prune?: boolean; tags?: string[]; default_domain?: string }) => updateAgent(id, body),
|
||||
onSuccess: () => { setRename(null); setTagDraft(null); setDomainDraft(null); invalidate(); },
|
||||
});
|
||||
const pruneMutation = useMutation({
|
||||
mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }),
|
||||
onSuccess: () => { setConfirmVolumes(false); invalidate(); },
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteAgent(id),
|
||||
onSuccess: () => navigate('/usage'),
|
||||
});
|
||||
const cordonMutation = useMutation({
|
||||
mutationFn: (cordoned: boolean) => cordonAgent(id, cordoned),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const drainMutation = useMutation({
|
||||
mutationFn: () => drainAgent(id),
|
||||
onSuccess: () => { setConfirmDrain(false); invalidate(); },
|
||||
});
|
||||
const upgradeMutation = useMutation({
|
||||
mutationFn: () => upgradeAgent(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const agent = agentQuery.data;
|
||||
const metrics = useMemo(() => metricsQuery.data ?? [], [metricsQuery.data]);
|
||||
const latest = metrics.length > 0 ? metrics[metrics.length - 1] : null;
|
||||
const containers = containersQuery.data ?? [];
|
||||
const commands = commandsQuery.data ?? [];
|
||||
|
||||
if (isDemoMode) return <DemoRestricted feature="Nodes" />;
|
||||
|
||||
if (agentQuery.isLoading) {
|
||||
return <div className="px-8 py-10 text-xs text-[var(--text-muted)]">Loading node…</div>;
|
||||
}
|
||||
if (!agent) {
|
||||
return (
|
||||
<div className="px-8 py-10">
|
||||
<p className="text-sm text-[var(--text-secondary)]">Node not found.</p>
|
||||
<Link to="/usage" className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">← back to usage</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const online = agent.status === 'online' || agent.status === 'connecting';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="border-b border-[var(--border-subtle)]">
|
||||
<div className="w-full px-8 py-5">
|
||||
<div className="flex items-center gap-2 v-mono text-[11px] text-[var(--text-tertiary)] mb-2">
|
||||
<Link to="/usage" className="hover:text-[var(--text-primary)] flex items-center gap-1">
|
||||
<ArrowLeft size={12} /> usage
|
||||
</Link>
|
||||
<ChevronRight size={10} />
|
||||
<span className="text-[var(--text-primary)]">{agent.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="v-title">
|
||||
{rename === null ? agent.name : rename}
|
||||
{isAdmin && rename === null && (
|
||||
<button
|
||||
onClick={() => setRename(agent.name)}
|
||||
className="ml-2 inline-block align-middle text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
title="Rename node"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
)}
|
||||
<span className="v-cursor">_</span>
|
||||
</h1>
|
||||
{rename !== null && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
value={rename}
|
||||
onChange={(e) => setRename(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') updateMutation.mutate({ name: rename });
|
||||
if (e.key === 'Escape') setRename(null);
|
||||
}}
|
||||
autoFocus
|
||||
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-1 text-sm text-[var(--text-primary)]"
|
||||
/>
|
||||
<button onClick={() => updateMutation.mutate({ name: rename })} className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">save</button>
|
||||
<button onClick={() => setRename(null)} className="v-mono text-[11px] text-[var(--text-muted)] hover:underline">cancel</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
{agent.hostname} · {agent.ipAddress}:{agent.port}
|
||||
{agent.version ? ` · v${agent.version}` : ''}
|
||||
{agent.lastHeartbeat ? ` · heartbeat ${formatRelative(agent.lastHeartbeat)}` : ''}
|
||||
</p>
|
||||
{meshAddresses(agent).length > 0 && (
|
||||
<p className="v-mono mt-1 text-[11px] text-[var(--text-tertiary)]">
|
||||
mesh: {meshAddresses(agent).map(([iface, ip]) => `${iface}=${ip}`).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
<p className="v-mono mt-1 text-[11px] text-[var(--text-tertiary)] flex items-center gap-1.5 flex-wrap">
|
||||
tags:
|
||||
{tagDraft === null ? (
|
||||
<>
|
||||
{agent.tags.length > 0
|
||||
? agent.tags.map((t) => (
|
||||
<span key={t} className="px-1.5 py-0.5 rounded border border-[var(--border-subtle)] text-[var(--text-secondary)]">{t}</span>
|
||||
))
|
||||
: <span className="text-[var(--text-muted)]">none</span>}
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setTagDraft(agent.tags.join(', '))}
|
||||
className="text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
title="Edit placement tags — services can require them via placement_tags"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
value={tagDraft}
|
||||
onChange={(e) => setTagDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') updateMutation.mutate({ tags: tagDraft.split(',').map((t) => t.trim()).filter(Boolean) });
|
||||
if (e.key === 'Escape') setTagDraft(null);
|
||||
}}
|
||||
autoFocus
|
||||
placeholder="eu-west, gpu"
|
||||
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-52"
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateMutation.mutate({ tags: tagDraft.split(',').map((t) => t.trim()).filter(Boolean) })}
|
||||
className="text-[var(--accent-primary)] hover:underline"
|
||||
>save</button>
|
||||
<button onClick={() => setTagDraft(null)} className="text-[var(--text-muted)] hover:underline">cancel</button>
|
||||
</>
|
||||
)}
|
||||
</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 className="flex items-center gap-3">
|
||||
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online
|
||||
? 'border-[var(--success)]/40 text-[var(--success)]'
|
||||
: 'border-[var(--error)]/40 text-[var(--error)]'}`}>
|
||||
{agent.status}
|
||||
</span>
|
||||
{!agent.schedulable && (
|
||||
<span className="v-mono text-[11px] px-2 py-1 rounded-md border border-[var(--warning)]/40 text-[var(--warning)]">
|
||||
cordoned
|
||||
</span>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<label className="flex items-center gap-1.5 v-mono text-[11px] text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agent.autoPrune}
|
||||
onChange={(e) => updateMutation.mutate({ auto_prune: e.target.checked })}
|
||||
className="accent-[var(--accent-primary)]"
|
||||
/>
|
||||
auto-prune
|
||||
</label>
|
||||
<button
|
||||
onClick={() => cordonMutation.mutate(agent.schedulable)}
|
||||
disabled={cordonMutation.isPending}
|
||||
title={agent.schedulable
|
||||
? 'Block new placements — running replicas stay'
|
||||
: 'Re-open for placement'}
|
||||
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
||||
>
|
||||
{agent.schedulable ? 'cordon' : 'uncordon'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDrain(true)}
|
||||
disabled={drainMutation.isPending || !online}
|
||||
title={online
|
||||
? 'Cordon + remove all service containers + unpin services (they redeploy locally)'
|
||||
: 'Node must be online to drain'}
|
||||
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--warning)]/40 text-[var(--warning)] hover:bg-[var(--warning)]/10 disabled:opacity-50"
|
||||
>
|
||||
drain
|
||||
</button>
|
||||
<button
|
||||
onClick={() => upgradeMutation.mutate()}
|
||||
disabled={upgradeMutation.isPending || !online}
|
||||
title={online
|
||||
? `Upgrade agent to the server's bundled binary (server ${upgradeMutation.data?.version ?? 'build'}) — the agent restarts itself`
|
||||
: 'Node must be online to upgrade'}
|
||||
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
||||
>
|
||||
{upgradeMutation.isPending ? 'upgrading…' : 'upgrade agent'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmVolumes(true)}
|
||||
disabled={pruneMutation.isPending}
|
||||
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
||||
>
|
||||
prune now
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--error)]/40 text-[var(--error)] hover:bg-[var(--error)]/10"
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 space-y-6">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Stat label="cpu" value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} />
|
||||
<Stat label="memory" value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} />
|
||||
<Stat label="load 1m" value={latest ? latest.system_load.load_1m.toFixed(2) : '—'} sub={latest ? `5m ${latest.system_load.load_5m.toFixed(2)} · 15m ${latest.system_load.load_15m.toFixed(2)}` : ''} />
|
||||
<Stat label="containers" value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" />
|
||||
</div>
|
||||
|
||||
<section className="panel p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">telemetry</h2>
|
||||
<div className="flex gap-1">
|
||||
{TIME_RANGES.map((r) => (
|
||||
<button
|
||||
key={r.value}
|
||||
onClick={() => setTimeRange(r.value)}
|
||||
className={`v-mono text-[11px] px-2.5 py-1 rounded-md border ${timeRange === r.value
|
||||
? 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)]'
|
||||
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{metricsQuery.isLoading ? (
|
||||
<p className="text-xs text-[var(--text-muted)]">Loading metrics…</p>
|
||||
) : metrics.length < 2 ? (
|
||||
<p className="text-xs text-[var(--text-muted)]">Not enough heartbeat history yet — the agent reports every few seconds once connected.</p>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Sparkline label="cpu %" points={metrics} value={(m) => m.cpu.usage_percent} />
|
||||
<Sparkline label="memory %" points={metrics} value={(m) => m.memory.usage_percent} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section className="panel p-0">
|
||||
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">containers</h2>
|
||||
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
|
||||
{containers.length === 0 && (
|
||||
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No containers reported on this node.</p>
|
||||
)}
|
||||
{containers.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between gap-3 px-5 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-[var(--text-primary)]">{c.name ?? c.id}</p>
|
||||
<p className="truncate v-mono text-[11px] text-[var(--text-tertiary)]">{c.image}</p>
|
||||
</div>
|
||||
<span className="v-mono text-[11px] text-[var(--text-secondary)]">{c.status ?? '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel p-0">
|
||||
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">command history</h2>
|
||||
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
|
||||
{commands.length === 0 && (
|
||||
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No commands sent to this node yet.</p>
|
||||
)}
|
||||
{commands.slice(0, 15).map((cmd) => (
|
||||
<div key={cmd.id} className="flex items-center justify-between gap-3 px-5 py-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{cmd.status === 'completed'
|
||||
? <CheckCircle2 size={13} className="text-[var(--success)] shrink-0" />
|
||||
: cmd.status === 'failed'
|
||||
? <XCircle size={13} className="text-[var(--error)] shrink-0" />
|
||||
: <Loader2 size={13} className="text-[var(--text-muted)] animate-spin shrink-0" />}
|
||||
<span className="v-mono text-[11px] text-[var(--text-primary)]">{cmd.type}</span>
|
||||
{cmd.error && <span className="truncate text-[11px] text-[var(--error)]">{cmd.error}</span>}
|
||||
</div>
|
||||
<span className="v-mono text-[11px] text-[var(--text-muted)] whitespace-nowrap">
|
||||
{cmd.created_at ? formatRelative(cmd.created_at) : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmVolumes && (
|
||||
<ConfirmModal
|
||||
title="Prune this node?"
|
||||
body={`Runs a bounded docker system prune on ${agent.name}. Unused images, stopped containers, and networks are removed.`}
|
||||
confirmLabel="prune"
|
||||
danger={false}
|
||||
extra={
|
||||
<label className="flex items-center gap-2 text-xs text-[var(--text-secondary)] cursor-pointer">
|
||||
<input type="checkbox" id="prune-volumes" className="accent-[var(--error)]" />
|
||||
also prune volumes (can delete database data)
|
||||
</label>
|
||||
}
|
||||
onCancel={() => setConfirmVolumes(false)}
|
||||
onConfirm={() => {
|
||||
const volumes = (document.getElementById('prune-volumes') as HTMLInputElement | null)?.checked ?? false;
|
||||
pruneMutation.mutate(volumes);
|
||||
}}
|
||||
pending={pruneMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
{confirmDrain && (
|
||||
<ConfirmModal
|
||||
title={`Drain ${agent.name}?`}
|
||||
body="Every service container on this node is removed and the affected services are unpinned — they redeploy on the local host until re-pinned elsewhere. The node stays cordoned."
|
||||
confirmLabel="drain node"
|
||||
danger
|
||||
onCancel={() => setConfirmDrain(false)}
|
||||
onConfirm={() => drainMutation.mutate()}
|
||||
pending={drainMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
{confirmDelete && (
|
||||
<ConfirmModal
|
||||
title={`Remove ${agent.name}?`}
|
||||
body="The agent record and its history are deleted. Containers running on the host are not stopped — uninstall the agent there separately."
|
||||
confirmLabel="remove node"
|
||||
danger
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
onConfirm={() => deleteMutation.mutate()}
|
||||
pending={deleteMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function meshAddresses(agent: { metadata: Record<string, unknown> }): [string, string][] {
|
||||
const mesh = agent.metadata?.mesh;
|
||||
if (!mesh || typeof mesh !== 'object' || Array.isArray(mesh)) return [];
|
||||
return Object.entries(mesh as Record<string, unknown>)
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '');
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<div className="panel p-4">
|
||||
<p className="v-mono text-[10px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
|
||||
<p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{value}</p>
|
||||
{sub && <p className="mt-0.5 v-mono text-[11px] text-[var(--text-muted)]">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Sparkline({ label, points, value }: {
|
||||
label: string;
|
||||
points: AgentMetricPoint[];
|
||||
value: (m: AgentMetricPoint) => number;
|
||||
}) {
|
||||
const w = 560;
|
||||
const h = 90;
|
||||
const vals = points.map(value);
|
||||
const max = Math.max(100, ...vals);
|
||||
const step = w / Math.max(points.length - 1, 1);
|
||||
const polyline = vals.map((v, i) => `${(i * step).toFixed(1)},${(h - (v / max) * (h - 6)).toFixed(1)}`).join(' ');
|
||||
return (
|
||||
<div>
|
||||
<p className="v-mono text-[10px] uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
{label} — now {vals[vals.length - 1].toFixed(0)}% / peak {Math.max(...vals).toFixed(0)}%
|
||||
</p>
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-24 rounded-md border border-[var(--border-subtle)] bg-[var(--bg-secondary)]">
|
||||
<polyline points={polyline} fill="none" stroke="var(--accent-primary)" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmModal({ title, body, confirmLabel, danger, extra, onCancel, onConfirm, pending }: {
|
||||
title: string;
|
||||
body: string;
|
||||
confirmLabel: string;
|
||||
danger: boolean;
|
||||
extra?: React.ReactNode;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
pending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||
<div className="panel w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||
<p className="mt-2 text-xs text-[var(--text-secondary)]">{body}</p>
|
||||
{extra && <div className="mt-3">{extra}</div>}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button onClick={onCancel} className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)]">
|
||||
cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={pending}
|
||||
className={`v-mono text-[11px] px-3 py-1.5 rounded-md border disabled:opacity-50 ${danger
|
||||
? 'border-[var(--error)]/50 text-[var(--error)] hover:bg-[var(--error)]/10'
|
||||
: 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)] hover:bg-[var(--accent-primary)]/10'}`}
|
||||
>
|
||||
{pending ? 'working…' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
getCurrentUserProfile,
|
||||
listAuditLogs,
|
||||
listAgents,
|
||||
pruneAgent,
|
||||
updateAgent,
|
||||
listBuilds,
|
||||
listGitProviders,
|
||||
listProjects,
|
||||
@@ -73,6 +75,11 @@ import {
|
||||
type CreateDatabaseInput,
|
||||
type DatabaseEntity,
|
||||
type FailoverPolicy,
|
||||
listNotificationChannels,
|
||||
createNotificationChannel,
|
||||
deleteNotificationChannel,
|
||||
testNotificationChannel,
|
||||
type NotificationChannel,
|
||||
} from '@/lib/api-client';
|
||||
import {
|
||||
demoBuilds,
|
||||
@@ -137,6 +144,8 @@ import {
|
||||
Cloud,
|
||||
ExternalLink,
|
||||
Upload,
|
||||
Bell,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
|
||||
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
||||
@@ -293,6 +302,14 @@ export function UsagePage() {
|
||||
mutationFn: (id: string) => revokeAgentToken(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }),
|
||||
});
|
||||
const pruneMutation = useMutation({
|
||||
mutationFn: (id: string) => pruneAgent(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['usage-agents'] }),
|
||||
});
|
||||
const autoPruneMutation = useMutation({
|
||||
mutationFn: ({ id, on }: { id: string; on: boolean }) => updateAgent(id, { auto_prune: on }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['usage-agents'] }),
|
||||
});
|
||||
|
||||
const builds = useMemo(
|
||||
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
|
||||
@@ -377,9 +394,9 @@ export function UsagePage() {
|
||||
const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
|
||||
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
|
||||
const agentEndpoint = getAgentPublicBaseUrl();
|
||||
const connectCommand = `CONTAINR_API_URL=${getApiBaseUrl().replace(/\/api\/v1$/, '')} \\
|
||||
CONTAINR_AGENT_AUTH_TOKEN=${issuedToken?.token ?? '<token>'} \\
|
||||
containr-agent`;
|
||||
const apiBase = getApiBaseUrl().replace(/\/api\/v1$/, '');
|
||||
const installCommand = `curl -fsSL ${apiBase}/api/agents/install.sh | bash -s -- --url ${apiBase} --token ${issuedToken?.token ?? '<token>'}`;
|
||||
const sshCommand = `containr nodes add --ssh <user@host>${issuedToken?.token ? '' : ' # run on a host with the CLI'}`;
|
||||
|
||||
let buildActivityBody = 'Track deployment frequency and failed rollouts over time.';
|
||||
let runtimeBody = 'Build duration telemetry will appear after completed builds are available.';
|
||||
@@ -603,12 +620,35 @@ containr-agent`;
|
||||
agents.slice(0, 5).map((agent) => (
|
||||
<div key={agent.id} className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-[var(--text-primary)]">{agent.name}</p>
|
||||
<Link to={`/nodes/${agent.id}`} className="truncate text-sm font-medium text-[var(--text-primary)] hover:text-[var(--accent-primary)]">{agent.name}</Link>
|
||||
<p className="truncate text-xs text-[var(--text-tertiary)]">{agent.hostname} · {agent.ipAddress}:{agent.port}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-semibold text-[var(--text-primary)]">{agent.status}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">{formatBytes(agent.resources.memory.available)} free</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => pruneMutation.mutate(agent.id)}
|
||||
disabled={pruneMutation.isPending}
|
||||
title="Enqueue a bounded docker prune on this node"
|
||||
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:underline disabled:opacity-50"
|
||||
>
|
||||
Prune
|
||||
</button>
|
||||
<label className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agent.autoPrune}
|
||||
onChange={(e) => autoPruneMutation.mutate({ id: agent.id, on: e.target.checked })}
|
||||
className="accent-[var(--accent-primary)]"
|
||||
/>
|
||||
auto
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-semibold text-[var(--text-primary)]">{agent.status}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">{formatBytes(agent.resources.memory.available)} free</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -666,7 +706,8 @@ containr-agent`;
|
||||
</p>
|
||||
)}
|
||||
|
||||
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{connectCommand}</pre>
|
||||
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{installCommand}</pre>
|
||||
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap mt-2">{sshCommand}</pre>
|
||||
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
|
||||
<p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
|
||||
<p>Use an issued token above, or set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p>
|
||||
@@ -1212,7 +1253,11 @@ export function GitHubAppCallbackPage() {
|
||||
mutationFn: (appCode: string) => convertGitHubAppCode(appCode),
|
||||
onSuccess: () => {
|
||||
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);
|
||||
@@ -1874,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() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -2047,6 +2237,7 @@ export function SettingsPage() {
|
||||
|
||||
{/* Personal Access Tokens */}
|
||||
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
||||
<NotificationChannelsSection />
|
||||
<RegistriesSection />
|
||||
<BackupTargetsSection />
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
cloneService,
|
||||
moveService,
|
||||
listProjects,
|
||||
listNodeOptions,
|
||||
} from '@/lib/api-client';
|
||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
@@ -466,6 +467,7 @@ export function ServiceDetailPage() {
|
||||
onSuccess: () => {
|
||||
setNetworkForm(null);
|
||||
setSleepDraft(null);
|
||||
setNodeDraft(null);
|
||||
invalidateService();
|
||||
},
|
||||
});
|
||||
@@ -483,8 +485,15 @@ export function ServiceDetailPage() {
|
||||
const [volumesForm, setVolumesForm] = useState<ServiceVolume[] | null>(null);
|
||||
const [domainInput, setDomainInput] = useState('');
|
||||
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
|
||||
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
|
||||
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string; traefikLabels: string } | null>(null);
|
||||
const [sleepDraft, setSleepDraft] = useState<{ id: string; enabled: boolean; minutes: string } | null>(null);
|
||||
const [nodeDraft, setNodeDraft] = useState<string | null>(null);
|
||||
const nodesQuery = useQuery({
|
||||
queryKey: ['node-options'],
|
||||
queryFn: listNodeOptions,
|
||||
enabled: !isDemoMode,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const [buildForm, setBuildForm] = useState<{
|
||||
builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string;
|
||||
} | null>(null);
|
||||
@@ -2384,6 +2393,95 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && (nodesQuery.data?.length ?? 0) > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-medium text-[var(--text-primary)]">Placement</h3>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-0.5">
|
||||
Which node runs this service's containers. Remote nodes pull the image
|
||||
from its registry — git builds and domains stay on the local host.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<select
|
||||
value={service.spread ? '__spread__' : (nodeDraft ?? (service.nodeId || 'local'))}
|
||||
disabled={service.spread}
|
||||
onChange={(e) => setNodeDraft(e.target.value)}
|
||||
className="px-2 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] disabled:opacity-50"
|
||||
>
|
||||
{service.spread && <option value="__spread__">All nodes (spread)</option>}
|
||||
<option value="local">This server (local Docker)</option>
|
||||
<option value="auto">Auto — least-loaded online node</option>
|
||||
{(nodesQuery.data ?? []).map((n) => (
|
||||
<option key={n.id} value={n.id}>
|
||||
{n.name}{n.status !== 'online' ? ' (offline)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="flex items-center gap-1.5 text-[var(--text-secondary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={service.spread ?? false}
|
||||
disabled={updateServiceMutation.isPending || (!!service.nodeId && !service.spread)}
|
||||
title={service.nodeId && !service.spread ? 'Clear the node pin first (choose "This server")' : undefined}
|
||||
onChange={(e) => updateServiceMutation.mutate({ spread: e.target.checked })}
|
||||
className="accent-[var(--accent-primary)]"
|
||||
/>
|
||||
spread replicas across nodes
|
||||
</label>
|
||||
{nodeDraft !== null && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateServiceMutation.isPending}
|
||||
onClick={() => updateServiceMutation.mutate({ node_id: nodeDraft })}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNodeDraft(null)}
|
||||
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{service.nodeName && nodeDraft === null && (
|
||||
<span className="text-[var(--text-tertiary)]">running on {service.nodeName}</span>
|
||||
)}
|
||||
</div>
|
||||
{!service.nodeId && (
|
||||
<div className="mt-3 flex items-center gap-2 text-xs">
|
||||
<label className="text-[var(--text-tertiary)]" htmlFor="placement-tags">
|
||||
required node tags
|
||||
</label>
|
||||
<input
|
||||
id="placement-tags"
|
||||
type="text"
|
||||
placeholder="e.g. eu-west, gpu — empty = any node"
|
||||
defaultValue={(service.placementTags ?? []).join(', ')}
|
||||
disabled={updateServiceMutation.isPending}
|
||||
title="Remote placement only targets nodes carrying every tag. Re-resolved each reconcile — the service follows the tag, not a stored node."
|
||||
onBlur={(e) => {
|
||||
const next = e.target.value.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
const prev = service.placementTags ?? [];
|
||||
if (next.join('|') !== prev.join('|')) {
|
||||
updateServiceMutation.mutate({ placement_tags: next });
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
||||
className="px-2 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] w-72"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{updateServiceMutation.isError && (
|
||||
<p className="mt-2 text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -2392,7 +2490,13 @@ export function ServiceDetailPage() {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
@@ -2413,6 +2517,14 @@ export function ServiceDetailPage() {
|
||||
{(service.basicAuth ?? []).length > 0 ? (service.basicAuth ?? []).join(', ') : 'off'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">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 className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
|
||||
@@ -2434,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)]"
|
||||
/>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -2448,8 +2570,17 @@ export function ServiceDetailPage() {
|
||||
return i > 0 ? { username: l.slice(0, i), password: l.slice(i + 1) } : 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(
|
||||
{ maintenance_mode: accessForm.maintenance, basic_auth: creds },
|
||||
{ maintenance_mode: accessForm.maintenance, basic_auth: creds, traefik_labels: labels },
|
||||
{ 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getCurrentUserProfile,
|
||||
getTemplateById,
|
||||
importComposeTemplate,
|
||||
importGitComposeTemplate,
|
||||
listProjects,
|
||||
listTemplates,
|
||||
updateTemplate,
|
||||
@@ -41,6 +42,10 @@ import {
|
||||
Network,
|
||||
} from 'lucide-react';
|
||||
|
||||
const TemplateBuilder = lazy(() =>
|
||||
import('@/features/templates/builder/TemplateBuilder').then((m) => ({ default: m.TemplateBuilder })),
|
||||
);
|
||||
|
||||
function toServiceName(value: string): string {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
@@ -126,6 +131,7 @@ export function TemplatesPage() {
|
||||
const isAdmin = Boolean(profileQuery.data?.isAdmin);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<'json' | 'visual'>('json');
|
||||
const [editorJson, setEditorJson] = useState(EMPTY_TEMPLATE_JSON);
|
||||
const [editingTemplate, setEditingTemplate] = useState<TemplateEntity | null>(null);
|
||||
const [editorIsPublic, setEditorIsPublic] = useState(false);
|
||||
@@ -154,6 +160,10 @@ export function TemplatesPage() {
|
||||
const [composeYaml, setComposeYaml] = useState('');
|
||||
const [composeWarnings, setComposeWarnings] = useState<string[]>([]);
|
||||
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<{
|
||||
projectId: string;
|
||||
@@ -364,7 +374,10 @@ export function TemplatesPage() {
|
||||
});
|
||||
|
||||
const composeImportMutation = useMutation({
|
||||
mutationFn: (yaml: string) => importComposeTemplate(yaml),
|
||||
mutationFn: () =>
|
||||
composeMode === 'git'
|
||||
? importGitComposeTemplate(gitRepo.trim(), gitPath.trim(), gitRef.trim())
|
||||
: importComposeTemplate(composeYaml),
|
||||
onSuccess: (result) => {
|
||||
setComposeWarnings(result.warnings);
|
||||
setComposeError(null);
|
||||
@@ -1014,17 +1027,50 @@ export function TemplatesPage() {
|
||||
{/* Template editor — JSON only */}
|
||||
{editorOpen ? (
|
||||
<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>
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">
|
||||
{editingTemplate ? `Edit ${editingTemplate.name}` : 'New template'}
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
<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)]">
|
||||
<Upload size={12} /> Upload .json
|
||||
<input
|
||||
@@ -1047,12 +1093,49 @@ export function TemplatesPage() {
|
||||
</button>
|
||||
</div>
|
||||
</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"
|
||||
/>
|
||||
{editorMode === 'visual' ? (
|
||||
<div className="flex min-h-[520px] flex-1 flex-col">
|
||||
<Suspense
|
||||
fallback={
|
||||
<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 ? (
|
||||
<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>
|
||||
@@ -1066,7 +1149,7 @@ export function TemplatesPage() {
|
||||
{editorError}
|
||||
</div>
|
||||
) : 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)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1107,7 +1190,7 @@ export function TemplatesPage() {
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Import docker-compose</h2>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
@@ -1118,13 +1201,67 @@ export function TemplatesPage() {
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<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 gap-1 border-b border-[var(--border-subtle)] px-5 pt-3">
|
||||
{(['paste', 'git'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
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 ? (
|
||||
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
|
||||
{composeError}
|
||||
@@ -1140,8 +1277,8 @@ export function TemplatesPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => composeImportMutation.mutate(composeYaml)}
|
||||
disabled={!composeYaml.trim() || composeImportMutation.isPending}
|
||||
onClick={() => composeImportMutation.mutate()}
|
||||
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"
|
||||
>
|
||||
{composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />}
|
||||
|
||||
@@ -25,14 +25,22 @@ import {
|
||||
} from '@/lib/api-client';
|
||||
import { useToast } from '@/shared/hooks/use-toast';
|
||||
import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
|
||||
import {
|
||||
DEFAULT_VIEWPORT,
|
||||
type CanvasGroup,
|
||||
type CanvasNodeLayout,
|
||||
type ProjectCanvasMetadata,
|
||||
} from '../model';
|
||||
import { DEFAULT_VIEWPORT, type ProjectCanvasMetadata } from '../model';
|
||||
import { hasCanvasMetadata, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
|
||||
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 { ServiceInspector, type InspectorGroup } from './inspector';
|
||||
import { serviceAccent } from './service-visuals';
|
||||
@@ -65,11 +73,7 @@ type CanvasProps = {
|
||||
|
||||
type ServiceCanvasNode = Node<ServiceNodeData, 'serviceNode'>;
|
||||
type GroupCanvasNode = Node<GroupNodeData, 'groupNode'>;
|
||||
type CanvasNode = ServiceCanvasNode | GroupCanvasNode;
|
||||
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 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 }) {
|
||||
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,
|
||||
createService,
|
||||
getProjectById,
|
||||
createProjectEnvironment,
|
||||
importComposeIntoProject,
|
||||
listConnectedGitRepositories,
|
||||
listGitBranches,
|
||||
listGitProviders,
|
||||
listGitRepositories,
|
||||
listProjectActivity,
|
||||
listProjectEnvironments,
|
||||
listServiceLogs,
|
||||
listServiceVariables,
|
||||
listServicesByProject,
|
||||
@@ -589,6 +591,22 @@ export function ProjectWorkspacePage() {
|
||||
() => (isDemoMode ? getDemoServicesByProject(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 variablesQuery = useQuery({
|
||||
@@ -850,18 +868,50 @@ export function ProjectWorkspacePage() {
|
||||
|
||||
{/* Service List */}
|
||||
<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)]">
|
||||
{services.length === 0 ? (
|
||||
{visibleServices.length === 0 ? (
|
||||
<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">
|
||||
<Layers size={20} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">No services deployed yet</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">Add services from the Canvas view</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{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>
|
||||
) : (
|
||||
services.map((service) => (
|
||||
visibleServices.map((service) => (
|
||||
<button
|
||||
key={service.id}
|
||||
onClick={() => navigate(serviceHref(service.id))}
|
||||
@@ -1124,3 +1174,39 @@ export function ProjectWorkspacePage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user