mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
7547375cc2 | ||
|
|
d4a4cde1fd |
No files matched your search
@@ -47,6 +47,16 @@ SECRETS_KEY=
|
|||||||
CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
|
CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
|
||||||
# Optional rotation list (comma-separated). When set, this takes precedence.
|
# Optional rotation list (comma-separated). When set, this takes precedence.
|
||||||
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_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.
|
||||||
|
# CONTAINR_TEMPLATE_REGISTRY_URL=https://templates.example.com
|
||||||
CORS_ORIGINS=http://localhost:3000,http://localhost:8080
|
CORS_ORIGINS=http://localhost:3000,http://localhost:8080
|
||||||
# Optional build-time API URL overrides. Leave empty for the default
|
# Optional build-time API URL overrides. Leave empty for the default
|
||||||
# same-origin setup - the frontend nginx proxies /api to the backend, which
|
# same-origin setup - the frontend nginx proxies /api to the backend, which
|
||||||
|
|||||||
@@ -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
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Boot stack
|
- name: Boot stack
|
||||||
|
# .github/compose.ci.yml swaps every docker.io ref for
|
||||||
|
# mirror.gcr.io — anonymous Hub pulls 429 on shared CI IPs.
|
||||||
run: |
|
run: |
|
||||||
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
|
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
|
||||||
BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
|
BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
|
||||||
export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN
|
export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN
|
||||||
docker compose -f infra/docker-compose.yml up -d --build postgres dragonfly backend frontend traefik
|
docker compose -f infra/docker-compose.yml -f .github/compose.ci.yml up -d --build postgres dragonfly backend frontend traefik
|
||||||
|
|
||||||
- name: Wait for backend health
|
- name: Wait for backend health
|
||||||
run: |
|
run: |
|
||||||
@@ -204,7 +206,13 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Docker Buildx
|
- name: Setup Docker Buildx
|
||||||
|
# Mirror docker.io through gcr — shared CI IPs hit anonymous
|
||||||
|
# rate limits on base-image metadata resolution otherwise.
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
config-inline: |
|
||||||
|
[registry."docker.io"]
|
||||||
|
mirrors = ["mirror.gcr.io"]
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
@@ -248,6 +256,10 @@ jobs:
|
|||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||||
|
# Stamp the release tag into the binary so the in-app upgrade
|
||||||
|
# check knows what it's running ("dev" disables it).
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ github.ref_type == 'tag' && github.ref_name || 'dev' }}
|
||||||
cache-from: type=gha,scope=backend
|
cache-from: type=gha,scope=backend
|
||||||
cache-to: type=gha,mode=max,scope=backend
|
cache-to: type=gha,mode=max,scope=backend
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ dedicated console.
|
|||||||
- **Public project catalog** — approved projects are browsable anonymously; new projects stay private until an admin approves them
|
- **Public project catalog** — approved projects are browsable anonymously; new projects stay private until an admin approves them
|
||||||
- **Admin console** (`/admin`) — platform stats, project approval/edit/delete, user admin management
|
- **Admin console** (`/admin`) — platform stats, project approval/edit/delete, user admin management
|
||||||
- **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references
|
- **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references
|
||||||
- **Deployments & builds** — Docker deploys with history, image rollback, live build status
|
- **Deployments & builds** — Docker deploys with history, image rollback, live build status; git-sourced services build directly on remote node agents (no registry required)
|
||||||
- **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page
|
- **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page
|
||||||
- **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App
|
- **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App
|
||||||
- **Managed databases** — create PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Dragonfly, or ClickHouse directly from `/databases`, with backup/restore
|
- **Managed databases** — create PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Dragonfly, or ClickHouse directly from `/databases`, with backup/restore
|
||||||
|
|||||||
+193
-71
@@ -91,10 +91,10 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
|
|||||||
| White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` — **Containr remains the default brand** |
|
| White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` — **Containr remains the default brand** |
|
||||||
| Activity feed (icon/severity/category/metadata, 90-day TTL job) | ✅ `/activity` + project view | — |
|
| Activity feed (icon/severity/category/metadata, 90-day TTL job) | ✅ `/activity` + project view | — |
|
||||||
| Admin metrics dashboard (users/servers/queued/failed counts) | 🟡 `/admin` exists | Extend |
|
| Admin metrics dashboard (users/servers/queued/failed counts) | 🟡 `/admin` exists | Extend |
|
||||||
| **Impersonate user** | ❌ | Admin support tool |
|
| **Impersonate user** | ✅ admin token + `impersonated_by` claim (browser swap pending) | Done (CLI/API/MCP) |
|
||||||
| In-app docs (content-collections markdown) | ✅ `/docs` | — |
|
| In-app docs (content-collections markdown) | ✅ `/docs` | — |
|
||||||
| Dynamic client-side filter framework (535-line `filter.utils`) | 🟡 audit page filters only | Reuse pattern for tables |
|
| Dynamic client-side filter framework (535-line `filter.utils`) | ✅ `dynamic-filter.ts` + URL state; audit/builds/activity | Done |
|
||||||
| Ops "bubble" (queue monitor, terminal, sync) | ❌ | Operations page |
|
| Ops "bubble" (queue monitor, terminal, sync) | ✅ `/operations` + interactive terminal | Done |
|
||||||
|
|
||||||
#### Agent surface
|
#### Agent surface
|
||||||
| dflow capability | Containr status | Action |
|
| dflow capability | Containr status | Action |
|
||||||
@@ -103,8 +103,8 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
|
|||||||
| CLI | ❌ | none in dflow — **Containr CLI is a genuine leap past it** |
|
| CLI | ❌ | none in dflow — **Containr CLI is a genuine leap past it** |
|
||||||
| MCP | ❌ | none — same leap |
|
| MCP | ❌ | none — same leap |
|
||||||
| Railway migrator script | ❌ | `containr migrate railway` |
|
| Railway migrator script | ❌ | `containr migrate railway` |
|
||||||
| GitHub App **manifest flow** (instance self-provisions the App) | 🟡 env-configured App | `POST /git/github-app/manifest` flow |
|
| GitHub App **manifest flow** (instance self-provisions the App) | ✅ manifest + convert + App webhook | Done |
|
||||||
| fork-sync webhook deploy trigger | ❌ | ~20 lines on receiver |
|
| fork-sync webhook deploy trigger | ✅ sync/repository_dispatch/workflow_run | Done |
|
||||||
| Azure DevOps provider | ❌ | Optional (dflow does token-clone only) |
|
| Azure DevOps provider | ❌ | Optional (dflow does token-clone only) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -192,8 +192,11 @@ goroutines; this fixes correctness *and* unlocks the ops surface.
|
|||||||
compares vs edge IP → `ok|wrong-target|pending`.
|
compares vs edge IP → `ok|wrong-target|pending`.
|
||||||
- [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
|
- [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
|
||||||
no proxy code needed.
|
no proxy code needed.
|
||||||
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with
|
- [x] **Routing overrides** — `services.traefik_labels` jsonb with
|
||||||
allowlist validation (middlewares, headers, redirects).
|
allowlist validation. Keys `middlewares.<name>.<type>[.<field>]` scope
|
||||||
|
to the service router (`svc-<id>-<name>`), auto-attach after builtins;
|
||||||
|
router/service/provider-level keys rejected. API + CLI
|
||||||
|
(`--traefik-label`) + MCP + service page Access editor.
|
||||||
- [x] **Builder override** — `services.builder` enum
|
- [x] **Builder override** — `services.builder` enum
|
||||||
`auto|railpack|nixpacks|dockerfile|static`; static builder = build +
|
`auto|railpack|nixpacks|dockerfile|static`; static builder = build +
|
||||||
nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug.
|
nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug.
|
||||||
@@ -223,58 +226,144 @@ goroutines; this fixes correctness *and* unlocks the ops surface.
|
|||||||
{target_node_id}`: dump → stream transfer → provision → restore →
|
{target_node_id}`: dump → stream transfer → provision → restore →
|
||||||
re-point bound services. Deferred until Phase F lands real second
|
re-point bound services. Deferred until Phase F lands real second
|
||||||
nodes — nothing to migrate between today.
|
nodes — nothing to migrate between today.
|
||||||
- [ ] **Backup import** — upload archive → new restore point (pairs with
|
- [x] **Backup import** — `POST /databases/:id/backups/import` accepts a
|
||||||
|
`.tar.gz` (multipart `file` or raw body, 2 GiB cap, gzip-magic
|
||||||
|
validated), stores it in the backups volume via a holder container,
|
||||||
|
registers a completed restore point, and ships offsite when a backup
|
||||||
|
target is configured. `api/backuptargets.go`. (pairs with
|
||||||
migrators).
|
migrators).
|
||||||
|
|
||||||
## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates)
|
## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates)
|
||||||
|
|
||||||
- [ ] **Template = ordered service graph** — `services[]` entries with
|
- [x] **Template = ordered service graph** — `services[]` entries with
|
||||||
`{name, type, source, build, ports, volumes, variables, depends_on}`;
|
`{name, type, source, build, ports, volumes, variables, depends_on}`;
|
||||||
deploy order from dependency refs.
|
deploy order from dependency refs. `templategraph.go`,
|
||||||
- [ ] **Expressions** — `${{service.KEY}}` (already the runtime ref
|
`topoSortServices`.
|
||||||
syntax — reuse), `{{secret(len,"charset")}}`, `{{random:int}}`,
|
- [x] **Expressions** — `{{service.KEY.prop}}` (host/port/user/password/
|
||||||
`{{VAR:-default}}`; `POST /templates/:id/plan` returns the resolved
|
database/url), `{{secret}}`/`{{secret(N)}}`, `{{random:N}}`,
|
||||||
graph without deploying (agents love this).
|
`{{VAR:-default}}` (incl. explicitly-empty defaults); `POST
|
||||||
- [ ] **Compose import** — `POST /templates/import/compose` (compose-go
|
/templates/:id/plan` returns the resolved graph without deploying.
|
||||||
parser → graph), `POST /templates/import/github` (compose file from
|
- [x] **Compose import** — `POST /templates/import/compose` (yaml →
|
||||||
repo path). Implements `docs/superpowers/specs/2026-04-14-*`.
|
graph, known DB images become managed databases), `POST
|
||||||
- [ ] **Graph deploy pipeline** — creates project + services in
|
/templates/import/git` (compose file fetched from any git repo — clone
|
||||||
dependency order, resolves vars, triggers builds; defined
|
URL, ssh remote, or owner/repo via connected providers, `ref` selects
|
||||||
partial-failure policy.
|
branch/commit); `templates import-compose|import-git` CLI, MCP tools,
|
||||||
|
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`.
|
||||||
- [ ] **Visual template builder** — canvas mode: drop git/image/db
|
- [ ] **Visual template builder** — canvas mode: drop git/image/db
|
||||||
nodes, wire refs, set order, save/publish. Reuses existing canvas
|
nodes, wire refs, set order, save/publish. Reuses existing canvas
|
||||||
primitives (dflow's `templates/compose` is exactly this).
|
primitives (dflow's `templates/compose` is exactly this).
|
||||||
- [ ] **Catalog growth** — convert `templates/*.md` (20 app guides) into
|
- [x] **Catalog growth** — seeded graph stacks for n8n, Gitea,
|
||||||
real graph templates; screenshots + badges per the old spec.
|
Nextcloud, Vaultwarden, Umami, Immich (Plausible already shipped).
|
||||||
|
`TestSeedTemplatesResolveCleanly` keeps every seed parseable,
|
||||||
|
topo-sortable, and fully resolved. Remaining single-service guides can
|
||||||
|
convert incrementally via the same endpoint.
|
||||||
|
|
||||||
**Gate**: paste n8n compose → 3-service project with generated secrets
|
**Gate**: paste n8n compose → 3-service project with generated secrets
|
||||||
and wired refs, one click or one CLI call.
|
and wired refs, one click or one CLI call.
|
||||||
|
|
||||||
## 7. Phase F — Nodes & infrastructure
|
## 7. Phase F — Nodes & infrastructure
|
||||||
|
|
||||||
- [ ] **SSH bootstrap install** — `containr nodes add --ssh user@host` +
|
- [x] **SSH bootstrap install** — `containr nodes add <user@host>` mints an
|
||||||
UI flow: one SSH session runs agent install (binary + systemd +
|
enroll token, SSHes once (agent/key/default keys), runs
|
||||||
enroll token), SSH closed forever. dflow onboarding UX, agent-model
|
`GET /api/agents/install.sh | bash` remotely, closes the session.
|
||||||
security.
|
`--print` emits the one-liner for manual runs; the Usage page shows the
|
||||||
- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/
|
same command. Agent binaries ship in the image under `/agents` and are
|
||||||
zt0 IPs at register; backend stores + displays. Optional
|
served at `/api/agents/download/{linux-amd64,linux-arm64}` (install
|
||||||
`TAILSCALE_AUTH_KEY` enroll-at-provision later.
|
script falls back to GH releases). `cli/commands/nodes_add.go`,
|
||||||
- [ ] **Node detail page** — placed services, telemetry history, agent
|
`api/assets/install-agent.sh`, `agents.go:ServeInstallScript`/`ServeAgentBinary`.
|
||||||
version + self-upgrade, **drain/cordon**, remove.
|
- [x] **Mesh awareness** — agent reports tailscale/netbird/wireguard/
|
||||||
- [ ] **Node housekeeping** — agent `prune` command (docker system
|
zerotier interface IPs at register (`mesh` field, authoritative on
|
||||||
prune, bounded) + optional schedule (`setServerAutoCleanup` analog —
|
re-register), stored in `node_agents.metadata.mesh`, shown on the node
|
||||||
disk pressure is the #1 small-VPS killer).
|
detail page. `TAILSCALE_AUTH_KEY` enroll-at-provision still open.
|
||||||
- [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists):
|
- [x] **Node detail page** — `/nodes/:id` (admin): heartbeat telemetry
|
||||||
pin/spread/affinity, prefer-lowest-usage; deploy engine routes to
|
(cpu/mem sparklines over 1h/24h/7d via `GET /agents/:id/metrics`),
|
||||||
`services.node_id` via agent commands (agent already runs containers).
|
containers, command history, rename, auto-prune toggle, prune-now,
|
||||||
- [ ] **Build-on-node** — agents currently `docker run` prebuilt images
|
remove. Drain/cordon and self-upgrade deferred — they need
|
||||||
only; extend agent to pull registry images (build stays central) —
|
`services.node_id` routing which lands with multi-node scheduling.
|
||||||
private registry creds flow through Phase C.
|
- [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/
|
- [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/
|
||||||
OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module
|
OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module
|
||||||
(dflow's second provider), cloud-init → auto-enroll.
|
(dflow's second provider), cloud-init → auto-enroll.
|
||||||
- [ ] **Global/wildcard domain per node** (`nodes.default_domain`) —
|
- [x] **Global/wildcard domain per node** (`node_agents.default_domain`) —
|
||||||
auto-domains for services on that node.
|
`PUT /agents/:id {default_domain}`; domainless services placed on the
|
||||||
|
node get `<name>.<default_domain>` (pinned → node's base; spread →
|
||||||
|
union of eligible nodes). `serviceDomainNames` resolves them so
|
||||||
|
routing, wake placeholders, and `public_url` all pick them up.
|
||||||
|
CLI `nodes update --domain`, MCP arg, node-detail editor.
|
||||||
|
|
||||||
## 8. Phase G — Platform polish
|
## 8. Phase G — Platform polish
|
||||||
|
|
||||||
@@ -339,11 +428,19 @@ and wired refs, one click or one CLI call.
|
|||||||
Also fixed a latent bug where the webhook's deployment insert omitted
|
Also fixed a latent bug where the webhook's deployment insert omitted
|
||||||
the NOT NULL `version` column — push-triggered deploys silently
|
the NOT NULL `version` column — push-triggered deploys silently
|
||||||
failed for every service.
|
failed for every service.
|
||||||
- [ ] **Dynamic filter framework** — port dflow's `filter.utils`
|
- [x] **Dynamic filter framework** — ported dflow's `filter.utils`
|
||||||
pattern (typed filter configs → URL state → client filtering) for
|
pattern as `lib/dynamic-filter.ts` + `use-dynamic-filter.ts`
|
||||||
audit logs, deployments, notifications tables.
|
(`FilterConfig` schema → `FilterEngine` → `FilterBar`; state lives in
|
||||||
- [ ] **Notification producers round-out** — backups, agent offline,
|
`?f.<key>` search params so filtered views are shareable links).
|
||||||
upgrade available, security findings.
|
`useFilterState` exposes URL state pre-fetch for server-backed
|
||||||
|
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.
|
||||||
|
- [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) —
|
- [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) —
|
||||||
`services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper
|
`services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper
|
||||||
polls container NetworkIO deltas; idle past threshold → workload
|
polls container NetworkIO deltas; idle past threshold → workload
|
||||||
@@ -375,19 +472,27 @@ and wired refs, one click or one CLI call.
|
|||||||
|
|
||||||
## 9. Phase H — Migration & ecosystem
|
## 9. Phase H — Migration & ecosystem
|
||||||
|
|
||||||
- [ ] **Railway migrator** — `containr migrate railway --token --project
|
- [x] **Railway migrator** — `containr migrate railway --token --project
|
||||||
--dry-run`: Railway GraphQL → services/vars/domains → recreate via
|
--environment --into --dry-run`: Railway GraphQL (backboard) →
|
||||||
PAT. Landing-page pitch: "off Railway in one command."
|
services (git-repo/image + command + replicas), variables, custom +
|
||||||
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST
|
service domains → recreates via the Containr API. Project tokens and
|
||||||
export or Mongo dump: servers→nodes, projects, services(+vars/
|
account tokens both supported. `cli/commands/migrate.go`.
|
||||||
domains/volumes), databases, templates. Onboards dflow refugees.
|
- [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`
|
- [x] **`containr import compose`** — `POST /projects/:id/import-compose`
|
||||||
deploys a compose file straight into a project; CLI `import compose`,
|
deploys a compose file straight into a project; CLI `import compose`,
|
||||||
MCP tool, workspace import modal; project delete cascades to
|
MCP tool, workspace import modal; project delete cascades to
|
||||||
graph-provisioned managed databases (`database_services.project_id`).
|
graph-provisioned managed databases (`database_services.project_id`).
|
||||||
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
|
- [x] **Template registry** — `CONTAINR_TEMPLATE_REGISTRY_URL` merges a
|
||||||
fetch/publish; bundled catalog works offline (dflow's self-contained
|
remote Containr catalog into `GET /templates` as `source:"registry"`
|
||||||
principle — nothing phones home).
|
(60s cache, dead registry never blocks local). `GET /templates/:id`
|
||||||
|
falls through to the registry; deploy materializes a deterministic
|
||||||
|
`reg-<hash>` private copy for the deployer so re-deploys don't dup.
|
||||||
|
`api/templateregistry.go`, `.env.example`.
|
||||||
|
|
||||||
## 9b. Phase I — Companion surfaces
|
## 9b. Phase I — Companion surfaces
|
||||||
|
|
||||||
@@ -400,27 +505,44 @@ and wired refs, one click or one CLI call.
|
|||||||
a prior deployment, database start/stop/restart/backup-now.
|
a prior deployment, database start/stop/restart/backup-now.
|
||||||
- **Observe** — polling log tail (300 lines, 5s refresh), per-service
|
- **Observe** — polling log tail (300 lines, 5s refresh), per-service
|
||||||
deployment history, env-doctor issues surfaced on the service view.
|
deployment history, env-doctor issues surfaced on the service view.
|
||||||
- [ ] **env var edit + apply-redeploy, domain management, replica
|
- [x] **env var edit + domain management + replica scaling** (PR #61 —
|
||||||
scaling, cron execution history** — next iteration.
|
mobile v1.5: variables editor, domains screen, scale + sleep
|
||||||
- [ ] **Push notifications** — zero-infra path = self-hosted
|
settings, notifications tab, cron jobs screen with run-now and
|
||||||
ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional.
|
execution history).
|
||||||
- [ ] **Offline resilience** — cached last-known state + unreachable
|
- [x] **Push notifications** — per-user `notification_channels`
|
||||||
banner.
|
(ntfy + Gotify): every `insertUserNotification` fans out best-effort
|
||||||
- [ ] **Status/offline resilience** — app + CLI degrade gracefully when
|
to enabled channels; CRUD + live test-ping via web settings, CLI
|
||||||
the instance is unreachable (cached last-known state, clear banner).
|
`notifications channels`, and MCP. FCM/APNs remain optional.
|
||||||
|
- [x] **Offline resilience** — AsyncStorage-persisted query cache
|
||||||
|
(7d, high-churn keys excluded) + banner when the instance is
|
||||||
|
unreachable (`/health` ping + expo-network).
|
||||||
|
- [x] **Status/offline resilience** — web persists the query cache to
|
||||||
|
localStorage (24h) and PlatformShell shows a "last-known data" banner
|
||||||
|
on offline/unreachable; CLI exits `ExitUnavailable` with a clean
|
||||||
|
"cannot reach" message (no stack traces).
|
||||||
|
|
||||||
## 10. Carried-over debt (ROADMAP.md items still open)
|
## 10. Carried-over debt (ROADMAP.md items still open)
|
||||||
|
|
||||||
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit →
|
- [x] **Gateway traffic path** — `/g/:slug/*` mounted → api_key auth →
|
||||||
`gateway/proxy.go` → metrics. Management API exists; proxy unwired.
|
rpm + monthly quota → `gateway/proxy.go` → usage_counters /
|
||||||
- [ ] **Preview environments runtime** — real build+deploy+subdomain+TTL
|
metrics_timeseries / incident_events.
|
||||||
(bookkeeping only now). Pairs with clone + multi-domain.
|
- [x] **Preview environments runtime** — previews are real service clones:
|
||||||
- [ ] **Environments** — env switcher, per-env vars/domains, promote.
|
branch override, queued deployment, node-domain hostnames, TTL sweeper,
|
||||||
|
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/`.
|
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`.
|
||||||
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
|
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
|
||||||
webhook table tests, Playwright e2e golden path.
|
webhook table tests, Playwright e2e golden path.
|
||||||
- [ ] **First-run wizard** — admin → GitHub App (manifest flow from
|
- [x] **First-run wizard** — public `GET /setup/status` + authed
|
||||||
Phase G makes this self-serve) → Tunnel → done.
|
`POST /setup/complete`; `/setup` page steps admin account → GitHub App
|
||||||
|
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.
|
||||||
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release.
|
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release.
|
||||||
|
|
||||||
## 11. Deliberately not imported from dflow
|
## 11. Deliberately not imported from dflow
|
||||||
|
|||||||
+14
-5
@@ -1,5 +1,7 @@
|
|||||||
# Build Go API binary
|
# Build Go API binary
|
||||||
FROM golang:1.26-alpine AS go-builder
|
ARG GO_IMAGE=golang:1.26-alpine
|
||||||
|
ARG NODE_IMAGE=node:22-alpine
|
||||||
|
FROM ${GO_IMAGE} AS go-builder
|
||||||
|
|
||||||
RUN apk add --no-cache git ca-certificates tzdata
|
RUN apk add --no-cache git ca-certificates tzdata
|
||||||
|
|
||||||
@@ -9,10 +11,16 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go
|
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
|
# Build embedded Better Auth runtime dependencies
|
||||||
FROM node:22-alpine AS auth-builder
|
FROM ${NODE_IMAGE} AS auth-builder
|
||||||
|
|
||||||
WORKDIR /auth
|
WORKDIR /auth
|
||||||
|
|
||||||
@@ -22,9 +30,9 @@ RUN npm ci --omit=dev
|
|||||||
COPY auth/src ./src
|
COPY auth/src ./src
|
||||||
|
|
||||||
# Final runtime image (Go API + Better Auth sidecar in one container)
|
# Final runtime image (Go API + Better Auth sidecar in one container)
|
||||||
FROM node:22-alpine
|
FROM ${NODE_IMAGE}
|
||||||
|
|
||||||
RUN apk --no-cache add ca-certificates tzdata wget
|
RUN apk --no-cache add ca-certificates tzdata wget git
|
||||||
|
|
||||||
RUN addgroup -g 1001 -S appgroup && \
|
RUN addgroup -g 1001 -S appgroup && \
|
||||||
adduser -u 1001 -S appuser -G appgroup
|
adduser -u 1001 -S appuser -G appgroup
|
||||||
@@ -33,6 +41,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY --from=go-builder /app/main ./main
|
COPY --from=go-builder /app/main ./main
|
||||||
COPY --from=go-builder /app/migrations_goose ./migrations_goose
|
COPY --from=go-builder /app/migrations_goose ./migrations_goose
|
||||||
|
COPY --from=go-builder /agents /agents
|
||||||
COPY --from=auth-builder /auth ./auth
|
COPY --from=auth-builder /auth ./auth
|
||||||
|
|
||||||
RUN chown -R appuser:appgroup /app
|
RUN chown -R appuser:appgroup /app
|
||||||
|
|||||||
@@ -3,14 +3,18 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -104,7 +108,15 @@ type registerResponse struct {
|
|||||||
Status string `json:"status"`
|
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() {
|
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()
|
cfg, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@@ -167,6 +179,9 @@ func register(ctx context.Context, client *http.Client, cfg agentConfig) (string
|
|||||||
"ip_address": cfg.IP,
|
"ip_address": cfg.IP,
|
||||||
"port": cfg.Port,
|
"port": cfg.Port,
|
||||||
"capabilities": detectCapabilities(),
|
"capabilities": detectCapabilities(),
|
||||||
|
"version": firstEnv("CONTAINR_AGENT_VERSION", version),
|
||||||
|
// Always send mesh so a removed interface clears stored state.
|
||||||
|
"mesh": detectMeshInterfaces(),
|
||||||
}
|
}
|
||||||
var response registerResponse
|
var response registerResponse
|
||||||
if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil {
|
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(),
|
"container_count": dockerContainerCount(),
|
||||||
"system_load": readSystemLoad(),
|
"system_load": readSystemLoad(),
|
||||||
"uptime": readSystemUptime(),
|
"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)
|
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 {
|
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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/agents/%s/commands", cfg.APIURL, agentID), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -214,7 +311,13 @@ func processCommands(ctx context.Context, client *http.Client, cfg agentConfig,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, item := range list.Commands {
|
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"
|
status := "completed"
|
||||||
errText := ""
|
errText := ""
|
||||||
if runErr != nil {
|
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 {
|
if err := postJSON(ctx, client, cfg, path, payload, nil); err != nil {
|
||||||
log.Printf("report command result failed: command=%s err=%v", item.ID, err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeCommand(item command) (string, error) {
|
func executeCommand(cfg agentConfig, item command) (string, error) {
|
||||||
switch item.Type {
|
switch item.Type {
|
||||||
case "create_container":
|
case "create_container":
|
||||||
return createContainer(item.Payload)
|
return createContainer(item.Payload)
|
||||||
@@ -245,12 +354,100 @@ func executeCommand(item command) (string, error) {
|
|||||||
case "restart_container":
|
case "restart_container":
|
||||||
return runDocker("restart", commandTarget(item.Payload))
|
return runDocker("restart", commandTarget(item.Payload))
|
||||||
case "remove_container":
|
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:
|
default:
|
||||||
return "", fmt.Errorf("unsupported command type %q", item.Type)
|
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) {
|
func createContainer(payload map[string]interface{}) (string, error) {
|
||||||
container, ok := payload["container"].(map[string]interface{})
|
container, ok := payload["container"].(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -263,7 +460,38 @@ func createContainer(payload map[string]interface{}) (string, error) {
|
|||||||
return "", errors.New("container name and image are required")
|
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 {
|
if env, ok := container["environment"].(map[string]interface{}); ok {
|
||||||
for key, value := range env {
|
for key, value := range env {
|
||||||
args = append(args, "-e", fmt.Sprintf("%s=%v", key, value))
|
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"])
|
hostPort := intValue(port["host_port"])
|
||||||
containerPort := intValue(port["container_port"])
|
containerPort := intValue(port["container_port"])
|
||||||
protocol := firstString(stringValue(port["protocol"]), "tcp")
|
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))
|
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)
|
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 {
|
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 {
|
func collectResources(ip string) nodeResources {
|
||||||
mem := readMemory()
|
mem := readMemory()
|
||||||
storage := readStorage("/")
|
storage := readStorage("/")
|
||||||
@@ -469,6 +897,15 @@ func runDocker(args ...string) (string, error) {
|
|||||||
return strings.TrimSpace(string(output)), err
|
return strings.TrimSpace(string(output)), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runDockerStdin pipes stdin into docker — build contexts and saved image
|
||||||
|
// tarballs stream in without a second copy on disk.
|
||||||
|
func runDockerStdin(stdin io.Reader, args ...string) (string, error) {
|
||||||
|
cmd := exec.Command("docker", args...)
|
||||||
|
cmd.Stdin = stdin
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
return strings.TrimSpace(string(output)), err
|
||||||
|
}
|
||||||
|
|
||||||
func dockerAvailable() bool {
|
func dockerAvailable() bool {
|
||||||
_, err := exec.LookPath("docker")
|
_, err := exec.LookPath("docker")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -564,3 +1001,35 @@ func boolValue(value interface{}) bool {
|
|||||||
typed, _ := value.(bool)
|
typed, _ := value.(bool)
|
||||||
return typed
|
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/sha256"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
_ "embed"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -36,6 +40,14 @@ type NodeAgent struct {
|
|||||||
Capabilities AgentCapabilities `json:"capabilities"`
|
Capabilities AgentCapabilities `json:"capabilities"`
|
||||||
Resources NodeResources `json:"resources"`
|
Resources NodeResources `json:"resources"`
|
||||||
LastHeartbeat time.Time `json:"last_heartbeat"`
|
LastHeartbeat time.Time `json:"last_heartbeat"`
|
||||||
|
AutoPrune bool `json:"auto_prune"`
|
||||||
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
Metadata map[string]interface{} `json:"metadata"`
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
@@ -189,6 +201,17 @@ type AgentHeartbeat struct {
|
|||||||
SystemLoad SystemLoad `json:"system_load"`
|
SystemLoad SystemLoad `json:"system_load"`
|
||||||
Uptime int64 `json:"uptime"`
|
Uptime int64 `json:"uptime"`
|
||||||
Version string `json:"version"`
|
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 {
|
type SystemLoad struct {
|
||||||
@@ -225,6 +248,31 @@ 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 {
|
func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
|
||||||
agent := NodeAgent{
|
agent := NodeAgent{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
@@ -234,6 +282,10 @@ func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
|
|||||||
Port: int(row.Port),
|
Port: int(row.Port),
|
||||||
Status: row.Status.String,
|
Status: row.Status.String,
|
||||||
Version: row.Version.String,
|
Version: row.Version.String,
|
||||||
|
AutoPrune: row.AutoPrune,
|
||||||
|
Schedulable: row.Schedulable,
|
||||||
|
Tags: []string{},
|
||||||
|
DefaultDomain: row.DefaultDomain,
|
||||||
Metadata: map[string]interface{}{},
|
Metadata: map[string]interface{}{},
|
||||||
}
|
}
|
||||||
if row.LastHeartbeat.Valid {
|
if row.LastHeartbeat.Valid {
|
||||||
@@ -248,6 +300,9 @@ func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
|
|||||||
unmarshalRaw(row.Capabilities, &agent.Capabilities)
|
unmarshalRaw(row.Capabilities, &agent.Capabilities)
|
||||||
unmarshalRaw(row.Resources, &agent.Resources)
|
unmarshalRaw(row.Resources, &agent.Resources)
|
||||||
unmarshalRaw(row.Metadata, &agent.Metadata)
|
unmarshalRaw(row.Metadata, &agent.Metadata)
|
||||||
|
if len(row.Tags) > 0 {
|
||||||
|
_ = json.Unmarshal(row.Tags, &agent.Tags)
|
||||||
|
}
|
||||||
return agent
|
return agent
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +381,9 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
|||||||
IPAddress string `json:"ip_address" binding:"required"`
|
IPAddress string `json:"ip_address" binding:"required"`
|
||||||
Port int `json:"port" binding:"required"`
|
Port int `json:"port" binding:"required"`
|
||||||
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
|
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
|
||||||
|
Version string `json:"version"`
|
||||||
AuthToken string `json:"auth_token"`
|
AuthToken string `json:"auth_token"`
|
||||||
|
Mesh map[string]string `json:"mesh"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -341,12 +398,28 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := c.Request.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.
|
// Re-registering an existing host updates it in place.
|
||||||
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
|
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
|
||||||
Hostname: req.Hostname,
|
Hostname: req.Hostname,
|
||||||
IpAddress: req.IPAddress,
|
IpAddress: req.IPAddress,
|
||||||
}); err == nil {
|
}); 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{
|
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
|
||||||
ID: existing.ID,
|
ID: existing.ID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
@@ -354,11 +427,14 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
|||||||
IpAddress: existing.IpAddress,
|
IpAddress: existing.IpAddress,
|
||||||
Port: int32(req.Port),
|
Port: int32(req.Port),
|
||||||
Status: sql.NullString{String: "connecting", Valid: true},
|
Status: sql.NullString{String: "connecting", Valid: true},
|
||||||
Version: existing.Version,
|
// 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),
|
Capabilities: rawJSON(req.Capabilities),
|
||||||
Resources: existing.Resources,
|
Resources: existing.Resources,
|
||||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
Metadata: existing.Metadata,
|
Metadata: rawJSON(metadata),
|
||||||
|
AutoPrune: existing.AutoPrune,
|
||||||
|
Schedulable: existing.Schedulable,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
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,
|
IpAddress: req.IPAddress,
|
||||||
Port: int32(req.Port),
|
Port: int32(req.Port),
|
||||||
Status: sql.NullString{String: "connecting", Valid: true},
|
Status: sql.NullString{String: "connecting", Valid: true},
|
||||||
Version: sql.NullString{},
|
Version: sql.NullString{String: req.Version, Valid: req.Version != ""},
|
||||||
Capabilities: rawJSON(req.Capabilities),
|
Capabilities: rawJSON(req.Capabilities),
|
||||||
Resources: rawJSON(resources),
|
Resources: rawJSON(resources),
|
||||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
Metadata: rawJSON(map[string]interface{}{}),
|
Metadata: rawJSON(metadata),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
|
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),
|
Resources: rawJSON(agent.Resources),
|
||||||
LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()},
|
LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()},
|
||||||
Metadata: rawJSON(agent.Metadata),
|
Metadata: rawJSON(agent.Metadata),
|
||||||
|
AutoPrune: agent.AutoPrune,
|
||||||
|
Schedulable: agent.Schedulable,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
||||||
return
|
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.
|
// 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("capabilities", &agent.Capabilities)
|
||||||
remarshal("resources", &agent.Resources)
|
remarshal("resources", &agent.Resources)
|
||||||
remarshal("metadata", &agent.Metadata)
|
remarshal("metadata", &agent.Metadata)
|
||||||
|
remarshal("auto_prune", &agent.AutoPrune)
|
||||||
|
remarshal("schedulable", &agent.Schedulable)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteAgent removes an agent
|
// DeleteAgent removes an agent
|
||||||
@@ -581,6 +692,22 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
|
|||||||
return
|
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)
|
c.Status(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,9 +777,51 @@ func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
|
|||||||
h.updateContainerStatusAfterCommand(ctx, out)
|
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})
|
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) {
|
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(ctx context.Context, command AgentCommand) {
|
||||||
row, err := h.q.GetContainer(ctx, *command.ContainerID)
|
row, err := h.q.GetContainer(ctx, *command.ContainerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -803,6 +972,103 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, gin.H{"container": container})
|
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
|
// ExecuteCommand executes a command on an agent
|
||||||
func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
|
func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
|
||||||
agentID := c.Param("id")
|
agentID := c.Param("id")
|
||||||
@@ -1214,9 +1480,49 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
|
|||||||
agents.POST("/heartbeat", h.SendHeartbeat)
|
agents.POST("/heartbeat", h.SendHeartbeat)
|
||||||
agents.GET("/:id/commands", h.GetPendingCommandsForAgent)
|
agents.GET("/:id/commands", h.GetPendingCommandsForAgent)
|
||||||
agents.POST("/:id/commands/:commandId/result", h.CompleteCommand)
|
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
|
// SetupAdminRoutes registers dashboard/control agent routes. Command
|
||||||
// history and container details can leak sensitive output, so these stay
|
// history and container details can leak sensitive output, so these stay
|
||||||
// admin-only rather than joining the public read group.
|
// 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.DELETE("/:id/containers/:containerId", h.ContainerAction)
|
||||||
|
|
||||||
agents.GET("/:id/metrics", h.GetAgentMetrics)
|
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.POST("/:id/commands", h.ExecuteCommand)
|
||||||
agents.GET("/:id/commands", h.GetAgentCommands)
|
agents.GET("/:id/commands", h.GetAgentCommands)
|
||||||
agents.GET("/:id/commands/:commandId", h.GetCommandStatus)
|
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"
|
||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
|
"containr/internal/database"
|
||||||
"containr/internal/database/sqlcdb"
|
"containr/internal/database/sqlcdb"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
"containr/internal/docker"
|
"containr/internal/docker"
|
||||||
@@ -1868,14 +1869,22 @@ func (h *DatabaseHandler) createBackupProcess(databaseID, backupID, backupPath s
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
notify := func(title, body string) {
|
||||||
|
if row, err := h.queries.GetDatabaseServiceByID(context.Background(), databaseID); err == nil && row.UserID != "" {
|
||||||
|
insertUserNotification(&database.DB{DB: h.db}, row.UserID, "backup", title, body, "database", databaseID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if h.dockerClient == nil {
|
if h.dockerClient == nil {
|
||||||
_ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false)
|
_ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false)
|
||||||
|
notify("Database backup failed", "Backup could not start: docker unavailable.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sizeLabel, err := h.snapshotDatabaseVolume(ctx, databaseID, backupPath)
|
sizeLabel, err := h.snapshotDatabaseVolume(ctx, databaseID, backupPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false)
|
_ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false)
|
||||||
|
notify("Database backup failed", fmt.Sprintf("Snapshot failed: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil {
|
if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil {
|
||||||
@@ -1886,6 +1895,7 @@ func (h *DatabaseHandler) createBackupProcess(databaseID, backupID, backupPath s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = h.setDatabaseBackupStatus(backupID, "completed", sizeLabel, true)
|
_ = h.setDatabaseBackupStatus(backupID, "completed", sizeLabel, true)
|
||||||
|
notify("Database backup completed", fmt.Sprintf("Backup archived (%s).", sizeLabel))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) restoreBackupProcess(databaseID, backupID string, backupPath string) {
|
func (h *DatabaseHandler) restoreBackupProcess(databaseID, backupID string, backupPath string) {
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import (
|
|||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
"containr/internal/deployqueue"
|
"containr/internal/deployqueue"
|
||||||
"containr/internal/docker"
|
"containr/internal/docker"
|
||||||
|
"containr/internal/source"
|
||||||
|
"containr/internal/types"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -377,6 +381,23 @@ func runDeploymentAndSyncWithImage(
|
|||||||
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
|
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
// The checkout and any shipped artifacts die with this deployment.
|
||||||
|
var artifactIDs []string
|
||||||
|
checkoutRoot := filepath.Join(containrWorkDir, "repos", dbDeployment.ID.String())
|
||||||
|
defer func() {
|
||||||
|
_ = os.RemoveAll(checkoutRoot)
|
||||||
|
for _, id := range artifactIDs {
|
||||||
|
removeArtifact(id)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
failDeploy := func(msg string) {
|
||||||
|
failedAt := time.Now()
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
||||||
|
msg, failedAt, dbDeployment.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
sourcePath := strings.TrimSpace(service.BuildPath)
|
sourcePath := strings.TrimSpace(service.BuildPath)
|
||||||
if sourcePath == "" {
|
if sourcePath == "" {
|
||||||
sourcePath = "."
|
sourcePath = "."
|
||||||
@@ -421,6 +442,33 @@ func runDeploymentAndSyncWithImage(
|
|||||||
maintURL = maintenanceURL(db)
|
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{
|
deployReq := &deployment.DeploymentRequest{
|
||||||
ProjectID: service.ProjectID.String(),
|
ProjectID: service.ProjectID.String(),
|
||||||
ServiceID: service.ID.String(),
|
ServiceID: service.ID.String(),
|
||||||
@@ -447,6 +495,8 @@ func runDeploymentAndSyncWithImage(
|
|||||||
Maintenance: maintenanceMode,
|
Maintenance: maintenanceMode,
|
||||||
MaintenanceURL: maintURL,
|
MaintenanceURL: maintURL,
|
||||||
BasicAuthUsers: basicAuthUsers,
|
BasicAuthUsers: basicAuthUsers,
|
||||||
|
NodeID: nodeID,
|
||||||
|
Spread: spread,
|
||||||
},
|
},
|
||||||
Trigger: deployment.TriggerConfig{
|
Trigger: deployment.TriggerConfig{
|
||||||
Type: req.Trigger,
|
Type: req.Trigger,
|
||||||
@@ -463,6 +513,27 @@ func runDeploymentAndSyncWithImage(
|
|||||||
if image == "" {
|
if image == "" {
|
||||||
image = service.Image
|
image = service.Image
|
||||||
}
|
}
|
||||||
|
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
|
var ownerID string
|
||||||
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
|
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
|
||||||
auth := registryAuthFor(db, ownerID, image)
|
auth := registryAuthFor(db, ownerID, image)
|
||||||
@@ -472,6 +543,7 @@ func runDeploymentAndSyncWithImage(
|
|||||||
PullUsername: auth.Username,
|
PullUsername: auth.Username,
|
||||||
PullPassword: auth.Password,
|
PullPassword: auth.Password,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// "auto" leaves BuildType empty so the manager detects it; the static
|
// "auto" leaves BuildType empty so the manager detects it; the static
|
||||||
// builder additionally feeds its generated Dockerfile the service's
|
// builder additionally feeds its generated Dockerfile the service's
|
||||||
@@ -480,16 +552,54 @@ func runDeploymentAndSyncWithImage(
|
|||||||
if service.Builder != "" && service.Builder != "auto" {
|
if service.Builder != "" && service.Builder != "auto" {
|
||||||
buildType = service.Builder
|
buildType = service.Builder
|
||||||
}
|
}
|
||||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
buildArgs := map[string]string{}
|
||||||
|
buildCommand := ""
|
||||||
|
if buildType == "static" {
|
||||||
|
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,
|
BuildType: buildType,
|
||||||
SourcePath: sourcePath,
|
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,
|
Branch: req.Branch,
|
||||||
Commit: req.CommitHash,
|
Commit: req.CommitHash,
|
||||||
NoCache: req.NoCache,
|
NoCache: req.NoCache,
|
||||||
}
|
}
|
||||||
if buildType == "static" {
|
|
||||||
deployReq.BuildConfig.BuildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build")
|
|
||||||
deployReq.BuildConfig.BuildArgs = map[string]string{"STATIC_DIR": firstNonEmpty(service.StaticDir, "dist")}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,9 +803,15 @@ func splitImageReference(image, fallbackTag string) (string, string) {
|
|||||||
return "", fallbackTag
|
return "", fallbackTag
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Digest-pinned refs (name[:tag]@sha256:…) carry no usable tag — the
|
||||||
|
// colon lives inside the digest and must not be split.
|
||||||
|
if strings.Contains(image, "@") {
|
||||||
|
return image, fallbackTag
|
||||||
|
}
|
||||||
|
|
||||||
lastSlash := strings.LastIndex(image, "/")
|
lastSlash := strings.LastIndex(image, "/")
|
||||||
lastColon := strings.LastIndex(image, ":")
|
lastColon := strings.LastIndex(image, ":")
|
||||||
if lastColon > lastSlash && !strings.Contains(image[lastColon:], "@") {
|
if lastColon > lastSlash {
|
||||||
return image[:lastColon], image[lastColon+1:]
|
return image[:lastColon], image[lastColon+1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSplitImageReference(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
image string
|
||||||
|
fallback string
|
||||||
|
wantImage string
|
||||||
|
wantTag string
|
||||||
|
}{
|
||||||
|
{"empty uses fallback tag", "", "latest", "", "latest"},
|
||||||
|
{"bare image", "nginx", "latest", "nginx", "latest"},
|
||||||
|
{"tagged image", "nginx:1.27", "latest", "nginx", "1.27"},
|
||||||
|
{"registry with port untagged", "registry.local:5000/app", "latest", "registry.local:5000/app", "latest"},
|
||||||
|
{"registry with port tagged", "registry.local:5000/app:v2", "latest", "registry.local:5000/app", "v2"},
|
||||||
|
{"digest stays whole", "nginx@sha256:abc123", "latest", "nginx@sha256:abc123", "latest"},
|
||||||
|
{"tag and digest", "nginx:1.27@sha256:abc", "latest", "nginx:1.27@sha256:abc", "latest"},
|
||||||
|
{"namespaced tagged", "ghcr.io/owner/repo:dev", "latest", "ghcr.io/owner/repo", "dev"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
img, tag := splitImageReference(tc.image, tc.fallback)
|
||||||
|
if img != tc.wantImage || tag != tc.wantTag {
|
||||||
|
t.Fatalf("splitImageReference(%q, %q) = (%q, %q), want (%q, %q)",
|
||||||
|
tc.image, tc.fallback, img, tag, tc.wantImage, tc.wantTag)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapEngineStatusToDBStatus(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"running": "deployed",
|
||||||
|
"building": "building",
|
||||||
|
"failed": "failed",
|
||||||
|
"cancelled": "cancelled",
|
||||||
|
"queued": "queued",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := mapEngineStatusToDBStatus(in); got != want {
|
||||||
|
t.Fatalf("mapEngineStatusToDBStatus(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
|
"containr/internal/database/sqlcdb"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -68,6 +70,72 @@ func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) [
|
|||||||
if len(out) == 0 && fallback != "" {
|
if len(out) == 0 && fallback != "" {
|
||||||
out = []string{fallback}
|
out = []string{fallback}
|
||||||
}
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
out = serviceAutoDomains(db, serviceID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// dnsLabel normalizes a service name into a DNS label: lowercase alnum
|
||||||
|
// plus single dashes, never leading/trailing, ≤63 chars.
|
||||||
|
func dnsLabel(name string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
lastWasSep := false
|
||||||
|
for _, r := range strings.ToLower(strings.TrimSpace(name)) {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||||
|
b.WriteRune(r)
|
||||||
|
lastWasSep = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !lastWasSep && b.Len() > 0 {
|
||||||
|
b.WriteByte('-')
|
||||||
|
lastWasSep = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Trim(b.String(), "-")
|
||||||
|
if len(out) > 63 {
|
||||||
|
out = strings.Trim(out[:63], "-")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceAutoDomains generates <service>.<node-default-domain> hostnames
|
||||||
|
// for services with no explicit domains. Pinned services take their node's
|
||||||
|
// base domain; spread services union every eligible node's base domain.
|
||||||
|
// Local services get nothing — there is no instance-level base domain yet.
|
||||||
|
func serviceAutoDomains(db *database.DB, serviceID uuid.UUID) []string {
|
||||||
|
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
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Notification producers — the emitters behind `insertUserNotification`.
|
||||||
|
// Deployment success/failure lives in deployments.go; this file covers
|
||||||
|
// the remaining producers from the roadmap: admin fan-out helper, the
|
||||||
|
// agent offline/online sweep, and the upgrade-available check.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/database/sqlcdb"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is stamped at image build time via -ldflags "-X
|
||||||
|
// containr/internal/api.Version=…". "dev" disables the upgrade check.
|
||||||
|
var Version = "dev"
|
||||||
|
|
||||||
|
// notifyAdmins inserts a notification for every admin user. Best-effort.
|
||||||
|
func notifyAdmins(db *database.DB, kind, title, body, resourceType, resourceID string) {
|
||||||
|
if db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := db.Query(`SELECT id FROM users WHERE is_admin`)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("containr: notify admins lookup failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
|
if err := rows.Scan(&id); err == nil {
|
||||||
|
insertUserNotification(db, id.String(), kind, title, body, resourceType, resourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAgentHealthSweep marks agents offline when their heartbeat goes
|
||||||
|
// stale (and back online when it resumes), notifying admins on each
|
||||||
|
// transition. Heartbeats keep the row fresh via UpdateAgentHeartbeat.
|
||||||
|
func StartAgentHealthSweep(ctx context.Context, db *database.DB) {
|
||||||
|
const staleAfter = 3 * time.Minute
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
reconcileAgentHealth(db, staleAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
var agents []agentHealthRow
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''),
|
||||||
|
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 agentHealthRow
|
||||||
|
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale, &a.autoPrune); err == nil {
|
||||||
|
agents = append(agents, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
for _, a := range agents {
|
||||||
|
switch {
|
||||||
|
case a.stale && a.status != "offline":
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`UPDATE node_agents SET status = 'offline', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
notifyAdmins(db, "agent", "Node agent offline",
|
||||||
|
fmt.Sprintf("Agent %s missed its heartbeat for over %s.", a.name, staleAfter), "agent", a.id)
|
||||||
|
case !a.stale && a.status == "offline":
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`UPDATE node_agents SET status = 'online', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
notifyAdmins(db, "agent", "Node agent back online",
|
||||||
|
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
|
||||||
|
// notifies admins when it is newer than the running version. The last
|
||||||
|
// notified tag is persisted in app_settings so restarts don't re-fire.
|
||||||
|
func StartUpgradeCheck(ctx context.Context, db *database.DB, currentVersion string) {
|
||||||
|
if currentVersion == "" || currentVersion == "dev" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
checkForUpgrade(db, currentVersion)
|
||||||
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
checkForUpgrade(db, currentVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkForUpgrade(db *database.DB, currentVersion string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||||
|
"https://api.github.com/repos/Dvorinka/Containr/releases/latest", nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
req.Header.Set("User-Agent", "containr-upgrade-check")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var release struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil || release.TagName == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !semverNewer(release.TagName, currentVersion) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastNotified string
|
||||||
|
_ = db.QueryRow(`SELECT value FROM app_settings WHERE key = 'upgrade_notified_tag'`).Scan(&lastNotified)
|
||||||
|
if lastNotified == release.TagName {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notifyAdmins(db, "upgrade", "Containr update available",
|
||||||
|
fmt.Sprintf("Release %s is available (running %s). %s", release.TagName, currentVersion, release.HTMLURL),
|
||||||
|
"release", release.TagName)
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`INSERT INTO app_settings (key, value, updated_at) VALUES ('upgrade_notified_tag', $1, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`, release.TagName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// semverNewer reports whether candidate is a higher semver than current.
|
||||||
|
// Non-semver input (e.g. "dev", commit hashes) compares false.
|
||||||
|
func semverNewer(candidate, current string) bool {
|
||||||
|
parse := func(s string) ([]int, bool) {
|
||||||
|
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
|
||||||
|
// Prerelease/build metadata sorts after the same base version
|
||||||
|
// upstream — for "is a newer release available" purposes the base
|
||||||
|
// comparison is what matters.
|
||||||
|
if j := strings.IndexAny(s, "-+"); j >= 0 {
|
||||||
|
s = s[:j]
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ".")
|
||||||
|
if len(parts) == 0 || len(parts) > 3 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
out := make([]int, 3)
|
||||||
|
for i, p := range parts {
|
||||||
|
n, err := strconv.Atoi(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
out[i] = n
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
c, ok1 := parse(candidate)
|
||||||
|
cur, ok2 := parse(current)
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if c[i] != cur[i] {
|
||||||
|
return c[i] > cur[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSemverNewer(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
candidate, current string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"v1.2.3", "v1.2.2", true},
|
||||||
|
{"1.3.0", "v1.2.9", true},
|
||||||
|
{"v2.0.0", "v1.9.9", true},
|
||||||
|
{"v1.2.3", "v1.2.3", false},
|
||||||
|
{"v1.2.0", "v1.2.3", false},
|
||||||
|
{"v1.10.0", "v1.9.9", true}, // numeric, not lexicographic
|
||||||
|
{"dev", "v1.0.0", false},
|
||||||
|
{"v1.0.0", "main", false},
|
||||||
|
{"v1.2.3-rc.1", "v1.2.2", true}, // prerelease suffix tolerated
|
||||||
|
{"", "v1.0.0", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := semverNewer(tc.candidate, tc.current); got != tc.want {
|
||||||
|
t.Errorf("semverNewer(%q, %q) = %v, want %v", tc.candidate, tc.current, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ func insertUserNotification(db *database.DB, userID, kind, title, body, resource
|
|||||||
ResourceID: sql.NullString{String: resourceID, Valid: resourceID != ""},
|
ResourceID: sql.NullString{String: resourceID, Valid: resourceID != ""},
|
||||||
CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
})
|
})
|
||||||
|
go fanoutUserPush(db, userID, kind, title, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapNotification(row sqlcdb.Notification) NotificationItem {
|
func mapNotification(row sqlcdb.Notification) NotificationItem {
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
|
"containr/internal/database/sqlcdb"
|
||||||
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/deployqueue"
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -26,6 +31,10 @@ type PreviewEnvironment struct {
|
|||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
|
|
||||||
|
// PreviewServiceID is the cloned service this preview actually deploys;
|
||||||
|
// its status is the source of truth while the preview is active.
|
||||||
|
PreviewServiceID *uuid.UUID `json:"preview_service_id,omitempty" db:"preview_service_id"`
|
||||||
|
|
||||||
// Related data
|
// Related data
|
||||||
Service *Service `json:"service,omitempty"`
|
Service *Service `json:"service,omitempty"`
|
||||||
DeploymentID *uuid.UUID `json:"deployment_id,omitempty"`
|
DeploymentID *uuid.UUID `json:"deployment_id,omitempty"`
|
||||||
@@ -78,7 +87,7 @@ func handleGetPreviewEnvironments(c *gin.Context) {
|
|||||||
|
|
||||||
// Get preview environments for the project with service info
|
// Get preview environments for the project with service info
|
||||||
rows, err := db.(*database.DB).Query(
|
rows, err := db.(*database.DB).Query(
|
||||||
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
|
`SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.pr_number,
|
||||||
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
|
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
|
||||||
s.id as service_id, s.name as service_name, s.type as service_type
|
s.id as service_id, s.name as service_name, s.type as service_type
|
||||||
FROM preview_environments pe
|
FROM preview_environments pe
|
||||||
@@ -101,7 +110,7 @@ func handleGetPreviewEnvironments(c *gin.Context) {
|
|||||||
var serviceType sql.NullString
|
var serviceType sql.NullString
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
|
&env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.PRNumber,
|
||||||
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
|
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
|
||||||
&serviceID, &serviceName, &serviceType,
|
&serviceID, &serviceName, &serviceType,
|
||||||
)
|
)
|
||||||
@@ -215,87 +224,111 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
|
|||||||
ttlHours = 24 // Default 24 hours
|
ttlHours = 24 // Default 24 hours
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create preview environment
|
// A preview is a real service: clone the source, pin it to the
|
||||||
|
// requested branch, swap its domains for a preview hostname, and run a
|
||||||
|
// normal deployment through the queue. Expiry sweeps the clone away.
|
||||||
|
envName := generatePreviewEnvironmentName(req.BranchName)
|
||||||
|
cloneName := previewServiceName(service.Name, req.BranchName)
|
||||||
|
cloneID, err := cloneServiceRow(db.(*database.DB), req.ServiceID, req.ProjectID, cloneName, "")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service for preview"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clone's branch override is what makes it a preview — everything
|
||||||
|
// else (env vars, volumes, builder, resources) stays identical.
|
||||||
|
if _, err := db.(*database.DB).Exec(
|
||||||
|
`UPDATE services SET git_branch = $1, domain = '', updated_at = NOW() WHERE id = $2`,
|
||||||
|
req.BranchName, cloneID,
|
||||||
|
); err != nil {
|
||||||
|
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure preview service"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Cloned service_domains would collide with the parent's Traefik
|
||||||
|
// router — previews get their own hostname or none.
|
||||||
|
if _, err := db.(*database.DB).Exec(
|
||||||
|
`DELETE FROM service_domains WHERE service_id = $1`, cloneID,
|
||||||
|
); err != nil {
|
||||||
|
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure preview domains"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previewHost := resolvePreviewHost(db.(*database.DB), cloneID, envName)
|
||||||
|
if previewHost != "" {
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`INSERT INTO service_domains (service_id, domain, is_default, cert_type) VALUES ($1, $2, true, 'tls')`,
|
||||||
|
cloneID, previewHost,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(time.Duration(ttlHours) * time.Hour)
|
||||||
env := PreviewEnvironment{
|
env := PreviewEnvironment{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
ProjectID: req.ProjectID,
|
ProjectID: req.ProjectID,
|
||||||
ServiceID: req.ServiceID,
|
ServiceID: req.ServiceID,
|
||||||
BranchName: req.BranchName,
|
BranchName: req.BranchName,
|
||||||
PRNumber: req.PRNumber,
|
PRNumber: req.PRNumber,
|
||||||
Environment: generatePreviewEnvironmentName(req.BranchName),
|
Environment: envName,
|
||||||
Status: "building",
|
Status: "building",
|
||||||
ExpiresAt: &[]time.Time{time.Now().Add(time.Duration(ttlHours) * time.Hour)}[0],
|
ExpiresAt: &expiresAt,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
if previewHost != "" {
|
||||||
|
env.URL = "https://" + previewHost
|
||||||
|
}
|
||||||
|
|
||||||
// Insert preview environment into database
|
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
`INSERT INTO preview_environments
|
`INSERT INTO preview_environments
|
||||||
(id, project_id, service_id, branch_name, pr_number, environment,
|
(id, project_id, service_id, preview_service_id, branch_name, pr_number, environment,
|
||||||
status, url, expires_at, created_at, updated_at)
|
status, url, expires_at, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||||
env.ID, env.ProjectID, env.ServiceID, env.BranchName, env.PRNumber,
|
env.ID, env.ProjectID, env.ServiceID, cloneID, env.BranchName, env.PRNumber,
|
||||||
env.Environment, env.Status, env.URL, env.ExpiresAt, env.CreatedAt, env.UpdatedAt,
|
env.Environment, env.Status, env.URL, env.ExpiresAt, env.CreatedAt, env.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create preview environment"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create preview environment"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deploymentID := uuid.New()
|
deploymentID := uuid.New()
|
||||||
sanitizedBranch := strings.ReplaceAll(req.BranchName, "/", "-")
|
_, err = db.(*database.DB).Exec(
|
||||||
sanitizedBranch = strings.ReplaceAll(sanitizedBranch, "_", "-")
|
|
||||||
version := fmt.Sprintf("preview-%s-%d", sanitizedBranch, time.Now().Unix())
|
|
||||||
startedAt := time.Now().UTC()
|
|
||||||
completedAt := startedAt
|
|
||||||
|
|
||||||
_, deployErr := db.(*database.DB).Exec(
|
|
||||||
`INSERT INTO deployments
|
`INSERT INTO deployments
|
||||||
(id, service_id, version, commit_hash, status, started_at, completed_at, deployment_log, created_at, updated_at)
|
(id, service_id, version, commit_hash, status, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())`,
|
VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW())`,
|
||||||
deploymentID,
|
deploymentID, cloneID, "preview-"+env.Environment, req.BranchName,
|
||||||
req.ServiceID,
|
|
||||||
version,
|
|
||||||
req.BranchName,
|
|
||||||
"running",
|
|
||||||
startedAt,
|
|
||||||
completedAt,
|
|
||||||
fmt.Sprintf("Preview environment %s activated for branch %s", env.Environment, req.BranchName),
|
|
||||||
)
|
|
||||||
if deployErr != nil {
|
|
||||||
_, _ = 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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to provision preview deployment"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(env.URL) == "" {
|
|
||||||
env.URL = fmt.Sprintf("https://%s.preview.containr.local", env.Environment)
|
|
||||||
}
|
|
||||||
env.Status = "running"
|
|
||||||
env.UpdatedAt = time.Now().UTC()
|
|
||||||
env.DeploymentID = &deploymentID
|
env.DeploymentID = &deploymentID
|
||||||
|
|
||||||
_, err = db.(*database.DB).Exec(
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||||
`UPDATE preview_environments
|
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
||||||
SET status = $1, url = $2, updated_at = $3
|
cloneService, loadErr := loadServiceForDeploy(db.(*database.DB), cloneID)
|
||||||
WHERE id = $4`,
|
if loadErr == nil {
|
||||||
env.Status,
|
engineInstance := engine
|
||||||
env.URL,
|
d := DeploymentModel{ID: deploymentID, ServiceID: cloneID, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||||||
env.UpdatedAt,
|
getDeployQueue(c).Enqueue(cloneID, deployqueue.Job{
|
||||||
env.ID,
|
DeploymentID: deploymentID,
|
||||||
)
|
Run: func(jctx context.Context) {
|
||||||
if err != nil {
|
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, cloneService, CreateDeploymentRequest{Branch: req.BranchName, Trigger: "preview"}, userID.(string))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to finalize preview environment"})
|
},
|
||||||
return
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LogAuditWithRequest(c, "service", req.ServiceID.String(), "preview.create", map[string]interface{}{
|
||||||
|
"preview_environment_id": env.ID.String(),
|
||||||
|
"preview_service_id": cloneID.String(),
|
||||||
|
"branch": req.BranchName,
|
||||||
|
})
|
||||||
c.JSON(http.StatusCreated, gin.H{"preview_environment": env})
|
c.JSON(http.StatusCreated, gin.H{"preview_environment": env})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +354,7 @@ func handleGetPreviewEnvironment(c *gin.Context) {
|
|||||||
var serviceName sql.NullString
|
var serviceName sql.NullString
|
||||||
var serviceType sql.NullString
|
var serviceType sql.NullString
|
||||||
err = db.(*database.DB).QueryRow(
|
err = db.(*database.DB).QueryRow(
|
||||||
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
|
`SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.pr_number,
|
||||||
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
|
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
|
||||||
s.id as service_id, s.name as service_name, s.type as service_type
|
s.id as service_id, s.name as service_name, s.type as service_type
|
||||||
FROM preview_environments pe
|
FROM preview_environments pe
|
||||||
@@ -331,7 +364,7 @@ func handleGetPreviewEnvironment(c *gin.Context) {
|
|||||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
|
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
|
||||||
envID, userID, contextIsAdmin(c),
|
envID, userID, contextIsAdmin(c),
|
||||||
).Scan(
|
).Scan(
|
||||||
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
|
&env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.PRNumber,
|
||||||
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
|
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
|
||||||
&serviceID, &serviceName, &serviceType,
|
&serviceID, &serviceName, &serviceType,
|
||||||
)
|
)
|
||||||
@@ -480,22 +513,19 @@ func handleDeletePreviewEnvironment(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupID := uuid.New()
|
// Tear down the clone's runtime and row — the preview is the clone.
|
||||||
cleanupStartedAt := time.Now().UTC()
|
var cloneID *uuid.UUID
|
||||||
cleanupCompletedAt := cleanupStartedAt
|
_ = db.(*database.DB).QueryRow(
|
||||||
|
`SELECT preview_service_id FROM preview_environments WHERE id = $1`, envID,
|
||||||
_, _ = db.(*database.DB).Exec(
|
).Scan(&cloneID)
|
||||||
`INSERT INTO deployments
|
if cloneID != nil {
|
||||||
(id, service_id, version, status, started_at, completed_at, deployment_log, created_at, updated_at)
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||||
VALUES ($1, (SELECT service_id FROM preview_environments WHERE id = $2), $3, $4, $5, $6, $7, NOW(), NOW())`,
|
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
||||||
cleanupID,
|
removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, *cloneID)
|
||||||
envID,
|
}
|
||||||
"preview-cleanup",
|
}
|
||||||
"rolled_back",
|
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *cloneID)
|
||||||
cleanupStartedAt,
|
}
|
||||||
cleanupCompletedAt,
|
|
||||||
"Preview environment resources cleaned up",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Delete preview environment
|
// Delete preview environment
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
@@ -542,13 +572,13 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
|
|||||||
// Get preview environment details
|
// Get preview environment details
|
||||||
var env PreviewEnvironment
|
var env PreviewEnvironment
|
||||||
err = db.(*database.DB).QueryRow(
|
err = db.(*database.DB).QueryRow(
|
||||||
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status
|
`SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.environment, pe.status
|
||||||
FROM preview_environments pe
|
FROM preview_environments pe
|
||||||
JOIN projects p ON pe.project_id = p.id
|
JOIN projects p ON pe.project_id = p.id
|
||||||
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
|
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
|
||||||
envID, userID, contextIsAdmin(c),
|
envID, userID, contextIsAdmin(c),
|
||||||
).Scan(
|
).Scan(
|
||||||
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status,
|
&env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.Environment, &env.Status,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -562,38 +592,53 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Promote is a real redeploy: the source service builds the preview's
|
||||||
|
// branch onto its own deployment track.
|
||||||
deploymentID := uuid.New()
|
deploymentID := uuid.New()
|
||||||
promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix())
|
promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix())
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
`INSERT INTO deployments (id, service_id, version, status, created_at, updated_at)
|
`INSERT INTO deployments (id, service_id, version, commit_hash, status, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, NOW(), NOW())`,
|
VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW())`,
|
||||||
deploymentID, env.ServiceID, promotionVersion, "pending",
|
deploymentID, env.ServiceID, promotionVersion, env.BranchName,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := db.(*database.DB).Exec(
|
sourceService, loadErr := loadServiceForDeploy(db.(*database.DB), env.ServiceID)
|
||||||
`UPDATE services
|
if loadErr != nil {
|
||||||
SET environment = $1, updated_at = NOW()
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load promotion target service"})
|
||||||
WHERE id = $2`,
|
|
||||||
req.TargetEnvironment, env.ServiceID,
|
|
||||||
); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service target environment"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||||
|
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
||||||
|
engineInstance := engine
|
||||||
|
d := DeploymentModel{ID: deploymentID, ServiceID: env.ServiceID, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||||||
|
getDeployQueue(c).Enqueue(env.ServiceID, deployqueue.Job{
|
||||||
|
DeploymentID: deploymentID,
|
||||||
|
Run: func(jctx context.Context) {
|
||||||
|
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, sourceService, CreateDeploymentRequest{Branch: env.BranchName, Trigger: "promote"}, userID.(string))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
previewStatus := "stopped"
|
// Promotion retires the preview — the clone's containers and row go.
|
||||||
if req.CreateBackup {
|
if env.PreviewServiceID != nil {
|
||||||
previewStatus = "expired"
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||||
|
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
||||||
|
removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, *env.PreviewServiceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *env.PreviewServiceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := db.(*database.DB).Exec(
|
if _, err := db.(*database.DB).Exec(
|
||||||
`UPDATE preview_environments
|
`UPDATE preview_environments
|
||||||
SET status = $1, updated_at = NOW()
|
SET status = 'stopped', updated_at = NOW()
|
||||||
WHERE id = $2`,
|
WHERE id = $1`,
|
||||||
previewStatus, env.ID,
|
env.ID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment status"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment status"})
|
||||||
return
|
return
|
||||||
@@ -608,7 +653,7 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
|
|||||||
"create_backup": req.CreateBackup,
|
"create_backup": req.CreateBackup,
|
||||||
"deployment_id": deploymentID,
|
"deployment_id": deploymentID,
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"preview_status": previewStatus,
|
"preview_status": "stopped",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -639,7 +684,7 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
|
|||||||
|
|
||||||
// Find expired preview environments for user's projects
|
// Find expired preview environments for user's projects
|
||||||
rows, err := db.(*database.DB).Query(
|
rows, err := db.(*database.DB).Query(
|
||||||
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment
|
`SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.environment
|
||||||
FROM preview_environments pe
|
FROM preview_environments pe
|
||||||
JOIN projects p ON pe.project_id = p.id
|
JOIN projects p ON pe.project_id = p.id
|
||||||
WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`,
|
WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`,
|
||||||
@@ -655,7 +700,7 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var env PreviewEnvironment
|
var env PreviewEnvironment
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment,
|
&env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.Environment,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -663,10 +708,9 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
|
|||||||
expiredEnvs = append(expiredEnvs, env)
|
expiredEnvs = append(expiredEnvs, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark expired environments as expired and trigger cleanup
|
// Mark expired environments as expired and tear down their clones
|
||||||
cleanupCount := 0
|
cleanupCount := 0
|
||||||
for _, env := range expiredEnvs {
|
for _, env := range expiredEnvs {
|
||||||
// Update status to expired
|
|
||||||
_, err := db.(*database.DB).Exec(
|
_, err := db.(*database.DB).Exec(
|
||||||
"UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1",
|
"UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1",
|
||||||
env.ID,
|
env.ID,
|
||||||
@@ -674,7 +718,14 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if env.PreviewServiceID != nil {
|
||||||
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||||
|
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
||||||
|
removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, *env.PreviewServiceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *env.PreviewServiceID)
|
||||||
|
}
|
||||||
cleanupCount++
|
cleanupCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,3 +735,157 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
|
|||||||
"expired_environments": expiredEnvs,
|
"expired_environments": expiredEnvs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// previewServiceName builds the clone's service name — deterministic enough
|
||||||
|
// to spot in lists, short enough for dnsLabel-derived hostnames.
|
||||||
|
func previewServiceName(serviceName, branch string) string {
|
||||||
|
label := dnsLabel(serviceName + "-" + branch)
|
||||||
|
if label == "" {
|
||||||
|
return "preview-" + uuid.NewString()[:8]
|
||||||
|
}
|
||||||
|
return label + "-preview"
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePreviewHost picks a preview hostname under the clone's node default
|
||||||
|
// domain — same source as serviceAutoDomains. Returns "" when no node
|
||||||
|
// carries a default domain; the preview still runs, reachable internally.
|
||||||
|
func resolvePreviewHost(db *database.DB, cloneID uuid.UUID, envName string) string {
|
||||||
|
label := dnsLabel(envName)
|
||||||
|
if label == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var nodeID sql.NullString
|
||||||
|
var spread bool
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT node_id, COALESCE(spread, false) FROM services WHERE id = $1`, cloneID,
|
||||||
|
).Scan(&nodeID, &spread); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
q := sqlcdb.New(db.DB)
|
||||||
|
ctx := context.Background()
|
||||||
|
var base string
|
||||||
|
switch {
|
||||||
|
case nodeID.Valid && nodeID.String != "":
|
||||||
|
if a, err := q.GetAgent(ctx, nodeID.String); err == nil {
|
||||||
|
base = a.DefaultDomain
|
||||||
|
}
|
||||||
|
case spread:
|
||||||
|
if bases, err := q.ListSchedulableAgentDomains(ctx, json.RawMessage(tagsJSON(servicePlacementTags(db, cloneID)))); err == nil && len(bases) > 0 {
|
||||||
|
base = bases[0]
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Local preview: borrow the first node default domain if any —
|
||||||
|
// still better than no hostname for a Traefik-fronted local host.
|
||||||
|
if bases, err := q.ListSchedulableAgentDomains(ctx, json.RawMessage("[]")); err == nil && len(bases) > 0 {
|
||||||
|
base = bases[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if base == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return label + "." + base
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadServiceForDeploy hydrates the Service fields runDeploymentAndSync
|
||||||
|
// needs — mirrors the loader in handleDeployService.
|
||||||
|
func loadServiceForDeploy(db *database.DB, serviceID uuid.UUID) (Service, error) {
|
||||||
|
var service Service
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||||
|
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||||
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
|
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||||
|
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||||
|
COALESCE(s.static_dir, ''),
|
||||||
|
s.created_at, s.updated_at
|
||||||
|
FROM services s
|
||||||
|
WHERE s.id = $1`,
|
||||||
|
serviceID,
|
||||||
|
).Scan(
|
||||||
|
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||||
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
|
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||||
|
&service.CreatedAt, &service.UpdatedAt,
|
||||||
|
)
|
||||||
|
return service, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartPreviewSweeper expires preview environments past their TTL — tears
|
||||||
|
// down each clone's runtime and row, marks the preview expired.
|
||||||
|
func StartPreviewSweeper(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine) {
|
||||||
|
if db == nil || engine == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
sweepExpiredPreviews(ctx, db, engine)
|
||||||
|
syncPreviewStatuses(ctx, db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepExpiredPreviews retires previews whose TTL elapsed.
|
||||||
|
func sweepExpiredPreviews(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine) {
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
`SELECT id, preview_service_id FROM preview_environments
|
||||||
|
WHERE expires_at < NOW() AND status NOT IN ('expired', 'stopped')`)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ids []uuid.UUID
|
||||||
|
var clones []uuid.UUID
|
||||||
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
|
var cloneID *uuid.UUID
|
||||||
|
if err := rows.Scan(&id, &cloneID); err == nil {
|
||||||
|
ids = append(ids, id)
|
||||||
|
if cloneID != nil {
|
||||||
|
clones = append(clones, *cloneID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
for _, cloneID := range clones {
|
||||||
|
removeServiceRuntime(ctx, db, engine, cloneID)
|
||||||
|
_, _ = db.ExecContext(ctx, `DELETE FROM services WHERE id = $1`, cloneID)
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
_, _ = db.ExecContext(ctx,
|
||||||
|
`UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1`, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncPreviewStatuses folds the clone's live service status into the
|
||||||
|
// preview row so list/detail endpoints reflect the real deploy state.
|
||||||
|
func syncPreviewStatuses(ctx context.Context, db *database.DB) {
|
||||||
|
_, _ = db.ExecContext(ctx, `
|
||||||
|
UPDATE preview_environments pe
|
||||||
|
SET status = derived.st, updated_at = NOW()
|
||||||
|
FROM (
|
||||||
|
SELECT pe2.id,
|
||||||
|
CASE
|
||||||
|
WHEN s.status IN ('running', 'deployed') THEN 'running'
|
||||||
|
WHEN s.status IN ('failed', 'error') THEN 'failed'
|
||||||
|
WHEN s.status IN ('building', 'deploying', 'queued', 'pending', 'cloning') THEN 'building'
|
||||||
|
ELSE pe2.status
|
||||||
|
END AS st
|
||||||
|
FROM preview_environments pe2
|
||||||
|
JOIN services s ON pe2.preview_service_id = s.id
|
||||||
|
) derived
|
||||||
|
WHERE pe.id = derived.id
|
||||||
|
AND pe.status IN ('building', 'running', 'failed')
|
||||||
|
AND pe.status <> derived.st
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreviewServiceName(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
svc, branch string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"whoami-web", "feature-x", "whoami-web-feature-x-preview"},
|
||||||
|
{"My App", "feat/JARV-123_foo", "my-app-feat-jarv-123-foo-preview"},
|
||||||
|
{"svc", "", "svc-preview"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := previewServiceName(tc.svc, tc.branch)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("previewServiceName(%q,%q) = %q, want %q", tc.svc, tc.branch, got, tc.want)
|
||||||
|
}
|
||||||
|
if len(got) > 63 {
|
||||||
|
t.Fatalf("name exceeds DNS label limit: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A name that sanitizes to nothing must still be usable.
|
||||||
|
if got := previewServiceName("!!!", "%%%"); !strings.HasPrefix(got, "preview-") {
|
||||||
|
t.Fatalf("expected fallback name, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"containr/internal/build"
|
"containr/internal/build"
|
||||||
@@ -43,6 +44,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
dockerClient = client
|
dockerClient = client
|
||||||
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
||||||
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
|
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
|
||||||
|
initArtifacts(filepath.Join("/tmp/containr-builds", "artifacts"))
|
||||||
|
purgeStaleArtifacts()
|
||||||
|
// Node-pinned services dispatch container lifecycle to the agent's
|
||||||
|
// command queue instead of the local Docker socket.
|
||||||
|
deploymentEngine.SetNodeRunner(newAgentNodeRunner(db))
|
||||||
// Restore managed sidecars (e.g. cloudflared) from saved settings.
|
// Restore managed sidecars (e.g. cloudflared) from saved settings.
|
||||||
syncManagedContainers(dockerClient, db)
|
syncManagedContainers(dockerClient, db)
|
||||||
}
|
}
|
||||||
@@ -105,10 +111,18 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
|
|
||||||
// Sleep sweeper — scales idle services to zero and prunes wake containers.
|
// Sleep sweeper — scales idle services to zero and prunes wake containers.
|
||||||
StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port)
|
StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port)
|
||||||
|
StartPreviewSweeper(context.Background(), db, deploymentEngine)
|
||||||
|
|
||||||
// Audit retention — prunes rows past AUDIT_RETENTION_DAYS (default 90).
|
// Audit retention — prunes rows past AUDIT_RETENTION_DAYS (default 90).
|
||||||
StartAuditRetention(context.Background(), db)
|
StartAuditRetention(context.Background(), db)
|
||||||
|
|
||||||
|
// Agent health — marks stale heartbeats offline, notifies admins on
|
||||||
|
// offline/recovery transitions.
|
||||||
|
StartAgentHealthSweep(context.Background(), db)
|
||||||
|
|
||||||
|
// Daily upgrade check — notifies admins when a newer release exists.
|
||||||
|
StartUpgradeCheck(context.Background(), db, Version)
|
||||||
|
|
||||||
// Initialize database handler
|
// Initialize database handler
|
||||||
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
||||||
|
|
||||||
@@ -256,6 +270,12 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// endpoint, verified against the provisioned webhook secret.
|
// endpoint, verified against the provisioned webhook secret.
|
||||||
router.POST("/api/git/github-app/webhook", handleGitHubAppWebhook)
|
router.POST("/api/git/github-app/webhook", handleGitHubAppWebhook)
|
||||||
|
|
||||||
|
// Public API gateway data plane. Lives outside /api/v1 so each service's
|
||||||
|
// route_prefix (/g/<slug>) maps cleanly; auth is the caller's api_key,
|
||||||
|
// not a session.
|
||||||
|
router.Any("/g/:slug", handleGatewayProxy)
|
||||||
|
router.Any("/g/:slug/*path", handleGatewayProxy)
|
||||||
|
|
||||||
v1 := router.Group("/api/v1")
|
v1 := router.Group("/api/v1")
|
||||||
{
|
{
|
||||||
// Public routes (no authentication required)
|
// Public routes (no authentication required)
|
||||||
@@ -266,6 +286,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
public.POST("/auth/register", handleRegister)
|
public.POST("/auth/register", handleRegister)
|
||||||
public.GET("/maintenance", handleMaintenancePage)
|
public.GET("/maintenance", handleMaintenancePage)
|
||||||
public.GET("/branding", handleGetBranding)
|
public.GET("/branding", handleGetBranding)
|
||||||
|
public.GET("/setup/status", handleSetupStatus)
|
||||||
|
|
||||||
// Invite accept flow — validates the link, then registers
|
// Invite accept flow — validates the link, then registers
|
||||||
// even while public signup is closed.
|
// even while public signup is closed.
|
||||||
@@ -275,6 +296,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// Wake callback for sleep-mode placeholders. Unauthenticated but
|
// Wake callback for sleep-mode placeholders. Unauthenticated but
|
||||||
// non-destructive: it only reconciles the service back to running.
|
// non-destructive: it only reconciles the service back to running.
|
||||||
public.Any("/internal/wake/:id", handleServiceWake)
|
public.Any("/internal/wake/:id", handleServiceWake)
|
||||||
|
public.Any("/internal/wake-page/:id", handleServiceWakePage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read routes — public browsing. OptionalAuth resolves a session when
|
// Read routes — public browsing. OptionalAuth resolves a session when
|
||||||
@@ -286,6 +308,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
read.GET("/projects", handleGetProjects)
|
read.GET("/projects", handleGetProjects)
|
||||||
read.GET("/projects/:id", handleGetProject)
|
read.GET("/projects/:id", handleGetProject)
|
||||||
read.GET("/projects/:id/services", handleGetServices)
|
read.GET("/projects/:id/services", handleGetServices)
|
||||||
|
read.GET("/projects/:id/environments", handleListEnvironments)
|
||||||
read.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
|
read.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
|
||||||
read.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
|
read.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
|
||||||
read.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
|
read.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
|
||||||
@@ -333,6 +356,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
{
|
{
|
||||||
authed.GET("/user/profile", handleGetProfile)
|
authed.GET("/user/profile", handleGetProfile)
|
||||||
authed.PUT("/user/profile", handleUpdateProfile)
|
authed.PUT("/user/profile", handleUpdateProfile)
|
||||||
|
authed.POST("/setup/complete", handleSetupComplete)
|
||||||
|
|
||||||
// Personal access tokens for CLI/MCP/agent auth.
|
// Personal access tokens for CLI/MCP/agent auth.
|
||||||
authed.GET("/user/tokens", handleListUserTokens)
|
authed.GET("/user/tokens", handleListUserTokens)
|
||||||
@@ -342,12 +366,17 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
authed.GET("/notifications", handleListNotifications)
|
authed.GET("/notifications", handleListNotifications)
|
||||||
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
|
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
|
||||||
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
|
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
|
||||||
|
authed.GET("/notifications/channels", handleListNotificationChannels)
|
||||||
|
authed.POST("/notifications/channels", handleCreateNotificationChannel)
|
||||||
|
authed.POST("/notifications/channels/:id/test", handleTestNotificationChannel)
|
||||||
|
authed.DELETE("/notifications/channels/:id", handleDeleteNotificationChannel)
|
||||||
|
|
||||||
// WebSocket endpoint
|
// WebSocket endpoint
|
||||||
authed.GET("/ws", handleWebSocket)
|
authed.GET("/ws", handleWebSocket)
|
||||||
|
|
||||||
// Cross-cutting ops view — active/queued/failed work
|
// Cross-cutting ops view — active/queued/failed work
|
||||||
authed.GET("/operations", handleGetOperations)
|
authed.GET("/operations", handleGetOperations)
|
||||||
|
authed.GET("/nodes/options", agentHandler.ListNodeOptions)
|
||||||
authed.GET("/activity", handleGetActivity)
|
authed.GET("/activity", handleGetActivity)
|
||||||
authed.GET("/projects/:id/activity", handleGetProjectActivity)
|
authed.GET("/projects/:id/activity", handleGetProjectActivity)
|
||||||
|
|
||||||
@@ -454,6 +483,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
authed.POST("/databases/:id/backups/import", databaseHandler.ImportBackup)
|
authed.POST("/databases/:id/backups/import", databaseHandler.ImportBackup)
|
||||||
authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
|
authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
|
||||||
|
|
||||||
|
authed.POST("/projects/:id/environments", handleCreateEnvironment)
|
||||||
|
authed.DELETE("/environments/:id", handleDeleteEnvironment)
|
||||||
authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
|
authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
|
||||||
authed.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
|
authed.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
|
||||||
authed.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
|
authed.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
|
||||||
@@ -472,6 +503,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
|
authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
|
||||||
authed.POST("/templates/:id/plan", handlePlanTemplate)
|
authed.POST("/templates/:id/plan", handlePlanTemplate)
|
||||||
authed.POST("/templates/import/compose", handleImportCompose)
|
authed.POST("/templates/import/compose", handleImportCompose)
|
||||||
|
authed.POST("/templates/import/git", handleImportGitCompose)
|
||||||
authed.POST("/templates/deploy", handleDeployInlineGraph)
|
authed.POST("/templates/deploy", handleDeployInlineGraph)
|
||||||
|
|
||||||
authed.POST("/cron-jobs", handleCreateCronJob)
|
authed.POST("/cron-jobs", handleCreateCronJob)
|
||||||
|
|||||||
@@ -18,6 +18,61 @@ import (
|
|||||||
"github.com/google/uuid"
|
"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
|
// serviceRuntimeSpec builds the container spec for a service: stored env vars
|
||||||
// merged with ${{Service.KEY}} references resolved across the project.
|
// merged with ${{Service.KEY}} references resolved across the project.
|
||||||
func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpec, error) {
|
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,
|
Domain: service.Domain,
|
||||||
HealthPath: service.HealthCheckPath,
|
HealthPath: service.HealthCheckPath,
|
||||||
RestartPolicy: service.RestartPolicy,
|
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
|
// Best effort: reuse the host port from the last live deployment so the
|
||||||
// public URL survives restarts. Column exists post-migration; older DBs
|
// 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.Volumes = loadServiceVolumes(db, service.ID)
|
||||||
spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
|
spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
|
||||||
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
|
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
|
||||||
|
spec.TraefikLabels = serviceTraefikLabels(db, service.ID)
|
||||||
if spec.Maintenance {
|
if spec.Maintenance {
|
||||||
spec.MaintenanceURL = maintenanceURL(db)
|
spec.MaintenanceURL = maintenanceURL(db)
|
||||||
}
|
}
|
||||||
@@ -341,6 +400,7 @@ func handleGetServiceRuntime(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
mergeRemoteRuntime(c.Request.Context(), engine, service.ID.String(), state)
|
||||||
engine.ProbeServiceHealth(c.Request.Context(), state, service.ProjectID.String(), service.Name, service.Port, service.HealthCheckPath)
|
engine.ProbeServiceHealth(c.Request.Context(), state, service.ProjectID.String(), service.Name, service.Port, service.HealthCheckPath)
|
||||||
persistPublishedPort(db, service.ID, state.Ports)
|
persistPublishedPort(db, service.ID, state.Ports)
|
||||||
if host := requestHostname(c); host != "" {
|
if host := requestHostname(c); host != "" {
|
||||||
@@ -384,7 +444,7 @@ func handleServiceStart(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -402,7 +462,7 @@ func handleServiceStop(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -421,11 +481,7 @@ func handleServiceRestart(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
if err := engine.StopService(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
|
|
||||||
}
|
|
||||||
if err := engine.StartService(ctx, service.ID.String()); err != nil {
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -535,6 +591,37 @@ func enqueueServiceRedeploy(c *gin.Context, db *database.DB, service Service) bo
|
|||||||
return true
|
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
|
// persistPublishedPort records the host port Docker actually bound so later
|
||||||
// redeploys can reuse it. Idempotent — only writes when the value changed.
|
// redeploys can reuse it. Idempotent — only writes when the value changed.
|
||||||
func persistPublishedPort(db *database.DB, serviceID uuid.UUID, ports []uint16) {
|
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)
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
state, err := engine.RuntimeState(ctx, service.ID.String(), service.Replicas)
|
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
|
return
|
||||||
}
|
}
|
||||||
if state.Status != service.Status {
|
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)
|
persistPublishedPort(db, service.ID, state.Ports)
|
||||||
if service.Domain != "" {
|
if service.Domain != "" {
|
||||||
service.PublicURL = "https://" + service.Domain
|
service.PublicURL = "https://" + service.Domain
|
||||||
|
} else if auto := serviceAutoDomains(db, service.ID); len(auto) > 0 {
|
||||||
|
service.PublicURL = "https://" + auto[0]
|
||||||
} else if len(state.URLs) > 0 {
|
} else if len(state.URLs) > 0 {
|
||||||
service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c))
|
service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,27 @@ type SecurityHandler struct {
|
|||||||
func NewSecurityHandler(db *database.DB, encryptionKey string) *SecurityHandler {
|
func NewSecurityHandler(db *database.DB, encryptionKey string) *SecurityHandler {
|
||||||
encryptionManager, _ := security.NewEncryptionManager(encryptionKey)
|
encryptionManager, _ := security.NewEncryptionManager(encryptionKey)
|
||||||
|
|
||||||
|
scanner := security.NewScanner(db)
|
||||||
|
// Notify the project owner when a background scan completes — criticals
|
||||||
|
// earn a distinct title so they stand out in the notification list.
|
||||||
|
scanner.OnScanComplete = func(scan *security.SecurityScan) {
|
||||||
|
var ownerID string
|
||||||
|
if err := db.QueryRow(`SELECT owner_id::text FROM projects WHERE id = $1`, scan.ProjectID).Scan(&ownerID); err != nil || ownerID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title := "Security scan completed"
|
||||||
|
if scan.Summary.Critical > 0 || scan.Summary.High > 0 {
|
||||||
|
title = "Security scan found issues"
|
||||||
|
}
|
||||||
|
insertUserNotification(db, ownerID, "security", title,
|
||||||
|
fmt.Sprintf("%s scan finished: %d critical, %d high, %d total findings (score %d/100).",
|
||||||
|
scan.ScanType, scan.Summary.Critical, scan.Summary.High, scan.Summary.Total, scan.Summary.Score),
|
||||||
|
"project", scan.ProjectID)
|
||||||
|
}
|
||||||
|
|
||||||
return &SecurityHandler{
|
return &SecurityHandler{
|
||||||
db: db,
|
db: db,
|
||||||
scanner: security.NewScanner(db),
|
scanner: scanner,
|
||||||
complianceManager: security.NewComplianceManager(db),
|
complianceManager: security.NewComplianceManager(db),
|
||||||
encryptionManager: encryptionManager,
|
encryptionManager: encryptionManager,
|
||||||
dataRetentionManager: security.NewDataRetentionManager(encryptionManager),
|
dataRetentionManager: security.NewDataRetentionManager(encryptionManager),
|
||||||
|
|||||||
@@ -67,22 +67,45 @@ func handleCloneService(c *gin.Context) {
|
|||||||
if req.Environment != "" {
|
if req.Environment != "" {
|
||||||
environment = req.Environment
|
environment = req.Environment
|
||||||
}
|
}
|
||||||
if environment == "" {
|
|
||||||
environment = "production"
|
newID, err := cloneServiceRow(db.(*database.DB), source.ID, targetProjectID, name, environment)
|
||||||
}
|
|
||||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), targetProjectID, environment)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
maintenance, basicAuth := serviceAccess(db.(*database.DB), source.ID)
|
LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneServiceRow copies a service's full configuration — source, build
|
||||||
|
// settings, volumes, access gates, domains, and variables — under a new id
|
||||||
|
// in the target project. The clone starts stopped. An empty environment
|
||||||
|
// inherits the source's. Used by the clone endpoint and preview
|
||||||
|
// environments, which additionally override git_branch and domains.
|
||||||
|
func cloneServiceRow(db *database.DB, sourceID uuid.UUID, targetProjectID uuid.UUID, name, environment string) (uuid.UUID, error) {
|
||||||
|
var sourceEnv string
|
||||||
|
if err := db.QueryRow(`SELECT COALESCE(environment, '') FROM services WHERE id = $1`, sourceID).Scan(&sourceEnv); err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
if environment == "" {
|
||||||
|
environment = sourceEnv
|
||||||
|
}
|
||||||
|
if environment == "" {
|
||||||
|
environment = "production"
|
||||||
|
}
|
||||||
|
environmentID, err := getProjectEnvironmentID(db, targetProjectID, environment)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
maintenance, basicAuth := serviceAccess(db, sourceID)
|
||||||
newID := uuid.New()
|
newID := uuid.New()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
// string() so pq sends text — []byte would go as bytea and fail ::jsonb.
|
// string() so pq sends text — []byte would go as bytea and fail ::jsonb.
|
||||||
volumesJSON := string(loadServiceVolumesJSON(db.(*database.DB), source.ID))
|
volumesJSON := string(loadServiceVolumesJSON(db, sourceID))
|
||||||
|
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.Exec(
|
||||||
`INSERT INTO services
|
`INSERT INTO services
|
||||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||||
build_command, start_command, type, status, image, command, environment,
|
build_command, start_command, type, status, image, command, environment,
|
||||||
@@ -98,35 +121,30 @@ func handleCloneService(c *gin.Context) {
|
|||||||
$9, $9
|
$9, $9
|
||||||
FROM services WHERE id = $10`,
|
FROM services WHERE id = $10`,
|
||||||
newID, targetProjectID, name, environmentID, environment, volumesJSON,
|
newID, targetProjectID, name, environmentID, environment, volumesJSON,
|
||||||
maintenance, basicAuth, now, source.ID,
|
maintenance, basicAuth, now, sourceID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
|
return uuid.Nil, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := db.(*database.DB).Exec(
|
if _, err := db.Exec(
|
||||||
`INSERT INTO service_domains (service_id, domain, is_default, cert_type)
|
`INSERT INTO service_domains (service_id, domain, is_default, cert_type)
|
||||||
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
|
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
|
||||||
newID, source.ID,
|
newID, sourceID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
|
_, _ = db.Exec(`DELETE FROM services WHERE id = $1`, newID)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service domains"})
|
return uuid.Nil, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// Variable values are copied as stored — ciphertext stays ciphertext.
|
// Variable values are copied as stored — ciphertext stays ciphertext.
|
||||||
if _, err := db.(*database.DB).Exec(
|
if _, err := db.Exec(
|
||||||
`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
|
`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
|
||||||
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
|
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
|
||||||
newID, source.ID,
|
newID, sourceID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
|
_, _ = db.Exec(`DELETE FROM services WHERE id = $1`, newID)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service variables"})
|
return uuid.Nil, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
return newID, nil
|
||||||
LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MoveServiceRequest struct {
|
type MoveServiceRequest struct {
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
"containr/internal/deployqueue"
|
"containr/internal/deployqueue"
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -48,6 +50,19 @@ type Service struct {
|
|||||||
StaticDir string `json:"static_dir,omitempty" db:"static_dir"`
|
StaticDir string `json:"static_dir,omitempty" db:"static_dir"`
|
||||||
SleepEnabled bool `json:"sleep_enabled" db:"-"`
|
SleepEnabled bool `json:"sleep_enabled" db:"-"`
|
||||||
SleepIdleMinutes int `json:"sleep_idle_minutes" db:"-"`
|
SleepIdleMinutes int `json:"sleep_idle_minutes" db:"-"`
|
||||||
|
// 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"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -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.
|
// ServiceVolume is a volume/bind mount applied to every replica.
|
||||||
type ServiceVolume struct {
|
type ServiceVolume struct {
|
||||||
Type string `json:"type"` // volume | bind (empty = volume)
|
Type string `json:"type"` // volume | bind (empty = volume)
|
||||||
@@ -141,6 +301,23 @@ type CreateServiceRequest struct {
|
|||||||
MemoryReserve string `json:"memory_reserve"`
|
MemoryReserve string `json:"memory_reserve"`
|
||||||
StaticBuildCmd string `json:"static_build_cmd"`
|
StaticBuildCmd string `json:"static_build_cmd"`
|
||||||
StaticDir string `json:"static_dir"`
|
StaticDir string `json:"static_dir"`
|
||||||
|
// SleepEnabled/SleepIdleMinutes opt the service into scale-to-zero at
|
||||||
|
// create time — previously update-only, which surprised API/CLI users.
|
||||||
|
SleepEnabled *bool `json:"sleep_enabled"`
|
||||||
|
SleepIdleMinutes *int `json:"sleep_idle_minutes"`
|
||||||
|
// NodeID pins placement to a node agent; "auto" picks the least-loaded
|
||||||
|
// 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
|
// UpdateServiceRequest represents a request to update a service
|
||||||
@@ -170,6 +347,18 @@ type UpdateServiceRequest struct {
|
|||||||
StaticDir *string `json:"static_dir"`
|
StaticDir *string `json:"static_dir"`
|
||||||
SleepEnabled *bool `json:"sleep_enabled"`
|
SleepEnabled *bool `json:"sleep_enabled"`
|
||||||
SleepIdleMinutes *int `json:"sleep_idle_minutes"`
|
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
|
// 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).
|
// Reconcile stored status with live container state (no-op without Docker).
|
||||||
for i := range services {
|
for i := range services {
|
||||||
liveServiceStatus(c, db.(*database.DB), &services[i])
|
liveServiceStatus(c, db.(*database.DB), &services[i])
|
||||||
|
loadServiceNode(db.(*database.DB), &services[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"services": services})
|
c.JSON(http.StatusOK, gin.H{"services": services})
|
||||||
@@ -372,6 +562,14 @@ func handleCreateService(c *gin.Context) {
|
|||||||
if service.Builder == "static" && service.StaticDir == "" {
|
if service.Builder == "static" && service.StaticDir == "" {
|
||||||
service.StaticDir = "dist"
|
service.StaticDir = "dist"
|
||||||
}
|
}
|
||||||
|
if len(req.TraefikLabels) > 0 {
|
||||||
|
labels, err := normalizeTraefikLabels(req.TraefikLabels)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service.TraefikLabels = labels
|
||||||
|
}
|
||||||
for label, check := range map[string]func(string) error{
|
for label, check := range map[string]func(string) error{
|
||||||
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
||||||
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
||||||
@@ -409,6 +607,39 @@ func handleCreateService(c *gin.Context) {
|
|||||||
|
|
||||||
sourceType := inferServiceSourceType(service)
|
sourceType := inferServiceSourceType(service)
|
||||||
|
|
||||||
|
if req.SleepEnabled != nil {
|
||||||
|
service.SleepEnabled = *req.SleepEnabled
|
||||||
|
}
|
||||||
|
if req.SleepIdleMinutes != nil {
|
||||||
|
if *req.SleepIdleMinutes < 1 || *req.SleepIdleMinutes > 1440 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "sleep_idle_minutes must be 1-1440", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service.SleepIdleMinutes = *req.SleepIdleMinutes
|
||||||
|
}
|
||||||
|
if req.Spread != nil {
|
||||||
|
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
|
// Insert service into database
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
`INSERT INTO services
|
`INSERT INTO services
|
||||||
@@ -417,16 +648,24 @@ func handleCreateService(c *gin.Context) {
|
|||||||
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||||
healthcheck_path, restart_policy, volumes,
|
healthcheck_path, restart_policy, volumes,
|
||||||
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
||||||
created_at, updated_at)
|
node_id, spread, placement_tags, sleep_enabled, sleep_idle_minutes,
|
||||||
|
traefik_labels, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
|
||||||
$27, $28, $29, $30, $31, $32, $33)`,
|
$27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39)`,
|
||||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||||
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
||||||
volumesJSON, service.Builder, service.CPUReserve, service.MemoryReserve,
|
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 {
|
if err != nil {
|
||||||
@@ -456,7 +695,9 @@ func handleCreateService(c *gin.Context) {
|
|||||||
|
|
||||||
LogAuditWithRequest(c, "service", service.ID.String(), "create", map[string]interface{}{
|
LogAuditWithRequest(c, "service", service.ID.String(), "create", map[string]interface{}{
|
||||||
"name": service.Name, "type": service.Type, "project_id": service.ProjectID,
|
"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})
|
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)
|
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
|
||||||
loadServiceSleep(db.(*database.DB), &service)
|
loadServiceSleep(db.(*database.DB), &service)
|
||||||
|
loadServiceNode(db.(*database.DB), &service)
|
||||||
maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
|
maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
|
||||||
service.MaintenanceMode = maintenance
|
service.MaintenanceMode = maintenance
|
||||||
service.BasicAuth = basicAuthUsernames(basicAuth)
|
service.BasicAuth = basicAuthUsernames(basicAuth)
|
||||||
@@ -699,6 +941,16 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
existingService.SleepIdleMinutes = *req.SleepIdleMinutes
|
existingService.SleepIdleMinutes = *req.SleepIdleMinutes
|
||||||
}
|
}
|
||||||
|
if req.TraefikLabels != nil {
|
||||||
|
labels, err := normalizeTraefikLabels(*req.TraefikLabels)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existingService.TraefikLabels = labels
|
||||||
|
} else {
|
||||||
|
existingService.TraefikLabels = serviceTraefikLabels(db.(*database.DB), serviceID)
|
||||||
|
}
|
||||||
for label, check := range map[string]func(string) error{
|
for label, check := range map[string]func(string) error{
|
||||||
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
||||||
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
||||||
@@ -725,6 +977,44 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
volumesArg = volumesJSON
|
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()
|
existingService.UpdatedAt = time.Now()
|
||||||
|
|
||||||
// Update service in database
|
// Update service in database
|
||||||
@@ -736,7 +1026,8 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
|
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
|
||||||
builder = $19, cpu_reserve = $20, memory_reserve = $21,
|
builder = $19, cpu_reserve = $20, memory_reserve = $21,
|
||||||
static_build_cmd = $22, static_dir = $23,
|
static_build_cmd = $22, static_dir = $23,
|
||||||
sleep_enabled = $24, sleep_idle_minutes = $25
|
sleep_enabled = $24, sleep_idle_minutes = $25, node_id = $26, spread = $27,
|
||||||
|
placement_tags = $28::jsonb, traefik_labels = $29::jsonb
|
||||||
WHERE id = $18`,
|
WHERE id = $18`,
|
||||||
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
||||||
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
||||||
@@ -747,6 +1038,10 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
|
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
|
||||||
existingService.StaticBuildCmd, existingService.StaticDir,
|
existingService.StaticBuildCmd, existingService.StaticDir,
|
||||||
existingService.SleepEnabled, existingService.SleepIdleMinutes,
|
existingService.SleepEnabled, existingService.SleepIdleMinutes,
|
||||||
|
sql.NullString{String: existingService.NodeID, Valid: existingService.NodeID != ""},
|
||||||
|
existingService.Spread,
|
||||||
|
tagsJSON(existingService.PlacementTags),
|
||||||
|
traefikLabelsJSON(existingService.TraefikLabels),
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
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})
|
c.JSON(http.StatusOK, gin.H{"service": existingService})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,7 +1161,7 @@ func handleDeleteService(c *gin.Context) {
|
|||||||
// Remove runtime containers before dropping the row.
|
// Remove runtime containers before dropping the row.
|
||||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||||
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
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"
|
"time"
|
||||||
|
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
|
"containr/internal/database/sqlcdb"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
"containr/internal/docker"
|
"containr/internal/docker"
|
||||||
|
|
||||||
@@ -100,7 +101,20 @@ func sweepSleeping(ctx context.Context, db *database.DB, dc *docker.Client, engi
|
|||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
|
|
||||||
|
q := sqlcdb.New(db.DB)
|
||||||
for _, svc := range candidates {
|
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())
|
containers, err := engine.ListServiceContainers(ctx, svc.ID.String())
|
||||||
if err != nil || len(containers) == 0 {
|
if err != nil || len(containers) == 0 {
|
||||||
continue
|
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
|
// markIdle returns true when the network counter stopped growing past the
|
||||||
// timeout. First sighting only sets the baseline — no idle without history.
|
// timeout. First sighting only sets the baseline — no idle without history.
|
||||||
func markIdle(id uuid.UUID, bytes uint64, timeout time.Duration) bool {
|
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
|
// spawnWakeContainer runs a busybox httpd holding the service's Traefik
|
||||||
// host rule at higher priority. On boot it calls the wake endpoint, then
|
// host rule at higher priority. Inbound hits redirect to /cgi-bin/wake,
|
||||||
// keeps serving a reloading page until the next reconcile removes it.
|
// which calls the internal wake endpoint and serves a reloading page
|
||||||
|
// until the reconcile removes the placeholder.
|
||||||
func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, svc sleepCandidate, domains []string, apiPort int) error {
|
func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, svc sleepCandidate, domains []string, apiPort int) error {
|
||||||
edgeNet := ""
|
edgeNet := ""
|
||||||
for _, n := range networkList(ctx, dc) {
|
for _, n := range networkList(ctx, dc) {
|
||||||
@@ -223,19 +307,25 @@ func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client,
|
|||||||
"traefik.http.services." + router + ".loadbalancer.server.port": "80",
|
"traefik.http.services." + router + ".loadbalancer.server.port": "80",
|
||||||
}
|
}
|
||||||
|
|
||||||
wakeURL := wakeEndpointURL(ctx, dc, apiPort, svc.ID, edgeNet)
|
wakeURLs := wakeEndpointURLs(ctx, dc, apiPort, svc.ID, edgeNet)
|
||||||
page := `<!doctype html><html><head><meta http-equiv="refresh" content="4"><title>Waking up</title>` +
|
page := `<!doctype html><html><head><meta http-equiv="refresh" content="4"><title>Waking up</title>` +
|
||||||
`<style>body{font-family:system-ui,sans-serif;background:#0b0d12;color:#dde2ea;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}` +
|
`<style>body{font-family:system-ui,sans-serif;background:#0b0d12;color:#dde2ea;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}` +
|
||||||
`.card{text-align:center;max-width:34rem;padding:2rem}h1{font-size:1.25rem;font-weight:600}p{color:#98a2b3}</style></head>` +
|
`.card{text-align:center;max-width:34rem;padding:2rem}h1{font-size:1.25rem;font-weight:600}p{color:#98a2b3}</style></head>` +
|
||||||
`<body><div class="card"><h1>Service is waking up</h1><p>This Containr service was sleeping and is resuming now. The page reloads automatically.</p></div></body></html>`
|
`<body><div class="card"><h1>Service is waking up</h1><p>This Containr service was sleeping and is resuming now. The page reloads automatically.</p></div></body></html>`
|
||||||
// httpd without -f daemonizes, so the sequence is: write page, start
|
// The wake callback must fire when a request arrives, not at boot —
|
||||||
// httpd, fire the wake callback, then hold PID1 on sleep infinity.
|
// firing unconditionally makes the service flap sleep→wake forever.
|
||||||
|
// index.html redirects to /cgi-bin/wake; the CGI fires the callback
|
||||||
|
// (first reachable URL wins) and serves the reloading page.
|
||||||
|
indexPage := `<!doctype html><html><head><meta http-equiv="refresh" content="0;url=/cgi-bin/wake"></head><body></body></html>`
|
||||||
|
// httpd without -f daemonizes, so PID1 ends on sleep infinity. URLs
|
||||||
|
// and the page travel via env so the CGI script needs no quoting.
|
||||||
cmd := fmt.Sprintf(
|
cmd := fmt.Sprintf(
|
||||||
`mkdir -p /www && printf '%%s' '%s' > /www/index.html && `+
|
`mkdir -p /www/cgi-bin && printf '%%s' '%s' > /www/index.html && `+
|
||||||
`httpd -p 80 -h /www && `+
|
`printf '%%s' '%s' > /www/cgi-bin/wake && chmod +x /www/cgi-bin/wake && `+
|
||||||
`(sleep 2; wget -q -O /dev/null --timeout=20 '%s' 2>/dev/null || true) && `+
|
`printf '%%s\n' 'E404:/cgi-bin/wake' > /www/httpd.conf && `+
|
||||||
`sleep infinity`,
|
`httpd -p 80 -h /www && sleep infinity`,
|
||||||
strings.ReplaceAll(page, "'", "'\\''"), wakeURL)
|
strings.ReplaceAll(indexPage, "'", "'\\''"),
|
||||||
|
strings.ReplaceAll(wakeCGIScript, "'", "'\\''"))
|
||||||
|
|
||||||
endpoints := map[string]*network.EndpointSettings{edgeNet: {}}
|
endpoints := map[string]*network.EndpointSettings{edgeNet: {}}
|
||||||
if ownNet := dc.OwnNetworkName(ctx); ownNet != "" && ownNet != edgeNet {
|
if ownNet := dc.OwnNetworkName(ctx); ownNet != "" && ownNet != edgeNet {
|
||||||
@@ -259,6 +349,10 @@ func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client,
|
|||||||
Name: name,
|
Name: name,
|
||||||
Image: wakeImage,
|
Image: wakeImage,
|
||||||
Cmd: []string{"sh", "-c", cmd},
|
Cmd: []string{"sh", "-c", cmd},
|
||||||
|
Env: []string{
|
||||||
|
"WAKE_URLS=" + strings.Join(wakeURLs, " "),
|
||||||
|
"WAKE_PAGE=" + page,
|
||||||
|
},
|
||||||
Labels: labels,
|
Labels: labels,
|
||||||
Networks: endpoints,
|
Networks: endpoints,
|
||||||
})
|
})
|
||||||
@@ -280,25 +374,41 @@ func networkList(ctx context.Context, dc *docker.Client) []string {
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
// wakeEndpointURL builds the internal URL the wake container calls. The
|
// wakeEndpointURLs builds ordered candidate URLs for the wake container to
|
||||||
// edge network's gateway reaches the host's published API port; when the
|
// call, deduplicated. No single address covers every topology: a containerized
|
||||||
// API itself is containerized its own name resolves on the shared network.
|
// API resolves by name on the shared network; Docker Desktop containers reach
|
||||||
func wakeEndpointURL(ctx context.Context, dc *docker.Client, apiPort int, serviceID uuid.UUID, edgeNet string) string {
|
// the host via host.docker.internal; on native Docker the edge network's
|
||||||
|
// gateway routes to host-published ports. busybox wget walks the chain until
|
||||||
|
// one answers.
|
||||||
|
func wakeEndpointURLs(ctx context.Context, dc *docker.Client, apiPort int, serviceID uuid.UUID, edgeNet string) []string {
|
||||||
|
var hosts []string
|
||||||
if dc.OwnNetworkName(ctx) != "" {
|
if dc.OwnNetworkName(ctx) != "" {
|
||||||
if self, err := dc.GetContainer(ctx, selfID()); err == nil {
|
if self, err := dc.GetContainer(ctx, selfID()); err == nil {
|
||||||
name := strings.TrimPrefix(self.Name, "/")
|
if name := strings.TrimPrefix(self.Name, "/"); name != "" {
|
||||||
if name != "" {
|
hosts = append(hosts, name)
|
||||||
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", name, apiPort, serviceID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
hosts = append(hosts, "host.docker.internal")
|
||||||
if gw := networkGateway(ctx, dc, edgeNet); gw != "" {
|
if gw := networkGateway(ctx, dc, edgeNet); gw != "" {
|
||||||
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", gw, apiPort, serviceID)
|
hosts = append(hosts, gw)
|
||||||
}
|
}
|
||||||
if probe := dc.HostProbeHost(ctx); probe != "" {
|
if probe := dc.HostProbeHost(ctx); probe != "" {
|
||||||
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", probe, apiPort, serviceID)
|
hosts = append(hosts, probe)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("http://127.0.0.1:%d/api/v1/internal/wake/%s", apiPort, serviceID)
|
seen := map[string]bool{}
|
||||||
|
var urls []string
|
||||||
|
for _, h := range hosts {
|
||||||
|
if h == "" || seen[h] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[h] = true
|
||||||
|
urls = append(urls, fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", h, apiPort, serviceID))
|
||||||
|
}
|
||||||
|
if len(urls) == 0 {
|
||||||
|
urls = append(urls, fmt.Sprintf("http://127.0.0.1:%d/api/v1/internal/wake/%s", apiPort, serviceID))
|
||||||
|
}
|
||||||
|
return urls
|
||||||
}
|
}
|
||||||
|
|
||||||
func networkGateway(ctx context.Context, dc *docker.Client, name string) string {
|
func networkGateway(ctx context.Context, dc *docker.Client, name string) string {
|
||||||
@@ -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
|
// idempotent and non-destructive, so no token is required; the endpoint
|
||||||
// never mutates configuration.
|
// never mutates configuration.
|
||||||
func handleServiceWake(c *gin.Context) {
|
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")
|
dbVal, _ := c.Get("db")
|
||||||
db := dbVal.(*database.DB)
|
db := dbVal.(*database.DB)
|
||||||
engineVal, _ := c.Get("deployment_engine")
|
engineVal, _ := c.Get("deployment_engine")
|
||||||
engine, _ := engineVal.(*deployment.DeploymentEngine)
|
engine, _ := engineVal.(*deployment.DeploymentEngine)
|
||||||
if engine == nil {
|
if engine == nil {
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "deployment engine unavailable"})
|
return http.StatusServiceUnavailable, gin.H{"error": "deployment engine unavailable"}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
id, err := uuid.Parse(c.Param("id"))
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid service id"})
|
return http.StatusBadRequest, gin.H{"error": "invalid service id"}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
var service Service
|
var service Service
|
||||||
err = db.QueryRow(
|
err = db.QueryRow(
|
||||||
@@ -385,22 +517,23 @@ func handleServiceWake(c *gin.Context) {
|
|||||||
&service.CreatedAt, &service.UpdatedAt,
|
&service.CreatedAt, &service.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "service not found"})
|
return http.StatusNotFound, gin.H{"error": "service not found"}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if service.Status == "running" || service.Status == "deployed" {
|
if service.Status == "running" || service.Status == "deployed" {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": service.Status})
|
return http.StatusOK, gin.H{"status": service.Status}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
spec, err := serviceRuntimeSpec(db, service)
|
spec, err := serviceRuntimeSpec(db, service)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("wake: runtime spec for %s failed: %v", service.ID, err)
|
||||||
|
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state, err := engine.ReconcileService(ctx, spec)
|
state, err := engine.ReconcileService(ctx, spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("wake: reconcile for %s failed: %v", service.ID, err)
|
||||||
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -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)
|
_, _ = 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).
|
// handleServiceSleep puts a service to sleep immediately (owner+ only).
|
||||||
@@ -438,7 +571,12 @@ func handleServiceSleep(c *gin.Context) {
|
|||||||
Name: service.Name,
|
Name: service.Name,
|
||||||
Status: service.Status,
|
Status: service.Status,
|
||||||
}
|
}
|
||||||
|
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))
|
go putToSleep(context.Background(), db, dc, engine, svc, apiPortFromContext(c))
|
||||||
|
}
|
||||||
c.JSON(http.StatusAccepted, gin.H{"status": "sleeping"})
|
c.JSON(http.StatusAccepted, gin.H{"status": "sleeping"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -22,6 +24,7 @@ import (
|
|||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
"containr/internal/deployqueue"
|
"containr/internal/deployqueue"
|
||||||
"containr/internal/secrets"
|
"containr/internal/secrets"
|
||||||
|
"containr/internal/source"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -227,7 +230,9 @@ func resolveStaticExpr(value string, userVars map[string]string, unresolved *[]s
|
|||||||
if val, ok := userVars[key]; ok {
|
if val, ok := userVars[key]; ok {
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
if sub[2] != "" {
|
// {{VAR:-x}} supplies a default; {{VAR:-}} declares an explicitly
|
||||||
|
// empty one. Both resolve — only a bare {{VAR}} is unresolved.
|
||||||
|
if strings.Contains(m, ":-") {
|
||||||
return sub[2]
|
return sub[2]
|
||||||
}
|
}
|
||||||
if unresolved != nil {
|
if unresolved != nil {
|
||||||
@@ -637,7 +642,7 @@ func deployTemplateGraph(
|
|||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
for _, sid := range createdServiceIDs {
|
for _, sid := range createdServiceIDs {
|
||||||
if engine != nil {
|
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 environment_variables WHERE service_id = $1`, sid)
|
||||||
_, _ = db.Exec(`DELETE FROM services WHERE 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.
|
// composeToSpecs converts a parsed compose file into graph service specs.
|
||||||
// Compose service names become keys; declaration order follows YAML document
|
// Compose service names become keys; declaration order follows YAML document
|
||||||
// order — yaml.v3 maps lose order, so sort keys for stability.
|
// order — yaml.v3 maps lose order, so sort keys for stability.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -124,3 +126,42 @@ func TestComposeToSpecsWarnings(t *testing.T) {
|
|||||||
t.Errorf("missing env warning: %v", warnings)
|
t.Errorf("missing env warning: %v", warnings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveRepoFileContainment(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
inner := filepath.Join(dir, "sub")
|
||||||
|
if err := os.MkdirAll(inner, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(inner, "compose.yml"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := resolveRepoFile(dir, "sub/compose.yml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid file rejected: %v", err)
|
||||||
|
}
|
||||||
|
if p != filepath.Join(dir, "sub", "compose.yml") {
|
||||||
|
t.Errorf("unexpected path %q", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bad := range []string{"", "/etc/passwd", "../escape.yml", "..", "sub/../../out.yml"} {
|
||||||
|
if _, err := resolveRepoFile(dir, bad); err == nil {
|
||||||
|
t.Errorf("path %q should have been rejected", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := resolveRepoFile(dir, "sub"); err == nil {
|
||||||
|
t.Error("directory accepted as file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsCommitSHA(t *testing.T) {
|
||||||
|
if !isCommitSHA("0123456789abcdef0123456789abcdef01234567") {
|
||||||
|
t.Error("40-hex ref not recognised as a commit")
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"main", "v1.2.3", "0123456789abcdef0123456789abcdef0123456", "0123456789abcdef0123456789abcdef0123456G"} {
|
||||||
|
if isCommitSHA(bad) {
|
||||||
|
t.Errorf("ref %q misclassified as commit SHA", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Template registry — CONTAINR_TEMPLATE_REGISTRY_URL points at another
|
||||||
|
// Containr instance (or any server answering the same template shape):
|
||||||
|
// GET {base}/api/v1/templates → {"templates": [ServiceTemplate…]}
|
||||||
|
// GET {base}/api/v1/templates/:id → {"template": …, "config": …, "variables": …}
|
||||||
|
// Registry entries merge into the catalog as source:"registry"; deploying
|
||||||
|
// one materializes it as a user template for the deployer first. The
|
||||||
|
// bundled catalog always works offline — a dead registry never blocks it.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"containr/internal/database/sqlcdb"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/sqlc-dev/pqtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const registryCacheTTL = 60 * time.Second
|
||||||
|
|
||||||
|
var registryCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
fetchedAt time.Time
|
||||||
|
templates []ServiceTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryBaseURL() string {
|
||||||
|
base := strings.TrimRight(strings.TrimSpace(os.Getenv("CONTAINR_TEMPLATE_REGISTRY_URL")), "/")
|
||||||
|
base = strings.TrimSuffix(base, "/api/v1")
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchRegistryTemplates lists the remote catalog, cached for
|
||||||
|
// registryCacheTTL. Failures degrade to an empty list — never fatal.
|
||||||
|
func fetchRegistryTemplates(ctx context.Context) []ServiceTemplate {
|
||||||
|
if registryBaseURL() == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
registryCache.mu.Lock()
|
||||||
|
fresh := time.Since(registryCache.fetchedAt) < registryCacheTTL && registryCache.templates != nil
|
||||||
|
cached := registryCache.templates
|
||||||
|
registryCache.mu.Unlock()
|
||||||
|
if fresh {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
var templates []ServiceTemplate
|
||||||
|
var payload struct {
|
||||||
|
Templates []ServiceTemplate `json:"templates"`
|
||||||
|
}
|
||||||
|
if err := registryGet(ctx, "/api/v1/templates", &payload); err == nil {
|
||||||
|
templates = payload.Templates
|
||||||
|
}
|
||||||
|
for i := range templates {
|
||||||
|
templates[i].Source = "registry"
|
||||||
|
}
|
||||||
|
|
||||||
|
registryCache.mu.Lock()
|
||||||
|
registryCache.fetchedAt = time.Now()
|
||||||
|
registryCache.templates = templates
|
||||||
|
registryCache.mu.Unlock()
|
||||||
|
return templates
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchRegistryTemplate fetches one remote template's full document
|
||||||
|
// (template + config + variables). nil when the registry lacks it.
|
||||||
|
func fetchRegistryTemplate(ctx context.Context, id string) *ServiceTemplate {
|
||||||
|
base := registryBaseURL()
|
||||||
|
if base == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Template ServiceTemplate `json:"template"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
|
Variables []TemplateVariable `json:"variables"`
|
||||||
|
}
|
||||||
|
if err := registryGet(ctx, "/api/v1/templates/"+id, &payload); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t := payload.Template
|
||||||
|
if t.ID == "" {
|
||||||
|
t.ID = id
|
||||||
|
}
|
||||||
|
t.Source = "registry"
|
||||||
|
// The detail endpoint returns config/variables decoded; store them back
|
||||||
|
// as raw JSON so they can persist unmodified into a local copy.
|
||||||
|
if len(payload.Config) > 0 {
|
||||||
|
t.Config = string(payload.Config)
|
||||||
|
}
|
||||||
|
if payload.Variables != nil {
|
||||||
|
if raw, err := json.Marshal(payload.Variables); err == nil {
|
||||||
|
t.Variables = string(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryGet(ctx context.Context, path string, out interface{}) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, registryBaseURL()+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return errRegistry{resp.StatusCode}
|
||||||
|
}
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type errRegistry struct{ status int }
|
||||||
|
|
||||||
|
func (e errRegistry) Error() string { return "template registry status " + http.StatusText(e.status) }
|
||||||
|
|
||||||
|
// respondRegistryTemplate answers GET /templates/:id for a registry-only
|
||||||
|
// template with the same envelope shape as local templates.
|
||||||
|
func respondRegistryTemplate(c *gin.Context, t *ServiceTemplate) {
|
||||||
|
config := json.RawMessage(t.Config)
|
||||||
|
if len(config) == 0 {
|
||||||
|
config = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
variables := json.RawMessage(t.Variables)
|
||||||
|
if len(variables) == 0 {
|
||||||
|
variables = json.RawMessage(`[]`)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"template": t,
|
||||||
|
"config": config,
|
||||||
|
"variables": variables,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// materializeRegistryTemplate copies a registry template into the local
|
||||||
|
// catalog as a private template owned by the deployer, so the normal
|
||||||
|
// deploy path (and future re-deploys) work without the registry. The
|
||||||
|
// local id is deterministic per (registry, template, user) so repeat
|
||||||
|
// deploys reuse the same copy instead of accumulating duplicates.
|
||||||
|
func materializeRegistryTemplate(c *gin.Context, q *sqlcdb.Queries, userID, templateID string) (sqlcdb.ServiceTemplate, error) {
|
||||||
|
ownerUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return sqlcdb.ServiceTemplate{}, err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(registryBaseURL() + "|" + templateID + "|" + userID))
|
||||||
|
localID := "reg-" + hex.EncodeToString(sum[:8])
|
||||||
|
if existing, err := q.GetServiceTemplateByID(c.Request.Context(), localID); err == nil {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rt := fetchRegistryTemplate(c.Request.Context(), templateID)
|
||||||
|
if rt == nil {
|
||||||
|
return sqlcdb.ServiceTemplate{}, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
variables := pqtype.NullRawMessage{}
|
||||||
|
if rt.Variables != "" {
|
||||||
|
variables = pqtype.NullRawMessage{RawMessage: json.RawMessage(rt.Variables), Valid: true}
|
||||||
|
}
|
||||||
|
if err := q.CreateUserTemplate(c.Request.Context(), sqlcdb.CreateUserTemplateParams{
|
||||||
|
ID: localID,
|
||||||
|
Name: rt.Name,
|
||||||
|
Description: nullableText(rt.Description),
|
||||||
|
Category: rt.Category,
|
||||||
|
Logo: nullableText(rt.Logo),
|
||||||
|
Config: json.RawMessage(rt.Config),
|
||||||
|
Variables: variables,
|
||||||
|
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
|
||||||
|
IsPublic: false,
|
||||||
|
}); err != nil {
|
||||||
|
return sqlcdb.ServiceTemplate{}, err
|
||||||
|
}
|
||||||
|
return q.GetServiceTemplateByID(c.Request.Context(), localID)
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ type ServiceTemplate struct {
|
|||||||
IsOfficial bool `json:"is_official" db:"is_official"`
|
IsOfficial bool `json:"is_official" db:"is_official"`
|
||||||
OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
|
OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
|
||||||
IsPublic bool `json:"is_public" db:"is_public"`
|
IsPublic bool `json:"is_public" db:"is_public"`
|
||||||
|
// Source marks catalog origin: "" (local/official) or "registry".
|
||||||
|
Source string `json:"source,omitempty" db:"-"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -97,10 +99,24 @@ func handleGetTemplates(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templates := make([]ServiceTemplate, 0, len(templateRows))
|
templates := make([]ServiceTemplate, 0, len(templateRows))
|
||||||
|
localIDs := make(map[string]bool, len(templateRows))
|
||||||
for _, row := range templateRows {
|
for _, row := range templateRows {
|
||||||
|
localIDs[row.ID] = true
|
||||||
templates = append(templates, mapSQLCTemplate(row))
|
templates = append(templates, mapSQLCTemplate(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge the configured remote registry (CONTAINR_TEMPLATE_REGISTRY_URL).
|
||||||
|
// Registry ids never shadow local templates.
|
||||||
|
for _, t := range fetchRegistryTemplates(ctx) {
|
||||||
|
if localIDs[t.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if category != "" && t.Category != category {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
templates = append(templates, t)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"templates": templates})
|
c.JSON(http.StatusOK, gin.H{"templates": templates})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +128,11 @@ func handleGetTemplate(c *gin.Context) {
|
|||||||
row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID)
|
row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// Registry fallback — remote templates materialize read-only.
|
||||||
|
if rt := fetchRegistryTemplate(c.Request.Context(), templateID); rt != nil {
|
||||||
|
respondRegistryTemplate(c, rt)
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -190,12 +211,19 @@ func handleCreateFromTemplate(c *gin.Context) {
|
|||||||
templateRow, err := queries.GetServiceTemplateByID(ctx, templateID)
|
templateRow, err := queries.GetServiceTemplateByID(ctx, templateID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// Registry template — materialize a local copy for the
|
||||||
|
// deployer, then continue down the normal deploy path.
|
||||||
|
row, mErr := materializeRegistryTemplate(c, queries, userID, templateID)
|
||||||
|
if mErr != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
templateRow = row
|
||||||
|
} else {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
template := mapSQLCTemplate(templateRow)
|
template := mapSQLCTemplate(templateRow)
|
||||||
if template.ID == "" || !templateVisibleTo(c, template) {
|
if template.ID == "" || !templateVisibleTo(c, template) {
|
||||||
@@ -1074,6 +1102,98 @@ func SeedTemplates() []ServiceTemplate {
|
|||||||
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
|
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
|
||||||
IsOfficial: true,
|
IsOfficial: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "tpl-n8n",
|
||||||
|
Name: "n8n + PostgreSQL + Redis",
|
||||||
|
Description: "Workflow automation in queue mode with managed PostgreSQL and Redis",
|
||||||
|
Category: "stack",
|
||||||
|
Logo: "https://cdn.simpleicons.org/n8n",
|
||||||
|
Config: `{"version":2,"services":[
|
||||||
|
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"n8n","POSTGRES_USER":"n8n"},"volumes":[{"source":"n8n-db","target":"/var/lib/postgresql/data"}]},
|
||||||
|
{"key":"redis","type":"database","runtime":"redis"},
|
||||||
|
{"key":"n8n","type":"web","runtime":"docker.n8n.io/n8nio/n8n:latest","port":5678,"health_check":"/healthz","depends_on":["db","redis"],
|
||||||
|
"environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"5432","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","QUEUE_BULL_REDIS_HOST":"{{service.redis.host}}","QUEUE_BULL_REDIS_PASSWORD":"{{service.redis.password}}","EXECUTIONS_MODE":"queue","N8N_ENCRYPTION_KEY":"{{secret(32)}}","N8N_HOST":"{{APP_URL:-http://localhost:5678}}","N8N_EDITOR_BASE_URL":"{{APP_URL:-http://localhost:5678}}"},
|
||||||
|
"volumes":[{"source":"n8n-data","target":"/home/node/.n8n"}]},
|
||||||
|
{"key":"worker","name":"n8n-worker","type":"worker","runtime":"docker.n8n.io/n8nio/n8n:latest","start_command":"worker","depends_on":["n8n","db","redis"],
|
||||||
|
"environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"5432","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","QUEUE_BULL_REDIS_HOST":"{{service.redis.host}}","QUEUE_BULL_REDIS_PASSWORD":"{{service.redis.password}}","EXECUTIONS_MODE":"queue","N8N_ENCRYPTION_KEY":"{{secret(32)}}","QUEUE_HEALTH_CHECK_ACTIVE":"true"},
|
||||||
|
"volumes":[{"source":"n8n-data","target":"/home/node/.n8n"}]}]}`,
|
||||||
|
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
|
||||||
|
IsOfficial: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "tpl-gitea",
|
||||||
|
Name: "Gitea + PostgreSQL",
|
||||||
|
Description: "Self-hosted git service with managed PostgreSQL",
|
||||||
|
Category: "stack",
|
||||||
|
Logo: "https://cdn.simpleicons.org/gitea",
|
||||||
|
Config: `{"version":2,"services":[
|
||||||
|
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"gitea","POSTGRES_USER":"gitea"},"volumes":[{"source":"gitea-db","target":"/var/lib/postgresql/data"}]},
|
||||||
|
{"key":"gitea","type":"web","runtime":"gitea/gitea:latest","port":3000,"health_check":"/api/healthz","depends_on":["db"],
|
||||||
|
"environment":{"GITEA__database__DB_TYPE":"postgres","GITEA__database__HOST":"{{service.db.host}}:{{service.db.port}}","GITEA__database__NAME":"{{service.db.database}}","GITEA__database__USER":"{{service.db.user}}","GITEA__database__PASSWD":"{{service.db.password}}","GITEA__security__INSTALL_LOCK":"true","GITEA__security__SECRET_KEY":"{{secret(32)}}","GITEA__security__INTERNAL_TOKEN":"{{secret(64)}}","GITEA__server__DOMAIN":"{{DOMAIN:-localhost}}","GITEA__server__ROOT_URL":"{{ROOT_URL:-http://localhost:3000}}","GITEA__server__SSH_PORT":"2222","USER_UID":"1000","USER_GID":"1000"},
|
||||||
|
"volumes":[{"source":"gitea-data","target":"/data"}]}]}`,
|
||||||
|
Variables: `[{"key":"DOMAIN","label":"Public domain","default":"","required":false,"secret":false},{"key":"ROOT_URL","label":"Root URL","default":"","required":false,"secret":false}]`,
|
||||||
|
IsOfficial: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "tpl-nextcloud",
|
||||||
|
Name: "Nextcloud + PostgreSQL + Redis",
|
||||||
|
Description: "Self-hosted file sync and collaboration with managed PostgreSQL and Redis",
|
||||||
|
Category: "stack",
|
||||||
|
Logo: "https://cdn.simpleicons.org/nextcloud",
|
||||||
|
Config: `{"version":2,"services":[
|
||||||
|
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"nextcloud","POSTGRES_USER":"nextcloud"},"volumes":[{"source":"nextcloud-db","target":"/var/lib/postgresql/data"}]},
|
||||||
|
{"key":"redis","type":"database","runtime":"redis"},
|
||||||
|
{"key":"nextcloud","type":"web","runtime":"nextcloud:latest","port":80,"depends_on":["db","redis"],
|
||||||
|
"environment":{"POSTGRES_HOST":"{{service.db.host}}","POSTGRES_DB":"{{service.db.database}}","POSTGRES_USER":"{{service.db.user}}","POSTGRES_PASSWORD":"{{service.db.password}}","REDIS_HOST":"{{service.redis.host}}","REDIS_HOST_PASSWORD":"{{service.redis.password}}","NEXTCLOUD_ADMIN_USER":"admin","NEXTCLOUD_ADMIN_PASSWORD":"{{secret(24)}}","NEXTCLOUD_TRUSTED_DOMAINS":"{{DOMAIN:-localhost}}","OVERWRITEHOST":"{{DOMAIN:-}}","OVERWRITEPROTOCOL":"https"},
|
||||||
|
"volumes":[{"source":"nextcloud-data","target":"/var/www/html"}]}]}`,
|
||||||
|
Variables: `[{"key":"DOMAIN","label":"Trusted domain","default":"localhost","required":false,"secret":false}]`,
|
||||||
|
IsOfficial: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "tpl-vaultwarden",
|
||||||
|
Name: "Vaultwarden + PostgreSQL",
|
||||||
|
Description: "Bitwarden-compatible password manager with managed PostgreSQL",
|
||||||
|
Category: "stack",
|
||||||
|
Logo: "https://cdn.simpleicons.org/vaultwarden",
|
||||||
|
Config: `{"version":2,"services":[
|
||||||
|
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"vaultwarden","POSTGRES_USER":"vaultwarden"},"volumes":[{"source":"vaultwarden-db","target":"/var/lib/postgresql/data"}]},
|
||||||
|
{"key":"vaultwarden","type":"web","runtime":"vaultwarden/server:latest","port":80,"health_check":"/alive","depends_on":["db"],
|
||||||
|
"environment":{"DATABASE_URL":"{{service.db.url}}","ADMIN_TOKEN":"{{secret(48)}}","DOMAIN":"{{DOMAIN:-http://localhost:80}}","SIGNUPS_ALLOWED":"false","WEBSOCKET_ENABLED":"true"},
|
||||||
|
"volumes":[{"source":"vaultwarden-data","target":"/data"}]}]}`,
|
||||||
|
Variables: `[{"key":"DOMAIN","label":"Public URL","default":"","required":false,"secret":false}]`,
|
||||||
|
IsOfficial: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "tpl-umami",
|
||||||
|
Name: "Umami + PostgreSQL",
|
||||||
|
Description: "Lightweight privacy-first web analytics with managed PostgreSQL",
|
||||||
|
Category: "stack",
|
||||||
|
Logo: "https://cdn.simpleicons.org/umami",
|
||||||
|
Config: `{"version":2,"services":[
|
||||||
|
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"umami","POSTGRES_USER":"umami"},"volumes":[{"source":"umami-db","target":"/var/lib/postgresql/data"}]},
|
||||||
|
{"key":"umami","type":"web","runtime":"ghcr.io/umami-software/umami:postgresql-latest","port":3000,"depends_on":["db"],
|
||||||
|
"environment":{"DATABASE_URL":"{{service.db.url}}","APP_SECRET":"{{secret(32)}}"}}]}`,
|
||||||
|
Variables: `[]`,
|
||||||
|
IsOfficial: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "tpl-immich",
|
||||||
|
Name: "Immich + PostgreSQL + Redis",
|
||||||
|
Description: "Self-hosted photo and video backup with managed PostgreSQL and Redis",
|
||||||
|
Category: "stack",
|
||||||
|
Logo: "https://cdn.simpleicons.org/immich",
|
||||||
|
Config: `{"version":2,"services":[
|
||||||
|
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"immich","POSTGRES_USER":"immich"},"volumes":[{"source":"immich-db","target":"/var/lib/postgresql/data"}]},
|
||||||
|
{"key":"redis","type":"database","runtime":"redis"},
|
||||||
|
{"key":"ml","name":"immich-machine-learning","type":"worker","runtime":"ghcr.io/immich-app/immich-machine-learning:release",
|
||||||
|
"environment":{"DB_DATABASE_NAME":"{{service.db.database}}","DB_HOSTNAME":"{{service.db.host}}","DB_USERNAME":"{{service.db.user}}","DB_PASSWORD":"{{service.db.password}}","REDIS_HOSTNAME":"{{service.redis.host}}","REDIS_PASSWORD":"{{service.redis.password}}"},
|
||||||
|
"volumes":[{"source":"immich-model-cache","target":"/cache"}]},
|
||||||
|
{"key":"immich","name":"immich-server","type":"web","runtime":"ghcr.io/immich-app/immich-server:release","port":2283,"health_check":"/api/server/ping","depends_on":["db","redis","ml"],
|
||||||
|
"environment":{"DB_DATABASE_NAME":"{{service.db.database}}","DB_HOSTNAME":"{{service.db.host}}","DB_USERNAME":"{{service.db.user}}","DB_PASSWORD":"{{service.db.password}}","REDIS_HOSTNAME":"{{service.redis.host}}","REDIS_PASSWORD":"{{service.redis.password}}"},
|
||||||
|
"volumes":[{"source":"immich-upload","target":"/usr/src/app/upload"}]}]}`,
|
||||||
|
Variables: `[]`,
|
||||||
|
IsOfficial: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return templates
|
return templates
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestNormalizeTemplateServiceType(t *testing.T) {
|
func TestNormalizeTemplateServiceType(t *testing.T) {
|
||||||
valid := []string{"web", "worker", "database", "cron"}
|
valid := []string{"web", "worker", "database", "cron"}
|
||||||
@@ -71,3 +74,32 @@ func TestResolveTemplateRuntimeImage(t *testing.T) {
|
|||||||
t.Fatalf("expected unknown runtime passthrough, got %q", got)
|
t.Fatalf("expected unknown runtime passthrough, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSeedTemplatesResolveCleanly(t *testing.T) {
|
||||||
|
for _, tpl := range SeedTemplates() {
|
||||||
|
var config TemplateConfig
|
||||||
|
if err := json.Unmarshal([]byte(tpl.Config), &config); err != nil {
|
||||||
|
t.Fatalf("%s: invalid config JSON: %v", tpl.ID, err)
|
||||||
|
}
|
||||||
|
var variables []TemplateVariable
|
||||||
|
if err := json.Unmarshal([]byte(tpl.Variables), &variables); err != nil {
|
||||||
|
t.Fatalf("%s: invalid variables JSON: %v", tpl.ID, err)
|
||||||
|
}
|
||||||
|
if len(config.Services) == 0 {
|
||||||
|
continue // legacy single-service template
|
||||||
|
}
|
||||||
|
sorted, err := topoSortServices(config.Services)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: topo sort: %v", tpl.ID, err)
|
||||||
|
}
|
||||||
|
if err := validateTemplateGraph(sorted); err != nil {
|
||||||
|
t.Fatalf("%s: graph validation: %v", tpl.ID, err)
|
||||||
|
}
|
||||||
|
// User vars left empty — every expression must still resolve via
|
||||||
|
// defaults, secrets, or service refs.
|
||||||
|
graph := resolveTemplateGraph(sorted, map[string]string{})
|
||||||
|
if len(graph.unresolved) > 0 {
|
||||||
|
t.Fatalf("%s: unresolved expressions: %v", tpl.ID, graph.unresolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -539,12 +539,16 @@ func createBuildContext(sourcePath, dockerfileContent string) (io.ReadCloser, er
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add source files to tar
|
// Add source files to tar. The checkout's .git metadata is never build
|
||||||
|
// context — it bloats the tar and ships clone URLs to remote daemons.
|
||||||
err = filepath.Walk(sourcePath, func(file string, fi os.FileInfo, err error) error {
|
err = filepath.Walk(sourcePath, func(file string, fi os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if fi.IsDir() {
|
if fi.IsDir() {
|
||||||
|
if fi.Name() == ".git" {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package build
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -92,6 +94,75 @@ func (bm *BuildManager) Build(ctx context.Context, req *types.BuildRequest) (*ty
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PackageContext produces the same build-context tar Build would hand the
|
||||||
|
// local daemon, without building it. Node agents download the tar and run
|
||||||
|
// docker build themselves — the Dockerfile lands at the tar root.
|
||||||
|
func (bm *BuildManager) PackageContext(ctx context.Context, req *types.BuildRequest) (io.ReadCloser, error) {
|
||||||
|
if req.BuildType == "" {
|
||||||
|
detectedType, err := bm.DetectBuildType(ctx, req.SourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to detect build type: %w", err)
|
||||||
|
}
|
||||||
|
req.BuildType = string(detectedType)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dockerfileContent string
|
||||||
|
switch BuildType(req.BuildType) {
|
||||||
|
case BuildTypeDockerfile:
|
||||||
|
name, err := bm.dockerfileBuilder.DetectDockerfile(ctx, req.SourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to detect Dockerfile: %w", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(req.SourcePath, name)
|
||||||
|
if optimized, err := bm.dockerfileBuilder.OptimizeDockerfile(ctx, path); err == nil {
|
||||||
|
path = optimized
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read Dockerfile: %w", err)
|
||||||
|
}
|
||||||
|
dockerfileContent = string(data)
|
||||||
|
case BuildTypeRailpack:
|
||||||
|
if err := bm.railpackBuilder.DetectRailpack(ctx, req.SourcePath); err != nil {
|
||||||
|
return nil, fmt.Errorf("Railpack cannot build this project: %w", err)
|
||||||
|
}
|
||||||
|
content, err := bm.railpackBuilder.generateDockerfile(ctx, req.SourcePath, &RailpackConfig{
|
||||||
|
BuildCmd: req.BuildCommand,
|
||||||
|
StartCmd: req.StartCommand,
|
||||||
|
Env: req.Environment,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate Dockerfile with Railpack: %w", err)
|
||||||
|
}
|
||||||
|
dockerfileContent = content
|
||||||
|
case BuildTypeNixpacks:
|
||||||
|
plan, err := bm.nixpacksBuilder.GeneratePlan(ctx, req.SourcePath, &NixpacksConfig{
|
||||||
|
BuildCmd: req.BuildCommand,
|
||||||
|
StartCmd: req.StartCommand,
|
||||||
|
Env: req.Environment,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate build plan: %w", err)
|
||||||
|
}
|
||||||
|
path, err := bm.nixpacksBuilder.generateDockerfile(plan)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate Dockerfile: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(path)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read generated Dockerfile: %w", err)
|
||||||
|
}
|
||||||
|
dockerfileContent = string(data)
|
||||||
|
case BuildTypeStatic:
|
||||||
|
dockerfileContent = staticDockerfile
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported build type for remote packaging: %s", req.BuildType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createBuildContext(req.SourcePath, dockerfileContent)
|
||||||
|
}
|
||||||
|
|
||||||
// buildPrebuilt handles prebuilt image deployments
|
// buildPrebuilt handles prebuilt image deployments
|
||||||
func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
|
func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
|
||||||
if req.PrebuiltImage == "" {
|
if req.PrebuiltImage == "" {
|
||||||
|
|||||||
@@ -476,17 +476,22 @@ func (n *NixpacksBuilder) Build(ctx context.Context, req *types.BuildRequest) (*
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create temporary Dockerfile
|
// Create temporary Dockerfile
|
||||||
dockerfile, err := n.generateDockerfile(plan)
|
dockerfilePath, err := n.generateDockerfile(plan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate Dockerfile: %w", err)
|
return nil, fmt.Errorf("failed to generate Dockerfile: %w", err)
|
||||||
}
|
}
|
||||||
defer os.Remove(dockerfile)
|
defer os.Remove(dockerfilePath)
|
||||||
|
|
||||||
// Build Docker image
|
// Build Docker image
|
||||||
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
||||||
|
|
||||||
// Create build context tar
|
// Create build context tar — createBuildContext takes the Dockerfile
|
||||||
buildCtx, err := createBuildContext(req.SourcePath, dockerfile)
|
// contents, not its path.
|
||||||
|
dockerfileData, err := os.ReadFile(dockerfilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read generated Dockerfile: %w", err)
|
||||||
|
}
|
||||||
|
buildCtx, err := createBuildContext(req.SourcePath, string(dockerfileData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create build context: %w", err)
|
return nil, fmt.Errorf("failed to create build context: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnvironmentsCmd manages a project's environment lanes
|
||||||
|
// (production/development/preview are seeded; custom names allowed).
|
||||||
|
var EnvironmentsCmd = &cobra.Command{
|
||||||
|
Use: "environments",
|
||||||
|
Aliases: []string{"env", "envs"},
|
||||||
|
Short: "Manage project environments",
|
||||||
|
}
|
||||||
|
|
||||||
|
var envListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List environments in a project",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
project, _ := cmd.Flags().GetString("project")
|
||||||
|
if project == "" {
|
||||||
|
return fmt.Errorf("--project is required")
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/projects/"+project+"/environments", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "environments")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, e := range items {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(e, "id"), str(e, "name"), str(e, "service_count"), relTime(e, "created_at"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "NAME", "SERVICES", "CREATED"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var envCreateCmd = &cobra.Command{
|
||||||
|
Use: "create <name>",
|
||||||
|
Short: "Create an environment in a project",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
project, _ := cmd.Flags().GetString("project")
|
||||||
|
if project == "" {
|
||||||
|
return fmt.Errorf("--project is required")
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/projects/"+project+"/environments", map[string]interface{}{
|
||||||
|
"name": args[0],
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var envDeleteCmd = &cobra.Command{
|
||||||
|
Use: "delete <id>",
|
||||||
|
Short: "Delete an empty environment",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if !Confirm("Delete environment " + args[0] + "?") {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = c.Do("DELETE", "/environments/"+args[0], nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("Environment deleted")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
envListCmd.Flags().String("project", "", "project ID (required)")
|
||||||
|
envCreateCmd.Flags().String("project", "", "project ID (required)")
|
||||||
|
EnvironmentsCmd.AddCommand(envListCmd, envCreateCmd, envDeleteCmd)
|
||||||
|
}
|
||||||
@@ -4,9 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
@@ -44,20 +42,13 @@ var ShellCmd = &cobra.Command{
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
defer term.Restore(fd, oldState)
|
defer term.Restore(fd, oldState)
|
||||||
}
|
}
|
||||||
if w, h, err := term.GetSize(fd); err == nil {
|
sendSize := func() {
|
||||||
_ = 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 {
|
if w, h, err := term.GetSize(fd); err == nil {
|
||||||
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
|
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
sendSize()
|
||||||
|
defer watchResize(sendSize)()
|
||||||
}
|
}
|
||||||
|
|
||||||
done := make(chan struct{})
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"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.
|
// Agent tokens (cagt_) onboard new nodes.
|
||||||
var nodeTokensCmd = &cobra.Command{
|
var nodeTokensCmd = &cobra.Command{
|
||||||
Use: "tokens",
|
Use: "tokens",
|
||||||
@@ -145,6 +304,12 @@ var nodeTokensRevokeCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
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)
|
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
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,6 +62,104 @@ var notifReadCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var notifChannelsCmd = &cobra.Command{
|
||||||
|
Use: "channels",
|
||||||
|
Short: "Manage push notification channels (ntfy/Gotify)",
|
||||||
|
}
|
||||||
|
|
||||||
|
var notifChannelsListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List push channels",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/notifications/channels", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "channels")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, n := range items {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
str(n, "id"), str(n, "kind"), str(n, "endpoint"), str(n, "enabled"), relTime(n, "created_at"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "KIND", "ENDPOINT", "ENABLED", "AGE"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var notifChannelsAddCmd = &cobra.Command{
|
||||||
|
Use: "add <ntfy|gotify> <endpoint>",
|
||||||
|
Short: "Add a push channel (endpoint includes topic for ntfy)",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token, _ := cmd.Flags().GetString("token")
|
||||||
|
data, err := c.Do("POST", "/notifications/channels", map[string]interface{}{
|
||||||
|
"kind": args[0],
|
||||||
|
"endpoint": args[1],
|
||||||
|
"token": token,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var notifChannelsTestCmd = &cobra.Command{
|
||||||
|
Use: "test <id>",
|
||||||
|
Short: "Send a test notification through a channel",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/notifications/channels/"+args[0]+"/test", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var notifChannelsRemoveCmd = &cobra.Command{
|
||||||
|
Use: "remove <id>",
|
||||||
|
Short: "Remove a push channel",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
yes, _ := cmd.Flags().GetBool("yes")
|
||||||
|
if !yes && !Confirm(fmt.Sprintf("Remove channel %s?", args[0])) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("DELETE", "/notifications/channels/"+args[0], nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
NotificationsCmd.AddCommand(notifListCmd, notifReadCmd)
|
notifChannelsAddCmd.Flags().String("token", "", "Auth token (ntfy access token or Gotify app token)")
|
||||||
|
notifChannelsRemoveCmd.Flags().Bool("yes", false, "Skip confirmation")
|
||||||
|
notifChannelsCmd.AddCommand(notifChannelsListCmd, notifChannelsAddCmd, notifChannelsTestCmd, notifChannelsRemoveCmd)
|
||||||
|
NotificationsCmd.AddCommand(notifListCmd, notifReadCmd, notifChannelsCmd)
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ var servicesGetCmd = &cobra.Command{
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s, _ := unwrapObject(data, "service")
|
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))
|
fmt.Printf("%-14s %s\n", k+":", str(s, k))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -79,7 +79,7 @@ var servicesCreateCmd = &cobra.Command{
|
|||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
"name": args[1],
|
"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 != "" {
|
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||||
key := f
|
key := f
|
||||||
// JSON uses snake_case keys matching the API schema.
|
// JSON uses snake_case keys matching the API schema.
|
||||||
@@ -102,6 +102,8 @@ var servicesCreateCmd = &cobra.Command{
|
|||||||
key = "static_build_cmd"
|
key = "static_build_cmd"
|
||||||
case "static-dir":
|
case "static-dir":
|
||||||
key = "static_dir"
|
key = "static_dir"
|
||||||
|
case "node":
|
||||||
|
key = "node_id"
|
||||||
}
|
}
|
||||||
body[key] = v
|
body[key] = v
|
||||||
}
|
}
|
||||||
@@ -112,6 +114,31 @@ var servicesCreateCmd = &cobra.Command{
|
|||||||
if v, _ := cmd.Flags().GetInt("replicas"); v > 0 {
|
if v, _ := cmd.Flags().GetInt("replicas"); v > 0 {
|
||||||
body["replicas"] = v
|
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)
|
data, err := c.Do("POST", "/projects/"+args[0]+"/services", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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{
|
var servicesDeleteCmd = &cobra.Command{
|
||||||
Use: "delete <id>",
|
Use: "delete <id>",
|
||||||
Short: "Delete a service",
|
Short: "Delete a service",
|
||||||
@@ -159,7 +208,7 @@ var servicesUpdateCmd = &cobra.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
body := map[string]interface{}{}
|
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) {
|
if cmd.Flags().Changed(f) {
|
||||||
key := f
|
key := f
|
||||||
switch f {
|
switch f {
|
||||||
@@ -175,6 +224,8 @@ var servicesUpdateCmd = &cobra.Command{
|
|||||||
key = "static_build_cmd"
|
key = "static_build_cmd"
|
||||||
case "static-dir":
|
case "static-dir":
|
||||||
key = "static_dir"
|
key = "static_dir"
|
||||||
|
case "node":
|
||||||
|
key = "node_id"
|
||||||
}
|
}
|
||||||
v, _ := cmd.Flags().GetString(f)
|
v, _ := cmd.Flags().GetString(f)
|
||||||
body[key] = v
|
body[key] = v
|
||||||
@@ -199,10 +250,29 @@ var servicesUpdateCmd = &cobra.Command{
|
|||||||
v, _ := cmd.Flags().GetBool("sleep")
|
v, _ := cmd.Flags().GetBool("sleep")
|
||||||
body["sleep_enabled"] = v
|
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") {
|
if cmd.Flags().Changed("sleep-idle") {
|
||||||
v, _ := cmd.Flags().GetInt("sleep-idle")
|
v, _ := cmd.Flags().GetInt("sleep-idle")
|
||||||
body["sleep_idle_minutes"] = v
|
body["sleep_idle_minutes"] = v
|
||||||
}
|
}
|
||||||
|
if labels, err := traefikLabelFlag(cmd); err != nil {
|
||||||
|
return err
|
||||||
|
} else if labels != nil {
|
||||||
|
body["traefik_labels"] = labels
|
||||||
|
}
|
||||||
if cmd.Flags().Changed("maintenance") {
|
if cmd.Flags().Changed("maintenance") {
|
||||||
v, _ := cmd.Flags().GetString("maintenance")
|
v, _ := cmd.Flags().GetString("maintenance")
|
||||||
body["maintenance_mode"] = v == "on" || v == "true"
|
body["maintenance_mode"] = v == "on" || v == "true"
|
||||||
@@ -490,6 +560,12 @@ func init() {
|
|||||||
f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)")
|
f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)")
|
||||||
f.String("static-cmd", "", "build command for the static builder")
|
f.String("static-cmd", "", "build command for the static builder")
|
||||||
f.String("static-dir", "", "output dir for the static builder (default dist)")
|
f.String("static-dir", "", "output dir for the static builder (default dist)")
|
||||||
|
f.String("node", "", "pin to a node agent id, or 'auto' for least-loaded")
|
||||||
|
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 := servicesUpdateCmd.Flags()
|
||||||
uf.String("name", "", "service name")
|
uf.String("name", "", "service name")
|
||||||
@@ -514,6 +590,10 @@ func init() {
|
|||||||
uf.String("static-dir", "", "output dir for the static builder (empty clears)")
|
uf.String("static-dir", "", "output dir for the static builder (empty clears)")
|
||||||
uf.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
|
uf.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
|
||||||
uf.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)")
|
uf.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)")
|
||||||
|
uf.StringArray("traefik-label", nil, "Traefik middleware override, key=value; repeatable; empty list clears via --traefik-label ''")
|
||||||
|
uf.String("node", "", "pin to a node agent id, 'auto' for least-loaded, 'local' to clear")
|
||||||
|
uf.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"} {
|
for _, a := range []string{"start", "stop", "restart", "redeploy", "sleep", "wake"} {
|
||||||
verb := a
|
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{
|
var templatesDeployGraphCmd = &cobra.Command{
|
||||||
Use: "deploy-graph <project-id> <config.json|->",
|
Use: "deploy-graph <project-id> <config.json|->",
|
||||||
Short: "Deploy an ad-hoc service graph (v2 config with services[]) without a stored template",
|
Short: "Deploy an ad-hoc service graph (v2 config with services[]) without a stored template",
|
||||||
@@ -257,5 +283,6 @@ func init() {
|
|||||||
templatesDeployCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
templatesDeployCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
||||||
templatesPlanCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
templatesPlanCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
||||||
templatesDeployGraphCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
templatesDeployGraphCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
||||||
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd, templatesPlanCmd, templatesImportComposeCmd, templatesDeployGraphCmd)
|
templatesImportGitCmd.Flags().String("ref", "", "git branch or commit (default: remote HEAD)")
|
||||||
|
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd, templatesPlanCmd, templatesImportComposeCmd, templatesImportGitCmd, templatesDeployGraphCmd)
|
||||||
}
|
}
|
||||||
@@ -94,6 +94,8 @@ func init() {
|
|||||||
rootCmd.AddCommand(commands.BannersCmd)
|
rootCmd.AddCommand(commands.BannersCmd)
|
||||||
rootCmd.AddCommand(commands.InvitesCmd)
|
rootCmd.AddCommand(commands.InvitesCmd)
|
||||||
rootCmd.AddCommand(commands.UpCmd)
|
rootCmd.AddCommand(commands.UpCmd)
|
||||||
|
rootCmd.AddCommand(commands.MigrateCmd)
|
||||||
|
rootCmd.AddCommand(commands.EnvironmentsCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func initConfig() {
|
func initConfig() {
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ import (
|
|||||||
"github.com/sqlc-dev/pqtype"
|
"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
|
const completeCommand = `-- name: CompleteCommand :one
|
||||||
UPDATE agent_commands SET
|
UPDATE agent_commands SET
|
||||||
status = $3,
|
status = $3,
|
||||||
@@ -63,7 +75,7 @@ INSERT INTO node_agents (
|
|||||||
id, name, hostname, ip_address, port, status, version,
|
id, name, hostname, ip_address, port, status, version,
|
||||||
capabilities, resources, last_heartbeat, metadata
|
capabilities, resources, last_heartbeat, metadata
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata
|
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateAgentParams struct {
|
type CreateAgentParams struct {
|
||||||
@@ -109,6 +121,10 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeA
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Metadata,
|
&i.Metadata,
|
||||||
|
&i.AutoPrune,
|
||||||
|
&i.Schedulable,
|
||||||
|
&i.Tags,
|
||||||
|
&i.DefaultDomain,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -228,8 +244,22 @@ func (q *Queries) DeleteAgent(ctx context.Context, id string) error {
|
|||||||
return err
|
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
|
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) {
|
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.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Metadata,
|
&i.Metadata,
|
||||||
|
&i.AutoPrune,
|
||||||
|
&i.Schedulable,
|
||||||
|
&i.Tags,
|
||||||
|
&i.DefaultDomain,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one
|
const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one
|
||||||
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata FROM node_agents
|
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain FROM node_agents
|
||||||
WHERE hostname = $1 AND ip_address = $2
|
WHERE hostname = $1 AND ip_address = $2
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
@@ -281,6 +315,10 @@ func (q *Queries) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAnd
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Metadata,
|
&i.Metadata,
|
||||||
|
&i.AutoPrune,
|
||||||
|
&i.Schedulable,
|
||||||
|
&i.Tags,
|
||||||
|
&i.DefaultDomain,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -378,6 +416,36 @@ func (q *Queries) GetContainerForAgent(ctx context.Context, arg GetContainerForA
|
|||||||
return i, err
|
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
|
const insertAgentHeartbeat = `-- name: InsertAgentHeartbeat :exec
|
||||||
INSERT INTO agent_heartbeats (
|
INSERT INTO agent_heartbeats (
|
||||||
id, node_agent_id, timestamp, status, resources,
|
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
|
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) {
|
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.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Metadata,
|
&i.Metadata,
|
||||||
|
&i.AutoPrune,
|
||||||
|
&i.Schedulable,
|
||||||
|
&i.Tags,
|
||||||
|
&i.DefaultDomain,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -627,6 +699,296 @@ func (q *Queries) ListPendingCommands(ctx context.Context, nodeAgentID string) (
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listSchedulableAgentDomains = `-- name: ListSchedulableAgentDomains :many
|
||||||
|
SELECT DISTINCT default_domain FROM node_agents
|
||||||
|
WHERE status = 'online' AND schedulable
|
||||||
|
AND default_domain <> ''
|
||||||
|
AND tags @> $1::jsonb
|
||||||
|
ORDER BY default_domain
|
||||||
|
`
|
||||||
|
|
||||||
|
// Base domains of every online, schedulable agent carrying the required
|
||||||
|
// placement tags — auto-domain candidates for spread services.
|
||||||
|
func (q *Queries) ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listSchedulableAgentDomains, dollar_1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var default_domain string
|
||||||
|
if err := rows.Scan(&default_domain); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, default_domain)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listSchedulableAgents = `-- name: ListSchedulableAgents :many
|
||||||
|
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
|
const updateAgent = `-- name: UpdateAgent :one
|
||||||
UPDATE node_agents SET
|
UPDATE node_agents SET
|
||||||
name = $2,
|
name = $2,
|
||||||
@@ -639,9 +1001,11 @@ UPDATE node_agents SET
|
|||||||
resources = $9,
|
resources = $9,
|
||||||
last_heartbeat = $10,
|
last_heartbeat = $10,
|
||||||
metadata = $11,
|
metadata = $11,
|
||||||
|
auto_prune = $12,
|
||||||
|
schedulable = $13,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata
|
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateAgentParams struct {
|
type UpdateAgentParams struct {
|
||||||
@@ -656,6 +1020,8 @@ type UpdateAgentParams struct {
|
|||||||
Resources pqtype.NullRawMessage `json:"resources"`
|
Resources pqtype.NullRawMessage `json:"resources"`
|
||||||
LastHeartbeat sql.NullTime `json:"last_heartbeat"`
|
LastHeartbeat sql.NullTime `json:"last_heartbeat"`
|
||||||
Metadata pqtype.NullRawMessage `json:"metadata"`
|
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) {
|
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.Resources,
|
||||||
arg.LastHeartbeat,
|
arg.LastHeartbeat,
|
||||||
arg.Metadata,
|
arg.Metadata,
|
||||||
|
arg.AutoPrune,
|
||||||
|
arg.Schedulable,
|
||||||
)
|
)
|
||||||
var i NodeAgent
|
var i NodeAgent
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -687,6 +1055,10 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Metadata,
|
&i.Metadata,
|
||||||
|
&i.AutoPrune,
|
||||||
|
&i.Schedulable,
|
||||||
|
&i.Tags,
|
||||||
|
&i.DefaultDomain,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -717,6 +1089,53 @@ func (q *Queries) UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeart
|
|||||||
return err
|
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
|
const updateContainerStatus = `-- name: UpdateContainerStatus :exec
|
||||||
UPDATE container_instances SET status = $2, updated_at = NOW()
|
UPDATE container_instances SET status = $2, updated_at = NOW()
|
||||||
WHERE id = $1
|
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)
|
_, err := q.db.ExecContext(ctx, updateContainerStatus, arg.ID, arg.Status)
|
||||||
return err
|
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"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
Metadata pqtype.NullRawMessage `json:"metadata"`
|
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 {
|
type Notification struct {
|
||||||
@@ -189,6 +193,17 @@ type Notification struct {
|
|||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NotificationChannel struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Token sql.NullString `json:"token"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type OutboundWebhook struct {
|
type OutboundWebhook struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
UserID uuid.UUID `json:"user_id"`
|
UserID uuid.UUID `json:"user_id"`
|
||||||
@@ -273,6 +288,10 @@ type Service struct {
|
|||||||
MemoryReserve string `json:"memory_reserve"`
|
MemoryReserve string `json:"memory_reserve"`
|
||||||
StaticBuildCmd string `json:"static_build_cmd"`
|
StaticBuildCmd string `json:"static_build_cmd"`
|
||||||
StaticDir string `json:"static_dir"`
|
StaticDir string `json:"static_dir"`
|
||||||
|
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 {
|
type ServiceDomain struct {
|
||||||
|
|||||||
@@ -53,6 +53,135 @@ func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotification
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteNotificationChannel = `-- name: DeleteNotificationChannel :execrows
|
||||||
|
DELETE FROM notification_channels WHERE id = $1 AND user_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteNotificationChannelParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, deleteNotificationChannel, arg.ID, arg.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertNotificationChannel = `-- name: InsertNotificationChannel :one
|
||||||
|
INSERT INTO notification_channels (user_id, kind, endpoint, token)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, user_id, kind, endpoint, token, enabled, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertNotificationChannelParams struct {
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Token sql.NullString `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, insertNotificationChannel,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Kind,
|
||||||
|
arg.Endpoint,
|
||||||
|
arg.Token,
|
||||||
|
)
|
||||||
|
var i NotificationChannel
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Endpoint,
|
||||||
|
&i.Token,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAllNotificationChannelsByUser = `-- name: ListAllNotificationChannelsByUser :many
|
||||||
|
SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at
|
||||||
|
FROM notification_channels
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listAllNotificationChannelsByUser, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []NotificationChannel{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i NotificationChannel
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Endpoint,
|
||||||
|
&i.Token,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listNotificationChannelsByUser = `-- name: ListNotificationChannelsByUser :many
|
||||||
|
SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at
|
||||||
|
FROM notification_channels
|
||||||
|
WHERE user_id = $1 AND enabled
|
||||||
|
ORDER BY created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listNotificationChannelsByUser, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []NotificationChannel{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i NotificationChannel
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Endpoint,
|
||||||
|
&i.Token,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listNotificationsByUser = `-- name: ListNotificationsByUser :many
|
const listNotificationsByUser = `-- name: ListNotificationsByUser :many
|
||||||
SELECT id, user_id, kind, title, body, resource_type, resource_id, read_at, created_at
|
SELECT id, user_id, kind, title, body, resource_type, resource_id, read_at, created_at
|
||||||
FROM notifications
|
FROM notifications
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const countEnvironmentServices = `-- name: CountEnvironmentServices :one
|
||||||
|
SELECT COUNT(*) FROM services WHERE environment_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, countEnvironmentServices, environmentID)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
const countProjectsByUser = `-- name: CountProjectsByUser :one
|
const countProjectsByUser = `-- name: CountProjectsByUser :one
|
||||||
SELECT COUNT(*)::bigint AS total
|
SELECT COUNT(*)::bigint AS total
|
||||||
FROM projects p
|
FROM projects p
|
||||||
@@ -86,6 +97,20 @@ func (q *Queries) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (i
|
|||||||
return result.RowsAffected()
|
return result.RowsAffected()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteProjectEnvironment = `-- name: DeleteProjectEnvironment :exec
|
||||||
|
DELETE FROM environments WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteProjectEnvironmentParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteProjectEnvironment, arg.ID, arg.ProjectID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const getProjectByIDForUser = `-- name: GetProjectByIDForUser :one
|
const getProjectByIDForUser = `-- name: GetProjectByIDForUser :one
|
||||||
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
|
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
|
||||||
FROM projects p
|
FROM projects p
|
||||||
@@ -123,6 +148,30 @@ func (q *Queries) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDF
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getProjectEnvironmentByName = `-- name: GetProjectEnvironmentByName :one
|
||||||
|
SELECT id, name, project_id, created_at, updated_at
|
||||||
|
FROM environments
|
||||||
|
WHERE project_id = $1 AND name = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetProjectEnvironmentByNameParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetProjectEnvironmentByName(ctx context.Context, arg GetProjectEnvironmentByNameParams) (Environment, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getProjectEnvironmentByName, arg.ProjectID, arg.Name)
|
||||||
|
var i Environment
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getProjectOwnerByID = `-- name: GetProjectOwnerByID :one
|
const getProjectOwnerByID = `-- name: GetProjectOwnerByID :one
|
||||||
SELECT owner_id
|
SELECT owner_id
|
||||||
FROM projects
|
FROM projects
|
||||||
@@ -212,6 +261,53 @@ func (q *Queries) ListPendingProjects(ctx context.Context) ([]Project, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listProjectEnvironments = `-- name: ListProjectEnvironments :many
|
||||||
|
SELECT e.id, e.name, e.project_id, e.created_at, e.updated_at,
|
||||||
|
(SELECT COUNT(*) FROM services s WHERE s.environment_id = e.id) AS service_count
|
||||||
|
FROM environments e
|
||||||
|
WHERE e.project_id = $1
|
||||||
|
ORDER BY e.created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListProjectEnvironmentsRow struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
|
ServiceCount int64 `json:"service_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListProjectEnvironments(ctx context.Context, projectID uuid.UUID) ([]ListProjectEnvironmentsRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listProjectEnvironments, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []ListProjectEnvironmentsRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListProjectEnvironmentsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.ServiceCount,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listProjectsWithStatsByUser = `-- name: ListProjectsWithStatsByUser :many
|
const listProjectsWithStatsByUser = `-- name: ListProjectsWithStatsByUser :many
|
||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@@ -7,14 +7,17 @@ package sqlcdb
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Querier interface {
|
type Querier interface {
|
||||||
|
ClearServiceNodePins(ctx context.Context, nodeID sql.NullString) (int64, error)
|
||||||
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
|
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
|
||||||
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
|
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
|
||||||
CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error)
|
CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error)
|
||||||
|
CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error)
|
||||||
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
|
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
|
||||||
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
|
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
|
||||||
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
|
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
|
||||||
@@ -41,8 +44,11 @@ type Querier interface {
|
|||||||
DeleteBanner(ctx context.Context, id uuid.UUID) error
|
DeleteBanner(ctx context.Context, id uuid.UUID) error
|
||||||
DeleteDatabaseServiceByID(ctx context.Context, id string) error
|
DeleteDatabaseServiceByID(ctx context.Context, id string) error
|
||||||
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
||||||
|
DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error)
|
||||||
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
|
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
|
||||||
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
|
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||||
|
DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error
|
||||||
|
DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error
|
||||||
DeleteUserInvite(ctx context.Context, id uuid.UUID) error
|
DeleteUserInvite(ctx context.Context, id uuid.UUID) error
|
||||||
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
|
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
|
||||||
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
|
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
|
||||||
@@ -58,9 +64,11 @@ type Querier interface {
|
|||||||
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
|
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
|
||||||
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
|
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
|
||||||
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, 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)
|
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
|
||||||
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
|
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
|
||||||
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
|
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
|
||||||
|
GetProjectEnvironmentByName(ctx context.Context, arg GetProjectEnvironmentByNameParams) (Environment, error)
|
||||||
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
|
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
|
||||||
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||||
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
||||||
@@ -68,12 +76,14 @@ type Querier interface {
|
|||||||
GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error)
|
GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error)
|
||||||
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
|
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
|
||||||
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
|
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
|
||||||
|
InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error)
|
||||||
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
|
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
|
||||||
ListActiveBanners(ctx context.Context) ([]Banner, error)
|
ListActiveBanners(ctx context.Context) ([]Banner, error)
|
||||||
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
||||||
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
|
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
|
||||||
ListAgents(ctx context.Context) ([]NodeAgent, error)
|
ListAgents(ctx context.Context) ([]NodeAgent, error)
|
||||||
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
|
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
|
||||||
|
ListAllNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
|
||||||
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
|
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
|
||||||
ListBanners(ctx context.Context) ([]Banner, error)
|
ListBanners(ctx context.Context) ([]Banner, error)
|
||||||
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||||
@@ -83,14 +93,25 @@ type Querier interface {
|
|||||||
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
|
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
|
||||||
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
||||||
ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error)
|
ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error)
|
||||||
|
ListNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
|
||||||
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
|
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
|
||||||
ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error)
|
ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error)
|
||||||
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||||
ListPendingProjects(ctx context.Context) ([]Project, error)
|
ListPendingProjects(ctx context.Context) ([]Project, error)
|
||||||
|
ListProjectEnvironments(ctx context.Context, projectID uuid.UUID) ([]ListProjectEnvironmentsRow, error)
|
||||||
// Visibility model: a project is readable when it is approved for public
|
// Visibility model: a project is readable when it is approved for public
|
||||||
// display, when the caller owns or is a member of it, or when the caller is a
|
// display, when the caller owns or is a member of it, or when the caller is a
|
||||||
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
|
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
|
||||||
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
||||||
|
// Base domains of every online, schedulable agent carrying the required
|
||||||
|
// placement tags — auto-domain candidates for spread services.
|
||||||
|
ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error)
|
||||||
|
ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error)
|
||||||
|
// 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)
|
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
||||||
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
|
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
|
||||||
ListUserInvites(ctx context.Context) ([]UserInvite, error)
|
ListUserInvites(ctx context.Context) ([]UserInvite, error)
|
||||||
@@ -99,8 +120,18 @@ type Querier interface {
|
|||||||
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
||||||
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
||||||
MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) 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)
|
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
||||||
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
|
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
|
||||||
|
ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error
|
||||||
|
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
|
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
|
||||||
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
||||||
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
||||||
@@ -117,6 +148,13 @@ type Querier interface {
|
|||||||
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
||||||
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
|
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
|
||||||
UpdateBanner(ctx context.Context, arg UpdateBannerParams) (Banner, 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
|
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
||||||
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
|
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
|
||||||
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
||||||
@@ -126,6 +164,7 @@ type Querier interface {
|
|||||||
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
|
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
|
||||||
UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error
|
UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error
|
||||||
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
|
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
|
||||||
|
UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) error
|
||||||
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
|
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package deployment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEdgeLabelsCustomMiddlewares(t *testing.T) {
|
||||||
|
spec := RuntimeSpec{
|
||||||
|
ServiceID: "01234567-89ab-cdef-0123-456789abcdef",
|
||||||
|
Port: 8080,
|
||||||
|
Domains: []string{"app.example.com"},
|
||||||
|
TraefikLabels: map[string]string{
|
||||||
|
"middlewares.rl.ratelimit.average": "100",
|
||||||
|
"middlewares.rl.ratelimit.period": "1m",
|
||||||
|
"middlewares.sec.headers.customresponseheaders.X-Frame-Options": "DENY",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
labels := edgeLabels(spec, "containr-edge")
|
||||||
|
|
||||||
|
if got := labels["traefik.http.middlewares.svc-01234567-rl.ratelimit.average"]; got != "100" {
|
||||||
|
t.Fatalf("ratelimit label missing: %v", labels)
|
||||||
|
}
|
||||||
|
if got := labels["traefik.http.middlewares.svc-01234567-sec.headers.customresponseheaders.X-Frame-Options"]; got != "DENY" {
|
||||||
|
t.Fatalf("headers label missing: %v", labels)
|
||||||
|
}
|
||||||
|
chain := labels["traefik.http.routers.svc-01234567.middlewares"]
|
||||||
|
if chain != "svc-01234567-rl,svc-01234567-sec" {
|
||||||
|
t.Fatalf("middleware chain wrong: %q", chain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdgeLabelsCustomAfterBuiltins(t *testing.T) {
|
||||||
|
spec := RuntimeSpec{
|
||||||
|
ServiceID: "01234567-89ab-cdef-0123-456789abcdef",
|
||||||
|
Port: 8080,
|
||||||
|
Domains: []string{"app.example.com"},
|
||||||
|
BasicAuthUsers: "u:$apr1$x",
|
||||||
|
TraefikLabels: map[string]string{
|
||||||
|
"middlewares.rl.ratelimit.average": "10",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
labels := edgeLabels(spec, "containr-edge")
|
||||||
|
chain := labels["traefik.http.routers.svc-01234567.middlewares"]
|
||||||
|
if !strings.HasPrefix(chain, "svc-01234567-auth,") || !strings.HasSuffix(chain, "svc-01234567-rl") {
|
||||||
|
t.Fatalf("builtin should precede custom: %q", chain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdgeLabelsNoCustom(t *testing.T) {
|
||||||
|
spec := RuntimeSpec{
|
||||||
|
ServiceID: "01234567-89ab-cdef-0123-456789abcdef",
|
||||||
|
Port: 8080,
|
||||||
|
Domains: []string{"app.example.com"},
|
||||||
|
}
|
||||||
|
labels := edgeLabels(spec, "containr-edge")
|
||||||
|
if _, ok := labels["traefik.http.routers.svc-01234567.middlewares"]; ok {
|
||||||
|
t.Fatal("middleware chain should be absent with no middlewares")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,10 +11,37 @@ import (
|
|||||||
"containr/internal/types"
|
"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 {
|
type DeploymentEngine struct {
|
||||||
buildManager *build.BuildManager
|
buildManager *build.BuildManager
|
||||||
dockerClient *docker.Client
|
dockerClient *docker.Client
|
||||||
scheduler *Scheduler
|
scheduler *Scheduler
|
||||||
|
nodeRunner NodeRunner
|
||||||
deployments map[string]*Deployment
|
deployments map[string]*Deployment
|
||||||
deploymentLog chan *DeploymentEvent
|
deploymentLog chan *DeploymentEvent
|
||||||
}
|
}
|
||||||
@@ -67,6 +94,30 @@ type ServiceConfig struct {
|
|||||||
// PublishedPort is the host port bound by the previous deployment; replica 0
|
// PublishedPort is the host port bound by the previous deployment; replica 0
|
||||||
// reuses it so public URLs survive restarts. 0 = pick ephemeral.
|
// reuses it so public URLs survive restarts. 0 = pick ephemeral.
|
||||||
PublishedPort int32 `json:"published_port,omitempty"`
|
PublishedPort int32 `json:"published_port,omitempty"`
|
||||||
|
// NodeID pins the service to a registered node agent; "" = local Docker
|
||||||
|
// 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 {
|
type PortMapping struct {
|
||||||
@@ -177,6 +228,17 @@ func (de *DeploymentEngine) DockerClient() *docker.Client {
|
|||||||
return de.dockerClient
|
return de.dockerClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildManager exposes the engine's builders for context packaging — remote
|
||||||
|
// nodes build the tar this produces themselves.
|
||||||
|
func (de *DeploymentEngine) BuildManager() *build.BuildManager {
|
||||||
|
return de.buildManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNodeRunner wires remote-node dispatch for services pinned to an agent.
|
||||||
|
func (de *DeploymentEngine) SetNodeRunner(r NodeRunner) {
|
||||||
|
de.nodeRunner = r
|
||||||
|
}
|
||||||
|
|
||||||
func NewDeploymentEngine(buildManager *build.BuildManager, dockerClient *docker.Client) *DeploymentEngine {
|
func NewDeploymentEngine(buildManager *build.BuildManager, dockerClient *docker.Client) *DeploymentEngine {
|
||||||
return &DeploymentEngine{
|
return &DeploymentEngine{
|
||||||
buildManager: buildManager,
|
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) {
|
func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployment, buildConfig *BuildConfig) (string, error) {
|
||||||
if buildConfig == nil {
|
if buildConfig == nil {
|
||||||
return "", fmt.Errorf("build config is required")
|
return "", fmt.Errorf("build config is required")
|
||||||
}
|
}
|
||||||
|
if buildConfig.BuildType == "remote" {
|
||||||
|
if buildConfig.PrebuiltImage != "" {
|
||||||
|
return buildConfig.PrebuiltImage, nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("containr-%s-%s:%s", deployment.ServiceID, deployment.Environment, deployment.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
buildReq := &types.BuildRequest{
|
buildReq := &types.BuildRequest{
|
||||||
BuildType: buildConfig.BuildType,
|
BuildType: buildConfig.BuildType,
|
||||||
@@ -352,6 +422,10 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
|||||||
Maintenance: cfg.Maintenance,
|
Maintenance: cfg.Maintenance,
|
||||||
MaintenanceURL: cfg.MaintenanceURL,
|
MaintenanceURL: cfg.MaintenanceURL,
|
||||||
BasicAuthUsers: cfg.BasicAuthUsers,
|
BasicAuthUsers: cfg.BasicAuthUsers,
|
||||||
|
NodeID: cfg.NodeID,
|
||||||
|
Spread: cfg.Spread,
|
||||||
|
RemoteBuild: cfg.RemoteBuild,
|
||||||
|
RemoteLoad: cfg.RemoteLoad,
|
||||||
}
|
}
|
||||||
for _, pm := range cfg.PortMappings {
|
for _, pm := range cfg.PortMappings {
|
||||||
if spec.Port == 0 {
|
if spec.Port == 0 {
|
||||||
|
|||||||
@@ -55,6 +55,24 @@ type RuntimeSpec struct {
|
|||||||
Maintenance bool // redirect traffic to MaintenanceURL
|
Maintenance bool // redirect traffic to MaintenanceURL
|
||||||
MaintenanceURL string // absolute URL; empty = serve empty response
|
MaintenanceURL string // absolute URL; empty = serve empty response
|
||||||
BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
|
BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
|
||||||
|
// TraefikLabels defines extra middlewares for this service's router —
|
||||||
|
// keys "middlewares.<name>.<type>[.<field>]" (API-validated against an
|
||||||
|
// allowlist). Defined middlewares attach to the router after builtins.
|
||||||
|
TraefikLabels map[string]string
|
||||||
|
// NodeID pins the service to a node agent; "" runs on the local host.
|
||||||
|
NodeID 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.
|
// 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
|
// ReconcileService brings live containers in line with the spec. When the
|
||||||
// spec hash changed (image/env/ports/etc.) all replicas are recreated;
|
// 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) {
|
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 {
|
if spec.Replicas < 1 {
|
||||||
spec.Replicas = 1
|
spec.Replicas = 1
|
||||||
}
|
}
|
||||||
@@ -233,44 +278,9 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
|
|||||||
projectNet: {Aliases: []string{spec.Name}},
|
projectNet: {Aliases: []string{spec.Name}},
|
||||||
}
|
}
|
||||||
if edgeNet != "" {
|
if edgeNet != "" {
|
||||||
router := "svc-" + spec.ServiceID[:8]
|
|
||||||
domains := spec.Domains
|
|
||||||
if len(domains) == 0 && spec.Domain != "" {
|
|
||||||
domains = []string{spec.Domain}
|
|
||||||
}
|
|
||||||
sort.Strings(domains)
|
|
||||||
rules := make([]string, 0, len(domains))
|
|
||||||
for _, d := range domains {
|
|
||||||
rules = append(rules, "Host(`"+d+"`)")
|
|
||||||
}
|
|
||||||
endpoints[edgeNet] = &network.EndpointSettings{}
|
endpoints[edgeNet] = &network.EndpointSettings{}
|
||||||
labels["traefik.enable"] = "true"
|
for k, v := range edgeLabels(spec, edgeNet) {
|
||||||
labels["traefik.docker.network"] = edgeNet
|
labels[k] = v
|
||||||
labels["traefik.http.routers."+router+".rule"] = strings.Join(rules, " || ")
|
|
||||||
labels["traefik.http.routers."+router+".entrypoints"] = "web"
|
|
||||||
labels["traefik.http.services."+router+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
|
|
||||||
|
|
||||||
var middlewares []string
|
|
||||||
if spec.Maintenance {
|
|
||||||
if spec.MaintenanceURL != "" {
|
|
||||||
mw := router + "-maint"
|
|
||||||
labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
|
|
||||||
labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
|
|
||||||
labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
|
|
||||||
middlewares = append(middlewares, mw)
|
|
||||||
} else {
|
|
||||||
// No redirect target configured — take the site offline with
|
|
||||||
// empty responses instead of leaking traffic to the app.
|
|
||||||
labels["traefik.http.routers."+router+".service"] = "noop@internal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if spec.BasicAuthUsers != "" {
|
|
||||||
mw := router + "-auth"
|
|
||||||
labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
|
|
||||||
middlewares = append(middlewares, mw)
|
|
||||||
}
|
|
||||||
if len(middlewares) > 0 {
|
|
||||||
labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +368,81 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
|
|||||||
// vpnkit drops a conflicting binding).
|
// vpnkit drops a conflicting binding).
|
||||||
var errPortNotBound = errors.New("requested host port not bound")
|
var errPortNotBound = errors.New("requested host port not bound")
|
||||||
|
|
||||||
|
// edgeLabels builds the Traefik router/service/middleware labels for a
|
||||||
|
// replica on the edge network: host rules, load balancer port, builtin
|
||||||
|
// maintenance/basic-auth middlewares, then operator-defined overrides.
|
||||||
|
func edgeLabels(spec RuntimeSpec, edgeNet string) map[string]string {
|
||||||
|
router := "svc-" + spec.ServiceID[:8]
|
||||||
|
domains := spec.Domains
|
||||||
|
if len(domains) == 0 && spec.Domain != "" {
|
||||||
|
domains = []string{spec.Domain}
|
||||||
|
}
|
||||||
|
sort.Strings(domains)
|
||||||
|
rules := make([]string, 0, len(domains))
|
||||||
|
for _, d := range domains {
|
||||||
|
rules = append(rules, "Host(`"+d+"`)")
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := map[string]string{
|
||||||
|
"traefik.enable": "true",
|
||||||
|
"traefik.docker.network": edgeNet,
|
||||||
|
"traefik.http.routers." + router + ".rule": strings.Join(rules, " || "),
|
||||||
|
"traefik.http.routers." + router + ".entrypoints": "web",
|
||||||
|
"traefik.http.services." + router + ".loadbalancer.server.port": fmt.Sprintf("%d", spec.Port),
|
||||||
|
}
|
||||||
|
|
||||||
|
var middlewares []string
|
||||||
|
if spec.Maintenance {
|
||||||
|
if spec.MaintenanceURL != "" {
|
||||||
|
mw := router + "-maint"
|
||||||
|
labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
|
||||||
|
labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
|
||||||
|
labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
|
||||||
|
middlewares = append(middlewares, mw)
|
||||||
|
} else {
|
||||||
|
// No redirect target configured — take the site offline with
|
||||||
|
// empty responses instead of leaking traffic to the app.
|
||||||
|
labels["traefik.http.routers."+router+".service"] = "noop@internal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if spec.BasicAuthUsers != "" {
|
||||||
|
mw := router + "-auth"
|
||||||
|
labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
|
||||||
|
middlewares = append(middlewares, mw)
|
||||||
|
}
|
||||||
|
// Operator-defined middlewares: "middlewares.<name>.<rest>" becomes
|
||||||
|
// "<router>-<name>.<rest>", and every defined name joins the router's
|
||||||
|
// chain after builtins. Names sort for a deterministic chain order.
|
||||||
|
customMWs := map[string][]string{}
|
||||||
|
for k, v := range spec.TraefikLabels {
|
||||||
|
rest := strings.TrimPrefix(k, "middlewares.")
|
||||||
|
dot := strings.Index(rest, ".")
|
||||||
|
if dot <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
customMWs[rest[:dot]] = append(customMWs[rest[:dot]], rest[dot+1:]+"="+v)
|
||||||
|
}
|
||||||
|
mwNames := make([]string, 0, len(customMWs))
|
||||||
|
for name := range customMWs {
|
||||||
|
mwNames = append(mwNames, name)
|
||||||
|
}
|
||||||
|
sort.Strings(mwNames)
|
||||||
|
for _, name := range mwNames {
|
||||||
|
mw := router + "-" + name
|
||||||
|
sort.Strings(customMWs[name])
|
||||||
|
for _, kv := range customMWs[name] {
|
||||||
|
if i := strings.Index(kv, "="); i > 0 {
|
||||||
|
labels["traefik.http.middlewares."+mw+"."+kv[:i]] = kv[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
middlewares = append(middlewares, mw)
|
||||||
|
}
|
||||||
|
if len(middlewares) > 0 {
|
||||||
|
labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
// isPortConflict reports whether a container create failed because the
|
// isPortConflict reports whether a container create failed because the
|
||||||
// requested host port was already bound.
|
// requested host port was already bound.
|
||||||
func isPortConflict(err error) bool {
|
func isPortConflict(err error) bool {
|
||||||
@@ -498,6 +583,34 @@ func (de *DeploymentEngine) RemoveServiceContainers(ctx context.Context, service
|
|||||||
return nil
|
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.
|
// RemoveProjectContainers deletes every managed container in a project.
|
||||||
func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, projectID string) error {
|
func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, projectID string) error {
|
||||||
containers, err := de.dockerClient.ListContainersFiltered(ctx, filters.NewArgs(
|
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
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveImage streams refs as a docker-save tarball. Remote nodes receive
|
||||||
|
// these through the artifact endpoint and docker load them — that is how
|
||||||
|
// locally-built rollback tags reach nodes without a registry.
|
||||||
|
func (c *Client) SaveImage(ctx context.Context, refs ...string) (io.ReadCloser, error) {
|
||||||
|
return c.cli.ImageSave(ctx, refs)
|
||||||
|
}
|
||||||
|
|
||||||
// RemoveImage removes an image
|
// RemoveImage removes an image
|
||||||
func (c *Client) RemoveImage(ctx context.Context, imageID string, force bool) ([]image.DeleteResponse, error) {
|
func (c *Client) RemoveImage(ctx context.Context, imageID string, force bool) ([]image.DeleteResponse, error) {
|
||||||
return c.cli.ImageRemove(ctx, imageID, image.RemoveOptions{
|
return c.cli.ImageRemove(ctx, imageID, image.RemoveOptions{
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildTargetURL(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
upstream, prefix, path, query, want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "strips route prefix",
|
||||||
|
upstream: "https://api.internal:8080",
|
||||||
|
prefix: "/g/weather",
|
||||||
|
path: "/g/weather/v1/current",
|
||||||
|
query: "city=oslo",
|
||||||
|
want: "https://api.internal:8080/v1/current?city=oslo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prefix root maps to upstream root",
|
||||||
|
upstream: "https://api.internal",
|
||||||
|
prefix: "/g/weather",
|
||||||
|
path: "/g/weather",
|
||||||
|
want: "https://api.internal/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream base path is preserved",
|
||||||
|
upstream: "https://api.internal/base",
|
||||||
|
prefix: "/g/x",
|
||||||
|
path: "/g/x/ping",
|
||||||
|
want: "https://api.internal/base/ping",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "root prefix leaves path untouched",
|
||||||
|
upstream: "https://api.internal",
|
||||||
|
prefix: "/",
|
||||||
|
path: "/g/raw",
|
||||||
|
want: "https://api.internal/g/raw",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path without prefix is still proxied",
|
||||||
|
upstream: "https://api.internal",
|
||||||
|
prefix: "/g/x",
|
||||||
|
path: "/other",
|
||||||
|
want: "https://api.internal/other",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := BuildTargetURL(tc.upstream, tc.prefix, tc.path, tc.query)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("got %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTargetURL_InvalidUpstream(t *testing.T) {
|
||||||
|
if _, err := BuildTargetURL("://bad", "/g/x", "/g/x/y", ""); err == nil {
|
||||||
|
t.Fatal("expected error for unparsable upstream")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,10 +50,15 @@ func init() {
|
|||||||
register(spec{"containr_projects_create", "Create a project. Args: name (required), description.", "POST", "/projects", nil, []string{"name", "description"}, false})
|
register(spec{"containr_projects_create", "Create a project. Args: name (required), description.", "POST", "/projects", nil, []string{"name", "description"}, false})
|
||||||
register(spec{"containr_projects_delete", "Delete a project and all its services. Requires confirm:true.", "DELETE", "/projects/{id}", []string{"id"}, nil, true})
|
register(spec{"containr_projects_delete", "Delete a project and all its services. Requires confirm:true.", "DELETE", "/projects/{id}", []string{"id"}, nil, true})
|
||||||
|
|
||||||
|
// Environments
|
||||||
|
register(spec{"containr_environments_list", "List environments in a project with service counts", "GET", "/projects/{id}/environments", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_environments_create", "Create an environment. Args: name (required — lowercase alphanumeric with dashes, 1-50 chars).", "POST", "/projects/{id}/environments", []string{"id"}, []string{"name"}, false})
|
||||||
|
register(spec{"containr_environments_delete", "Delete an empty environment. Requires confirm:true.", "DELETE", "/environments/{id}", []string{"id"}, nil, true})
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||||
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
|
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir.", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir, sleep_enabled (bool — scale to zero on idle), sleep_idle_minutes (1-1440, default 15), node_id (agent id | 'auto' = least-loaded online node; git services build on the node itself), spread (bool — distribute replicas across every online schedulable node; mutually exclusive with a node pin), placement_tags (string array — remote placement only onto nodes carrying every tag; re-resolved per reconcile, pin overrides), traefik_labels (object — extra Traefik middlewares for this service's router; keys 'middlewares.<name>.<type>[.<field>]', allowlist-validated; defined middlewares attach to the router automatically).", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||||
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
|
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
|
||||||
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
|
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
|
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
|
||||||
@@ -66,7 +71,7 @@ func init() {
|
|||||||
register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false})
|
register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false})
|
||||||
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
|
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
|
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
|
||||||
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes.", "PUT", "/services/{id}", []string{"id"}, nil, false})
|
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes. node_id pins placement to a node agent ('auto' = least-loaded, 'local' = run on the Containr host). spread (bool) distributes replicas across every online schedulable node; mutually exclusive with a pin — clear node_id to 'local' first if pinned. placement_tags (string array; [] clears) restricts auto/spread placement to nodes carrying every tag; mutually exclusive with an explicit pin — clear the pin first. traefik_labels (object; {} clears) replaces the service's Traefik middleware overrides — keys 'middlewares.<name>.<type>[.<field>]', e.g. {'middlewares.rl.ratelimit.average': '100'}.", "PUT", "/services/{id}", []string{"id"}, nil, false})
|
||||||
|
|
||||||
// Domains
|
// Domains
|
||||||
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
|
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
|
||||||
@@ -133,12 +138,17 @@ func init() {
|
|||||||
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
|
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
|
||||||
register(spec{"containr_templates_plan", "Dry-resolve a template: expanded expressions, service refs, missing variables. Args: id, variables (object).", "POST", "/templates/{id}/plan", []string{"id"}, nil, false})
|
register(spec{"containr_templates_plan", "Dry-resolve a template: expanded expressions, service refs, missing variables. Args: id, variables (object).", "POST", "/templates/{id}/plan", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_templates_import_compose", "Convert docker-compose YAML into a v2 template graph config. Args: compose_yaml.", "POST", "/templates/import/compose", nil, []string{"compose_yaml"}, false})
|
register(spec{"containr_templates_import_compose", "Convert docker-compose YAML into a v2 template graph config. Args: compose_yaml.", "POST", "/templates/import/compose", nil, []string{"compose_yaml"}, false})
|
||||||
|
register(spec{"containr_templates_import_git", "Fetch a compose file from a git repo (clone URL, ssh remote, or owner/repo via connected providers) and convert it into a v2 template graph config. Args: repo (required), path (default: compose.yaml/yml or docker-compose.yml/yaml), ref (branch or commit).", "POST", "/templates/import/git", nil, []string{"repo", "path", "ref"}, false})
|
||||||
register(spec{"containr_templates_deploy_graph", "Deploy an ad-hoc service graph without a stored template. Args: project_id, config ({services:[...]}), variables, plan, region.", "POST", "/templates/deploy", nil, []string{"project_id", "config", "variables", "plan", "region"}, false})
|
register(spec{"containr_templates_deploy_graph", "Deploy an ad-hoc service graph without a stored template. Args: project_id, config ({services:[...]}), variables, plan, region.", "POST", "/templates/deploy", nil, []string{"project_id", "config", "variables", "plan", "region"}, false})
|
||||||
register(spec{"containr_projects_import_compose", "Deploy a docker-compose file straight into a project. Args: id (project), compose_yaml, variables, plan, region.", "POST", "/projects/{id}/import-compose", []string{"id"}, []string{"compose_yaml", "variables", "plan", "region"}, false})
|
register(spec{"containr_projects_import_compose", "Deploy a docker-compose file straight into a project. Args: id (project), compose_yaml, variables, plan, region.", "POST", "/projects/{id}/import-compose", []string{"id"}, []string{"compose_yaml", "variables", "plan", "region"}, false})
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
|
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
|
||||||
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
|
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_notification_channels_list", "List push notification channels (ntfy/Gotify)", "GET", "/notifications/channels", nil, nil, false})
|
||||||
|
register(spec{"containr_notification_channels_create", "Add a push notification channel. Args: kind (ntfy|gotify), endpoint (full URL incl. ntfy topic or Gotify base), token (ntfy access token or Gotify app token).", "POST", "/notifications/channels", nil, []string{"kind", "endpoint", "token"}, false})
|
||||||
|
register(spec{"containr_notification_channels_test", "Send a test notification through a push channel. Args: id.", "POST", "/notifications/channels/{id}/test", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_notification_channels_delete", "Delete a push notification channel. Args: id. Requires confirm=true.", "DELETE", "/notifications/channels/{id}", []string{"id"}, nil, true})
|
||||||
|
|
||||||
register(spec{"containr_webhooks_list", "List outbound webhooks", "GET", "/webhooks", nil, nil, false})
|
register(spec{"containr_webhooks_list", "List outbound webhooks", "GET", "/webhooks", nil, nil, false})
|
||||||
register(spec{"containr_webhooks_create", "Create an outbound webhook. Args: name, url, events (list, e.g. [\"service.*\",\"*\"]), secret (auto-generated if omitted), headers, enabled.", "POST", "/webhooks", nil, []string{"name", "url", "events", "secret", "headers", "enabled"}, false})
|
register(spec{"containr_webhooks_create", "Create an outbound webhook. Args: name, url, events (list, e.g. [\"service.*\",\"*\"]), secret (auto-generated if omitted), headers, enabled.", "POST", "/webhooks", nil, []string{"name", "url", "events", "secret", "headers", "enabled"}, false})
|
||||||
@@ -173,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_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
|
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
|
||||||
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
|
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
|
||||||
|
register(spec{"containr_nodes_update", "Update a node agent (admin). Args: id; optional auto_prune, name, tags (string array — placement labels services can require via placement_tags; [] clears), 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
|
// Scaling / HA / security
|
||||||
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
|
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ type ScanSummary struct {
|
|||||||
// Scanner handles security scanning operations
|
// Scanner handles security scanning operations
|
||||||
type Scanner struct {
|
type Scanner struct {
|
||||||
db *database.DB
|
db *database.DB
|
||||||
|
// OnScanComplete fires after a scan finishes (goroutine context) —
|
||||||
|
// wired by the api layer for notifications. Optional.
|
||||||
|
OnScanComplete func(scan *SecurityScan)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScanner creates a new security scanner
|
// NewScanner creates a new security scanner
|
||||||
@@ -115,6 +118,9 @@ func (s *Scanner) performScan(scan *SecurityScan) {
|
|||||||
|
|
||||||
// Update scan with results
|
// Update scan with results
|
||||||
completedAt := time.Now()
|
completedAt := time.Now()
|
||||||
|
scan.CompletedAt = &completedAt
|
||||||
|
scan.Summary = summary
|
||||||
|
scan.Status = "completed"
|
||||||
_, err := s.db.Exec(`
|
_, err := s.db.Exec(`
|
||||||
UPDATE security_scans
|
UPDATE security_scans
|
||||||
SET status = $1, completed_at = $2, summary = $3
|
SET status = $1, completed_at = $2, summary = $3
|
||||||
@@ -137,6 +143,10 @@ func (s *Scanner) performScan(scan *SecurityScan) {
|
|||||||
log.Printf("Failed to store vulnerability %s: %v", vuln.ID, err)
|
log.Printf("Failed to store vulnerability %s: %v", vuln.ID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.OnScanComplete != nil {
|
||||||
|
s.OnScanComplete(scan)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanDependencies scans for known vulnerable dependencies
|
// scanDependencies scans for known vulnerable dependencies
|
||||||
|
|||||||
@@ -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,
|
resources = $9,
|
||||||
last_heartbeat = $10,
|
last_heartbeat = $10,
|
||||||
metadata = $11,
|
metadata = $11,
|
||||||
|
auto_prune = $12,
|
||||||
|
schedulable = $13,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
@@ -107,3 +109,126 @@ RETURNING *;
|
|||||||
-- name: UpdateContainerStatus :exec
|
-- name: UpdateContainerStatus :exec
|
||||||
UPDATE container_instances SET status = $2, updated_at = NOW()
|
UPDATE container_instances SET status = $2, updated_at = NOW()
|
||||||
WHERE id = $1;
|
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
|
-- name: MarkAllNotificationsReadByUser :exec
|
||||||
UPDATE notifications SET read_at = $2 WHERE user_id = $1 AND read_at IS NULL;
|
UPDATE notifications SET read_at = $2 WHERE user_id = $1 AND read_at IS NULL;
|
||||||
|
|
||||||
|
-- name: ListNotificationChannelsByUser :many
|
||||||
|
SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at
|
||||||
|
FROM notification_channels
|
||||||
|
WHERE user_id = $1 AND enabled
|
||||||
|
ORDER BY created_at;
|
||||||
|
|
||||||
|
-- name: ListAllNotificationChannelsByUser :many
|
||||||
|
SELECT id, user_id, kind, endpoint, token, enabled, created_at, updated_at
|
||||||
|
FROM notification_channels
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at;
|
||||||
|
|
||||||
|
-- name: InsertNotificationChannel :one
|
||||||
|
INSERT INTO notification_channels (user_id, kind, endpoint, token)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, user_id, kind, endpoint, token, enabled, created_at, updated_at;
|
||||||
|
|
||||||
|
-- name: DeleteNotificationChannel :execrows
|
||||||
|
DELETE FROM notification_channels WHERE id = $1 AND user_id = $2;
|
||||||
@@ -125,3 +125,21 @@ SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.u
|
|||||||
FROM projects p
|
FROM projects p
|
||||||
WHERE p.is_approved = false
|
WHERE p.is_approved = false
|
||||||
ORDER BY p.created_at DESC;
|
ORDER BY p.created_at DESC;
|
||||||
|
|
||||||
|
-- name: ListProjectEnvironments :many
|
||||||
|
SELECT e.id, e.name, e.project_id, e.created_at, e.updated_at,
|
||||||
|
(SELECT COUNT(*) FROM services s WHERE s.environment_id = e.id) AS service_count
|
||||||
|
FROM environments e
|
||||||
|
WHERE e.project_id = $1
|
||||||
|
ORDER BY e.created_at;
|
||||||
|
|
||||||
|
-- name: GetProjectEnvironmentByName :one
|
||||||
|
SELECT id, name, project_id, created_at, updated_at
|
||||||
|
FROM environments
|
||||||
|
WHERE project_id = $1 AND name = $2;
|
||||||
|
|
||||||
|
-- name: DeleteProjectEnvironment :exec
|
||||||
|
DELETE FROM environments WHERE id = $1 AND project_id = $2;
|
||||||
|
|
||||||
|
-- name: CountEnvironmentServices :one
|
||||||
|
SELECT COUNT(*) FROM services WHERE environment_id = $1;
|
||||||
@@ -66,7 +66,11 @@ CREATE TABLE services (
|
|||||||
cpu_reserve VARCHAR(20) NOT NULL DEFAULT '',
|
cpu_reserve VARCHAR(20) NOT NULL DEFAULT '',
|
||||||
memory_reserve VARCHAR(20) NOT NULL DEFAULT '',
|
memory_reserve VARCHAR(20) NOT NULL DEFAULT '',
|
||||||
static_build_cmd VARCHAR(255) 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 (
|
CREATE TABLE registries (
|
||||||
@@ -192,7 +196,11 @@ CREATE TABLE node_agents (
|
|||||||
last_heartbeat TIMESTAMP WITH TIME ZONE,
|
last_heartbeat TIMESTAMP WITH TIME ZONE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
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 (
|
CREATE TABLE container_instances (
|
||||||
@@ -327,3 +335,14 @@ CREATE TABLE user_invites (
|
|||||||
used_at TIMESTAMP WITH TIME ZONE,
|
used_at TIMESTAMP WITH TIME ZONE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE notification_channels (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
kind VARCHAR(20) NOT NULL CHECK (kind IN ('ntfy', 'gotify')),
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
token VARCHAR(255),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM node:22-alpine AS builder
|
ARG NODE_IMAGE=node:22-alpine
|
||||||
|
ARG NGINX_IMAGE=nginx:alpine
|
||||||
|
FROM ${NODE_IMAGE} AS builder
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -24,7 +26,7 @@ ENV VITE_AUTH_URL=${VITE_AUTH_URL}
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM nginx:alpine
|
FROM ${NGINX_IMAGE}
|
||||||
|
|
||||||
# Copy custom nginx config
|
# Copy custom nginx config
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|||||||
Generated
+54
-8
@@ -31,7 +31,9 @@
|
|||||||
"@tabler/icons-react": "^3.37.1",
|
"@tabler/icons-react": "^3.37.1",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tanstack/query-sync-storage-persister": "^5.104.1",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
|
"@tanstack/react-query-persist-client": "^5.104.1",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
@@ -3441,22 +3443,49 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.90.20",
|
"version": "5.104.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz",
|
||||||
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
|
"integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/query-persist-client-core": {
|
||||||
"version": "5.90.21",
|
"version": "5.104.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.104.1.tgz",
|
||||||
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
"integrity": "sha512-ONiadXqe56iNrw6RESqKILpTIVG9ZMI/DageTQnrBDt/nZ6WiP4G13iIiMwJEsiO12gQ3Gychx8tcFIPw4k25g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.90.20"
|
"@tanstack/query-core": "5.104.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/query-sync-storage-persister": {
|
||||||
|
"version": "5.104.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-5.104.1.tgz",
|
||||||
|
"integrity": "sha512-eDsBMNds48+sjXILn0dQKu+uYwfh9tGIB+Rb0cKRdoxD22HHBMzJes5DJOhJVLUhBQbxtoFq+Hpha/Vohh14NQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.104.1",
|
||||||
|
"@tanstack/query-persist-client-core": "5.104.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.104.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz",
|
||||||
|
"integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.104.1"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3466,6 +3495,23 @@
|
|||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-query-persist-client": {
|
||||||
|
"version": "5.104.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.104.1.tgz",
|
||||||
|
"integrity": "sha512-kU3YaqjkwEUzUUfPF0JVg56d6gJKWHaFVqOlQztwc4C2IT0P5sv/23orrSoQqjxKaUe6Ejw5IIQtA/G12uNmow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-persist-client-core": "5.104.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tanstack/react-query": "^5.104.1",
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
|
|||||||
@@ -42,7 +42,9 @@
|
|||||||
"@tabler/icons-react": "^3.37.1",
|
"@tabler/icons-react": "^3.37.1",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tanstack/query-sync-storage-persister": "^5.104.1",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
|
"@tanstack/react-query-persist-client": "^5.104.1",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ import {
|
|||||||
ComponentShowcase,
|
ComponentShowcase,
|
||||||
GitHubAppCallbackPage,
|
GitHubAppCallbackPage,
|
||||||
} from '@/features/secondary/pages';
|
} from '@/features/secondary/pages';
|
||||||
|
import { NodeDetailPage } from '@/features/nodes/NodeDetailPage';
|
||||||
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
|
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
|
||||||
|
import { SetupWizardPage } from '@/features/setup/SetupWizardPage';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
import { useDemoMode } from '@/lib/demo-mode';
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
import { getCurrentUserProfile } from '@/lib/api-client';
|
import { getCurrentUserProfile } from '@/lib/api-client';
|
||||||
@@ -137,6 +139,7 @@ export default function App() {
|
|||||||
<Route path="/auth/sign-in" element={<SignInPage />} />
|
<Route path="/auth/sign-in" element={<SignInPage />} />
|
||||||
<Route path="/auth/sign-up" element={<SignUpPage />} />
|
<Route path="/auth/sign-up" element={<SignUpPage />} />
|
||||||
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} />
|
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} />
|
||||||
|
<Route path="/setup" element={<SetupWizardPage />} />
|
||||||
|
|
||||||
{/* Public browsing — approved projects, templates, docs. */}
|
{/* Public browsing — approved projects, templates, docs. */}
|
||||||
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
|
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
|
||||||
@@ -168,6 +171,7 @@ export default function App() {
|
|||||||
{/* Platform owner only */}
|
{/* Platform owner only */}
|
||||||
<Route element={<AdminRequired />}>
|
<Route element={<AdminRequired />}>
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
|
<Route path="/nodes/:id" element={<NodeDetailPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<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 { useEffect, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||||
@@ -30,10 +30,13 @@ import {
|
|||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
Webhook,
|
Webhook,
|
||||||
|
WifiOff,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getCurrentUserProfile,
|
getCurrentUserProfile,
|
||||||
getUpgradeStatus,
|
getUpgradeStatus,
|
||||||
|
pingServer,
|
||||||
|
getSetupStatus,
|
||||||
listNotifications,
|
listNotifications,
|
||||||
markAllNotificationsRead,
|
markAllNotificationsRead,
|
||||||
markNotificationRead,
|
markNotificationRead,
|
||||||
@@ -105,6 +108,42 @@ function getInitialSidebar(): boolean {
|
|||||||
return readStorage('containr.sidebar.expanded') !== '0';
|
return readStorage('containr.sidebar.expanded') !== '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConnectivityBanner pings /health every 15s and listens for browser
|
||||||
|
// online/offline events — when the instance is unreachable it shows a
|
||||||
|
// slim bar so the persisted last-known data doesn't masquerade as live.
|
||||||
|
function ConnectivityBanner() {
|
||||||
|
const [offline, setOffline] = useState(() => !navigator.onLine);
|
||||||
|
useEffect(() => {
|
||||||
|
const up = () => setOffline(false);
|
||||||
|
const down = () => setOffline(true);
|
||||||
|
window.addEventListener('online', up);
|
||||||
|
window.addEventListener('offline', down);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', up);
|
||||||
|
window.removeEventListener('offline', down);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const ping = useQuery({
|
||||||
|
queryKey: ['connectivity'],
|
||||||
|
queryFn: pingServer,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
retry: 0,
|
||||||
|
networkMode: 'always',
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
if (!offline && !ping.isError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-2 border-b border-[var(--warning,var(--border-subtle))] bg-[var(--surface-muted)] px-4 py-1.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
<WifiOff size={13} className="text-[var(--warning,#f59e0b)]" />
|
||||||
|
{offline
|
||||||
|
? 'No network connection — showing last-known data.'
|
||||||
|
: 'Cannot reach this Containr instance — showing last-known data. Retrying…'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PlatformShell() {
|
export function PlatformShell() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -147,6 +186,13 @@ export function PlatformShell() {
|
|||||||
enabled: !isDemoMode && signedIn,
|
enabled: !isDemoMode && signedIn,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
|
// Fresh installs get routed through the setup wizard until the flag lands.
|
||||||
|
const setupQuery = useQuery({
|
||||||
|
queryKey: ['setup-status'],
|
||||||
|
queryFn: getSetupStatus,
|
||||||
|
enabled: !isDemoMode && signedIn,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
const markReadMutation = useMutation({
|
const markReadMutation = useMutation({
|
||||||
mutationFn: (id: string) => markNotificationRead(id),
|
mutationFn: (id: string) => markNotificationRead(id),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] }),
|
||||||
@@ -209,6 +255,10 @@ export function PlatformShell() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) {
|
||||||
|
return <Navigate to="/setup" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell min-h-screen">
|
<div className="app-shell min-h-screen">
|
||||||
<div className="ambient-glow" />
|
<div className="ambient-glow" />
|
||||||
@@ -557,6 +607,7 @@ export function PlatformShell() {
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<ConnectivityBanner />
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main className="flex-1 overflow-y-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { listActivity, type ActivityEntry } from '@/lib/api-client';
|
import { listActivity, type ActivityEntry } from '@/lib/api-client';
|
||||||
import { useDemoMode } from '@/lib/demo-mode';
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
|
import { type FilterConfig } from '@/lib/dynamic-filter';
|
||||||
|
import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
|
||||||
import { formatRelative } from '@/lib/time';
|
import { formatRelative } from '@/lib/time';
|
||||||
import { DemoRestricted } from '@/shared/components';
|
import { DemoRestricted, FilterBar } from '@/shared/components';
|
||||||
import {
|
import {
|
||||||
Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key,
|
Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key,
|
||||||
Loader2, Package, Projector, Rocket, Shield, User, Users, Wrench,
|
Loader2, Package, Projector, Rocket, Shield, User, Users, Wrench,
|
||||||
@@ -22,23 +24,37 @@ const SEVERITY_STYLE: Record<string, { icon: typeof Info; cls: string; dot: stri
|
|||||||
info: { icon: Info, cls: 'text-[var(--accent-primary)]', dot: 'bg-[var(--accent-primary)]' },
|
info: { icon: Info, cls: 'text-[var(--accent-primary)]', dot: 'bg-[var(--accent-primary)]' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const SEVERITIES = ['', 'error', 'warning', 'success', 'info'] as const;
|
const SCHEMA: FilterConfig<ActivityEntry>[] = [
|
||||||
|
{ key: 'q', label: 'Search', type: 'search', searchFields: ['label', 'action', 'user_email'] },
|
||||||
|
{
|
||||||
|
key: 'severity', label: 'Severity', type: 'select',
|
||||||
|
options: [
|
||||||
|
{ value: 'error', label: 'Error' },
|
||||||
|
{ value: 'warning', label: 'Warning' },
|
||||||
|
{ value: 'success', label: 'Success' },
|
||||||
|
{ value: 'info', label: 'Info' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'category', label: 'Category', type: 'select' },
|
||||||
|
];
|
||||||
|
|
||||||
export function ActivityPage() {
|
export function ActivityPage() {
|
||||||
const isDemoMode = useDemoMode();
|
const isDemoMode = useDemoMode();
|
||||||
const [severity, setSeverity] = useState('');
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const { filters } = useFilterState(SCHEMA);
|
||||||
|
const severityParam = filters.severity === 'all' ? '' : String(filters.severity ?? '');
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['activity-feed', severity, page],
|
queryKey: ['activity-feed', severityParam, page],
|
||||||
queryFn: () => listActivity({ severity: severity || undefined, page, limit: 50 }),
|
queryFn: () => listActivity({ severity: severityParam || undefined, page, limit: 50 }),
|
||||||
enabled: !isDemoMode,
|
enabled: !isDemoMode,
|
||||||
refetchInterval: 15000,
|
refetchInterval: 15000,
|
||||||
});
|
});
|
||||||
|
const filter = useDynamicFilter<ActivityEntry>({ data: query.data?.activity ?? [], schema: SCHEMA });
|
||||||
|
|
||||||
if (isDemoMode) return <DemoRestricted feature="Activity" />;
|
if (isDemoMode) return <DemoRestricted feature="Activity" />;
|
||||||
|
|
||||||
const entries = query.data?.activity ?? [];
|
const entries = filter.filteredData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
@@ -52,21 +68,11 @@ export function ActivityPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-8 py-6 space-y-4">
|
<div className="px-8 py-6 space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start gap-3">
|
||||||
{SEVERITIES.map((s) => (
|
<div className="flex-1">
|
||||||
<button
|
<FilterBar {...filter} />
|
||||||
key={s || 'all'}
|
</div>
|
||||||
onClick={() => { setSeverity(s); setPage(1); }}
|
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)] mt-3" />}
|
||||||
className={`v-mono text-[10px] uppercase px-3 py-1.5 rounded-md border transition-colors ${
|
|
||||||
severity === s
|
|
||||||
? 'border-[var(--accent-primary)] text-[var(--accent-primary)] bg-[var(--accent-primary)]/10'
|
|
||||||
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{s || 'all'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)]" />}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel p-0 divide-y divide-[var(--border-subtle)]">
|
<div className="panel p-0 divide-y divide-[var(--border-subtle)]">
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { useDeferredValue, useState } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { listAuditLogs } from '@/lib/api-client';
|
import { listAuditLogs } from '@/lib/api-client';
|
||||||
import { useDemoMode } from '@/lib/demo-mode';
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
|
import { type FilterConfig } from '@/lib/dynamic-filter';
|
||||||
|
import { useDynamicFilter } from '@/lib/use-dynamic-filter';
|
||||||
import { formatDate, formatRelative } from '@/lib/time';
|
import { formatDate, formatRelative } from '@/lib/time';
|
||||||
import { DemoRestricted } from '@/shared/components';
|
import { DemoRestricted, FilterBar } from '@/shared/components';
|
||||||
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
@@ -15,20 +17,34 @@ const rangeOptions = [
|
|||||||
{ value: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 },
|
{ value: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const inputClass =
|
// Server-backed filters — the engine runs over empty data; values map to
|
||||||
'h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all';
|
// API params. URL state makes filtered views shareable.
|
||||||
|
const AUDIT_SCHEMA: FilterConfig<unknown>[] = [
|
||||||
|
{ key: 'resource', label: 'Resource', type: 'search' },
|
||||||
|
{ key: 'action', label: 'Action', type: 'search' },
|
||||||
|
{ key: 'actor', label: 'Actor', type: 'search' },
|
||||||
|
{
|
||||||
|
key: 'range', label: 'Time', type: 'select',
|
||||||
|
options: rangeOptions.filter((o) => o.value !== '').map((o) => ({ value: o.value, label: o.label })),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export function AuditLogsPage() {
|
export function AuditLogsPage() {
|
||||||
const isDemoMode = useDemoMode();
|
const isDemoMode = useDemoMode();
|
||||||
const [resource, setResource] = useState('');
|
const filter = useDynamicFilter<unknown>({ data: [], schema: AUDIT_SCHEMA });
|
||||||
const [action, setAction] = useState('');
|
|
||||||
const [actor, setActor] = useState('');
|
|
||||||
const [range, setRange] = useState('');
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
// Back to page 1 whenever the filter set changes.
|
||||||
|
const filtersJson = JSON.stringify(filter.filters);
|
||||||
|
const [prevFiltersJson, setPrevFiltersJson] = useState(filtersJson);
|
||||||
|
if (prevFiltersJson !== filtersJson) {
|
||||||
|
setPrevFiltersJson(filtersJson);
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
const deferredResource = useDeferredValue(resource);
|
const deferredResource = useDeferredValue(String(filter.filters.resource ?? ''));
|
||||||
const deferredAction = useDeferredValue(action);
|
const deferredAction = useDeferredValue(String(filter.filters.action ?? ''));
|
||||||
const deferredActor = useDeferredValue(actor);
|
const deferredActor = useDeferredValue(String(filter.filters.actor ?? ''));
|
||||||
|
const range = filter.filters.range === 'all' ? '' : String(filter.filters.range ?? '');
|
||||||
|
|
||||||
const filters = {
|
const filters = {
|
||||||
resource: deferredResource.trim() || undefined,
|
resource: deferredResource.trim() || undefined,
|
||||||
@@ -54,15 +70,7 @@ export function AuditLogsPage() {
|
|||||||
|
|
||||||
const logs = logsQuery.data ?? [];
|
const logs = logsQuery.data ?? [];
|
||||||
const hasNext = logs.length === PAGE_SIZE;
|
const hasNext = logs.length === PAGE_SIZE;
|
||||||
const hasFilters = Boolean(resource || action || actor || range);
|
const hasFilters = filter.activeCount > 0;
|
||||||
|
|
||||||
const resetFilters = () => {
|
|
||||||
setResource('');
|
|
||||||
setAction('');
|
|
||||||
setActor('');
|
|
||||||
setRange('');
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
@@ -79,63 +87,7 @@ export function AuditLogsPage() {
|
|||||||
|
|
||||||
<div className="w-full px-8 py-6">
|
<div className="w-full px-8 py-6">
|
||||||
<section className="panel p-6">
|
<section className="panel p-6">
|
||||||
<div className="flex flex-wrap items-end gap-3 mb-5">
|
<FilterBar {...filter} />
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
|
||||||
Resource
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={resource}
|
|
||||||
onChange={(e) => { setResource(e.target.value); setPage(1); }}
|
|
||||||
placeholder="service, project…"
|
|
||||||
className={`${inputClass} w-44`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
|
||||||
Action
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={action}
|
|
||||||
onChange={(e) => { setAction(e.target.value); setPage(1); }}
|
|
||||||
placeholder="create, deploy…"
|
|
||||||
className={`${inputClass} w-44`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
|
||||||
Actor
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={actor}
|
|
||||||
onChange={(e) => { setActor(e.target.value); setPage(1); }}
|
|
||||||
placeholder="user email"
|
|
||||||
className={`${inputClass} w-52`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
|
||||||
Time
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={range}
|
|
||||||
onChange={(e) => { setRange(e.target.value); setPage(1); }}
|
|
||||||
className={`${inputClass} w-40`}
|
|
||||||
>
|
|
||||||
{rangeOptions.map((o) => (
|
|
||||||
<option key={o.value} value={o.value}>{o.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{hasFilters && (
|
|
||||||
<button
|
|
||||||
onClick={resetFilters}
|
|
||||||
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { useBuildUpdates } from '@/lib/use-build-updates';
|
import { useBuildUpdates } from '@/lib/use-build-updates';
|
||||||
import { useDemoMode } from '@/lib/demo-mode';
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
|
import { type FilterConfig } from '@/lib/dynamic-filter';
|
||||||
|
import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
|
||||||
|
import { FilterBar } from '@/shared/components';
|
||||||
import { demoBuilds } from '@/lib/demo-data';
|
import { demoBuilds } from '@/lib/demo-data';
|
||||||
import { formatRelative } from '@/lib/time';
|
import { formatRelative } from '@/lib/time';
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +37,19 @@ const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
|
|||||||
{ value: 'cancelled', label: 'Cancelled' },
|
{ value: 'cancelled', label: 'Cancelled' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const BUILD_SCHEMA: FilterConfig<BuildEntity>[] = [
|
||||||
|
{
|
||||||
|
key: 'status', label: 'Status', type: 'select',
|
||||||
|
options: statusOptions.filter((o) => o.value !== '').map((o) => ({ value: o.value, label: o.label })),
|
||||||
|
},
|
||||||
|
{ key: 'project', label: 'Project ID', type: 'search', searchFields: ['projectId'] },
|
||||||
|
{ key: 'service', label: 'Service ID', type: 'search', searchFields: ['serviceId'] },
|
||||||
|
{
|
||||||
|
key: 'created', label: 'Time', type: 'date-range',
|
||||||
|
accessor: (b) => b.startedAt ?? b.completedAt,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: BuildStatus }) {
|
function StatusBadge({ status }: { status: BuildStatus }) {
|
||||||
const config = {
|
const config = {
|
||||||
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
|
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
|
||||||
@@ -72,9 +88,10 @@ export function BuildsPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isDemoMode = useDemoMode();
|
const isDemoMode = useDemoMode();
|
||||||
|
|
||||||
const [projectFilter, setProjectFilter] = useState('');
|
const { filters } = useFilterState(BUILD_SCHEMA);
|
||||||
const [serviceFilter, setServiceFilter] = useState('');
|
const projectFilter = String(filters.project ?? '');
|
||||||
const [statusFilter, setStatusFilter] = useState<'' | BuildStatus>('');
|
const serviceFilter = String(filters.service ?? '');
|
||||||
|
const statusFilter = filters.status === 'all' ? '' : String(filters.status ?? '');
|
||||||
const [limit, setLimit] = useState(50);
|
const [limit, setLimit] = useState(50);
|
||||||
const [selectedBuild, setSelectedBuild] = useState<BuildEntity | null>(null);
|
const [selectedBuild, setSelectedBuild] = useState<BuildEntity | null>(null);
|
||||||
|
|
||||||
@@ -85,20 +102,22 @@ export function BuildsPage() {
|
|||||||
listBuilds({
|
listBuilds({
|
||||||
projectId: projectFilter || undefined,
|
projectId: projectFilter || undefined,
|
||||||
serviceId: serviceFilter || undefined,
|
serviceId: serviceFilter || undefined,
|
||||||
status: statusFilter || undefined,
|
status: (statusFilter || undefined) as BuildStatus | undefined,
|
||||||
page: 1,
|
page: 1,
|
||||||
limit,
|
limit,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const builds = useMemo(
|
const rawBuilds = useMemo(
|
||||||
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
|
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
|
||||||
[isDemoMode, buildsQuery.data?.builds],
|
[isDemoMode, buildsQuery.data?.builds],
|
||||||
);
|
);
|
||||||
|
const filter = useDynamicFilter<BuildEntity>({ data: rawBuilds, schema: BUILD_SCHEMA });
|
||||||
|
const builds = filter.filteredData;
|
||||||
|
|
||||||
const subscribedBuildIds = useMemo(
|
const subscribedBuildIds = useMemo(
|
||||||
() => (isDemoMode ? [] : builds.map((build) => build.id)),
|
() => (isDemoMode ? [] : rawBuilds.map((build) => build.id)),
|
||||||
[isDemoMode, builds],
|
[isDemoMode, rawBuilds],
|
||||||
);
|
);
|
||||||
const liveStatus = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
|
const liveStatus = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['builds-page'] });
|
queryClient.invalidateQueries({ queryKey: ['builds-page'] });
|
||||||
@@ -217,46 +236,13 @@ export function BuildsPage() {
|
|||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="w-full px-8">
|
<div className="w-full px-8">
|
||||||
<div className="panel p-4">
|
<div className="panel p-4">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2">
|
||||||
<Filter size={16} className="text-[var(--text-tertiary)]" />
|
<Filter size={16} className="text-[var(--text-tertiary)]" />
|
||||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
|
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="flex flex-wrap items-end gap-3 mt-3">
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
<FilterBar {...filter} />
|
||||||
Status
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => setStatusFilter(e.target.value as '' | BuildStatus)}
|
|
||||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
{statusOptions.map((option) => (
|
|
||||||
<option key={option.label} value={option.value}>{option.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
|
||||||
Project ID
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={projectFilter}
|
|
||||||
onChange={(e) => setProjectFilter(e.target.value)}
|
|
||||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
|
||||||
placeholder="project-123"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
|
||||||
Service ID
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={serviceFilter}
|
|
||||||
onChange={(e) => setServiceFilter(e.target.value)}
|
|
||||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
|
||||||
placeholder="service-abc"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||||
|
|||||||
@@ -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,
|
getCurrentUserProfile,
|
||||||
listAuditLogs,
|
listAuditLogs,
|
||||||
listAgents,
|
listAgents,
|
||||||
|
pruneAgent,
|
||||||
|
updateAgent,
|
||||||
listBuilds,
|
listBuilds,
|
||||||
listGitProviders,
|
listGitProviders,
|
||||||
listProjects,
|
listProjects,
|
||||||
@@ -73,6 +75,11 @@ import {
|
|||||||
type CreateDatabaseInput,
|
type CreateDatabaseInput,
|
||||||
type DatabaseEntity,
|
type DatabaseEntity,
|
||||||
type FailoverPolicy,
|
type FailoverPolicy,
|
||||||
|
listNotificationChannels,
|
||||||
|
createNotificationChannel,
|
||||||
|
deleteNotificationChannel,
|
||||||
|
testNotificationChannel,
|
||||||
|
type NotificationChannel,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import {
|
import {
|
||||||
demoBuilds,
|
demoBuilds,
|
||||||
@@ -137,6 +144,8 @@ import {
|
|||||||
Cloud,
|
Cloud,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Upload,
|
Upload,
|
||||||
|
Bell,
|
||||||
|
Send,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
||||||
@@ -293,6 +302,14 @@ export function UsagePage() {
|
|||||||
mutationFn: (id: string) => revokeAgentToken(id),
|
mutationFn: (id: string) => revokeAgentToken(id),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }),
|
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(
|
const builds = useMemo(
|
||||||
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
|
() => (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 totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
|
||||||
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
|
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
|
||||||
const agentEndpoint = getAgentPublicBaseUrl();
|
const agentEndpoint = getAgentPublicBaseUrl();
|
||||||
const connectCommand = `CONTAINR_API_URL=${getApiBaseUrl().replace(/\/api\/v1$/, '')} \\
|
const apiBase = getApiBaseUrl().replace(/\/api\/v1$/, '');
|
||||||
CONTAINR_AGENT_AUTH_TOKEN=${issuedToken?.token ?? '<token>'} \\
|
const installCommand = `curl -fsSL ${apiBase}/api/agents/install.sh | bash -s -- --url ${apiBase} --token ${issuedToken?.token ?? '<token>'}`;
|
||||||
containr-agent`;
|
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 buildActivityBody = 'Track deployment frequency and failed rollouts over time.';
|
||||||
let runtimeBody = 'Build duration telemetry will appear after completed builds are available.';
|
let runtimeBody = 'Build duration telemetry will appear after completed builds are available.';
|
||||||
@@ -603,14 +620,37 @@ containr-agent`;
|
|||||||
agents.slice(0, 5).map((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 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">
|
<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>
|
<p className="truncate text-xs text-[var(--text-tertiary)]">{agent.hostname} · {agent.ipAddress}:{agent.port}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<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">
|
<div className="text-right">
|
||||||
<p className="text-xs font-semibold text-[var(--text-primary)]">{agent.status}</p>
|
<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>
|
<p className="text-xs text-[var(--text-tertiary)]">{formatBytes(agent.resources.memory.available)} free</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="px-4 py-6 text-sm text-[var(--text-tertiary)]">
|
<div className="px-4 py-6 text-sm text-[var(--text-tertiary)]">
|
||||||
@@ -666,7 +706,8 @@ containr-agent`;
|
|||||||
</p>
|
</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)]">
|
<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>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>
|
<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),
|
mutationFn: (appCode: string) => convertGitHubAppCode(appCode),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['github-app-status'] });
|
queryClient.invalidateQueries({ queryKey: ['github-app-status'] });
|
||||||
navigate('/settings', { replace: true });
|
// The setup wizard stashes a return marker before submitting the
|
||||||
|
// manifest form — resume there instead of the settings page.
|
||||||
|
const inSetup = sessionStorage.getItem('containr.setup_return') === '1';
|
||||||
|
sessionStorage.removeItem('containr.setup_return');
|
||||||
|
navigate(inSetup ? '/setup' : '/settings', { replace: true });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const firedRef = useRef(false);
|
const firedRef = useRef(false);
|
||||||
@@ -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() {
|
export function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -2047,6 +2237,7 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
{/* Personal Access Tokens */}
|
{/* Personal Access Tokens */}
|
||||||
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
||||||
|
<NotificationChannelsSection />
|
||||||
<RegistriesSection />
|
<RegistriesSection />
|
||||||
<BackupTargetsSection />
|
<BackupTargetsSection />
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
cloneService,
|
cloneService,
|
||||||
moveService,
|
moveService,
|
||||||
listProjects,
|
listProjects,
|
||||||
|
listNodeOptions,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
||||||
import { useDemoMode } from '@/lib/demo-mode';
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
@@ -466,6 +467,7 @@ export function ServiceDetailPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setNetworkForm(null);
|
setNetworkForm(null);
|
||||||
setSleepDraft(null);
|
setSleepDraft(null);
|
||||||
|
setNodeDraft(null);
|
||||||
invalidateService();
|
invalidateService();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -483,8 +485,15 @@ export function ServiceDetailPage() {
|
|||||||
const [volumesForm, setVolumesForm] = useState<ServiceVolume[] | null>(null);
|
const [volumesForm, setVolumesForm] = useState<ServiceVolume[] | null>(null);
|
||||||
const [domainInput, setDomainInput] = useState('');
|
const [domainInput, setDomainInput] = useState('');
|
||||||
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
|
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
|
||||||
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
|
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string; traefikLabels: string } | null>(null);
|
||||||
const [sleepDraft, setSleepDraft] = useState<{ id: string; enabled: boolean; minutes: string } | null>(null);
|
const [sleepDraft, setSleepDraft] = useState<{ id: string; enabled: boolean; minutes: string } | null>(null);
|
||||||
|
const [nodeDraft, setNodeDraft] = useState<string | null>(null);
|
||||||
|
const nodesQuery = useQuery({
|
||||||
|
queryKey: ['node-options'],
|
||||||
|
queryFn: listNodeOptions,
|
||||||
|
enabled: !isDemoMode,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
const [buildForm, setBuildForm] = useState<{
|
const [buildForm, setBuildForm] = useState<{
|
||||||
builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string;
|
builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
@@ -2384,6 +2393,95 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</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 && (
|
{!isDemoMode && (
|
||||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -2392,7 +2490,13 @@ export function ServiceDetailPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setAccessForm({ maintenance: Boolean(service.maintenanceMode), basicAuth: '' })
|
setAccessForm({
|
||||||
|
maintenance: Boolean(service.maintenanceMode),
|
||||||
|
basicAuth: '',
|
||||||
|
traefikLabels: Object.entries(service.traefikLabels ?? {})
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join('\n'),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||||
>
|
>
|
||||||
@@ -2413,6 +2517,14 @@ export function ServiceDetailPage() {
|
|||||||
{(service.basicAuth ?? []).length > 0 ? (service.basicAuth ?? []).join(', ') : 'off'}
|
{(service.basicAuth ?? []).length > 0 ? (service.basicAuth ?? []).join(', ') : 'off'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||||
|
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Routing overrides</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{Object.keys(service.traefikLabels ?? {}).length > 0
|
||||||
|
? `${Object.keys(service.traefikLabels ?? {}).length} label(s)`
|
||||||
|
: 'off'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
|
||||||
@@ -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)]"
|
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Routing overrides — Traefik middleware labels, key=value per line; empty clears</label>
|
||||||
|
<textarea
|
||||||
|
value={accessForm.traefikLabels}
|
||||||
|
onChange={(e) => setAccessForm({ ...accessForm, traefikLabels: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
placeholder={"middlewares.rl.ratelimit.average=100\nmiddlewares.sec.headers.customresponseheaders.X-Frame-Options=DENY"}
|
||||||
|
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2448,8 +2570,17 @@ export function ServiceDetailPage() {
|
|||||||
return i > 0 ? { username: l.slice(0, i), password: l.slice(i + 1) } : null;
|
return i > 0 ? { username: l.slice(0, i), password: l.slice(i + 1) } : null;
|
||||||
})
|
})
|
||||||
.filter((x): x is { username: string; password: string } => x !== null);
|
.filter((x): x is { username: string; password: string } => x !== null);
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
accessForm.traefikLabels
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((l) => {
|
||||||
|
const i = l.indexOf('=');
|
||||||
|
if (i > 0) labels[l.slice(0, i).trim()] = l.slice(i + 1).trim();
|
||||||
|
});
|
||||||
updateServiceMutation.mutate(
|
updateServiceMutation.mutate(
|
||||||
{ maintenance_mode: accessForm.maintenance, basic_auth: creds },
|
{ maintenance_mode: accessForm.maintenance, basic_auth: creds, traefik_labels: labels },
|
||||||
{ onSuccess: () => setAccessForm(null) },
|
{ onSuccess: () => setAccessForm(null) },
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { SetupWizardPage } from './SetupWizardPage';
|
||||||
|
import type { SetupStatus } from '@/lib/api-client';
|
||||||
|
|
||||||
|
const mockStatus = vi.fn<() => Promise<SetupStatus>>();
|
||||||
|
const mockSession = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-client', async (importOriginal) => {
|
||||||
|
const mod = await importOriginal<typeof import('@/lib/api-client')>();
|
||||||
|
return {
|
||||||
|
...mod,
|
||||||
|
getSetupStatus: () => mockStatus(),
|
||||||
|
completeSetup: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/lib/use-auth-session', () => ({
|
||||||
|
useAuthSession: () => mockSession(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderWizard() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={['/setup']}>
|
||||||
|
<SetupWizardPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base: SetupStatus = {
|
||||||
|
has_users: false,
|
||||||
|
setup_completed: false,
|
||||||
|
needs_setup: true,
|
||||||
|
has_github_app: false,
|
||||||
|
has_tunnel: false,
|
||||||
|
signup_enabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SetupWizardPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSession.mockReturnValue({ data: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prompts for the admin account on a fresh install', async () => {
|
||||||
|
mockStatus.mockResolvedValue({ ...base });
|
||||||
|
renderWizard();
|
||||||
|
expect(await screen.findByText(/welcome to containr/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/create admin account/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers sign-in when users exist but nobody is signed in', async () => {
|
||||||
|
mockStatus.mockResolvedValue({ ...base, has_users: true });
|
||||||
|
renderWizard();
|
||||||
|
expect(await screen.findByText(/sign in to continue setup/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the git provider step for a signed-in admin without GitHub App', async () => {
|
||||||
|
mockStatus.mockResolvedValue({ ...base, has_users: true });
|
||||||
|
mockSession.mockReturnValue({ data: { user: { id: 'u1' } } });
|
||||||
|
renderWizard();
|
||||||
|
expect(await screen.findByText(/connect a git provider/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/set up github app/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the tunnel step when git is already wired', async () => {
|
||||||
|
mockStatus.mockResolvedValue({ ...base, has_users: true, has_github_app: true });
|
||||||
|
mockSession.mockReturnValue({ data: { user: { id: 'u1' } } });
|
||||||
|
renderWizard();
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', { name: /public access/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lands on the finish step when everything is configured', async () => {
|
||||||
|
mockStatus.mockResolvedValue({
|
||||||
|
...base,
|
||||||
|
has_users: true,
|
||||||
|
has_github_app: true,
|
||||||
|
has_tunnel: true,
|
||||||
|
});
|
||||||
|
mockSession.mockReturnValue({ data: { user: { id: 'u1' } } });
|
||||||
|
renderWizard();
|
||||||
|
expect(await screen.findByText(/enter containr/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||||
|
import { Check, ChevronRight, Github, Loader2, Rocket, ShieldCheck } from 'lucide-react';
|
||||||
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
|
import {
|
||||||
|
completeSetup,
|
||||||
|
createGitHubAppManifest,
|
||||||
|
getSetupStatus,
|
||||||
|
updatePlatformSettings,
|
||||||
|
} from '@/lib/api-client';
|
||||||
|
|
||||||
|
// Marker the GitHub App callback reads so the wizard flow resumes here
|
||||||
|
// instead of the settings page.
|
||||||
|
const SETUP_RETURN_KEY = 'containr.setup_return';
|
||||||
|
|
||||||
|
type Step = 'account' | 'github' | 'tunnel' | 'done';
|
||||||
|
|
||||||
|
export function SetupWizardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const sessionQuery = useAuthSession();
|
||||||
|
const statusQuery = useQuery({ queryKey: ['setup-status'], queryFn: getSetupStatus });
|
||||||
|
const [override, setOverride] = useState<Step | null>(null);
|
||||||
|
|
||||||
|
const status = statusQuery.data;
|
||||||
|
const signedIn = Boolean(sessionQuery.data);
|
||||||
|
|
||||||
|
const step: Step = useMemo(() => {
|
||||||
|
if (override) return override;
|
||||||
|
if (!status) return 'account';
|
||||||
|
if (!status.has_users || !signedIn) return 'account';
|
||||||
|
if (!status.has_github_app) return 'github';
|
||||||
|
if (!status.has_tunnel) return 'tunnel';
|
||||||
|
return 'done';
|
||||||
|
}, [override, status, signedIn]);
|
||||||
|
|
||||||
|
const completeMutation = useMutation({
|
||||||
|
mutationFn: completeSetup,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['setup-status'] });
|
||||||
|
navigate('/projects', { replace: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (statusQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[var(--bg-primary)]">
|
||||||
|
<Loader2 size={22} className="animate-spin text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wizard is optional once the instance is already configured.
|
||||||
|
if (status && !status.needs_setup && !override) {
|
||||||
|
return <Navigate to="/projects" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps: { key: Step; label: string }[] = [
|
||||||
|
{ key: 'account', label: 'Admin account' },
|
||||||
|
{ key: 'github', label: 'Git provider' },
|
||||||
|
{ key: 'tunnel', label: 'Public access' },
|
||||||
|
{ key: 'done', label: 'Done' },
|
||||||
|
];
|
||||||
|
const activeIdx = steps.findIndex((s) => s.key === step);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[var(--bg-primary)] flex flex-col items-center px-5 py-16">
|
||||||
|
<div className="w-full max-w-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-8">
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<div key={s.key} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-semibold border ${
|
||||||
|
i < activeIdx
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-[var(--accent-on)]'
|
||||||
|
: i === activeIdx
|
||||||
|
? 'border-[var(--accent-primary)] text-[var(--accent-primary)]'
|
||||||
|
: 'border-[var(--border-subtle)] text-[var(--text-muted)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i < activeIdx ? <Check size={12} /> : i + 1}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs ${i === activeIdx ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]'}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
{i < steps.length - 1 ? <div className="w-6 h-px bg-[var(--border-subtle)]" /> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 'account' ? (
|
||||||
|
<section className="panel p-6">
|
||||||
|
<h1 className="text-xl font-bold text-[var(--text-primary)]">Welcome to Containr</h1>
|
||||||
|
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
First boot detected. Create the admin account — registration closes to the public
|
||||||
|
right after, so this account owns the instance.
|
||||||
|
</p>
|
||||||
|
{status?.has_users && !signedIn ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
An account already exists — sign in to continue setup.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/auth/sign-in?redirect=/setup"
|
||||||
|
className="mt-4 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
Sign in <ChevronRight size={14} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : !status?.has_users ? (
|
||||||
|
<Link
|
||||||
|
to="/auth/sign-up?redirect=/setup"
|
||||||
|
className="mt-4 inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)]"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
Create admin account <ChevronRight size={14} />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 'github' ? <GitHubStep onNext={() => setOverride('tunnel')} /> : null}
|
||||||
|
{step === 'tunnel' ? <TunnelStep onNext={() => setOverride('done')} /> : null}
|
||||||
|
|
||||||
|
{step === 'done' ? (
|
||||||
|
<section className="panel p-6 text-center">
|
||||||
|
<Rocket size={28} className="mx-auto text-[var(--accent-primary)]" />
|
||||||
|
<h1 className="mt-4 text-xl font-bold text-[var(--text-primary)]">All set</h1>
|
||||||
|
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
Deploy your first service from a git repo, an image, or a template — the dashboard is
|
||||||
|
ready.
|
||||||
|
</p>
|
||||||
|
{completeMutation.isError ? (
|
||||||
|
<p className="mt-3 text-xs text-[var(--error)]">
|
||||||
|
{completeMutation.error instanceof Error
|
||||||
|
? completeMutation.error.message
|
||||||
|
: 'Failed to save setup state'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => completeMutation.mutate()}
|
||||||
|
disabled={completeMutation.isPending}
|
||||||
|
className="mt-5 inline-flex items-center gap-2 h-10 px-6 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{completeMutation.isPending ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ShieldCheck size={15} />
|
||||||
|
)}
|
||||||
|
Enter Containr
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GitHubStep({ onNext }: { onNext: () => void }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const provisionMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { url, manifest } = await createGitHubAppManifest({
|
||||||
|
base_url: window.location.origin,
|
||||||
|
});
|
||||||
|
sessionStorage.setItem(SETUP_RETURN_KEY, '1');
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = url;
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'manifest';
|
||||||
|
input.value = manifest;
|
||||||
|
form.appendChild(input);
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
},
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['setup-status'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Github size={20} className="text-[var(--text-primary)]" />
|
||||||
|
<h1 className="text-xl font-bold text-[var(--text-primary)]">Connect a git provider</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
Provision the instance GitHub App — repo push events then trigger deployments and preview
|
||||||
|
environments automatically. Self-hosted GitLab/Gitea can be added later in Settings.
|
||||||
|
</p>
|
||||||
|
{provisionMutation.isError ? (
|
||||||
|
<p className="mt-3 text-xs text-[var(--error)]">
|
||||||
|
{provisionMutation.error instanceof Error
|
||||||
|
? provisionMutation.error.message
|
||||||
|
: 'Could not start GitHub App provisioning'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-5 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => provisionMutation.mutate()}
|
||||||
|
disabled={provisionMutation.isPending}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{provisionMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||||
|
Set up GitHub App
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNext}
|
||||||
|
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TunnelStep({ onNext }: { onNext: () => void }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
updatePlatformSettings({ cloudflareTunnelToken: token.trim() || undefined }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['setup-status'] });
|
||||||
|
onNext();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel p-6">
|
||||||
|
<h1 className="text-xl font-bold text-[var(--text-primary)]">Public access (optional)</h1>
|
||||||
|
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
Paste a Cloudflare Tunnel token to expose this instance on the internet. Without one,
|
||||||
|
Containr stays reachable on this network — you can add it later in Settings.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
placeholder="eyJhIjo… tunnel token"
|
||||||
|
className="mt-4 w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-colors"
|
||||||
|
/>
|
||||||
|
{saveMutation.isError ? (
|
||||||
|
<p className="mt-3 text-xs text-[var(--error)]">
|
||||||
|
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save token'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-5 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => (token.trim() ? saveMutation.mutate() : onNext())}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||||
|
{token.trim() ? 'Save tunnel token' : 'Continue'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNext}
|
||||||
|
className="h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getCurrentUserProfile,
|
getCurrentUserProfile,
|
||||||
getTemplateById,
|
getTemplateById,
|
||||||
importComposeTemplate,
|
importComposeTemplate,
|
||||||
|
importGitComposeTemplate,
|
||||||
listProjects,
|
listProjects,
|
||||||
listTemplates,
|
listTemplates,
|
||||||
updateTemplate,
|
updateTemplate,
|
||||||
@@ -154,6 +155,10 @@ export function TemplatesPage() {
|
|||||||
const [composeYaml, setComposeYaml] = useState('');
|
const [composeYaml, setComposeYaml] = useState('');
|
||||||
const [composeWarnings, setComposeWarnings] = useState<string[]>([]);
|
const [composeWarnings, setComposeWarnings] = useState<string[]>([]);
|
||||||
const [composeError, setComposeError] = useState<string | null>(null);
|
const [composeError, setComposeError] = useState<string | null>(null);
|
||||||
|
const [composeMode, setComposeMode] = useState<'paste' | 'git'>('paste');
|
||||||
|
const [gitRepo, setGitRepo] = useState('');
|
||||||
|
const [gitPath, setGitPath] = useState('');
|
||||||
|
const [gitRef, setGitRef] = useState('');
|
||||||
|
|
||||||
const [lastDeployment, setLastDeployment] = useState<{
|
const [lastDeployment, setLastDeployment] = useState<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -364,7 +369,10 @@ export function TemplatesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const composeImportMutation = useMutation({
|
const composeImportMutation = useMutation({
|
||||||
mutationFn: (yaml: string) => importComposeTemplate(yaml),
|
mutationFn: () =>
|
||||||
|
composeMode === 'git'
|
||||||
|
? importGitComposeTemplate(gitRepo.trim(), gitPath.trim(), gitRef.trim())
|
||||||
|
: importComposeTemplate(composeYaml),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setComposeWarnings(result.warnings);
|
setComposeWarnings(result.warnings);
|
||||||
setComposeError(null);
|
setComposeError(null);
|
||||||
@@ -644,6 +652,11 @@ export function TemplatesPage() {
|
|||||||
Public
|
Public
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{template.source === 'registry' ? (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] text-[10px] font-semibold">
|
||||||
|
Registry
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span
|
<span
|
||||||
@@ -1102,7 +1115,7 @@ export function TemplatesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Import docker-compose</h2>
|
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Import docker-compose</h2>
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">
|
<p className="text-xs text-[var(--text-tertiary)]">
|
||||||
Paste a compose file — known database images become managed databases, everything else becomes services.
|
Paste a compose file or pull one from a git repo — known database images become managed databases.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -1113,6 +1126,23 @@ export function TemplatesPage() {
|
|||||||
<X size={15} />
|
<X size={15} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<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
|
<textarea
|
||||||
value={composeYaml}
|
value={composeYaml}
|
||||||
onChange={(e) => setComposeYaml(e.target.value)}
|
onChange={(e) => setComposeYaml(e.target.value)}
|
||||||
@@ -1120,6 +1150,43 @@ export function TemplatesPage() {
|
|||||||
placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'}
|
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"
|
className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-[320px] flex-1 flex-col gap-3 bg-[var(--bg-void)] p-4">
|
||||||
|
<label className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
Repository
|
||||||
|
<input
|
||||||
|
value={gitRepo}
|
||||||
|
onChange={(e) => setGitRepo(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="owner/repo or https://github.com/owner/repo.git"
|
||||||
|
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
Compose file path <span className="opacity-60">(optional — compose.yaml / docker-compose.yml searched)</span>
|
||||||
|
<input
|
||||||
|
value={gitPath}
|
||||||
|
onChange={(e) => setGitPath(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="deploy/compose.yml"
|
||||||
|
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
Branch or commit <span className="opacity-60">(optional)</span>
|
||||||
|
<input
|
||||||
|
value={gitRef}
|
||||||
|
onChange={(e) => setGitRef(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="main"
|
||||||
|
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="text-[11px] text-[var(--text-tertiary)]">
|
||||||
|
Private repos use your connected git providers for credentials.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{composeError ? (
|
{composeError ? (
|
||||||
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
|
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
|
||||||
{composeError}
|
{composeError}
|
||||||
@@ -1135,8 +1202,8 @@ export function TemplatesPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => composeImportMutation.mutate(composeYaml)}
|
onClick={() => composeImportMutation.mutate()}
|
||||||
disabled={!composeYaml.trim() || composeImportMutation.isPending}
|
disabled={(composeMode === 'paste' ? !composeYaml.trim() : !gitRepo.trim()) || composeImportMutation.isPending}
|
||||||
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />}
|
{composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />}
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import {
|
|||||||
createManagedDatabase,
|
createManagedDatabase,
|
||||||
createService,
|
createService,
|
||||||
getProjectById,
|
getProjectById,
|
||||||
|
createProjectEnvironment,
|
||||||
importComposeIntoProject,
|
importComposeIntoProject,
|
||||||
listConnectedGitRepositories,
|
listConnectedGitRepositories,
|
||||||
listGitBranches,
|
listGitBranches,
|
||||||
listGitProviders,
|
listGitProviders,
|
||||||
listGitRepositories,
|
listGitRepositories,
|
||||||
listProjectActivity,
|
listProjectActivity,
|
||||||
|
listProjectEnvironments,
|
||||||
listServiceLogs,
|
listServiceLogs,
|
||||||
listServiceVariables,
|
listServiceVariables,
|
||||||
listServicesByProject,
|
listServicesByProject,
|
||||||
@@ -589,6 +591,22 @@ export function ProjectWorkspacePage() {
|
|||||||
() => (isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? []),
|
() => (isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? []),
|
||||||
[isDemoMode, projectId, servicesQuery.data],
|
[isDemoMode, projectId, servicesQuery.data],
|
||||||
);
|
);
|
||||||
|
const environmentsQuery = useQuery({
|
||||||
|
queryKey: ['project-environments', projectId],
|
||||||
|
queryFn: () => listProjectEnvironments(projectId!),
|
||||||
|
enabled: !!projectId && !isDemoMode,
|
||||||
|
});
|
||||||
|
const [envFilter, setEnvFilter] = useState<string | null>(null);
|
||||||
|
const environments = environmentsQuery.data ?? [];
|
||||||
|
const visibleServices = useMemo(
|
||||||
|
() => (envFilter ? services.filter((s) => (s.environment ?? 'production') === envFilter) : services),
|
||||||
|
[services, envFilter],
|
||||||
|
);
|
||||||
|
const createEnvMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => createProjectEnvironment(projectId!, name),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['project-environments', projectId] }),
|
||||||
|
onError: (err) => toast.showToast(err instanceof ApiError ? err.message : 'Failed to create environment', 'error'),
|
||||||
|
});
|
||||||
const serviceIdFingerprint = services.map((service) => service.id).sort().join('|');
|
const serviceIdFingerprint = services.map((service) => service.id).sort().join('|');
|
||||||
|
|
||||||
const variablesQuery = useQuery({
|
const variablesQuery = useQuery({
|
||||||
@@ -850,18 +868,50 @@ export function ProjectWorkspacePage() {
|
|||||||
|
|
||||||
{/* Service List */}
|
{/* Service List */}
|
||||||
<div className="panel p-6">
|
<div className="panel p-6">
|
||||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Services</h3>
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Services</h3>
|
||||||
|
{!isDemoMode && environments.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setEnvFilter(null)}
|
||||||
|
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
envFilter === null
|
||||||
|
? 'bg-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{environments.map((env) => (
|
||||||
|
<button
|
||||||
|
key={env.id}
|
||||||
|
onClick={() => setEnvFilter(envFilter === env.name ? null : env.name)}
|
||||||
|
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
envFilter === env.name
|
||||||
|
? 'bg-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{env.name} ({env.service_count})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<EnvCreateButton onCreate={(name) => createEnvMutation.mutate(name)} pending={createEnvMutation.isPending} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden divide-y divide-[var(--border-subtle)]">
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden divide-y divide-[var(--border-subtle)]">
|
||||||
{services.length === 0 ? (
|
{visibleServices.length === 0 ? (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
|
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
|
||||||
<Layers size={20} className="text-[var(--text-tertiary)]" />
|
<Layers size={20} className="text-[var(--text-tertiary)]" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">No services deployed yet</p>
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
<p className="text-xs text-[var(--text-muted)] mt-1">Add services from the Canvas view</p>
|
{envFilter ? `No services in ${envFilter}` : 'No services deployed yet'}
|
||||||
|
</p>
|
||||||
|
{!envFilter && <p className="text-xs text-[var(--text-muted)] mt-1">Add services from the Canvas view</p>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
services.map((service) => (
|
visibleServices.map((service) => (
|
||||||
<button
|
<button
|
||||||
key={service.id}
|
key={service.id}
|
||||||
onClick={() => navigate(serviceHref(service.id))}
|
onClick={() => navigate(serviceHref(service.id))}
|
||||||
@@ -1124,3 +1174,39 @@ export function ProjectWorkspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnvCreateButton expands into an inline name input for a new environment.
|
||||||
|
function EnvCreateButton({ onCreate, pending }: { onCreate: (name: string) => void; pending: boolean }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="px-2.5 py-1 rounded-full text-xs font-medium border border-dashed border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
|
||||||
|
>
|
||||||
|
+ env
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && name.trim()) {
|
||||||
|
onCreate(name.trim());
|
||||||
|
setName('');
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') setOpen(false);
|
||||||
|
}}
|
||||||
|
placeholder="staging"
|
||||||
|
className="w-24 px-2 py-1 rounded-full text-xs bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-[var(--text-primary)] outline-none"
|
||||||
|
/>
|
||||||
|
{pending && <Loader2 size={12} className="animate-spin text-[var(--text-muted)]" />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user