Compare commits

..
Author SHA1 Message Date
Tomas DvorakandDevin 19e096f8ae refactor(db): port deployments to sqlc
Tranche 4: all 32 raw sites in deployments.go — paginated service list,
the accessible-feed joins (owner/member/admin/approved), service+owner
load, deployment insert, status transitions (fail/complete/set), the
10-field progress sync, cancel access check, rollback reads/inserts, and
the published_port reuse read. The shared serviceForDeployFromRow mapper
now feeds both the preview and deployments loaders. Live test covers the
full lifecycle: insert, list, access join, feed predicate, queued →
deployed sync, failed transition.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 03:52:36 +02:00
Tomáš DvořákandDevin 202fdd7b5a refactor(db): port preview environments to sqlc (#77)
CI / Changed paths (push) Canceled after 0s
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Mobile (Expo) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
Tranche 3: all 30 raw sites in preview_environments.go — list/detail
joins, access-check reads, create/update/delete, promote, user cleanup,
TTL sweep, and the status-sync UPDATE...FROM CASE. Deployments and
preview_environments schema.sql definitions completed to match runtime.
Shared mapper converts generated rows to the PreviewEnvironment DTO;
DeleteServiceByID from tranche 2 is reused for clone teardown.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 03:39:54 +02:00
Tomáš DvořákandDevin 687555fc78 refactor(db): port service clone/move ops to sqlc (#76)
Tranche 2: cloneServiceRow's INSERT...SELECT (volumes and traefik_labels
now read straight from the source row — traefik_labels was silently
dropped before, and the jsonb param round-trip goes away), domain and
variable copies, name-conflict counts, move UPDATE, project owner check.
Also fixes serviceWriteAccess: user_id is a context string, not a
uuid.UUID — the tranche-1 cast silently failed owner checks for
non-admins. Live tests cover the domain invariants and a full clone
round-trip (config, domains, variables).

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 03:25:07 +02:00
Tomáš DvořákandDevin 302c595380 refactor(db): port metrics storage + service domains to sqlc (#75)
First tranche of the ~300-site raw SQL port. Metrics: node/service/
instance rows via generated queries; aggregation moved off TimescaleDB's
time_bucket onto portable date_bin (PG14+); interval uptime returns epoch
seconds; per-container writes dropped (container_metrics FKs a different
model — collector shape never matched). Domains: all reads/writes through
sqlcdb including the create transaction via WithTx; default-domain and
services.domain invariants unchanged. Both paths covered by live
Postgres tests gated on METRICS_TEST_DSN.

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

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

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

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

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

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

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

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

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

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

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

* test(setup): wizard step-state coverage

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

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

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

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 02:17:41 +02:00
Tomáš DvořákandDevin 985d22798c fix(deploy): don't split digest-pinned image refs (#72)
splitImageReference checked for '@' AFTER the last colon — but digests
put '@' before it, so `nginx@sha256:abc` split into
name=nginx@sha256/tag=abc. The deploy sync loop wrote those mangled
pairs into the deployments table for any digest-pinned image.

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 01:55:34 +02:00
Tomáš DvořákandDevin 9243d020cc feat(notifications): ntfy/Gotify push channels with live delivery (#69)
Per-user notification_channels (ntfy + Gotify): every
insertUserNotification now fans out best-effort to enabled channels —
deployment/backup/security/agent alerts reach the user's phone via the
ntfy app with zero extra infrastructure.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

Authored By: TDvorak <[email protected]>
2026-10-09 20:30:33 +02:00
Tomáš Dvořák d57ac72aa5 Merge pull request #59 from Dvorinka/feat/remote-ingress
CI / Changed paths (push) Canceled after 0s
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Mobile (Expo) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
feat(nodes): remote ingress via Traefik file provider
2026-10-09 19:26:51 +02:00
Tomas DvorakandDevin 59dfd35583 feat(nodes): remote sleep/wake — idle detection + wake-page ingress
Agents report per-container net IO at heartbeat; the sleeper measures
idleness from those counters for remote services, removes replicas via
the agent queue, and repoints the domain route at the backend's new
/internal/wake-page endpoint (replacePath + priority-100 route in the
file provider). First hit serves a reloading page and retriggers the
wake reconcile, which recreates replicas and restores real upstreams.

Manual POST /services/:id/sleep also routes remote; local sleep/wake
flow is unchanged.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 18:43:33 +02:00
Tomas DvorakandDevin 4028639239 feat(nodes): agent-reported container liveness at heartbeat
The agent now lists every containr-managed container's docker state at
each heartbeat; the server reconciles container_instances for rows that
node owns, so exits, OOMs, and crash loops surface in runtime state
between reconciles. Tombstoned rows are never resurrected.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 18:29:48 +02:00
Tomas DvorakandDevin c3a35081d0 feat(nodes): remote ingress via Traefik file provider
Remote replicas publish an ephemeral host port; the agent reports the
assigned binding back in the create_container result and the backend
renders a per-service file-provider config into TRAEFIK_DYNAMIC_DIR
pointing Traefik at the node's mesh IP + port. Routes are rewritten on
every remote reconcile and removed on teardown or local placement.

Also: RetireService retires remote leftovers when a service returns to
local placement; remove_container is idempotent agent-side; tombstoned
replicas skip remove dispatch; the domains-stay-local ceiling is gone.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 18:19:08 +02:00
Tomáš DvořákandDevin 4458de56b0 feat(scheduling): node tags + service placement_tags affinity (#58)
Operator-set node labels (node_agents.tags) gate which nodes a service
may schedule onto: services.placement_tags requires every listed tag.
auto and spread filter eligible agents by tags; tags are re-evaluated
each reconcile so retagging a node re-spreads replicas. Pin + tags and
local + tags rejected; empty array clears the requirement.

Surfaces: --placement-tags on services create/update, nodes update
--tags, MCP args, OpenAPI, node-detail tag editor, service-detail
placement-tags input.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 18:11:19 +02:00
Tomáš DvořákandDevin 9de586f177 feat(nodes): agent self-upgrade (#57)
POST /agents/:id/upgrade enqueues a self_upgrade command carrying the
target platform, the sha256 the server computed over its bundled binary,
and the server version. The agent downloads the replacement from the
existing /api/agents/download/:platform endpoint, verifies the checksum,
sanity-checks the new binary (--version), atomically swaps its own
executable, reports the command result, then re-execs via syscall.Exec —
no supervisor or restart plumbing required.

Agents stamp main.version via ldflags at image build, expose --version,
and now send version at register so node_agents.version refreshes on
re-register (older agents without the field keep their stored value).
The server rejects upgrades for offline nodes and for platforms with no
bundled binary (linux-amd64/linux-arm64 ship in the image).

Surfaces: nodes upgrade CLI, containr_nodes_upgrade MCP (confirm-gated),
node-detail page button, OpenAPI. Verified live: v0.0.3 agent upgraded
to v0.0.4 in place, re-registered with the new version; offline node
returns 409 NODE_OFFLINE.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 17:15:58 +02:00
Tomáš DvořákandDevin dd5ce80ce2 feat(scheduling): replica spread + resource-aware auto placement (#56)
services.spread distributes replicas across every online, schedulable
agent (replica i → agents[i mod n], deterministic). auto placement now
orders by real headroom — memory utilisation, cpu usage, then container
count — instead of count alone.

Lifecycle (start/stop/restart/delete) and GET /runtime are now driven
by container_instances inventory, so remote replicas are visible and
tear down wherever they run — covers spread, pins, and stale pin
leftovers. Spread and explicit pins are mutually exclusive.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 17:03:26 +02:00
Tomáš DvořákandDevin bc93c83cf9 feat(scheduling): multi-node service placement + remote lifecycle (#54)
* feat(scheduling): multi-node service placement + remote container lifecycle

services.node_id pins a service to a node agent: explicit id, "auto"
(least-loaded online node), or "local"/empty for the local Docker host.

- DeploymentEngine gains a NodeRunner seam; ReconcileService routes
  pinned services to the agent's command queue (create_container with
  image/env/command/ports/volumes/labels/resource limits), tracked in
  container_instances with deterministic per-replica ids.
- Stop/start/restart/delete fan out to the pinned node — no more leaked
  remote containers on service delete.
- Agent create_container is idempotent (rm -f first) and honors restart
  policy, labels, memory/cpus, published+ephemeral ports, volumes, env.
- GET /nodes/options exposes id/name/status to authed users; full agent
  management stays admin-only.
- CLI services create/update --node, MCP node_id docs, OpenAPI
  node_id/node_name + /nodes/options, service-detail Placement section.

Ceilings: remote nodes run registry-pulled images only — git builds,
Traefik domains, and remote sleep detection stay local until
build-on-node and per-node ingress land.

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

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

* feat(scheduling): node cordon/drain + schedulable gate

- node_agents.schedulable column; "auto" placement and explicit pins skip
  cordoned nodes; already-pinned services keep running (reconcile is
  unaffected — cordon is not eviction).
- POST /agents/:id/cordon|uncordon|drain (admin). Drain cordons, removes
  every service container via the agent queue, and clears the services'
  pins so they redeploy on the local host. Requires the node online —
  an offline node can't acknowledge removals.
- CLI nodes cordon|uncordon|drain (drain confirm-gated), MCP
  containr_nodes_cordon|uncordon|drain, OpenAPI paths + schedulable
  field, node-detail page cordon badge + drain/cordon controls.
- Fix: re-registering an agent no longer resets auto_prune.

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

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

* feat(scheduling): remote registry pulls + post-run health check

- Dispatch payload carries the project owner's registry credentials for
  the image host; the agent docker-logins for the pull and logs out
  after, so private images deploy to remote nodes.
- Completed command payloads get their registry password scrubbed —
  secrets don't linger in agent_commands rows.
- Agent verifies the container is still running ~1.5s after docker run;
  crash-loops now fail the deployment with the container's last log
  lines instead of reporting "running" optimistically.

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

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

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 16:19:12 +02:00
Tomáš DvořákandDevin 401daf69aa fix(cli): gate SIGWINCH resize watch behind unix build tag (#55)
syscall.SIGWINCH does not exist on Windows, breaking the release
cross-compile for windows/amd64.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 16:09:36 +02:00
Tomáš DvořákandDevin ae7e6b4991 feat(cli): containr migrate dflow + fix railway service types (#53)
dflow importer pages the Payload REST collections (users API-Key
auth) and recreates projects/services: app→git web services, docker
→image, database→typed image with DATABASE_URL for external
providers; variables (empty encrypted values flagged), bind volumes,
and domains carried over; soft-deleted docs excluded.

Also fixes the Railway migrator posting type "image"/"git" — the
create API only accepts web|worker|database|cron and requires an
environment, so every service create 400'd.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 14:37:26 +02:00
Tomáš DvořákandDevin ee43064c5c feat(migrate): containr migrate railway — GraphQL import of services/vars/domains (#51)
--token (or RAILWAY_TOKEN) reads the Railway project via backboard
GraphQL: services resolve to git-repo or image services with command +
replicas, variables and service/custom domains follow. --dry-run prints
the plan; --into targets an existing project. Both account (Bearer) and
project tokens (Project-Access-Token header) work.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 12:48:36 +02:00
Tomáš DvořákandDevin e054457156 feat(templates): remote template registry via CONTAINR_TEMPLATE_REGISTRY_URL (#52)
- GET /templates merges a remote Containr catalog as source:"registry"
  (5s timeout, 60s cache, offline-safe)
- GET /templates/:id falls through to the registry
- Deploy of a registry template materializes a deterministic private
  copy for the deployer — repeat deploys reuse it
- Templates page badges registry entries; openapi + env documented

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 12:46:35 +02:00
Tomáš DvořákandDevin 3f066b3b1e feat(nodes): SSH bootstrap + housekeeping — install script, bundled binaries, auto-prune (#50)
* feat(nodes): SSH bootstrap + housekeeping — install script, bundled binaries, auto-prune

- `containr nodes add <user@host>` mints an enroll token, SSHes once,
  runs the install script remotely; --print emits the one-liner
- Public /api/agents/install.sh + /api/agents/download/{platform};
  agent binaries built into the image under /agents
- Agent-side `prune` (bounded docker system prune, volumes opt-in)
  and `system_df` commands
- node_agents.auto_prune + health sweep enqueues daily prune;
  offline/online transitions notify admins
- CLI nodes update/prune/commands + MCP tools (prune confirm-gated)
- Usage page: install one-liner, per-node Prune + auto toggle

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

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

* feat(nodes): node detail page — telemetry, containers, commands, controls

/nodes/:id (admin): cpu/mem sparklines from heartbeat history
(1h/24h/7d), containers on node, command history, inline rename,
auto-prune toggle, prune-now with volume opt-in, remove. Agent rows
on the Usage page link into it.

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

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

* feat(nodes): mesh awareness + node detail page

- agent reports tailscale/netbird/zerotier/wireguard interface IPs at
  register (authoritative — absent interfaces clear stored state)
- backend stores under node_agents.metadata.mesh, merged on re-register
- node detail page shows mesh addresses under the primary IP

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

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

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 12:41:26 +02:00
Tomáš DvořákandDevin 7547375cc2 feat(notifications): producers — backups, agent health, scans, upgrades (#49)
* feat(filters): dynamic filter framework — typed schema, URL state, FilterBar

Port of dflow's filter.utils pattern, adapted to Containr:

- lib/dynamic-filter.ts: FilterConfig schema (search/select/multiselect/
  boolean/date-range/status), FilterEngine with client-side apply,
  derived options with counts, active-filter tracking, removal.
- lib/use-dynamic-filter.ts: filter state lives in ?f.<key> search
  params — filtered views are shareable links. useFilterState exposes
  URL state before data loads (server-backed filters); useDynamicFilter
  adds the engine over fetched data.
- shared/components/FilterBar.tsx: renders a schema as compact controls
  + active-filter chips, matching design tokens.
- Applied: audit logs (server-backed — state in URL, params to API),
  builds (server-backed + client date-range), activity (severity to the
  server query, category/search on the fetched page).
- 8 unit tests for the engine.

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

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

* fix(filters): erasableSyntaxOnly compat + BuildStatus cast

- Constructor parameter properties are banned under erasableSyntaxOnly —
  declare fields explicitly.
- listBuilds status param needs the BuildStatus type, not raw string.

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

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

* feat(notifications): producers round-out — backups, agent health, scans, upgrades

Four new emitters behind insertUserNotification:

- Backups: createBackupProcess notifies the database owner on
  completion (with size) and on failure (docker-down, snapshot error).
- Agent health: StartAgentHealthSweep marks agents offline after 3 min
  without heartbeat and back online on recovery — admins notified on
  each transition.
- Security scans: Scanner.OnScanComplete callback notifies the project
  owner with critical/high/total counts + score; scans with findings
  get a distinct title.
- Upgrades: daily check against the latest GitHub release; admins
  notified once per new tag (deduped via app_settings). Version stamped
  into the image via Dockerfile ARG + ldflags on tag builds; "dev"
  disables the check.

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

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

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 12:12:21 +02:00
Tomáš DvořákandDevin d4a4cde1fd feat(filters): dynamic filter framework — schema-driven filters with URL state (#48)
* feat(filters): dynamic filter framework — typed schema, URL state, FilterBar

Port of dflow's filter.utils pattern, adapted to Containr:

- lib/dynamic-filter.ts: FilterConfig schema (search/select/multiselect/
  boolean/date-range/status), FilterEngine with client-side apply,
  derived options with counts, active-filter tracking, removal.
- lib/use-dynamic-filter.ts: filter state lives in ?f.<key> search
  params — filtered views are shareable links. useFilterState exposes
  URL state before data loads (server-backed filters); useDynamicFilter
  adds the engine over fetched data.
- shared/components/FilterBar.tsx: renders a schema as compact controls
  + active-filter chips, matching design tokens.
- Applied: audit logs (server-backed — state in URL, params to API),
  builds (server-backed + client date-range), activity (severity to the
  server query, category/search on the fetched page).
- 8 unit tests for the engine.

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

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

* fix(filters): erasableSyntaxOnly compat + BuildStatus cast

- Constructor parameter properties are banned under erasableSyntaxOnly —
  declare fields explicitly.
- listBuilds status param needs the BuildStatus type, not raw string.

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

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

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 12:12:17 +02:00
Tomáš DvořákandDevin 8cfe2ca826 fix(git): webhook receiver — fork-sync events + missing deployment version (#47)
CI / Changed paths (push) Canceled after 0s
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Mobile (Expo) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
Two fixes in the push receiver:

- Accept repo-sync events (`sync`, `repository_dispatch`, `workflow_run`)
  that carry no ref — fork-sync bots and Actions dispatch these instead
  of pushes. Branch falls back to the webhook filter then the service's
  own git_branch; repo-only match when no branch is known.
- The deployment INSERT omitted the NOT NULL `version` column, so every
  webhook-triggered deploy failed the insert silently and enqueued
  nothing. Push-triggered deploys have been dead since the receiver
  shipped. Now set `v<unix>` like the manual deploy path.

Verified live: repository_dispatch → branch=main, 1 deployment enqueued;
push → 1; star → ignored.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 11:13:37 +02:00
Tomáš DvořákandDevin 612520bbff feat: interactive terminal — WS-bridged docker exec PTY (#46)
GET /services/:id/terminal upgrades to a WebSocket attached to a
`docker exec` PTY in the service's running replica. JSON frames both
ways: stdin/resize out, stdout/exit/error in. Shell probe tries each
candidate (bash→sh) since `command -v` needs a shell to run; distroless
images get a clean "no shell" error instead of an OCI failure dump.

- docker.Client gains ExecAttach + ExecResize wrappers
- Frontend: xterm.js replaces the one-off console section — live PTY,
  fit-addon resizes forwarded over the socket
- CLI: `containr shell <id>` bridges the local TTY (raw mode, SIGWINCH
  resize forwarding); `containr exec` keeps the one-off command path
- Access mirrors service logs: project manage scope required

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 11:13:34 +02:00
Tomáš DvořákandDevin 5eac4a382b feat: activity feed — enriched audit events + global/project timelines (#45)
* feat(branding): white-label — public /branding, admin write, shell theming

app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.

useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.

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

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

* feat(invites): team invite links — admin create/revoke, public accept

Admins mint single-use invite links (sha256-hashed token at rest,
optional email binding, configurable TTL). Public endpoints let the
accept page validate the link and register — working even while
public signup is disabled.

- migration + sqlc queries/model for user_invites
- POST /admin/invites (returns one-time token+url), GET list, DELETE revoke
- GET /auth/invites/:token public validation, POST /auth/accept-invite
- CLI containr invites list|create|revoke; MCP invites tools
- /auth/accept-invite page (expired/used/invalid states, bound-email lock)
- Admin page Team invites section: create, copy link, revoke
- OpenAPI + generated types + CLI guide

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

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

* feat(admin): impersonate user — 15-min token, audit-logged

POST /admin/users/:id/impersonate mints a short-lived bearer token
(impersonated_by claim) for support/debugging. Admin accounts cannot
be impersonated; every mint is audit-logged with actor + target.

- CLI: containr admin impersonate <user-id> (works via CONTAINR_TOKEN)
- MCP: containr_admin_impersonate
- Admin UI: per-user Impersonate action → token modal with usage hint
- OpenAPI + generated types

Verified live: token resolves to target, admin target 403, audit row.

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

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

* feat(git): GitHub App manifest self-provisioning + app-level webhook

Admins create the instance GitHub App in-browser instead of hand-
setting GITHUB_APP_* env vars:

- POST /admin/git/github-app/manifest builds the manifest + URL;
  the frontend auto-submits it to github.com
- /git/github-app/callback page exchanges the code via
  POST /admin/git/github-app/convert; credentials persist in
  app_settings (precedence over env)
- GET /admin/git/github-app reports configured/source
- POST /api/git/github-app/webhook — single app-level push receiver,
  HMAC-verified, dispatches via extracted dispatchPushToServices
- install-url + installation-token paths resolve settings→env
- CLI: containr admin github-app status|manifest|convert; MCP tools
- Git providers UI shows "Set up GitHub App" for admins when
  unconfigured

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

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

* feat: activity feed — enriched audit events, global + per-project timelines

audit_logs gains severity/category/label columns derived from
resource+action at write time; legacy rows are classified on read and
backfilled at boot in batches.

- GET /activity: global feed scoped to the caller's visible projects
  (admins see all); severity/category/resource/project_id/since
  filters, page+limit
- GET /projects/:id/activity: timeline correlated to the project's
  services, deployments, databases and the project record; access
  mirrors project read rules
- Retention worker prunes rows older than AUDIT_RETENTION_DAYS
  (default 90, 0 disables), running once at boot then daily
- project.create/delete now audited so timelines aren't empty
- CLI `containr activity` with filter flags; MCP tools
  containr_activity_list + containr_project_activity
- Frontend: /activity page (severity chips, icon per category) under
  Operate nav; project workspace gains an Activity view tab
- OpenAPI + generated types + CLI docs

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

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

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 10:52:15 +02:00
Tomáš DvořákandDevin 00e69b3733 feat(git): GitHub App manifest self-provisioning + app-level webhook (#44)
* feat(branding): white-label — public /branding, admin write, shell theming

app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.

useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.

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

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

* feat(invites): team invite links — admin create/revoke, public accept

Admins mint single-use invite links (sha256-hashed token at rest,
optional email binding, configurable TTL). Public endpoints let the
accept page validate the link and register — working even while
public signup is disabled.

- migration + sqlc queries/model for user_invites
- POST /admin/invites (returns one-time token+url), GET list, DELETE revoke
- GET /auth/invites/:token public validation, POST /auth/accept-invite
- CLI containr invites list|create|revoke; MCP invites tools
- /auth/accept-invite page (expired/used/invalid states, bound-email lock)
- Admin page Team invites section: create, copy link, revoke
- OpenAPI + generated types + CLI guide

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

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

* feat(admin): impersonate user — 15-min token, audit-logged

POST /admin/users/:id/impersonate mints a short-lived bearer token
(impersonated_by claim) for support/debugging. Admin accounts cannot
be impersonated; every mint is audit-logged with actor + target.

- CLI: containr admin impersonate <user-id> (works via CONTAINR_TOKEN)
- MCP: containr_admin_impersonate
- Admin UI: per-user Impersonate action → token modal with usage hint
- OpenAPI + generated types

Verified live: token resolves to target, admin target 403, audit row.

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

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

* feat(git): GitHub App manifest self-provisioning + app-level webhook

Admins create the instance GitHub App in-browser instead of hand-
setting GITHUB_APP_* env vars:

- POST /admin/git/github-app/manifest builds the manifest + URL;
  the frontend auto-submits it to github.com
- /git/github-app/callback page exchanges the code via
  POST /admin/git/github-app/convert; credentials persist in
  app_settings (precedence over env)
- GET /admin/git/github-app reports configured/source
- POST /api/git/github-app/webhook — single app-level push receiver,
  HMAC-verified, dispatches via extracted dispatchPushToServices
- install-url + installation-token paths resolve settings→env
- CLI: containr admin github-app status|manifest|convert; MCP tools
- Git providers UI shows "Set up GitHub App" for admins when
  unconfigured

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

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

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 10:43:12 +02:00
Tomáš DvořákandDevin 50e39fe140 feat(invites): team invite links — admin create/revoke, public accept (#42)
* feat(branding): white-label — public /branding, admin write, shell theming

app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.

useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.

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

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

* feat(invites): team invite links — admin create/revoke, public accept

Admins mint single-use invite links (sha256-hashed token at rest,
optional email binding, configurable TTL). Public endpoints let the
accept page validate the link and register — working even while
public signup is disabled.

- migration + sqlc queries/model for user_invites
- POST /admin/invites (returns one-time token+url), GET list, DELETE revoke
- GET /auth/invites/:token public validation, POST /auth/accept-invite
- CLI containr invites list|create|revoke; MCP invites tools
- /auth/accept-invite page (expired/used/invalid states, bound-email lock)
- Admin page Team invites section: create, copy link, revoke
- OpenAPI + generated types + CLI guide

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

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

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 10:35:12 +02:00
166 changed files with 23567 additions and 1724 deletions

No files matched your search

+13
View File
@@ -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
@@ -159,3 +169,6 @@ DOCKER_GID=0
# Optional: trust reverse proxy CIDR (default local Docker bridge used by Traefik) # Optional: trust reverse proxy CIDR (default local Docker bridge used by Traefik)
TRUSTED_PROXY_CIDR=172.20.0.0/16 TRUSTED_PROXY_CIDR=172.20.0.0/16
# Optional: audit log retention in days (default 90; 0 disables pruning)
# AUDIT_RETENTION_DAYS=90
+18
View File
@@ -0,0 +1,18 @@
# CI-only compose overlay: pull and build through mirror.gcr.io so shared
# runner egress IPs never touch docker.io (anonymous pulls get 429'd).
# Production refs in infra/docker-compose.yml stay unchanged.
services:
postgres:
image: mirror.gcr.io/library/postgres:15-alpine
traefik:
image: mirror.gcr.io/library/traefik:v3.2
backend:
build:
args:
GO_IMAGE: mirror.gcr.io/library/golang:1.26-alpine
NODE_IMAGE: mirror.gcr.io/library/node:22-alpine
frontend:
build:
args:
NODE_IMAGE: mirror.gcr.io/library/node:22-alpine
NGINX_IMAGE: mirror.gcr.io/library/nginx:alpine
+13 -1
View File
@@ -163,11 +163,13 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Boot stack - name: Boot stack
# .github/compose.ci.yml swaps every docker.io ref for
# mirror.gcr.io — anonymous Hub pulls 429 on shared CI IPs.
run: | run: |
BETTER_AUTH_SECRET=$(openssl rand -hex 32) BETTER_AUTH_SECRET=$(openssl rand -hex 32)
BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32) BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN
docker compose -f infra/docker-compose.yml up -d --build postgres dragonfly backend frontend traefik docker compose -f infra/docker-compose.yml -f .github/compose.ci.yml up -d --build postgres dragonfly backend frontend traefik
- name: Wait for backend health - name: Wait for backend health
run: | run: |
@@ -204,7 +206,13 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Docker Buildx - name: Setup Docker Buildx
# Mirror docker.io through gcr — shared CI IPs hit anonymous
# rate limits on base-image metadata resolution otherwise.
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
with:
config-inline: |
[registry."docker.io"]
mirrors = ["mirror.gcr.io"]
- name: Login to GHCR - name: Login to GHCR
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
@@ -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
+1 -1
View File
@@ -49,7 +49,7 @@ dedicated console.
- **Public project catalog** — approved projects are browsable anonymously; new projects stay private until an admin approves them - **Public project catalog** — approved projects are browsable anonymously; new projects stay private until an admin approves them
- **Admin console** (`/admin`) — platform stats, project approval/edit/delete, user admin management - **Admin console** (`/admin`) — platform stats, project approval/edit/delete, user admin management
- **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references - **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references
- **Deployments & builds** — Docker deploys with history, image rollback, live build status - **Deployments & builds** — Docker deploys with history, image rollback, live build status; git-sourced services build directly on remote node agents (no registry required)
- **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page - **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page
- **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App - **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App
- **Managed databases** — create PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Dragonfly, or ClickHouse directly from `/databases`, with backup/restore - **Managed databases** — create PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Dragonfly, or ClickHouse directly from `/databases`, with backup/restore
+247 -90
View File
@@ -89,12 +89,12 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
| Outbound webhooks (collection events, HMAC, custom headers) | ✅ `outbound_webhooks` + `webhook_deliveries`, HMAC `X-Containr-Signature`, wildcard events, 3-retry, UI page | — | | Outbound webhooks (collection events, HMAC, custom headers) | ✅ `outbound_webhooks` + `webhook_deliveries`, HMAC `X-Containr-Signature`, wildcard events, 3-retry, UI page | — |
| Banners (global + per-tenant, dismissible) | ❌ | `banners` table + top bar | | Banners (global + per-tenant, dismissible) | ❌ | `banners` table + top bar |
| White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` — **Containr remains the default brand** | | White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` — **Containr remains the default brand** |
| Activity feed (icon/severity/category/metadata, 90-day TTL job) | 🟡 `audit_logs` coarser | Enrich + project feed UI | | 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,147 @@ goroutines; this fixes correctness *and* unlocks the ops surface.
{target_node_id}`: dump → stream transfer → provision → restore → {target_node_id}`: dump → stream transfer → provision → restore →
re-point bound services. Deferred until Phase F lands real second re-point bound services. Deferred until Phase F lands real second
nodes — nothing to migrate between today. nodes — nothing to migrate between today.
- [ ] **Backup import** — upload archive → new restore point (pairs with - [x] **Backup import** — `POST /databases/:id/backups/import` accepts a
`.tar.gz` (multipart `file` or raw body, 2 GiB cap, gzip-magic
validated), stores it in the backups volume via a holder container,
registers a completed restore point, and ships offsite when a backup
target is configured. `api/backuptargets.go`. (pairs with
migrators). migrators).
## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates) ## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates)
- [ ] **Template = ordered service graph** — `services[]` entries with - [x] **Template = ordered service graph** — `services[]` entries with
`{name, type, source, build, ports, volumes, variables, depends_on}`; `{name, type, source, build, ports, volumes, variables, depends_on}`;
deploy order from dependency refs. deploy order from dependency refs. `templategraph.go`,
- [ ] **Expressions** — `${{service.KEY}}` (already the runtime ref `topoSortServices`.
syntax — reuse), `{{secret(len,"charset")}}`, `{{random:int}}`, - [x] **Expressions** — `{{service.KEY.prop}}` (host/port/user/password/
`{{VAR:-default}}`; `POST /templates/:id/plan` returns the resolved database/url), `{{secret}}`/`{{secret(N)}}`, `{{random:N}}`,
graph without deploying (agents love this). `{{VAR:-default}}` (incl. explicitly-empty defaults); `POST
- [ ] **Compose import** — `POST /templates/import/compose` (compose-go /templates/:id/plan` returns the resolved graph without deploying.
parser → graph), `POST /templates/import/github` (compose file from - [x] **Compose import** — `POST /templates/import/compose` (yaml →
repo path). Implements `docs/superpowers/specs/2026-04-14-*`. graph, known DB images become managed databases), `POST
- [ ] **Graph deploy pipeline** — creates project + services in /templates/import/git` (compose file fetched from any git repo — clone
dependency order, resolves vars, triggers builds; defined URL, ssh remote, or owner/repo via connected providers, `ref` selects
partial-failure policy. branch/commit); `templates import-compose|import-git` CLI, MCP tools,
- [ ] **Visual template builder** — canvas mode: drop git/image/db paste-or-git toggle in the template UI.
nodes, wire refs, set order, save/publish. Reuses existing canvas - [x] **Graph deploy pipeline** — creates services in dependency order,
primitives (dflow's `templates/compose` is exactly this). resolves vars/secrets, triggers builds. `deployTemplateGraph`,
- [ ] **Catalog growth** — convert `templates/*.md` (20 app guides) into `POST /templates/deploy` (inline), `POST /projects/:id/import-compose`.
real graph templates; screenshots + badges per the old spec. - [x] **Visual template builder** — canvas mode in the template editor:
web/worker/cron/database nodes, handles wire `depends_on`, inspector
edits image/repo/engine/port/env/volumes, live validation, applies
back to the version-2 graph JSON. Positions persist under
`config.canvas`; flat legacy configs open as a single node.
`builder-model.ts` + `TemplateBuilder.tsx`.
- [x] **Catalog growth** — seeded graph stacks for n8n, Gitea,
Nextcloud, Vaultwarden, Umami, Immich (Plausible already shipped).
`TestSeedTemplatesResolveCleanly` keeps every seed parseable,
topo-sortable, and fully resolved. Remaining single-service guides can
convert incrementally via the same endpoint.
**Gate**: paste n8n compose → 3-service project with generated secrets **Gate**: paste n8n compose → 3-service project with generated secrets
and wired refs, one click or one CLI call. and wired refs, one click or one CLI call.
## 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
@@ -282,8 +374,14 @@ and wired refs, one click or one CLI call.
headers, enabled)` + `webhook_deliveries` log; HMAC headers, enabled)` + `webhook_deliveries` log; HMAC
`X-Containr-Signature`; wildcard events on every audited action; `X-Containr-Signature`; wildcard events on every audited action;
CLI + MCP + `/settings/webhooks` page with delivery log + test ping. CLI + MCP + `/settings/webhooks` page with delivery log + test ping.
- [ ] **Activity feed** — enrich `audit_logs` with `severity category - [x] **Activity feed** — `audit_logs` enriched with `severity`
icon label metadata`; per-project timeline + global feed; TTL job. (`info|success|warning|error`), `category`, human `label`; derived at
write time, classified on read for legacy rows, boot-time backfill.
`GET /activity` (global, visibility-scoped, filters + `since`) and
`GET /projects/:id/activity` (owner/member/approved/admin). Frontend:
`/activity` page with severity chips + project workspace `Activity`
view. `containr activity` + `containr_activity_list`/`containr_project_activity`
MCP tools. Retention worker prunes > `AUDIT_RETENTION_DAYS` (default 90).
- [x] **Banners** — `banners` table with title/body/level/active/ - [x] **Banners** — `banners` table with title/body/level/active/
dismissible/schedule window. `GET /banners/active` for signed-in dismissible/schedule window. `GET /banners/active` for signed-in
users; admin CRUD at `/admin/banners`. `BannerBar` renders above the users; admin CRUD at `/admin/banners`. `BannerBar` renders above the
@@ -301,22 +399,51 @@ and wired refs, one click or one CLI call.
UI at `/operations` (Operate nav) with live refetch; `containr UI at `/operations` (Operate nav) with live refetch; `containr
operations` CLI + `containr_operations_get` MCP. Remaining: agent operations` CLI + `containr_operations_get` MCP. Remaining: agent
commands once node fleet lands; flush/stuck-job tools. commands once node fleet lands; flush/stuck-job tools.
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`); - [x] **Interactive terminal** — `GET /services/:id/terminal` upgrades
`nodes/:id/terminal` via agent (dflow's wetty without the extra to a WebSocket bridged to a `docker exec` PTY (bash→sh fallback,
container). `TERM=xterm-256color`, resize frames). xterm.js Console section on the
- [ ] **Team invites** — `POST /invites` → signed link → join flow; service page; `containr shell <id>` bridges a local TTY; distroless
replaces manual-only user creation. images get a clean "no shell" error. Remaining: `nodes/:id/terminal`
- [ ] **Impersonate user** — admin support tool, audit-logged. via agent.
- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest` - [x] **Team invites** — `POST /admin/invites` returns a one-time
returns manifest + redirect; instance self-provisions its App link (sha256-hashed token at rest, optional email binding, TTL);
(dflow pattern — removes the env-setup barrier). public `GET /auth/invites/:token` validates and
- [ ] **fork-sync trigger** — accept repo-sync events on webhook `POST /auth/accept-invite` registers — works while public signup is
receiver. closed. `/auth/accept-invite` page, admin manage/revoke UI,
- [ ] **Dynamic filter framework** — port dflow's `filter.utils` `containr invites` CLI + MCP tools.
pattern (typed filter configs → URL state → client filtering) for - [x] **Impersonate user** — `POST /admin/users/:id/impersonate`
audit logs, deployments, notifications tables. mints a 15-minute bearer token (`impersonated_by` claim),
- [ ] **Notification producers round-out** — backups, agent offline, audit-logged; admin accounts are not impersonatable. CLI
upgrade available, security findings. `admin impersonate`, MCP tool, admin UI token modal. Remaining:
in-browser session swap needs a session bridge.
- [x] **GitHub App manifest flow** — `POST /admin/git/github-app/manifest`
returns manifest + submission URL; the callback page exchanges
`code` via `POST /admin/git/github-app/convert` and credentials
persist in `app_settings` (overriding `GITHUB_APP_*` env). New
public `POST /api/git/github-app/webhook` receives App-level push
events (HMAC-verified) and enqueues matching services. Settings→env
fallback wired into install-URL + installation-token paths.
`containr admin github-app` CLI + MCP + OpenAPI.
- [x] **fork-sync trigger** — webhook receiver accepts `sync`,
`repository_dispatch`, and `workflow_run` events (no `ref`) alongside
pushes: branch falls back to the webhook filter, then the service's
own `git_branch`; repo-only matching when no branch is known.
Also fixed a latent bug where the webhook's deployment insert omitted
the NOT NULL `version` column — push-triggered deploys silently
failed for every service.
- [x] **Dynamic filter framework** — ported dflow's `filter.utils`
pattern as `lib/dynamic-filter.ts` + `use-dynamic-filter.ts`
(`FilterConfig` schema → `FilterEngine` → `FilterBar`; state lives in
`?f.<key>` search params so filtered views are shareable links).
`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
@@ -348,19 +475,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
@@ -373,28 +508,50 @@ and wired refs, one click or one CLI call.
a prior deployment, database start/stop/restart/backup-now. a prior deployment, database start/stop/restart/backup-now.
- **Observe** — polling log tail (300 lines, 5s refresh), per-service - **Observe** — polling log tail (300 lines, 5s refresh), per-service
deployment history, env-doctor issues surfaced on the service view. deployment history, env-doctor issues surfaced on the service view.
- [ ] **env var edit + apply-redeploy, domain management, replica - [x] **env var edit + domain management + replica scaling** (PR #61 —
scaling, cron execution history** — next iteration. mobile v1.5: variables editor, domains screen, scale + sleep
- [ ] **Push notifications** — zero-infra path = self-hosted settings, notifications tab, cron jobs screen with run-now and
ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional. execution history).
- [ ] **Offline resilience** — cached last-known state + unreachable - [x] **Push notifications** — per-user `notification_channels`
banner. (ntfy + Gotify): every `insertUserNotification` fans out best-effort
- [ ] **Status/offline resilience** — app + CLI degrade gracefully when to enabled channels; CRUD + live test-ping via web settings, CLI
the instance is unreachable (cached last-known state, clear banner). `notifications channels`, and MCP. FCM/APNs remain optional.
- [x] **Offline resilience** — AsyncStorage-persisted query cache
(7d, high-churn keys excluded) + banner when the instance is
unreachable (`/health` ping + expo-network).
- [x] **Status/offline resilience** — web persists the query cache to
localStorage (24h) and PlatformShell shows a "last-known data" banner
on offline/unreachable; CLI exits `ExitUnavailable` with a clean
"cannot reach" message (no stack traces).
## 10. Carried-over debt (ROADMAP.md items still open) ## 10. Carried-over debt (ROADMAP.md items still open)
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit → - [x] **Gateway traffic path** — `/g/:slug/*` mounted → api_key auth →
`gateway/proxy.go` → metrics. Management API exists; proxy unwired. rpm + monthly quota → `gateway/proxy.go` → usage_counters /
- [ ] **Preview environments runtime** — real build+deploy+subdomain+TTL metrics_timeseries / incident_events.
(bookkeeping only now). Pairs with clone + multi-domain. - [x] **Preview environments runtime** — previews are real service clones:
- [ ] **Environments** — env switcher, per-env vars/domains, promote. branch override, queued deployment, node-domain hostnames, TTL sweeper,
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`. promote = redeploy of the source at the preview branch.
- [x] **Environments** — CRUD (`GET/POST /projects/:id/environments`,
`DELETE /environments/:id`), switcher chips with service counts in the
workspace, CLI `environments` + MCP parity. Per-env vars/domains ride the
service's own scope (one env per service); promote ships via preview
promote.
- [ ] **sqlc port** — ~300 raw `database/sql` sites → `sqlc/queries/`. Done in tranches (dynamic queries stay raw): metrics storage + service domains ported.
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/ - [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
webhook table tests, Playwright e2e golden path. webhook table tests, Playwright e2e golden path.
- [ ] **First-run wizard** — admin → GitHub App (manifest flow from - [x] **First-run wizard** — public `GET /setup/status` + authed
Phase G makes this self-serve) → Tunnel → done. `POST /setup/complete`; `/setup` page steps admin account → GitHub App
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release. manifest flow (callback returns to the wizard via a session marker) →
optional Cloudflare tunnel token → done. Shell redirects signed-in
users to `/setup` while `needs_setup`; installs with existing projects
auto-complete so nobody gets nagged retroactively.
- [x] **Self-upgrade loop verification** — live-verified: admin
`POST /agents/:id/upgrade` → `self_upgrade` command → agent downloads
`/api/agents/download/linux-amd64` → sha256 verify → `--version`
sanity → atomic rename → result posts → `syscall.Exec` re-exec. Agent
ran `v0.0.1-old`, came back `v9.9.9-test`, command row `completed`.
- [ ] **Docs freshness, v1.0.0 release.**
## 11. Deliberately not imported from dflow ## 11. Deliberately not imported from dflow
+14 -5
View File
@@ -1,5 +1,7 @@
# Build Go API binary # Build Go API binary
FROM golang:1.26-alpine AS go-builder ARG GO_IMAGE=golang:1.26-alpine
ARG NODE_IMAGE=node:22-alpine
FROM ${GO_IMAGE} AS go-builder
RUN apk add --no-cache git ca-certificates tzdata RUN apk add --no-cache git ca-certificates tzdata
@@ -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
+476 -7
View File
@@ -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
}
}
+300
View File
@@ -0,0 +1,300 @@
package api
import (
"context"
"database/sql"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"containr/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Activity feed — enriched audit_logs rows (severity/category/label) served
// as a global feed and per-project timelines. Visibility mirrors the
// deployment/operations scoping: own actions, plus events on resources
// inside visible projects (approved / owned / member; admin sees all).
const activityVisibility = `(
a.user_id = $1::uuid OR $2::bool
OR (a.resource = 'project' AND a.resource_id IN (
SELECT id FROM projects p WHERE p.is_approved OR p.owner_id = $1::uuid
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1::uuid)))
OR (a.resource = 'service' AND a.resource_id IN (
SELECT s.id FROM services s JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1::uuid
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1::uuid)))
OR (a.resource = 'deployment' AND a.resource_id IN (
SELECT d.id FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1::uuid
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1::uuid)))
OR (a.resource = 'database' AND a.resource_id IN (
SELECT ds.id::uuid FROM database_services ds JOIN projects p ON p.id = ds.project_id
WHERE p.is_approved OR p.owner_id = $1::uuid
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1::uuid)))
)`
const activitySelect = `SELECT
a.id, COALESCE(a.user_id::text, ''), COALESCE(u.email, ''),
a.resource, COALESCE(a.resource_id::text, ''), a.action,
COALESCE(a.details::text, '{}'),
COALESCE(a.ip_address::text, ''), COALESCE(a.user_agent, ''),
COALESCE(a.severity, 'info'), COALESCE(a.category, ''), COALESCE(a.label, ''),
a.created_at
FROM audit_logs a
LEFT JOIN users u ON u.id = a.user_id`
func scanActivityRows(rows *sql.Rows) []AuditLog {
var logs []AuditLog
for rows.Next() {
var l AuditLog
if err := rows.Scan(&l.ID, &l.UserID, &l.UserEmail, &l.Resource, &l.ResourceID,
&l.Action, &l.Details, &l.IPAddress, &l.UserAgent,
&l.Severity, &l.Category, &l.Label, &l.CreatedAt); err != nil {
continue
}
// Rows written before enrichment carry empty metadata — classify on read.
if l.Label == "" || l.Category == "" {
sev, cat, label := classifyAuditEvent(l.Resource, l.Action)
if l.Severity == "" || l.Severity == "info" {
l.Severity = sev
}
if l.Category == "" {
l.Category = cat
}
if l.Label == "" {
l.Label = label
}
}
logs = append(logs, l)
}
return logs
}
// GET /activity — global feed. Filters: severity, category, resource,
// project_id, since, page, limit.
func handleGetActivity(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
isAdmin := contextIsAdmin(c)
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
limit := parsePositiveInt(c.DefaultQuery("limit", "50"), 50)
if limit > 200 {
limit = 200
}
offset := (page - 1) * limit
conditions := []string{activityVisibility}
args := []interface{}{userID, isAdmin}
nextArg := 3
if v := strings.TrimSpace(c.Query("severity")); v != "" {
conditions = append(conditions, fmt.Sprintf("a.severity = $%d", nextArg))
args = append(args, v)
nextArg++
}
if v := strings.TrimSpace(c.Query("category")); v != "" {
conditions = append(conditions, fmt.Sprintf("a.category = $%d", nextArg))
args = append(args, v)
nextArg++
}
if v := strings.TrimSpace(c.Query("resource")); v != "" {
conditions = append(conditions, fmt.Sprintf("a.resource = $%d", nextArg))
args = append(args, v)
nextArg++
}
if v := strings.TrimSpace(c.Query("project_id")); v != "" {
if _, err := uuid.Parse(v); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "invalid project_id")
return
}
conditions = append(conditions, fmt.Sprintf(`(
(a.resource = 'project' AND a.resource_id = $%d::uuid)
OR (a.resource = 'service' AND a.resource_id IN (SELECT id FROM services WHERE project_id = $%d::uuid))
OR (a.resource = 'deployment' AND a.resource_id IN (
SELECT d.id FROM deployments d JOIN services s ON s.id = d.service_id WHERE s.project_id = $%d::uuid))
OR (a.resource = 'database' AND a.resource_id IN (SELECT id::uuid FROM database_services WHERE project_id = $%d::uuid))
)`, nextArg, nextArg, nextArg, nextArg))
args = append(args, v)
nextArg++
}
if v := strings.TrimSpace(c.Query("since")); v != "" {
sinceTime, err := time.Parse(time.RFC3339, v)
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "invalid since timestamp, expected RFC3339")
return
}
conditions = append(conditions, fmt.Sprintf("a.created_at >= $%d", nextArg))
args = append(args, sinceTime)
nextArg++
}
query := fmt.Sprintf(`%s WHERE %s ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`,
activitySelect, strings.Join(conditions, " AND "), nextArg, nextArg+1)
args = append(args, limit, offset)
rows, err := db.Query(query, args...)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to fetch activity")
return
}
defer rows.Close()
logs := scanActivityRows(rows)
if logs == nil {
logs = []AuditLog{}
}
c.JSON(http.StatusOK, gin.H{
"activity": logs,
"page": page,
"limit": limit,
"has_more": len(logs) == limit,
})
}
// GET /projects/:id/activity — per-project timeline. Resource correlation
// mirrors the global feed, pinned to this project.
func handleGetProjectActivity(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
projectID := strings.TrimSpace(c.Param("id"))
if _, err := uuid.Parse(projectID); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid project id")
return
}
// Access check: approved / owner / member / admin.
var visible bool
if err := db.QueryRow(
`SELECT p.is_approved OR p.owner_id = $2::uuid OR $3::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2::uuid)
FROM projects p WHERE p.id = $1`,
projectID, userID, contextIsAdmin(c),
).Scan(&visible); err != nil || !visible {
respondError(c, http.StatusNotFound, "NOT_FOUND", "project not found")
return
}
limit := parsePositiveInt(c.DefaultQuery("limit", "50"), 50)
if limit > 200 {
limit = 200
}
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
rows, err := db.Query(fmt.Sprintf(`%s WHERE (
(a.resource = 'project' AND a.resource_id = $1::uuid)
OR (a.resource = 'service' AND a.resource_id IN (SELECT id FROM services WHERE project_id = $1::uuid))
OR (a.resource = 'deployment' AND a.resource_id IN (
SELECT d.id FROM deployments d JOIN services s ON s.id = d.service_id WHERE s.project_id = $1::uuid))
OR (a.resource = 'database' AND a.resource_id IN (SELECT id::uuid FROM database_services WHERE project_id = $1::uuid))
) ORDER BY a.created_at DESC LIMIT $2 OFFSET $3`,
activitySelect), projectID, limit, (page-1)*limit)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to fetch activity")
return
}
defer rows.Close()
logs := scanActivityRows(rows)
if logs == nil {
logs = []AuditLog{}
}
c.JSON(http.StatusOK, gin.H{"activity": logs})
}
// StartAuditRetention prunes audit rows past the retention window once at
// boot and then daily. AUDIT_RETENTION_DAYS overrides the 90-day default;
// 0 disables pruning entirely.
func StartAuditRetention(ctx context.Context, db *database.DB) {
days := 90
if raw := strings.TrimSpace(os.Getenv("AUDIT_RETENTION_DAYS")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
days = parsed
}
}
// Backfill enrichment for rows written before the classifier existed,
// batched so a large audit table doesn't hold a giant transaction.
go backfillAuditMetadata(db)
if days <= 0 {
return
}
prune := func() {
res, err := db.Exec(`DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day')`, days)
if err != nil {
log.Printf("audit retention prune failed: %v", err)
return
}
if n, _ := res.RowsAffected(); n > 0 {
log.Printf("audit retention: pruned %d rows older than %d days", n, days)
}
}
prune()
go func() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
prune()
}
}
}()
}
// backfillAuditMetadata classifies rows written before severity/category/label
// existed so filters and the feed see uniform metadata.
func backfillAuditMetadata(db *database.DB) {
cursor := uuid.Nil.String()
for {
rows, err := db.Query(
`SELECT id::text, resource, action FROM audit_logs
WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500`, cursor)
if err != nil {
log.Printf("audit backfill scan failed: %v", err)
return
}
type row struct{ id, resource, action string }
var batch []row
for rows.Next() {
var r row
if rows.Scan(&r.id, &r.resource, &r.action) == nil {
batch = append(batch, r)
}
}
rows.Close()
if len(batch) == 0 {
return
}
for _, r := range batch {
cursor = r.id
sev, cat, label := classifyAuditEvent(r.resource, r.action)
if _, err := db.Exec(
`UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid`,
sev, cat, label, r.id,
); err != nil {
log.Printf("audit backfill update failed: %v", err)
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
package api
import "testing"
func TestClassifyAuditEvent(t *testing.T) {
cases := []struct {
resource, action string
wantSev, wantCat, wantLab string
}{
{"service", "service.create", "success", "service", "Service created"},
{"service", "service.delete", "warning", "service", "Service deleted"},
{"deployment", "deploy", "success", "deployment", "Deployment deployed"},
{"project", "project.create", "success", "project", "Project created"},
{"user_invite", "user_invite.create", "success", "system", "User invite created"},
{"user_token", "user_token.revoke", "warning", "security", "User token revoked"},
{"service", "deploy.fail", "error", "service", "Service fail"},
{"user", "impersonate", "warning", "user", "User impersonated"},
{"webhook", "update", "info", "webhook", "Webhook updated"},
}
for _, tc := range cases {
sev, cat, lab := classifyAuditEvent(tc.resource, tc.action)
if sev != tc.wantSev {
t.Errorf("%s/%s: severity = %q, want %q", tc.resource, tc.action, sev, tc.wantSev)
}
if cat != tc.wantCat {
t.Errorf("%s/%s: category = %q, want %q", tc.resource, tc.action, cat, tc.wantCat)
}
if lab != tc.wantLab {
t.Errorf("%s/%s: label = %q, want %q", tc.resource, tc.action, lab, tc.wantLab)
}
}
}
+51
View File
@@ -246,6 +246,57 @@ func handleAdminSetUserAdmin(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "User updated"}) c.JSON(http.StatusOK, gin.H{"message": "User updated"})
} }
// POST /admin/users/:id/impersonate — mints a 15-minute bearer token acting
// as the target user. Admin-only; admin accounts cannot be impersonated
// (would blur audit attribution). Every use is audit-logged at mint time;
// token carries `impersonated_by` for downstream attribution.
func handleAdminImpersonateUser(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
targetID := strings.TrimSpace(c.Param("id"))
if _, err := uuid.Parse(targetID); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid user id")
return
}
var email, name string
var isAdmin bool
err := db.QueryRowContext(c.Request.Context(),
`SELECT email, COALESCE(name, ''), is_admin FROM users WHERE id = $1`, targetID).
Scan(&email, &name, &isAdmin)
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "user not found")
return
}
if isAdmin {
respondError(c, http.StatusForbidden, "FORBIDDEN", "admin accounts cannot be impersonated")
return
}
actorID := optionalUserID(c)
if actorID == targetID {
respondError(c, http.StatusBadRequest, "VALIDATION", "cannot impersonate yourself")
return
}
expiresAt := time.Now().UTC().Add(15 * time.Minute)
jwtSecret := c.MustGet("jwt_secret").(string)
token, err := generateImpersonationJWT(targetID, email, actorID, jwtSecret, expiresAt)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate token")
return
}
LogAuditWithRequest(c, "user", targetID, "impersonate", map[string]interface{}{
"actor_id": actorID,
"target_email": email,
"expires_at": expiresAt.Format(time.RFC3339),
})
c.JSON(http.StatusOK, gin.H{
"token": token,
"expires_at": expiresAt.Format(time.RFC3339),
"user": gin.H{"id": targetID, "email": email, "name": name},
})
}
func handleAdminSetProjectApproval(c *gin.Context) { func handleAdminSetProjectApproval(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB) queries := sqlcdb.New(db.DB)
+422 -29
View File
@@ -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"
@@ -26,16 +30,24 @@ import (
// NodeAgent represents a container orchestration agent // NodeAgent represents a container orchestration agent
type NodeAgent struct { type NodeAgent struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
IPAddress string `json:"ip_address"` IPAddress string `json:"ip_address"`
Port int `json:"port"` Port int `json:"port"`
Status string `json:"status"` Status string `json:"status"`
Version string `json:"version"` Version string `json:"version"`
Capabilities AgentCapabilities `json:"capabilities"` Capabilities AgentCapabilities `json:"capabilities"`
Resources NodeResources `json:"resources"` Resources NodeResources `json:"resources"`
LastHeartbeat time.Time `json:"last_heartbeat"` LastHeartbeat time.Time `json:"last_heartbeat"`
AutoPrune bool `json:"auto_prune"`
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,16 +248,45 @@ func unmarshalRaw(raw pqtype.NullRawMessage, dst interface{}) {
} }
} }
// ListNodeOptions returns the minimal id/name/status list used by the service
// placement picker — available to any authenticated user (no hostnames, IPs,
// or telemetry).
func (h *NodeAgentHandler) ListNodeOptions(c *gin.Context) {
rows, err := h.q.ListAgents(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list nodes"})
return
}
options := make([]gin.H, 0, len(rows))
for _, row := range rows {
var tags []string
if len(row.Tags) > 0 {
_ = json.Unmarshal(row.Tags, &tags)
}
options = append(options, gin.H{
"id": row.ID,
"name": row.Name,
"status": row.Status.String,
"tags": tags,
})
}
c.JSON(http.StatusOK, gin.H{"nodes": options})
}
func agentFromRow(row sqlcdb.NodeAgent) NodeAgent { func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
agent := NodeAgent{ agent := NodeAgent{
ID: row.ID, ID: row.ID,
Name: row.Name, Name: row.Name,
Hostname: row.Hostname, Hostname: row.Hostname,
IPAddress: row.IpAddress, IPAddress: row.IpAddress,
Port: int(row.Port), Port: int(row.Port),
Status: row.Status.String, Status: row.Status.String,
Version: row.Version.String, Version: row.Version.String,
Metadata: map[string]interface{}{}, AutoPrune: row.AutoPrune,
Schedulable: row.Schedulable,
Tags: []string{},
DefaultDomain: row.DefaultDomain,
Metadata: map[string]interface{}{},
} }
if row.LastHeartbeat.Valid { if row.LastHeartbeat.Valid {
agent.LastHeartbeat = row.LastHeartbeat.Time agent.LastHeartbeat = row.LastHeartbeat.Time
@@ -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,24 +398,43 @@ 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,
Hostname: existing.Hostname, Hostname: existing.Hostname,
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})
}
+136
View File
@@ -0,0 +1,136 @@
package api
import (
"crypto/rand"
"encoding/hex"
"io"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// containrWorkDir is the build workspace root — same dir the BuildManager
// was constructed with; checkouts and agent artifacts live beneath it.
const containrWorkDir = "/tmp/containr-builds"
// artifacts is a one-shot blob store for node agents: build contexts and
// docker-save tarballs the server produced on behalf of a deployment. Files
// live under workdir/artifacts/<id> and are removed when the deployment
// finishes or after maxArtifactAge.
var artifacts = &artifactStore{files: map[string]string{}}
type artifactStore struct {
mu sync.Mutex
dir string
files map[string]string
}
const maxArtifactAge = 2 // hours — a deployment can never outlive this
// initArtifacts points the store at dir (created on demand).
func initArtifacts(dir string) {
artifacts.mu.Lock()
defer artifacts.mu.Unlock()
artifacts.dir = dir
_ = os.MkdirAll(dir, 0o755)
}
// artifactDir returns the store root, defaulting under the build workdir.
func artifactDir() string {
artifacts.mu.Lock()
defer artifacts.mu.Unlock()
if artifacts.dir != "" {
return artifacts.dir
}
return filepath.Join(containrWorkDir, "artifacts")
}
// putArtifact streams rc into the store and returns the artifact id.
func putArtifact(rc io.Reader) (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
id := hex.EncodeToString(buf)
dir := artifactDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
path := filepath.Join(dir, id+".tar")
f, err := os.Create(path)
if err != nil {
return "", err
}
if _, err := io.Copy(f, rc); err != nil {
f.Close()
os.Remove(path)
return "", err
}
f.Close()
artifacts.mu.Lock()
artifacts.files[id] = path
artifacts.mu.Unlock()
return id, nil
}
// removeArtifact deletes the file and forgets the id. Safe on missing ids.
func removeArtifact(id string) {
artifacts.mu.Lock()
path, ok := artifacts.files[id]
delete(artifacts.files, id)
artifacts.mu.Unlock()
if ok {
_ = os.Remove(path)
return
}
// Registered before a restart — remove best-effort by name.
if id != "" {
_ = os.Remove(filepath.Join(artifactDir(), id+".tar"))
}
}
// ServeArtifact streams a stored blob to a token-authenticated agent. Files
// persist across downloads — spread builds share one context — and are
// deleted by the deployment that created them.
func (h *NodeAgentHandler) ServeArtifact(c *gin.Context) {
if !h.isValidAgentAuthToken(c.Request.Context(), agentAuthTokenFromRequest(c)) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
return
}
id := c.Param("id")
raw, err := hex.DecodeString(id)
if len(id) != 32 || err != nil || len(raw) != 16 {
c.JSON(http.StatusNotFound, gin.H{"error": "Artifact not found"})
return
}
path := filepath.Join(artifactDir(), id+".tar")
info, err := os.Stat(path)
if err != nil || info.IsDir() {
c.JSON(http.StatusNotFound, gin.H{"error": "Artifact not found"})
return
}
c.Header("Content-Type", "application/x-tar")
c.File(path)
}
// purgeStaleArtifacts drops files older than maxArtifactAge. Called on boot.
func purgeStaleArtifacts() {
entries, err := os.ReadDir(artifactDir())
if err != nil {
return
}
for _, e := range entries {
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().Before(time.Now().Add(-maxArtifactAge * time.Hour)) {
_ = os.Remove(filepath.Join(artifactDir(), e.Name()))
}
}
}
@@ -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"
+73 -7
View File
@@ -23,9 +23,67 @@ type AuditLog struct {
Details string `json:"details" db:"details"` Details string `json:"details" db:"details"`
IPAddress string `json:"ip_address" db:"ip_address"` IPAddress string `json:"ip_address" db:"ip_address"`
UserAgent string `json:"user_agent" db:"user_agent"` UserAgent string `json:"user_agent" db:"user_agent"`
Severity string `json:"severity" db:"severity"`
Category string `json:"category" db:"category"`
Label string `json:"label" db:"label"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
} }
// auditVerbs maps action verbs to past-tense words for feed labels.
var auditVerbs = map[string]string{
"create": "created", "delete": "deleted", "update": "updated",
"deploy": "deployed", "clone": "cloned", "move": "moved",
"backup": "backed up", "restore": "restored", "revoke": "revoked",
"impersonate": "impersonated", "provision": "provisioned",
"accept": "accepted", "login": "logged in", "scale": "scaled",
"sleep": "put to sleep", "wake": "woke", "approve": "approved",
"set_admin": "admin flag changed", "provisioned": "provisioned",
}
// classifyAuditEvent derives severity/category/label for the activity feed.
// Actions may already carry the resource prefix ("service.clone") — it is
// stripped before matching.
func classifyAuditEvent(resource, action string) (severity, category, label string) {
verb := action
if idx := strings.LastIndex(verb, "."); idx >= 0 {
verb = verb[idx+1:]
}
category = resource
switch resource {
case "user_invite", "invite", "github_app", "banner", "settings":
category = "system"
case "user_token", "agent_token", "registry_credential":
category = "security"
}
severity = "info"
switch {
case strings.Contains(verb, "fail"), strings.Contains(verb, "error"), strings.Contains(verb, "denied"):
severity = "error"
case strings.Contains(verb, "delete"), strings.Contains(verb, "revoke"),
strings.Contains(verb, "remove"), strings.Contains(verb, "disable"),
strings.Contains(verb, "impersonate"):
severity = "warning"
case strings.Contains(verb, "create"), strings.Contains(verb, "success"),
strings.Contains(verb, "complete"), strings.Contains(verb, "accept"),
strings.Contains(verb, "provision"), strings.Contains(verb, "clone"),
strings.Contains(verb, "deploy"), strings.Contains(verb, "approve"):
severity = "success"
}
past := verb
if v, ok := auditVerbs[verb]; ok {
past = v
}
resName := strings.ReplaceAll(resource, "_", " ")
if resName != "" {
resName = strings.ToUpper(resName[:1]) + resName[1:]
}
label = resName + " " + past
return severity, category, label
}
type AuditLogDetail struct { type AuditLogDetail struct {
OldValue interface{} `json:"old_value,omitempty"` OldValue interface{} `json:"old_value,omitempty"`
NewValue interface{} `json:"new_value,omitempty"` NewValue interface{} `json:"new_value,omitempty"`
@@ -42,12 +100,13 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in
detailsJSON, _ := json.Marshal(details) detailsJSON, _ := json.Marshal(details)
resourceUUID := parseUUIDOrNil(resourceID) resourceUUID := parseUUIDOrNil(resourceID)
userUUID := parseUUIDOrNil(userID) userUUID := parseUUIDOrNil(userID)
severity, category, label := classifyAuditEvent(resource, action)
auditID := uuid.New().String() auditID := uuid.New().String()
_, err := db.Exec( _, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, created_at) `INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), time.Now().UTC(), auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), severity, category, label, time.Now().UTC(),
) )
if err != nil { if err != nil {
@@ -59,6 +118,9 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in
func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) { func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) {
userID, _ := c.Get("user_id") userID, _ := c.Get("user_id")
if details == nil {
details = map[string]interface{}{}
}
details["ip_address"] = c.ClientIP() details["ip_address"] = c.ClientIP()
details["user_agent"] = c.GetHeader("User-Agent") details["user_agent"] = c.GetHeader("User-Agent")
@@ -71,12 +133,13 @@ func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, de
} }
userUUID := parseUUIDOrNil(userIDStr) userUUID := parseUUIDOrNil(userIDStr)
resourceUUID := parseUUIDOrNil(resourceID) resourceUUID := parseUUIDOrNil(resourceID)
severity, category, label := classifyAuditEvent(resource, action)
auditID := uuid.New().String() auditID := uuid.New().String()
_, err := db.Exec( _, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, created_at) `INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9)`, VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12)`,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), time.Now().UTC(), auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), severity, category, label, time.Now().UTC(),
) )
if err != nil { if err != nil {
@@ -155,6 +218,9 @@ func handleGetAuditLogs(c *gin.Context) {
COALESCE(a.details::text, '{}'), COALESCE(a.details::text, '{}'),
COALESCE(a.ip_address::text, ''), COALESCE(a.ip_address::text, ''),
COALESCE(a.user_agent, ''), COALESCE(a.user_agent, ''),
COALESCE(a.severity, 'info'),
COALESCE(a.category, ''),
COALESCE(a.label, ''),
a.created_at a.created_at
FROM audit_logs a FROM audit_logs a
LEFT JOIN users u ON u.id = a.user_id LEFT JOIN users u ON u.id = a.user_id
@@ -173,7 +239,7 @@ func handleGetAuditLogs(c *gin.Context) {
var logs []AuditLog var logs []AuditLog
for rows.Next() { for rows.Next() {
var log AuditLog var log AuditLog
err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt) err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.Severity, &log.Category, &log.Label, &log.CreatedAt)
if err != nil { if err != nil {
continue continue
} }
+13
View File
@@ -546,6 +546,19 @@ func generateJWT(userID, email, secret string) (string, error) {
return token.SignedString([]byte(secret)) return token.SignedString([]byte(secret))
} }
// generateImpersonationJWT mints a short-lived token acting as `userID`,
// tagged with the admin who minted it so downstream callers can attribute
// actions taken while impersonating.
func generateImpersonationJWT(userID, email, actorID, secret string, expiresAt time.Time) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": userID,
"email": email,
"impersonated_by": actorID,
"exp": expiresAt.Unix(),
})
return token.SignedString([]byte(secret))
}
// ValidateJWT validates a JWT token and returns the claims // ValidateJWT validates a JWT token and returns the claims
func ValidateJWT(tokenString, secret string) (jwt.MapClaims, error) { func ValidateJWT(tokenString, secret string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
+66
View File
@@ -0,0 +1,66 @@
package api
import (
"strings"
"testing"
"time"
)
func TestCalculateNextRun(t *testing.T) {
cases := []struct {
name string
schedule string
timezone string
wantErr bool
}{
{"every minute", "* * * * *", "", false},
{"daily at 4am", "0 4 * * *", "UTC", false},
{"weekday run", "0 9 * * 1-5", "Europe/Prague", false},
{"whitespace tolerated", " */15 * * * * ", "", false},
{"empty schedule", "", "", true},
{"bad field count", "* * *", "", true},
{"garbage", "not a cron", "", true},
{"bad timezone", "0 4 * * *", "Mars/Olympus", true},
}
now := time.Now()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
next, err := calculateNextRun(tc.schedule, tc.timezone)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got next=%v", next)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if next == nil {
t.Fatal("expected non-nil next run")
}
if !next.After(now) {
t.Errorf("next run %v is not after %v", next, now)
}
})
}
}
func TestCalculateNextRunTimezone(t *testing.T) {
// CRON_TZ must change the computed instant: 04:00 in a fixed-offset zone
// lands at a different UTC hour than 04:00 UTC.
utcNext, err := calculateNextRun("0 4 * * *", "UTC")
if err != nil {
t.Fatalf("utc: %v", err)
}
tokyoNext, err := calculateNextRun("0 4 * * *", "Asia/Tokyo")
if err != nil {
t.Fatalf("tokyo: %v", err)
}
if utcNext.Equal(*tokyoNext) {
t.Fatalf("timezone had no effect: both %v", utcNext)
}
if !strings.EqualFold(utcNext.Location().String(), "Local") {
// UTC-scheduled runs still land in server-local time representation.
t.Logf("utc next: %v / tokyo next: %v", utcNext, tokyoNext)
}
}
+10
View File
@@ -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) {
+398 -290
View File
@@ -2,14 +2,20 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"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"
"database/sql"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -33,6 +39,72 @@ type DeploymentModel struct {
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
// deploymentRowShape carries the deployment columns shared by the
// generated list/detail/rollback row types.
type deploymentRowShape struct {
ID uuid.UUID
ServiceID uuid.UUID
CommitHash sql.NullString
Status sql.NullString
ImageName sql.NullString
ImageTag sql.NullString
BuildLog sql.NullString
RuntimeLog sql.NullString
Error sql.NullString
StartedAt sql.NullTime
CompletedAt sql.NullTime
CreatedAt sql.NullTime
UpdatedAt sql.NullTime
}
func strPtrNS(v sql.NullString) *string {
if !v.Valid {
return nil
}
s := v.String
return &s
}
func ptrStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func timePtrNT(v sql.NullTime) *time.Time {
if !v.Valid {
return nil
}
t := v.Time
return &t
}
func ntPtr(t *time.Time) sql.NullTime {
if t == nil {
return sql.NullTime{}
}
return sql.NullTime{Time: *t, Valid: true}
}
func deploymentModelFrom(r deploymentRowShape) DeploymentModel {
return DeploymentModel{
ID: r.ID,
ServiceID: r.ServiceID,
CommitHash: strPtrNS(r.CommitHash),
Status: r.Status.String,
ImageName: r.ImageName.String,
ImageTag: r.ImageTag.String,
BuildLog: r.BuildLog.String,
RuntimeLog: r.RuntimeLog.String,
Error: strPtrNS(r.Error),
StartedAt: timePtrNT(r.StartedAt),
CompletedAt: timePtrNT(r.CompletedAt),
CreatedAt: r.CreatedAt.Time,
UpdatedAt: r.UpdatedAt.Time,
}
}
type CreateDeploymentRequest struct { type CreateDeploymentRequest struct {
CommitHash string `json:"commit_hash"` CommitHash string `json:"commit_hash"`
Branch string `json:"branch"` Branch string `json:"branch"`
@@ -80,44 +152,29 @@ func handleGetDeployments(c *gin.Context) {
offset, limit := pageWindow(c, 50, 100) offset, limit := pageWindow(c, 50, 100)
var total int q := sqlcdb.New(db.(*database.DB).DB)
_ = db.(*database.DB).QueryRow( total, _ := q.CountDeploymentsForService(c.Request.Context(), serviceID)
`SELECT COUNT(*) FROM deployments WHERE service_id = $1`, serviceID,
).Scan(&total)
rows, err := db.(*database.DB).Query( rows, err := q.ListDeploymentsForService(c.Request.Context(), sqlcdb.ListDeploymentsForServiceParams{
`SELECT id, service_id, commit_hash, status, image_name, image_tag, ServiceID: serviceID, Limit: int32(limit), Offset: int32(offset)})
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
FROM deployments
WHERE service_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`,
serviceID, limit, offset,
)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
return return
} }
defer rows.Close()
var deployments []DeploymentModel deployments := make([]DeploymentModel, 0, len(rows))
for rows.Next() { for _, r := range rows {
var d DeploymentModel deployments = append(deployments, deploymentModelFrom(deploymentRowShape{
err := rows.Scan( ID: r.ID, ServiceID: r.ServiceID, CommitHash: r.CommitHash, Status: r.Status,
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag, ImageName: r.ImageName, ImageTag: r.ImageTag, BuildLog: r.BuildLog,
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt, RuntimeLog: r.RuntimeLog, Error: r.Error, StartedAt: r.StartedAt,
&d.CreatedAt, &d.UpdatedAt, CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
) }))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"})
return
}
deployments = append(deployments, d)
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"deployments": deployments, "deployments": deployments,
"pagination": paginationMeta(offset, limit, total), "pagination": paginationMeta(offset, limit, int(total)),
}) })
} }
@@ -148,51 +205,33 @@ func handleGetRecentDeployments(c *gin.Context) {
offset, limit := pageWindow(c, 10, 50) offset, limit := pageWindow(c, 10, 50)
var total int q := sqlcdb.New(db.(*database.DB).DB)
if err := db.(*database.DB).QueryRow( total, err := q.CountAccessibleDeployments(c.Request.Context(), sqlcdb.CountAccessibleDeploymentsParams{
`SELECT COUNT(*) OwnerID: userID, Column2: isAdmin})
FROM deployments d if err != nil {
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`,
userID, isAdmin,
).Scan(&total); err != nil {
total = 0 total = 0
} }
rows, err := db.(*database.DB).Query( rows, err := q.ListRecentAccessibleDeployments(c.Request.Context(), sqlcdb.ListRecentAccessibleDeploymentsParams{
`SELECT d.id, d.service_id, s.name, p.name, d.status, OwnerID: userID, Column2: isAdmin, Limit: int32(limit), Offset: int32(offset)})
COALESCE(d.image_name, ''), d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
ORDER BY d.created_at DESC
LIMIT $3 OFFSET $4`,
userID, isAdmin, limit, offset,
)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
return return
} }
defer rows.Close()
deployments := []RecentDeployment{} deployments := make([]RecentDeployment, 0, len(rows))
for rows.Next() { for _, r := range rows {
var d RecentDeployment deployments = append(deployments, RecentDeployment{
if err := rows.Scan(&d.ID, &d.ServiceID, &d.ServiceName, &d.ProjectName, ID: r.ID, ServiceID: r.ServiceID, ServiceName: r.ServiceName,
&d.Status, &d.ImageName, &d.StartedAt, &d.CompletedAt, &d.CreatedAt); err != nil { ProjectName: r.ProjectName, Status: r.Status.String, ImageName: r.ImageName,
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"}) StartedAt: timePtrNT(r.StartedAt), CompletedAt: timePtrNT(r.CompletedAt),
return CreatedAt: r.CreatedAt.Time,
} })
deployments = append(deployments, d)
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"deployments": deployments, "deployments": deployments,
"pagination": paginationMeta(offset, limit, total), "pagination": paginationMeta(offset, limit, int(total)),
}) })
} }
@@ -226,38 +265,27 @@ func handleCreateDeployment(c *gin.Context) {
return return
} }
var service Service ctx := c.Request.Context()
var projectOwner string q := sqlcdb.New(db.(*database.DB).DB)
err = db.(*database.DB).QueryRow( sr, err := q.GetServiceForDeployWithOwner(ctx, serviceID)
`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, p.owner_id
FROM services s
JOIN projects p ON s.project_id = p.id
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, &projectOwner,
)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return return
} }
service := serviceForDeployFromRow(sqlcdb.GetServiceForDeployRow{
ID: sr.ID, ProjectID: sr.ProjectID, Name: sr.Name, Type: sr.Type,
Status: sr.Status, Image: sr.Image, Command: sr.Command,
Environment: sr.Environment, GitRepo: sr.GitRepo, GitBranch: sr.GitBranch,
BuildPath: sr.BuildPath, Cpu: sr.Cpu, Memory: sr.Memory,
Replicas: sr.Replicas, Port: sr.Port, Domain: sr.Domain,
HealthcheckPath: sr.HealthcheckPath, RestartPolicy: sr.RestartPolicy,
Builder: sr.Builder, CpuReserve: sr.CpuReserve, MemoryReserve: sr.MemoryReserve,
StaticBuildCmd: sr.StaticBuildCmd, StaticDir: sr.StaticDir,
CreatedAt: sr.CreatedAt, UpdatedAt: sr.UpdatedAt,
})
projectOwner := sr.OwnerID
if projectOwner != userID.(string) { if projectOwner.String() != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
@@ -283,12 +311,17 @@ func handleCreateDeployment(c *gin.Context) {
UpdatedAt: now, UpdatedAt: now,
} }
_, err = db.(*database.DB).Exec( err = q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
`INSERT INTO deployments ID: d.ID,
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at) ServiceID: d.ServiceID,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, Version: fmt.Sprintf("v%d", now.Unix()),
d.ID, d.ServiceID, fmt.Sprintf("v%d", now.Unix()), d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt, CommitHash: sql.NullString{String: ptrStr(d.CommitHash), Valid: d.CommitHash != nil},
) Status: sql.NullString{String: d.Status, Valid: true},
ImageName: sql.NullString{String: d.ImageName, Valid: true},
ImageTag: sql.NullString{String: d.ImageTag, Valid: true},
CreatedAt: sql.NullTime{Time: d.CreatedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: d.UpdatedAt, Valid: true},
})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deployment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deployment"})
@@ -299,20 +332,20 @@ func handleCreateDeployment(c *gin.Context) {
if !exists || engine == nil { if !exists || engine == nil {
unavailableErr := "Deployment engine unavailable. Docker may not be configured on this server." unavailableErr := "Deployment engine unavailable. Docker may not be configured on this server."
completedAt := time.Now() completedAt := time.Now()
_, _ = db.(*database.DB).Exec( _ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
`UPDATE deployments Error: sql.NullString{String: unavailableErr, Valid: true},
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 CompletedAt: sql.NullTime{Time: completedAt, Valid: true},
WHERE id = $3`, ID: d.ID,
unavailableErr, completedAt, d.ID, })
)
d.Status = "failed" d.Status = "failed"
d.Error = &unavailableErr d.Error = &unavailableErr
d.CompletedAt = &completedAt d.CompletedAt = &completedAt
} else { } else {
_, err = db.(*database.DB).Exec( err = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`, Status: sql.NullString{String: "building", Valid: true},
time.Now(), serviceID, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
) ID: serviceID,
})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service status"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service status"})
return return
@@ -327,14 +360,17 @@ func handleCreateDeployment(c *gin.Context) {
}) })
if pos > 0 { if pos > 0 {
d.Status = "queued" d.Status = "queued"
_, _ = db.(*database.DB).Exec( now := time.Now()
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`, _ = q.SetDeploymentStatus(ctx, sqlcdb.SetDeploymentStatusParams{
time.Now(), d.ID, Status: sql.NullString{String: "queued", Valid: true},
) UpdatedAt: sql.NullTime{Time: now, Valid: true},
_, _ = db.(*database.DB).Exec( ID: d.ID,
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`, })
time.Now(), serviceID, _ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
) Status: sql.NullString{String: "queued", Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
ID: serviceID,
})
} }
} }
@@ -376,6 +412,25 @@ func runDeploymentAndSyncWithImage(
) { ) {
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute) ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
defer cancel() defer cancel()
q := sqlcdb.New(db.DB)
// 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()
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
Error: sql.NullString{String: msg, Valid: true},
CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
ID: dbDeployment.ID,
})
}
sourcePath := strings.TrimSpace(service.BuildPath) sourcePath := strings.TrimSpace(service.BuildPath)
if sourcePath == "" { if sourcePath == "" {
@@ -402,11 +457,10 @@ func runDeploymentAndSyncWithImage(
replicas = 1 replicas = 1
} }
var publishedPort int32
// Best effort: reuse the host port from the last live deployment so public // Best effort: reuse the host port from the last live deployment so public
// URLs survive redeploys. Column exists post-migration; older DBs get // URLs survive redeploys. Column exists post-migration; older DBs get
// ephemeral ports. // ephemeral ports.
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort) publishedPort, _ := q.GetServicePublishedPort(ctx, service.ID)
publicPort := int32(service.Port) publicPort := int32(service.Port)
if service.Builder == "static" && publicPort == 0 { if service.Builder == "static" && publicPort == 0 {
@@ -421,6 +475,35 @@ 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
if o, err := q.GetProjectOwner(ctx, service.ProjectID); err == nil {
ownerID = o.String()
}
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 +530,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,14 +548,38 @@ func runDeploymentAndSyncWithImage(
if image == "" { if image == "" {
image = service.Image image = service.Image
} }
var ownerID string if remote && strings.HasPrefix(image, "containr-") {
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID) // Locally-built tag — no registry serves it. Ship the image
auth := registryAuthFor(db, ownerID, image) // itself as a docker-save artifact; the node loads it.
deployReq.BuildConfig = &deployment.BuildConfig{ rc, err := engine.DockerClient().SaveImage(ctx, image)
BuildType: "prebuilt", if err != nil {
PrebuiltImage: image, failDeploy("export rollback image: " + err.Error())
PullUsername: auth.Username, return
PullPassword: auth.Password, }
artifactID, err := putArtifact(rc)
_ = rc.Close()
if err != nil {
failDeploy("store rollback image artifact: " + err.Error())
return
}
artifactIDs = append(artifactIDs, artifactID)
deployReq.Config.RemoteLoad = &deployment.RemoteLoadSpec{ArtifactID: artifactID}
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "remote",
PrebuiltImage: image,
}
} else {
var ownerID string
if o, err := q.GetProjectOwner(ctx, service.ProjectID); err == nil {
ownerID = o.String()
}
auth := registryAuthFor(db, ownerID, image)
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "prebuilt",
PrebuiltImage: image,
PullUsername: auth.Username,
PullPassword: auth.Password,
}
} }
} else { } else {
// "auto" leaves BuildType empty so the manager detects it; the static // "auto" leaves BuildType empty so the manager detects it; the static
@@ -480,25 +589,64 @@ func runDeploymentAndSyncWithImage(
if service.Builder != "" && service.Builder != "auto" { if service.Builder != "" && service.Builder != "auto" {
buildType = service.Builder buildType = service.Builder
} }
deployReq.BuildConfig = &deployment.BuildConfig{ buildArgs := map[string]string{}
BuildType: buildType, buildCommand := ""
SourcePath: sourcePath,
Branch: req.Branch,
Commit: req.CommitHash,
NoCache: req.NoCache,
}
if buildType == "static" { if buildType == "static" {
deployReq.BuildConfig.BuildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build") buildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build")
deployReq.BuildConfig.BuildArgs = map[string]string{"STATIC_DIR": firstNonEmpty(service.StaticDir, "dist")} buildArgs["STATIC_DIR"] = firstNonEmpty(service.StaticDir, "dist")
}
if remote {
// Package the context — the node docker-builds it itself.
packReq := &types.BuildRequest{
BuildType: buildType,
SourcePath: sourcePath,
BuildCommand: buildCommand,
BuildArgs: buildArgs,
Environment: env,
}
rc, err := engine.BuildManager().PackageContext(ctx, packReq)
if err != nil {
failDeploy("package build context: " + err.Error())
return
}
artifactID, err := putArtifact(rc)
_ = rc.Close()
if err != nil {
failDeploy("store build context artifact: " + err.Error())
return
}
artifactIDs = append(artifactIDs, artifactID)
deployReq.Config.RemoteBuild = &deployment.RemoteBuildSpec{
ArtifactID: artifactID,
BuildArgs: buildArgs,
NoCache: req.NoCache,
}
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "remote",
Branch: req.Branch,
Commit: req.CommitHash,
NoCache: req.NoCache,
}
} else {
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: buildType,
SourcePath: sourcePath,
BuildCommand: buildCommand,
BuildArgs: buildArgs,
Branch: req.Branch,
Commit: req.CommitHash,
NoCache: req.NoCache,
}
} }
} }
if msg := capacityCheck(parentCtx, db, engine.DockerClient(), service, replicas); msg != "" { if msg := capacityCheck(parentCtx, db, engine.DockerClient(), service, replicas); msg != "" {
failedAt := time.Now() failedAt := time.Now()
_, _ = db.Exec( _ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`, Error: sql.NullString{String: msg, Valid: true},
msg, failedAt, dbDeployment.ID, CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
) ID: dbDeployment.ID,
})
return return
} }
@@ -506,16 +654,16 @@ func runDeploymentAndSyncWithImage(
if err != nil { if err != nil {
failedAt := time.Now() failedAt := time.Now()
failure := "Failed to start deployment engine: " + err.Error() failure := "Failed to start deployment engine: " + err.Error()
_, _ = db.Exec( _ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
`UPDATE deployments Error: sql.NullString{String: failure, Valid: true},
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
WHERE id = $3`, ID: dbDeployment.ID,
failure, failedAt, dbDeployment.ID, })
) _ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
_, _ = db.Exec( Status: sql.NullString{String: "failed", Valid: true},
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, UpdatedAt: sql.NullTime{Time: failedAt, Valid: true},
failedAt, service.ID, ID: service.ID,
) })
return return
} }
@@ -530,16 +678,17 @@ func runDeploymentAndSyncWithImage(
if errors.Is(ctx.Err(), context.Canceled) { if errors.Is(ctx.Err(), context.Canceled) {
finalStatus, finalErr = "cancelled", "Deployment cancelled" finalStatus, finalErr = "cancelled", "Deployment cancelled"
} }
_, _ = db.Exec( _ = q.CompleteDeployment(ctx, sqlcdb.CompleteDeploymentParams{
`UPDATE deployments Status: sql.NullString{String: finalStatus, Valid: true},
SET status = $1, error = $2, completed_at = $3, updated_at = $3 Error: sql.NullString{String: finalErr, Valid: true},
WHERE id = $4`, CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
finalStatus, finalErr, failedAt, dbDeployment.ID, ID: dbDeployment.ID,
) })
_, _ = db.Exec( _ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, Status: sql.NullString{String: finalStatus, Valid: true},
finalStatus, failedAt, service.ID, UpdatedAt: sql.NullTime{Time: failedAt, Valid: true},
) ID: service.ID,
})
return return
case <-syncTicker.C: case <-syncTicker.C:
current, getErr := engine.GetDeployment(engineDeployment.ID) current, getErr := engine.GetDeployment(engineDeployment.ID)
@@ -550,49 +699,36 @@ func runDeploymentAndSyncWithImage(
dbStatus := mapEngineStatusToDBStatus(current.Status) dbStatus := mapEngineStatusToDBStatus(current.Status)
imageName, imageTag := splitImageReference(current.ImageName, dbDeployment.ImageTag) imageName, imageTag := splitImageReference(current.ImageName, dbDeployment.ImageTag)
var dbError interface{} now := time.Now()
if current.Error != "" { _ = q.SyncDeploymentProgress(ctx, sqlcdb.SyncDeploymentProgressParams{
dbError = current.Error Status: sql.NullString{String: dbStatus, Valid: true},
} ImageName: sql.NullString{String: imageName, Valid: true},
ImageTag: sql.NullString{String: imageTag, Valid: true},
_, _ = db.Exec( BuildLog: sql.NullString{String: current.BuildLog, Valid: true},
`UPDATE deployments RuntimeLog: sql.NullString{String: current.DeployLog, Valid: true},
SET status = $1, Error: sql.NullString{String: current.Error, Valid: current.Error != ""},
image_name = $2, StartedAt: ntPtr(current.StartedAt),
image_tag = $3, CompletedAt: ntPtr(current.CompletedAt),
build_log = $4, UpdatedAt: sql.NullTime{Time: now, Valid: true},
runtime_log = $5, ID: dbDeployment.ID,
error = $6, })
started_at = $7,
completed_at = $8,
updated_at = $9
WHERE id = $10`,
dbStatus,
imageName,
imageTag,
current.BuildLog,
current.DeployLog,
dbError,
current.StartedAt,
current.CompletedAt,
time.Now(),
dbDeployment.ID,
)
switch dbStatus { switch dbStatus {
case "deployed": case "deployed":
_, _ = db.Exec( _ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, Status: sql.NullString{String: "running", Valid: true},
time.Now(), service.ID, UpdatedAt: sql.NullTime{Time: now, Valid: true},
) ID: service.ID,
})
insertUserNotification(db, userID, "deployment", "Deployment succeeded", insertUserNotification(db, userID, "deployment", "Deployment succeeded",
fmt.Sprintf("Service %s is now running.", service.Name), "service", service.ID.String()) fmt.Sprintf("Service %s is now running.", service.Name), "service", service.ID.String())
return return
case "failed": case "failed":
_, _ = db.Exec( _ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, Status: sql.NullString{String: "failed", Valid: true},
time.Now(), service.ID, UpdatedAt: sql.NullTime{Time: now, Valid: true},
) ID: service.ID,
})
body := fmt.Sprintf("Service %s failed to deploy.", service.Name) body := fmt.Sprintf("Service %s failed to deploy.", service.Name)
if current.Error != "" { if current.Error != "" {
body = fmt.Sprintf("Service %s failed to deploy: %s", service.Name, current.Error) body = fmt.Sprintf("Service %s failed to deploy: %s", service.Name, current.Error)
@@ -644,21 +780,16 @@ func handleCancelDeployment(c *gin.Context) {
return return
} }
var status, owner string ctx := c.Request.Context()
var serviceID uuid.UUID q := sqlcdb.New(db.(*database.DB).DB)
err = db.(*database.DB).QueryRow( access, err := q.GetDeploymentAccess(ctx, deploymentID)
`SELECT d.status, d.service_id, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(&status, &serviceID, &owner)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found", "code": "NOT_FOUND"}) c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found", "code": "NOT_FOUND"})
return return
} }
if owner != userID.(string) && !contextIsAdmin(c) { serviceID := access.ServiceID
status := access.Status.String
if access.OwnerID.String() != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied", "code": "FORBIDDEN"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied", "code": "FORBIDDEN"})
return return
} }
@@ -672,14 +803,17 @@ func handleCancelDeployment(c *gin.Context) {
switch getDeployQueue(c).Cancel(serviceID, deploymentID) { switch getDeployQueue(c).Cancel(serviceID, deploymentID) {
case deployqueue.WasQueued: case deployqueue.WasQueued:
now := time.Now() now := time.Now()
_, _ = db.(*database.DB).Exec( _ = q.CompleteDeployment(ctx, sqlcdb.CompleteDeploymentParams{
`UPDATE deployments SET status = 'cancelled', error = 'Deployment cancelled', completed_at = $1, updated_at = $1 WHERE id = $2`, Status: sql.NullString{String: "cancelled", Valid: true},
now, deploymentID, Error: sql.NullString{String: "Deployment cancelled", Valid: true},
) CompletedAt: sql.NullTime{Time: now, Valid: true},
_, _ = db.(*database.DB).Exec( ID: deploymentID,
`UPDATE services SET status = 'cancelled', updated_at = $1 WHERE id = $2`, })
now, serviceID, _ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
) Status: sql.NullString{String: "cancelled", Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
ID: serviceID,
})
c.JSON(http.StatusOK, gin.H{"status": "cancelled"}) c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
case deployqueue.WasActive: case deployqueue.WasActive:
c.JSON(http.StatusAccepted, gin.H{"status": "cancelling"}) c.JSON(http.StatusAccepted, gin.H{"status": "cancelling"})
@@ -693,9 +827,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:]
} }
@@ -716,27 +856,18 @@ func handleGetDeployment(c *gin.Context) {
return return
} }
var d DeploymentModel r, err := sqlcdb.New(db.(*database.DB).DB).GetDeploymentWithProject(c.Request.Context(), deploymentID)
var projectID uuid.UUID
err = db.(*database.DB).QueryRow(
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
&d.CreatedAt, &d.UpdatedAt, &projectID,
)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return return
} }
projectID := r.ProjectID
d := deploymentModelFrom(deploymentRowShape{
ID: r.ID, ServiceID: r.ServiceID, CommitHash: r.CommitHash, Status: r.Status,
ImageName: r.ImageName, ImageTag: r.ImageTag, BuildLog: r.BuildLog,
RuntimeLog: r.RuntimeLog, Error: r.Error, StartedAt: r.StartedAt,
CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
})
if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed { if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
@@ -766,33 +897,23 @@ func handleRollbackDeployment(c *gin.Context) {
return return
} }
var targetDeployment DeploymentModel ctx := c.Request.Context()
var serviceID uuid.UUID q := sqlcdb.New(db.(*database.DB).DB)
var ownerCheck string r, err := q.GetDeploymentForRollback(ctx, deploymentID)
err = db.(*database.DB).QueryRow(
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(
&targetDeployment.ID, &serviceID, &targetDeployment.CommitHash, &targetDeployment.Status,
&targetDeployment.ImageName, &targetDeployment.ImageTag, &targetDeployment.BuildLog,
&targetDeployment.RuntimeLog, &targetDeployment.Error, &targetDeployment.StartedAt,
&targetDeployment.CompletedAt, &targetDeployment.CreatedAt, &targetDeployment.UpdatedAt,
&ownerCheck,
)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return return
} }
serviceID := r.ServiceID
targetDeployment := deploymentModelFrom(deploymentRowShape{
ID: r.ID, ServiceID: r.ServiceID, CommitHash: r.CommitHash, Status: r.Status,
ImageName: r.ImageName, ImageTag: r.ImageTag, BuildLog: r.BuildLog,
RuntimeLog: r.RuntimeLog, Error: r.Error, StartedAt: r.StartedAt,
CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
})
ownerCheck := r.OwnerID
if ownerCheck != userID.(string) && !contextIsAdmin(c) { if ownerCheck.String() != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
@@ -815,23 +936,28 @@ func handleRollbackDeployment(c *gin.Context) {
UpdatedAt: now, UpdatedAt: now,
} }
_, err = db.(*database.DB).Exec( err = q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
`INSERT INTO deployments ID: rollback.ID,
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at) ServiceID: rollback.ServiceID,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, Version: fmt.Sprintf("rollback-%d", now.Unix()),
rollback.ID, rollback.ServiceID, fmt.Sprintf("rollback-%d", now.Unix()), rollback.CommitHash, rollback.Status, CommitHash: sql.NullString{String: ptrStr(rollback.CommitHash), Valid: rollback.CommitHash != nil},
rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt, Status: sql.NullString{String: rollback.Status, Valid: true},
) ImageName: sql.NullString{String: rollback.ImageName, Valid: true},
ImageTag: sql.NullString{String: rollback.ImageTag, Valid: true},
CreatedAt: sql.NullTime{Time: rollback.CreatedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: rollback.UpdatedAt, Valid: true},
})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create rollback deployment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create rollback deployment"})
return return
} }
_, err = db.(*database.DB).Exec( err = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`, Status: sql.NullString{String: "building", Valid: true},
time.Now(), serviceID, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
) ID: serviceID,
})
// Real rollback: redeploy the target deployment's image. // Real rollback: redeploy the target deployment's image.
engineValue, _ := c.Get("deployment_engine") engineValue, _ := c.Get("deployment_engine")
@@ -847,36 +973,18 @@ func handleRollbackDeployment(c *gin.Context) {
if engine == nil { if engine == nil {
reason = "Deployment engine unavailable. Docker may not be configured on this server." reason = "Deployment engine unavailable. Docker may not be configured on this server."
} }
_, _ = db.(*database.DB).Exec( _ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`, Error: sql.NullString{String: reason, Valid: true},
reason, completedAt, rollbackID, CompletedAt: sql.NullTime{Time: completedAt, Valid: true},
) ID: rollbackID,
_, _ = db.(*database.DB).Exec( })
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, _ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
completedAt, serviceID, Status: sql.NullString{String: "failed", Valid: true},
) UpdatedAt: sql.NullTime{Time: completedAt, Valid: true},
ID: serviceID,
})
} else { } else {
var service Service service, _ := loadServiceForDeploy(db.(*database.DB), serviceID)
_ = db.(*database.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,
)
rollbackReq := CreateDeploymentRequest{ rollbackReq := CreateDeploymentRequest{
CommitHash: func() string { CommitHash: func() string {
@@ -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)
}
}
}
+124 -42
View File
@@ -3,6 +3,8 @@ package api
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"fmt"
"net" "net"
"net/http" "net/http"
"regexp" "regexp"
@@ -10,6 +12,7 @@ import (
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -42,19 +45,26 @@ func validHostname(d string) bool {
} }
func loadServiceDomains(db *database.DB, serviceID uuid.UUID) []ServiceDomain { func loadServiceDomains(db *database.DB, serviceID uuid.UUID) []ServiceDomain {
rows, err := db.Query( rows, err := sqlcdb.New(db.DB).ListServiceDomains(context.Background(), serviceID)
`SELECT id, service_id, domain, is_default, cert_type, cert_status, last_checked_at, created_at
FROM service_domains WHERE service_id = $1 ORDER BY is_default DESC, created_at ASC`, serviceID)
if err != nil { if err != nil {
return nil return nil
} }
defer rows.Close() out := make([]ServiceDomain, 0, len(rows))
out := []ServiceDomain{} for _, r := range rows {
for rows.Next() { d := ServiceDomain{
var d ServiceDomain ID: r.ID,
if err := rows.Scan(&d.ID, &d.ServiceID, &d.Domain, &d.IsDefault, &d.CertType, &d.CertStatus, &d.LastCheckedAt, &d.CreatedAt); err == nil { ServiceID: r.ServiceID,
out = append(out, d) Domain: r.Domain,
IsDefault: r.IsDefault,
CertType: r.CertType,
CertStatus: r.CertStatus,
CreatedAt: r.CreatedAt,
} }
if r.LastCheckedAt.Valid {
t := r.LastCheckedAt.Time
d.LastCheckedAt = &t
}
out = append(out, d)
} }
return out return out
} }
@@ -68,30 +78,95 @@ func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) [
if len(out) == 0 && fallback != "" { if len(out) == 0 && fallback != "" {
out = []string{fallback} out = []string{fallback}
} }
if len(out) == 0 {
out = serviceAutoDomains(db, serviceID)
}
return out
}
// dnsLabel normalizes a service name into a DNS label: lowercase alnum
// plus single dashes, never leading/trailing, ≤63 chars.
func dnsLabel(name string) string {
var b strings.Builder
lastWasSep := false
for _, r := range strings.ToLower(strings.TrimSpace(name)) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastWasSep = false
continue
}
if !lastWasSep && b.Len() > 0 {
b.WriteByte('-')
lastWasSep = true
}
}
out := strings.Trim(b.String(), "-")
if len(out) > 63 {
out = strings.Trim(out[:63], "-")
}
return out
}
// serviceAutoDomains generates <service>.<node-default-domain> hostnames
// for services with no explicit domains. Pinned services take their node's
// base domain; spread services union every eligible node's base domain.
// Local services get nothing — there is no instance-level base domain yet.
func serviceAutoDomains(db *database.DB, serviceID uuid.UUID) []string {
ctx := context.Background()
q := sqlcdb.New(db.DB)
brief, err := q.GetServicePlacementBrief(ctx, serviceID)
if err != nil {
return nil
}
label := dnsLabel(brief.Name)
if label == "" {
return nil
}
var bases []string
nodeID := brief.NodeID
spread := brief.Spread
switch {
case nodeID.Valid && nodeID.String != "":
if a, err := q.GetAgent(ctx, nodeID.String); err == nil && a.DefaultDomain != "" {
bases = []string{a.DefaultDomain}
}
case spread:
var err error
bases, err = q.ListSchedulableAgentDomains(ctx, json.RawMessage(tagsJSON(servicePlacementTags(db, serviceID))))
if err != nil {
bases = nil
}
}
out := make([]string, 0, len(bases))
for _, base := range bases {
out = append(out, label+"."+base)
}
return out return out
} }
// syncDefaultDomain writes the is_default row back to services.domain so // syncDefaultDomain writes the is_default row back to services.domain so
// legacy reads (CLI tables, public_url) keep working. // legacy reads (CLI tables, public_url) keep working.
func syncDefaultDomain(db *database.DB, serviceID uuid.UUID) { func syncDefaultDomain(db *database.DB, serviceID uuid.UUID) {
var domain string ctx := context.Background()
err := db.QueryRow( q := sqlcdb.New(db.DB)
`SELECT domain FROM service_domains WHERE service_id = $1 AND is_default`, serviceID).Scan(&domain) domain, err := q.GetServiceDomainDefault(ctx, serviceID)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
err = db.QueryRow( domain, err = q.GetServiceDomainFirst(ctx, serviceID)
`SELECT domain FROM service_domains WHERE service_id = $1 ORDER BY created_at ASC LIMIT 1`, serviceID).Scan(&domain)
} }
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
domain = "" domain = ""
} }
_, _ = db.Exec(`UPDATE services SET domain = $1 WHERE id = $2`, domain, serviceID) _ = q.SetServiceLegacyDomain(ctx, sqlcdb.SetServiceLegacyDomainParams{Domain: domain, ID: serviceID})
} }
// serviceAccess loads the maintenance/basic-auth columns for the runtime spec. // serviceAccess loads the maintenance/basic-auth columns for the runtime spec.
func serviceAccess(db *database.DB, serviceID uuid.UUID) (maintenance bool, basicAuth string) { func serviceAccess(db *database.DB, serviceID uuid.UUID) (maintenance bool, basicAuth string) {
_ = db.QueryRow(`SELECT maintenance_mode, basic_auth_users FROM services WHERE id = $1`, serviceID). row, err := sqlcdb.New(db.DB).GetServiceAccess(context.Background(), serviceID)
Scan(&maintenance, &basicAuth) if err != nil {
return maintenance, basicAuth return false, ""
}
return row.MaintenanceMode, row.BasicAuthUsers
} }
// maintenanceURL is the redirect target for maintenance mode — the public // maintenanceURL is the redirect target for maintenance mode — the public
@@ -136,11 +211,12 @@ func basicAuthUsernames(encoded string) []string {
// serviceWriteAccess mirrors the service update handler's access check. // serviceWriteAccess mirrors the service update handler's access check.
func serviceWriteAccess(c *gin.Context, db *database.DB, serviceID uuid.UUID) bool { func serviceWriteAccess(c *gin.Context, db *database.DB, serviceID uuid.UUID) bool {
userID, _ := c.Get("user_id") userID, _ := c.Get("user_id")
var ok bool uid, _ := uuid.Parse(fmt.Sprint(userID))
err := db.QueryRow( ok, err := sqlcdb.New(db.DB).ServiceWriteAccess(context.Background(), sqlcdb.ServiceWriteAccessParams{
`SELECT EXISTS(SELECT 1 FROM services s JOIN projects p ON s.project_id = p.id ID: serviceID,
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool))`, OwnerID: uid,
serviceID, userID, contextIsAdmin(c)).Scan(&ok) Column3: contextIsAdmin(c),
})
return err == nil && ok return err == nil && ok
} }
@@ -187,9 +263,11 @@ func handleAddServiceDomain(c *gin.Context) {
return return
} }
ctx := c.Request.Context()
q := sqlcdb.New(db.(*database.DB).DB)
// First domain on a service becomes the default automatically. // First domain on a service becomes the default automatically.
var count int count, _ := q.CountServiceDomains(ctx, serviceID)
_ = db.(*database.DB).QueryRow(`SELECT COUNT(*) FROM service_domains WHERE service_id = $1`, serviceID).Scan(&count)
makeDefault := req.IsDefault || count == 0 makeDefault := req.IsDefault || count == 0
tx, err := db.(*database.DB).Begin() tx, err := db.(*database.DB).Begin()
@@ -198,19 +276,18 @@ func handleAddServiceDomain(c *gin.Context) {
return return
} }
defer tx.Rollback() defer tx.Rollback()
qtx := q.WithTx(tx)
if makeDefault { if makeDefault {
if _, err := tx.Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID); err != nil { if err := qtx.ClearServiceDomainDefault(ctx, serviceID); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain") respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return return
} }
} }
var inserted ServiceDomain row, err := qtx.CreateServiceDomain(ctx, sqlcdb.CreateServiceDomainParams{
err = tx.QueryRow( ServiceID: serviceID,
`INSERT INTO service_domains (service_id, domain, is_default) Domain: domain,
VALUES ($1, $2, $3) IsDefault: makeDefault,
RETURNING id, service_id, domain, is_default, cert_type, cert_status, created_at`, })
serviceID, domain, makeDefault).
Scan(&inserted.ID, &inserted.ServiceID, &inserted.Domain, &inserted.IsDefault, &inserted.CertType, &inserted.CertStatus, &inserted.CreatedAt)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") { if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
respondError(c, http.StatusConflict, "CONFLICT", "Domain already attached to this service") respondError(c, http.StatusConflict, "CONFLICT", "Domain already attached to this service")
@@ -224,7 +301,15 @@ func handleAddServiceDomain(c *gin.Context) {
return return
} }
syncDefaultDomain(db.(*database.DB), serviceID) syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusCreated, gin.H{"domain": inserted}) c.JSON(http.StatusCreated, gin.H{"domain": ServiceDomain{
ID: row.ID,
ServiceID: row.ServiceID,
Domain: row.Domain,
IsDefault: row.IsDefault,
CertType: row.CertType,
CertStatus: row.CertStatus,
CreatedAt: row.CreatedAt,
}})
} }
func handleDeleteServiceDomain(c *gin.Context) { func handleDeleteServiceDomain(c *gin.Context) {
@@ -247,9 +332,8 @@ func handleDeleteServiceDomain(c *gin.Context) {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID") respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
return return
} }
res, err := db.(*database.DB).Exec( affected, err := sqlcdb.New(db.(*database.DB).DB).DeleteServiceDomain(
`DELETE FROM service_domains WHERE id = $1 AND service_id = $2`, domainID, serviceID) c.Request.Context(), sqlcdb.DeleteServiceDomainParams{ID: domainID, ServiceID: serviceID})
affected, _ := res.RowsAffected()
if err != nil || affected == 0 { if err != nil || affected == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found") respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
return return
@@ -278,14 +362,12 @@ func handleSetDefaultDomain(c *gin.Context) {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID") respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
return return
} }
res, err := db.(*database.DB).Exec( affected, err := sqlcdb.New(db.(*database.DB).DB).SetServiceDomainDefault(
`UPDATE service_domains SET is_default = (id = $1) WHERE service_id = $2`, c.Request.Context(), sqlcdb.SetServiceDomainDefaultParams{ID: domainID, ServiceID: serviceID})
domainID, serviceID)
if err != nil { if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to set default domain") respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to set default domain")
return return
} }
affected, _ := res.RowsAffected()
if affected == 0 { if affected == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found") respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
return return
@@ -353,7 +435,7 @@ func handleCheckServiceDomains(c *gin.Context) {
r.Status = "ok" r.Status = "ok"
} }
results = append(results, r) results = append(results, r)
_, _ = dbh.Exec(`UPDATE service_domains SET last_checked_at = NOW() WHERE id = $1`, d.ID) _ = sqlcdb.New(dbh.DB).TouchServiceDomainChecked(c.Request.Context(), d.ID)
} }
c.JSON(http.StatusOK, gin.H{"domains": results}) c.JSON(http.StatusOK, gin.H{"domains": results})
} }
+446
View File
@@ -0,0 +1,446 @@
package api
import (
"context"
"database/sql"
"os"
"strings"
"testing"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/google/uuid"
)
func testDSNString(t *testing.T) string {
t.Helper()
dsn := os.Getenv("METRICS_TEST_DSN")
if dsn == "" {
dsn = os.Getenv("DATABASE_URL")
}
if dsn == "" {
t.Skip("METRICS_TEST_DSN not set")
}
return dsn
}
func TestDNSLabel(t *testing.T) {
cases := map[string]string{
"my-app": "my-app",
"My App_2": "my-app-2",
" web ": "web",
"__weird__name": "weird-name",
"---": "",
"": "",
}
for in, want := range cases {
if got := dnsLabel(in); got != want {
t.Errorf("dnsLabel(%q) = %q, want %q", in, got, want)
}
}
}
func TestDNSLabelLengthCap(t *testing.T) {
long := "a" + string(make([]byte, 0)) + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
if got := dnsLabel(long); len(got) > 63 {
t.Fatalf("label over 63 chars: %d", len(got))
}
}
// Live invariant test against real Postgres — set METRICS_TEST_DSN (or
// DATABASE_URL). Verifies the generated domain queries preserve the
// default-domain and legacy services.domain invariants.
func TestServiceDomainsLiveInvariants(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
serviceID := uuid.New()
suffix := strings.ReplaceAll(serviceID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
userID, "domtest-"+suffix+"@test.local")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
projectID, "domtest-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type)
VALUES ($1, 'domtest-`+suffix+`', $2, $3, 'web', 'image')`,
serviceID, projectID, envID)
must(err)
defer func() {
_, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID)
}()
d1, err := q.CreateServiceDomain(ctx, sqlcdb.CreateServiceDomainParams{
ServiceID: serviceID, Domain: "a-" + suffix + ".example.com", IsDefault: true})
if err != nil {
t.Fatalf("create d1: %v", err)
}
d2, err := q.CreateServiceDomain(ctx, sqlcdb.CreateServiceDomainParams{
ServiceID: serviceID, Domain: "b-" + suffix + ".example.com", IsDefault: false})
if err != nil {
t.Fatalf("create d2: %v", err)
}
syncDefaultDomain(db, serviceID)
var legacy string
if err := db.QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&legacy); err != nil {
t.Fatalf("read legacy: %v", err)
}
if legacy != d1.Domain {
t.Fatalf("legacy domain = %q, want %q", legacy, d1.Domain)
}
// Switch default: exactly one row stays default, legacy follows.
if err := q.ClearServiceDomainDefault(ctx, serviceID); err != nil {
t.Fatalf("clear: %v", err)
}
if _, err := q.SetServiceDomainDefault(ctx, sqlcdb.SetServiceDomainDefaultParams{
ID: d2.ID, ServiceID: serviceID}); err != nil {
t.Fatalf("set default: %v", err)
}
syncDefaultDomain(db, serviceID)
if err := db.QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&legacy); err != nil {
t.Fatalf("read legacy2: %v", err)
}
if legacy != d2.Domain {
t.Fatalf("legacy after switch = %q, want %q", legacy, d2.Domain)
}
doms := loadServiceDomains(db, serviceID)
if len(doms) != 2 || !doms[0].IsDefault || doms[0].Domain != d2.Domain {
t.Fatalf("list order wrong: %+v", doms)
}
// Deleting the default falls back to the remaining domain.
if _, err := q.DeleteServiceDomain(ctx, sqlcdb.DeleteServiceDomainParams{
ID: d2.ID, ServiceID: serviceID}); err != nil {
t.Fatalf("delete d2: %v", err)
}
syncDefaultDomain(db, serviceID)
if err := db.QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&legacy); err != nil {
t.Fatalf("read legacy3: %v", err)
}
if legacy != d1.Domain {
t.Fatalf("legacy after delete = %q, want %q", legacy, d1.Domain)
}
}
// Live clone test: cloneServiceRow must copy config, domains, and variables
// verbatim, start stopped, and land in the target project/environment.
func TestCloneServiceRowLive(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
serviceID := uuid.New()
suffix := strings.ReplaceAll(serviceID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
userID, "clone-"+suffix+"@test.local")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
projectID, "clone-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type,
image_name, port, volumes, traefik_labels, maintenance_mode)
VALUES ($1, $2, $3, $4, 'web', 'image', 'traefik/whoami:latest', 80,
'[{"mount":"/data"}]'::jsonb,
'{"middlewares.rl.ratelimit.average":"100"}'::jsonb, true)`,
serviceID, "clonesrc-"+suffix, projectID, envID)
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
if _, err := q.CreateServiceDomain(ctx, sqlcdb.CreateServiceDomainParams{
ServiceID: serviceID, Domain: "src-" + suffix + ".example.com", IsDefault: true}); err != nil {
t.Fatalf("seed domain: %v", err)
}
if _, err := db.Exec(`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
VALUES ($1, $2, 'FOO', 'bar', false)`, uuid.New(), serviceID); err != nil {
t.Fatalf("seed var: %v", err)
}
cloneID, err := cloneServiceRow(db, serviceID, projectID, "clone-"+suffix, "")
if err != nil {
t.Fatalf("clone: %v", err)
}
var status, imageName, env string
var maintenance bool
var volumes, labels []byte
if err := db.QueryRow(`SELECT status, image_name, COALESCE(environment,''), maintenance_mode,
volumes, traefik_labels FROM services WHERE id = $1`, cloneID).
Scan(&status, &imageName, &env, &maintenance, &volumes, &labels); err != nil {
t.Fatalf("read clone: %v", err)
}
if status != "stopped" || imageName != "traefik/whoami:latest" || env != "production" {
t.Fatalf("clone mismatch: %v %v %v", status, imageName, env)
}
if !maintenance || string(volumes) == "[]" || string(labels) == "{}" {
t.Fatalf("clone dropped config: maint=%v vol=%s labels=%s", maintenance, volumes, labels)
}
doms := loadServiceDomains(db, cloneID)
if len(doms) != 1 || doms[0].Domain != "src-"+suffix+".example.com" || !doms[0].IsDefault {
t.Fatalf("domains not cloned: %+v", doms)
}
var varVal string
if err := db.QueryRow(`SELECT value FROM environment_variables WHERE service_id = $1 AND key = 'FOO'`,
cloneID).Scan(&varVal); err != nil || varVal != "bar" {
t.Fatalf("vars not cloned: %v %q", err, varVal)
}
}
// Live preview-environment test: generated queries must preserve the
// access-check join, the status-sync CASE, and the sweep listing.
func TestPreviewEnvironmentsLiveInvariants(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
srcID := uuid.New()
cloneID := uuid.New()
previewID := uuid.New()
suffix := strings.ReplaceAll(previewID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
userID, "prev-"+suffix+"@test.local")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
projectID, "prev-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
for _, p := range [][3]interface{}{{srcID, "src-" + suffix, "running"}, {cloneID, "clone-" + suffix, "running"}} {
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type, status)
VALUES ($1, $2, $3, $4, 'web', 'image', $5)`,
p[0], p[1], projectID, envID, p[2])
must(err)
}
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
must(q.CreatePreviewEnvironment(ctx, sqlcdb.CreatePreviewEnvironmentParams{
ID: previewID,
ProjectID: projectID,
ServiceID: srcID,
PreviewServiceID: uuid.NullUUID{UUID: cloneID, Valid: true},
BranchName: "feat-" + suffix,
Environment: "preview-feat-" + suffix,
Status: "building",
ExpiresAt: time.Now().Add(-time.Hour), // already past TTL → sweepable
}))
// List + service join.
rows, err := q.ListPreviewEnvironmentsForProject(ctx, projectID)
if err != nil || len(rows) != 1 {
t.Fatalf("list: %v %d", err, len(rows))
}
if rows[0].ID != previewID || !rows[0].SvcID.Valid || rows[0].ServiceName.String != "src-"+suffix {
t.Fatalf("list row: %+v", rows[0])
}
// Access-check join: owner reads; a stranger does not.
if _, err := q.GetPreviewEnvironment(ctx, sqlcdb.GetPreviewEnvironmentParams{
ID: previewID, OwnerID: userID}); err != nil {
t.Fatalf("owner read: %v", err)
}
if _, err := q.GetPreviewEnvironment(ctx, sqlcdb.GetPreviewEnvironmentParams{
ID: previewID, OwnerID: uuid.New()}); err != nil {
t.Fatalf("approved project should be public-read: %v", err)
}
// Status sync folds clone's 'running' into the preview row.
if err := q.SyncPreviewStatuses(ctx); err != nil {
t.Fatalf("sync: %v", err)
}
var status string
if err := db.QueryRow(`SELECT status FROM preview_environments WHERE id = $1`, previewID).Scan(&status); err != nil {
t.Fatalf("read status: %v", err)
}
if status != "running" {
t.Fatalf("status after sync = %q, want running", status)
}
// TTL sweep picks it up (expired at insert), marks expired.
sweep, err := q.ListSweepablePreviews(ctx)
if err != nil {
t.Fatalf("sweep list: %v", err)
}
found := false
for _, s := range sweep {
if s.ID == previewID && s.PreviewServiceID.UUID == cloneID {
found = true
}
}
if !found {
t.Fatalf("sweepable list missing preview: %+v", sweep)
}
must(q.MarkPreviewEnvironmentStatus(ctx, sqlcdb.MarkPreviewEnvironmentStatusParams{Status: "expired", ID: previewID}))
sweep, _ = q.ListSweepablePreviews(ctx)
for _, s := range sweep {
if s.ID == previewID {
t.Fatal("expired preview still sweepable")
}
}
// Owner lookup and delete.
owner, err := q.GetPreviewEnvironmentOwner(ctx, previewID)
if err != nil || owner != userID {
t.Fatalf("owner: %v %v", owner, err)
}
must(q.DeletePreviewEnvironment(ctx, previewID))
}
// Live deployment lifecycle: generated queries preserve list ordering,
// the access join, and the status-transition writes.
func TestDeploymentsLiveInvariants(t *testing.T) {
dsn := testDSNString(t)
db, err := database.NewConnection(dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer db.Close()
ctx := context.Background()
q := sqlcdb.New(db.DB)
userID := uuid.New()
projectID := uuid.New()
envID := uuid.New()
serviceID := uuid.New()
deployID := uuid.New()
suffix := strings.ReplaceAll(deployID.String()[:8], "-", "")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("setup: %v", err)
}
}
_, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`,
userID, "dep-"+suffix+"@test.local")
must(err)
_, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`,
projectID, "dep-"+suffix, userID)
must(err)
_, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`,
envID, projectID)
must(err)
_, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type, status)
VALUES ($1, $2, $3, $4, 'web', 'image', 'running')`,
serviceID, "dep-"+suffix, projectID, envID)
must(err)
defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }()
must(q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
ID: deployID,
ServiceID: serviceID,
Version: "v1-" + suffix,
Status: sql.NullString{String: "pending", Valid: true},
}))
total, err := q.CountDeploymentsForService(ctx, serviceID)
if err != nil || total != 1 {
t.Fatalf("count: %v %d", err, total)
}
rows, err := q.ListDeploymentsForService(ctx, sqlcdb.ListDeploymentsForServiceParams{
ServiceID: serviceID, Limit: 10, Offset: 0})
if err != nil || len(rows) != 1 || rows[0].ID != deployID {
t.Fatalf("list: %v %+v", err, rows)
}
// Access join returns owner.
access, err := q.GetDeploymentAccess(ctx, deployID)
if err != nil || access.OwnerID != userID || access.ServiceID != serviceID {
t.Fatalf("access: %v %+v", err, access)
}
// Recent feed honors the access predicate.
feed, err := q.ListRecentAccessibleDeployments(ctx, sqlcdb.ListRecentAccessibleDeploymentsParams{
OwnerID: userID, Limit: 10, Offset: 0})
if err != nil {
t.Fatalf("feed: %v", err)
}
found := false
for _, d := range feed {
if d.ID == deployID && d.ServiceName == "dep-"+suffix {
found = true
}
}
if !found {
t.Fatalf("feed missing deployment: %+v", feed)
}
// Status transitions.
must(q.SetDeploymentStatus(ctx, sqlcdb.SetDeploymentStatusParams{
Status: sql.NullString{String: "queued", Valid: true}, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: deployID}))
must(q.SyncDeploymentProgress(ctx, sqlcdb.SyncDeploymentProgressParams{
Status: sql.NullString{String: "deployed", Valid: true},
ImageName: sql.NullString{String: "nginx", Valid: true},
ImageTag: sql.NullString{String: "alpine", Valid: true},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: deployID,
}))
r, err := q.GetDeploymentWithProject(ctx, deployID)
if err != nil {
t.Fatalf("get: %v", err)
}
if r.Status.String != "deployed" || r.ImageName.String != "nginx" || r.ProjectID != projectID {
t.Fatalf("post-sync row: %+v", r)
}
must(q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
Error: sql.NullString{String: "boom", Valid: true},
CompletedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: deployID,
}))
r, _ = q.GetDeploymentWithProject(ctx, deployID)
if r.Status.String != "failed" || r.Error.String != "boom" {
t.Fatalf("post-fail row: %+v", r)
}
}
+140
View File
@@ -0,0 +1,140 @@
package api
// Project environments — the named lanes services deploy into
// (production/development/preview are seeded; custom names allowed).
import (
"context"
"net/http"
"regexp"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
var envNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$`)
type environmentDTO struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
ProjectID uuid.UUID `json:"project_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ServiceCount int64 `json:"service_count"`
}
func toEnvironmentDTO(id uuid.UUID, name string, projectID uuid.UUID, created, updated time.Time, count int64) environmentDTO {
return environmentDTO{ID: id, Name: name, ProjectID: projectID, CreatedAt: created, UpdatedAt: updated, ServiceCount: count}
}
// handleListEnvironments returns the project's environments with live
// service counts — the data behind the env switcher.
func handleListEnvironments(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid project ID")
return
}
if exists, allowed := projectReadAccess(c, db, projectID); !exists || !allowed {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Project not found")
return
}
rows, err := sqlcdb.New(db.DB).ListProjectEnvironments(context.Background(), projectID)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list environments")
return
}
envs := make([]environmentDTO, 0, len(rows))
for _, r := range rows {
envs = append(envs, toEnvironmentDTO(r.ID, r.Name, r.ProjectID, r.CreatedAt.Time, r.UpdatedAt.Time, r.ServiceCount))
}
c.JSON(http.StatusOK, gin.H{"environments": envs})
}
// handleCreateEnvironment adds a named environment lane to the project.
func handleCreateEnvironment(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid project ID")
return
}
userID, exists := c.Get("user_id")
if !exists || !ownsProject(db, projectID, userID.(string), contextIsAdmin(c)) {
respondError(c, http.StatusForbidden, "FORBIDDEN", "Access denied")
return
}
var req struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "name is required")
return
}
name := strings.ToLower(strings.TrimSpace(req.Name))
if !envNamePattern.MatchString(name) {
respondError(c, http.StatusBadRequest, "VALIDATION", "Name must be lowercase alphanumeric with dashes, 1-50 chars")
return
}
q := sqlcdb.New(db.DB)
if _, err := q.GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
ProjectID: projectID, Name: name,
}); err == nil {
respondError(c, http.StatusConflict, "CONFLICT", "Environment already exists")
return
}
if err := q.InsertProjectEnvironment(context.Background(), sqlcdb.InsertProjectEnvironmentParams{
Name: name, ProjectID: projectID,
}); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create environment")
return
}
env, _ := q.GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
ProjectID: projectID, Name: name,
})
LogAuditWithRequest(c, "project", projectID.String(), "environment.create", map[string]interface{}{"name": name})
c.JSON(http.StatusCreated, gin.H{"environment": toEnvironmentDTO(env.ID, env.Name, env.ProjectID, env.CreatedAt.Time, env.UpdatedAt.Time, 0)})
}
// handleDeleteEnvironment removes an environment that holds no services.
func handleDeleteEnvironment(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
envID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid environment ID")
return
}
userID, exists := c.Get("user_id")
if !exists {
respondError(c, http.StatusUnauthorized, "UNAUTHORIZED", "User not authenticated")
return
}
var projectID uuid.UUID
if err := db.QueryRow(`SELECT project_id FROM environments WHERE id = $1`, envID).Scan(&projectID); err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Environment not found")
return
}
if !ownsProject(db, projectID, userID.(string), contextIsAdmin(c)) {
respondError(c, http.StatusForbidden, "FORBIDDEN", "Access denied")
return
}
q := sqlcdb.New(db.DB)
if n, err := q.CountEnvironmentServices(context.Background(), envID); err == nil && n > 0 {
respondError(c, http.StatusConflict, "CONFLICT", "Environment still contains services — move them first")
return
}
if err := q.DeleteProjectEnvironment(context.Background(), sqlcdb.DeleteProjectEnvironmentParams{
ID: envID, ProjectID: projectID,
}); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete environment")
return
}
LogAuditWithRequest(c, "project", projectID.String(), "environment.delete", map[string]interface{}{"environment_id": envID.String()})
c.JSON(http.StatusOK, gin.H{"message": "Environment deleted"})
}
+311
View File
@@ -0,0 +1,311 @@
package api
// Data plane for the public API gateway: /g/:slug/* proxies to the
// upstream_url of the matching api_services row. Callers authenticate with
// api_keys (X-API-Key or Authorization: Bearer), pass the rpm + monthly
// quota gates, and every request lands in usage_counters /
// metrics_timeseries / incident_events for the /admin/gateway analytics
// surfaces.
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"containr/internal/database"
"containr/internal/gateway"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
// jarvis: ceiling — a single shared transport; per-request timeouts come
// from api_services.request_timeout_ms on the request context.
var gatewayHTTPClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
// gatewayRPM is a process-local per-minute counter map. Containr runs a
// single API process, so an in-memory bucket is sufficient — a multi-node
// control plane would need a Redis-backed limiter instead.
var gatewayRPM = struct {
sync.Mutex
buckets map[string]*rpmBucket
}{buckets: map[string]*rpmBucket{}}
type rpmBucket struct {
window time.Time
count int
}
// gatewayRateAllow returns false when the bucket for the current minute has
// already reached limit. A limit <= 0 disables the check. `now` is injected
// so tests can freeze the window.
func gatewayRateAllow(bucketKey string, limit int, now time.Time) bool {
if limit <= 0 {
return true
}
window := now.Truncate(time.Minute)
gatewayRPM.Lock()
defer gatewayRPM.Unlock()
b := gatewayRPM.buckets[bucketKey]
if b == nil || !b.window.Equal(window) {
b = &rpmBucket{window: window}
gatewayRPM.buckets[bucketKey] = b
// Best-effort sweep so the map cannot grow without bound.
for k, old := range gatewayRPM.buckets {
if old.window.Before(window.Add(-time.Minute)) {
delete(gatewayRPM.buckets, k)
}
}
}
b.count++
return b.count <= limit
}
type gatewayService struct {
ID string
UpstreamURL string
RoutePrefix string
UpstreamAuthHeader sql.NullString
UpstreamAuthValue sql.NullString
Enabled bool
RPMLimit sql.NullInt64
MonthlyQuota sql.NullInt64
RequestTimeoutMS int
}
type gatewayKey struct {
ID string
Hash string
Enabled bool
RPMLimit sql.NullInt64
MonthlyQuota sql.NullInt64
AllowedServiceIDs string
}
// extractGatewayKey reads the caller's API key from X-API-Key first, then
// Authorization: Bearer.
func extractGatewayKey(c *gin.Context) string {
if k := strings.TrimSpace(c.GetHeader("X-API-Key")); k != "" {
return k
}
auth := strings.TrimSpace(c.GetHeader("Authorization"))
if len(auth) > 7 && strings.EqualFold(auth[:7], "Bearer ") {
return strings.TrimSpace(auth[7:])
}
return ""
}
// lookupGatewayService resolves the slug to an enabled service row.
func lookupGatewayService(ctx context.Context, db *database.DB, slug string) (*gatewayService, error) {
var s gatewayService
var enabled int
err := db.QueryRowContext(ctx, `
SELECT id, upstream_url, route_prefix, upstream_auth_header,
upstream_auth_value, enabled, rpm_limit, monthly_quota,
request_timeout_ms
FROM api_services WHERE slug = $1
`, slug).Scan(&s.ID, &s.UpstreamURL, &s.RoutePrefix,
&s.UpstreamAuthHeader, &s.UpstreamAuthValue, &enabled,
&s.RPMLimit, &s.MonthlyQuota, &s.RequestTimeoutMS)
if err != nil {
return nil, err
}
s.Enabled = enabled != 0
return &s, nil
}
// authenticateGatewayKey resolves a presented key by its indexed prefix and
// bcrypt-verifies candidates. Returns nil when nothing matches.
func authenticateGatewayKey(ctx context.Context, db *database.DB, presented string) (*gatewayKey, error) {
prefix := presented
if len(prefix) > 8 {
prefix = prefix[:8]
}
rows, err := db.QueryContext(ctx, `
SELECT id, key_hash, enabled, rpm_limit, monthly_quota, allowed_service_ids
FROM api_keys WHERE key_prefix = $1
`, prefix)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var k gatewayKey
var enabled int
if err := rows.Scan(&k.ID, &k.Hash, &enabled, &k.RPMLimit, &k.MonthlyQuota, &k.AllowedServiceIDs); err != nil {
return nil, err
}
k.Enabled = enabled != 0
if bcrypt.CompareHashAndPassword([]byte(k.Hash), []byte(presented)) == nil {
return &k, nil
}
}
return nil, rows.Err()
}
// keyAllowsService applies the allowed_service_ids JSON allowlist. An empty
// list means the key may call every service.
func keyAllowsService(k *gatewayKey, serviceID string) bool {
var allowed []string
if err := json.Unmarshal([]byte(k.AllowedServiceIDs), &allowed); err != nil || len(allowed) == 0 {
return true
}
for _, id := range allowed {
if id == serviceID {
return true
}
}
return false
}
// monthlyUsage sums a counter dimension for the current calendar month.
func monthlyUsage(ctx context.Context, db *database.DB, column, id, period string) (int, error) {
var total int
err := db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(request_count), 0) FROM usage_counters
WHERE `+column+` = $1 AND period_month = $2
`, id, period).Scan(&total)
return total, err
}
// recordGatewayRequest persists usage, metrics, and failure incidents for a
// proxied call. Runs in the request path but is three small writes.
func recordGatewayRequest(ctx context.Context, db *database.DB, svc *gatewayService, k *gatewayKey, status int, period string) {
_, _ = db.ExecContext(ctx, `
INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at)
VALUES ($1, $2, $3, 1, NOW())
ON CONFLICT (api_key_id, service_id, period_month)
DO UPDATE SET request_count = usage_counters.request_count + 1, updated_at = NOW()
`, k.ID, svc.ID, period)
_, _ = db.ExecContext(ctx, `
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1
`, k.ID)
labels, _ := json.Marshal(map[string]interface{}{
"service_id": svc.ID,
"key_id": k.ID,
"status": status,
})
_, _ = db.ExecContext(ctx, `
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
VALUES ('gateway_request', 1, $1, NOW())
`, string(labels))
}
func recordGatewayIncident(ctx context.Context, db *database.DB, svcID string, keyID *string, code, msg, severity string, status int) {
var kid interface{}
if keyID != nil {
kid = *keyID
}
_, _ = db.ExecContext(ctx, `
INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`, svcID, kid, code, msg, severity, status)
}
// handleGatewayProxy is mounted at /g/:slug and /g/:slug/*path on the root
// router — outside /api/v1 so route_prefix stays clean.
func handleGatewayProxy(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
ctx := c.Request.Context()
slug := c.Param("slug")
svc, err := lookupGatewayService(ctx, db, slug)
if err == sql.ErrNoRows {
respondError(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Gateway service not found")
return
}
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to resolve gateway service")
return
}
if !svc.Enabled {
respondError(c, http.StatusServiceUnavailable, "SERVICE_DISABLED", "Gateway service is disabled")
return
}
presented := extractGatewayKey(c)
if presented == "" {
recordGatewayIncident(ctx, db, svc.ID, nil, "MISSING_API_KEY", "Request without API key", "low", http.StatusUnauthorized)
respondError(c, http.StatusUnauthorized, "API_KEY_REQUIRED", "An API key is required for this service")
return
}
key, err := authenticateGatewayKey(ctx, db, presented)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to authenticate API key")
return
}
// Disabled and unknown keys collapse to the same 401 — a disabled key
// must not leak its existence.
if key == nil || !key.Enabled {
recordGatewayIncident(ctx, db, svc.ID, nil, "INVALID_API_KEY", "Request with invalid or disabled API key", "low", http.StatusUnauthorized)
respondError(c, http.StatusUnauthorized, "INVALID_API_KEY", "Invalid API key")
return
}
if !keyAllowsService(key, svc.ID) {
recordGatewayIncident(ctx, db, svc.ID, &key.ID, "KEY_SCOPE_DENIED", "API key not allowed on this service", "medium", http.StatusForbidden)
respondError(c, http.StatusForbidden, "KEY_SCOPE_DENIED", "API key is not permitted for this service")
return
}
now := time.Now()
if !gatewayRateAllow("key:"+key.ID, int(key.RPMLimit.Int64), now) ||
!gatewayRateAllow("svc:"+svc.ID, int(svc.RPMLimit.Int64), now) {
recordGatewayIncident(ctx, db, svc.ID, &key.ID, "RATE_LIMITED", "Per-minute rate limit exceeded", "medium", http.StatusTooManyRequests)
respondError(c, http.StatusTooManyRequests, "RATE_LIMITED", "Rate limit exceeded")
return
}
period := now.Format("2006-01")
if key.MonthlyQuota.Valid && key.MonthlyQuota.Int64 > 0 {
if used, err := monthlyUsage(ctx, db, "api_key_id", key.ID, period); err == nil && int64(used) >= key.MonthlyQuota.Int64 {
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Monthly request quota exceeded")
return
}
}
if svc.MonthlyQuota.Valid && svc.MonthlyQuota.Int64 > 0 {
if used, err := monthlyUsage(ctx, db, "service_id", svc.ID, period); err == nil && int64(used) >= svc.MonthlyQuota.Int64 {
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Service monthly quota exceeded")
return
}
}
target, err := gateway.BuildTargetURL(svc.UpstreamURL, svc.RoutePrefix, c.Request.URL.Path, c.Request.URL.RawQuery)
if err != nil {
respondError(c, http.StatusBadGateway, "UPSTREAM_URL_INVALID", "Gateway upstream is misconfigured")
return
}
headers := map[string]string{}
if svc.UpstreamAuthHeader.Valid && svc.UpstreamAuthHeader.String != "" {
headers[svc.UpstreamAuthHeader.String] = svc.UpstreamAuthValue.String
}
timeout := time.Duration(svc.RequestTimeoutMS) * time.Millisecond
if timeout <= 0 {
timeout = 8 * time.Second
}
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
status, _, err := gateway.ProxyRequest(gatewayHTTPClient, c.Writer, c.Request.WithContext(reqCtx), target, headers)
if err != nil {
recordGatewayIncident(reqCtx, db, svc.ID, &key.ID, "UPSTREAM_ERROR", err.Error(), "high", http.StatusBadGateway)
recordGatewayRequest(ctx, db, svc, key, http.StatusBadGateway, period)
respondError(c, http.StatusBadGateway, "UPSTREAM_ERROR", "Upstream request failed")
return
}
recordGatewayRequest(ctx, db, svc, key, status, period)
if status >= 500 {
recordGatewayIncident(ctx, db, svc.ID, &key.ID, "UPSTREAM_5XX", "Upstream returned "+http.StatusText(status), "high", status)
}
}
@@ -0,0 +1,81 @@
package api
import (
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestGatewayRateAllow(t *testing.T) {
now := time.Date(2026, 10, 15, 12, 0, 30, 0, time.UTC)
for i := 0; i < 3; i++ {
if !gatewayRateAllow("test:a", 3, now) {
t.Fatalf("call %d within limit rejected", i+1)
}
}
if gatewayRateAllow("test:a", 3, now) {
t.Fatal("call beyond limit allowed")
}
if !gatewayRateAllow("test:a", 3, now.Add(time.Minute)) {
t.Fatal("new minute window still rejected")
}
if !gatewayRateAllow("test:unlimited", 0, now) {
t.Fatal("non-positive limit should disable the check")
}
}
func TestExtractGatewayKey(t *testing.T) {
mkctx := func(headers map[string]string) *gin.Context {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/g/x", nil)
for k, v := range headers {
c.Request.Header.Set(k, v)
}
return c
}
if got := extractGatewayKey(mkctx(map[string]string{"X-API-Key": "ap_abc"})); got != "ap_abc" {
t.Fatalf("X-API-Key not extracted: %q", got)
}
if got := extractGatewayKey(mkctx(map[string]string{"Authorization": "Bearer ap_def"})); got != "ap_def" {
t.Fatalf("Bearer not extracted: %q", got)
}
// X-API-Key wins when both are present.
if got := extractGatewayKey(mkctx(map[string]string{
"X-API-Key": "ap_first",
"Authorization": "Bearer ap_second",
})); got != "ap_first" {
t.Fatalf("X-API-Key should take precedence: %q", got)
}
if got := extractGatewayKey(mkctx(map[string]string{})); got != "" {
t.Fatalf("expected empty key, got %q", got)
}
}
func TestKeyAllowsService(t *testing.T) {
svcID := "11111111-2222-3333-4444-555555555555"
other := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
cases := []struct {
name string
allowed string
want bool
}{
{"empty list permits all", "[]", true},
{"blank permits all", "", true},
{"invalid json permits all", "not-json", true},
{"listed service allowed", `["` + svcID + `"]`, true},
{"unlisted service denied", `["` + other + `"]`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
k := &gatewayKey{AllowedServiceIDs: tc.allowed}
if got := keyAllowsService(k, svcID); got != tc.want {
t.Fatalf("keyAllowsService(%q) = %v, want %v", tc.allowed, got, tc.want)
}
})
}
}
+5 -3
View File
@@ -133,7 +133,9 @@ func handleGetGitProviders(c *gin.Context) {
} }
func handleGetGitHubAppInstallURL(c *gin.Context) { func handleGetGitHubAppInstallURL(c *gin.Context) {
slug := strings.TrimSpace(os.Getenv("GITHUB_APP_SLUG")) db, _ := c.Get("db")
d, _ := db.(*database.DB)
slug := strings.TrimSpace(settingValue(d, settingGitHubAppSlug, "GITHUB_APP_SLUG", ""))
if slug == "" { if slug == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "GITHUB_APP_SLUG is not configured"}) c.JSON(http.StatusBadRequest, gin.H{"error": "GITHUB_APP_SLUG is not configured"})
return return
@@ -1073,8 +1075,8 @@ func fetchBitbucketRepositories(providerAPIURL, accessToken string) ([]remoteGit
} }
func getGitHubAppInstallationToken(baseURL string, installationID int64) (string, error) { func getGitHubAppInstallationToken(baseURL string, installationID int64) (string, error) {
appID := strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) appID := platformSetting(settingGitHubAppID, "GITHUB_APP_ID")
privateKeyPEM := strings.TrimSpace(os.Getenv("GITHUB_APP_PRIVATE_KEY")) privateKeyPEM := platformSetting(settingGitHubAppPrivateKey, "GITHUB_APP_PRIVATE_KEY")
if appID == "" || privateKeyPEM == "" { if appID == "" || privateKeyPEM == "" {
return "", fmt.Errorf("GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY must be configured") return "", fmt.Errorf("GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY must be configured")
} }
+53 -20
View File
@@ -7,6 +7,7 @@ import (
"crypto/subtle" "crypto/subtle"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"strings" "strings"
@@ -90,11 +91,24 @@ func handleGitWebhookPush(c *gin.Context) {
return return
} }
if !strings.HasPrefix(payload.Ref, "refs/heads/") { // Non-push events that still mean "the repo changed": fork-sync bots
// (sync), repository_dispatch, workflow_run completions. They carry no
// `ref`, so fall back to the webhook's branch filter (or the service's
// own git_branch via the repo match below).
event := c.Request.Header.Get("X-GitHub-Event")
if event == "" {
event = c.Request.Header.Get("X-Gitea-Event")
}
syncEvent := event == "sync" || event == "repository_dispatch" || event == "workflow_run"
if !strings.HasPrefix(payload.Ref, "refs/heads/") && !syncEvent {
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "not a branch push"}) c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "not a branch push"})
return return
} }
branch := strings.TrimPrefix(payload.Ref, "refs/heads/") branch := strings.TrimPrefix(payload.Ref, "refs/heads/")
if branch == "" {
branch = branchFilter
}
if branchFilter != "" && branchFilter != branch { if branchFilter != "" && branchFilter != branch {
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "branch not watched"}) c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "branch not watched"})
return return
@@ -113,16 +127,40 @@ func handleGitWebhookPush(c *gin.Context) {
return return
} }
rows, err := db.Query(` engineValue, _ := c.Get("deployment_engine")
engine, _ := engineValue.(*deployment.DeploymentEngine)
enqueued, err := dispatchPushToServices(c, db, engine, cloneURL, fullName, branch, commit, repoUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"})
return
}
c.JSON(http.StatusAccepted, gin.H{
"received": true,
"branch": branch,
"deployments": enqueued,
})
}
// dispatchPushToServices enqueues a deployment for every service tracking
// the pushed repo+branch. Shared by the per-repo webhook receiver and the
// GitHub App webhook endpoint.
func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.DeploymentEngine, cloneURL, fullName, branch, commit, repoUserID string) (int, error) {
// Sync-style events may carry no branch — match services on repo only.
branchClause := "s.git_branch = $1"
if branch == "" {
branchClause = "TRUE"
}
rows, err := db.Query(fmt.Sprintf(`
SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command, 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, s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
s.created_at, s.updated_at s.created_at, s.updated_at
FROM services s FROM services s
WHERE s.git_branch = $1 AND (s.git_repo = $2 OR s.git_repo = $3)`, WHERE %s AND (s.git_repo = $2 OR s.git_repo = $3)`, branchClause),
branch, cloneURL, fullName) branch, cloneURL, fullName)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"}) return 0, err
return
} }
defer rows.Close() defer rows.Close()
@@ -134,15 +172,11 @@ func handleGitWebhookPush(c *gin.Context) {
&s.Environment, &s.GitRepo, &s.GitBranch, &s.BuildPath, &s.CPU, &s.Memory, &s.Environment, &s.GitRepo, &s.GitBranch, &s.BuildPath, &s.CPU, &s.Memory,
&s.CreatedAt, &s.UpdatedAt, &s.CreatedAt, &s.UpdatedAt,
); err != nil { ); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"}) return 0, err
return
} }
services = append(services, s) services = append(services, s)
} }
engineValue, _ := c.Get("deployment_engine")
engine, _ := engineValue.(*deployment.DeploymentEngine)
enqueued := 0 enqueued := 0
for _, service := range services { for _, service := range services {
now := time.Now() now := time.Now()
@@ -160,9 +194,9 @@ func handleGitWebhookPush(c *gin.Context) {
} }
if _, err := db.Exec( if _, err := db.Exec(
`INSERT INTO deployments `INSERT INTO deployments
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at) (id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt, d.ID, d.ServiceID, fmt.Sprintf("v%d", now.Unix()), d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
); err != nil { ); err != nil {
continue continue
} }
@@ -181,7 +215,11 @@ func handleGitWebhookPush(c *gin.Context) {
) )
// Webhook pushes default to a clean build — stale layers are the // Webhook pushes default to a clean build — stale layers are the
// classic silent-rollback failure (dflow convention). // classic silent-rollback failure (dflow convention).
webhookReq := CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook", NoCache: true} branchForBuild := branch
if branchForBuild == "" {
branchForBuild = service.GitBranch
}
webhookReq := CreateDeploymentRequest{CommitHash: commit, Branch: branchForBuild, Trigger: "webhook", NoCache: true}
if pos := getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{ if pos := getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
DeploymentID: d.ID, DeploymentID: d.ID,
Run: func(jctx context.Context) { Run: func(jctx context.Context) {
@@ -199,12 +237,7 @@ func handleGitWebhookPush(c *gin.Context) {
} }
enqueued++ enqueued++
} }
return enqueued, nil
c.JSON(http.StatusAccepted, gin.H{
"received": true,
"branch": branch,
"deployments": enqueued,
})
} }
// verifyGitWebhookSignature checks the push signature per provider: // verifyGitWebhookSignature checks the push signature per provider:
+84
View File
@@ -0,0 +1,84 @@
package api
import (
"fmt"
"strconv"
"strings"
"containr/internal/database"
"containr/internal/source"
)
// resolveGitCloneSpec turns a service's git_repo (clone URL, ssh remote, or
// owner/repo) into a source.Spec with the project owner's provider token.
// Public repos get no token — the clone proceeds anonymously. github_app
// providers store the installation id, not a usable token; a fresh
// installation token is minted per deploy.
func resolveGitCloneSpec(db *database.DB, ownerID, gitRepo, branch, commit, buildPath string) (source.Spec, error) {
raw := strings.TrimSpace(gitRepo)
cloneURL, err := source.NormalizeCloneURL(raw)
if err != nil {
return source.Spec{}, err
}
spec := source.Spec{Branch: branch, Commit: commit, BuildPath: buildPath}
var providerID string
if !strings.Contains(cloneURL, "://") {
// Bare owner/repo — the connected repository row knows the host.
err = db.QueryRow(
`SELECT clone_url, provider_id FROM git_repositories
WHERE full_name = $1 AND user_id = $2 LIMIT 1`,
cloneURL, ownerID,
).Scan(&cloneURL, &providerID)
if err != nil {
return source.Spec{}, fmt.Errorf("repository %s is not connected — connect it under Git settings or store a clone URL", raw)
}
} else {
// URL form — match a connected repo for its provider when possible;
// an unmatched public URL still clones, just without credentials.
_ = db.QueryRow(
`SELECT provider_id FROM git_repositories
WHERE clone_url = $1 AND user_id = $2 LIMIT 1`,
cloneURL, ownerID,
).Scan(&providerID)
}
if err := source.ValidateRemoteURL(cloneURL); err != nil {
return source.Spec{}, err
}
spec.CloneURL = cloneURL
if providerID == "" {
return spec, nil
}
var name, apiURL, token string
if err := db.QueryRow(
`SELECT name, api_url, access_token FROM git_providers
WHERE id = $1 AND user_id = $2`,
providerID, ownerID,
).Scan(&name, &apiURL, &token); err != nil {
return spec, nil // provider row gone — anonymous clone still works for public repos
}
switch strings.ToLower(name) {
case "github_app":
installationID, err := strconv.ParseInt(strings.TrimSpace(token), 10, 64)
if err == nil && installationID > 0 {
if minted, err := getGitHubAppInstallationToken(apiURL, installationID); err == nil {
spec.Token = minted
spec.User = "x-access-token"
}
}
case "gitlab":
spec.Token = token
spec.User = "oauth2"
case "bitbucket":
spec.Token = token
spec.User = "x-token-auth"
default:
spec.Token = token
spec.User = "x-access-token"
}
return spec, nil
}
+310
View File
@@ -0,0 +1,310 @@
package api
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"containr/internal/database"
"containr/internal/deployment"
"github.com/gin-gonic/gin"
)
// GitHub App self-provisioning (manifest flow): an admin POSTs the manifest
// to github.com/settings/apps/new, GitHub redirects back with `code`, and the
// code is exchanged at /app-manifests/{code}/conversions for real credentials
// stored in app_settings. Values stored there take precedence over the
// GITHUB_APP_* env vars, so the flow removes the manual env-setup barrier.
const (
settingGitHubAppID = "github_app.id"
settingGitHubAppSlug = "github_app.slug"
settingGitHubAppName = "github_app.name"
settingGitHubAppClientID = "github_app.client_id"
settingGitHubAppClientSecret = "github_app.client_secret"
settingGitHubAppPrivateKey = "github_app.private_key"
settingGitHubAppWebhookSecret = "github_app.webhook_secret"
)
// platformSetting resolves a setting row first, then env, then empty.
func platformSetting(key, envKey string) string {
return strings.TrimSpace(settingValue(GetAuditDB(), key, envKey, ""))
}
func githubAppConfigured(db *database.DB) bool {
return strings.TrimSpace(settingValue(db, settingGitHubAppID, "GITHUB_APP_ID", "")) != "" &&
strings.TrimSpace(settingValue(db, settingGitHubAppPrivateKey, "GITHUB_APP_PRIVATE_KEY", "")) != ""
}
// POST /admin/git/github-app/manifest — builds the GitHub App manifest the
// browser POSTs to github.com. Response carries the manifest as a JSON string
// (the form submits it verbatim) plus the destination URL.
func handleAdminGitHubAppManifest(c *gin.Context) {
var req struct {
BaseURL string `json:"base_url" binding:"required"`
Name string `json:"name"`
Organization string `json:"organization"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "base_url is required")
return
}
base := strings.TrimSuffix(strings.TrimSpace(req.BaseURL), "/")
if !strings.HasPrefix(base, "https://") &&
!strings.HasPrefix(base, "http://localhost") &&
!strings.HasPrefix(base, "http://127.0.0.1") {
respondError(c, http.StatusBadRequest, "VALIDATION", "base_url must be an https URL (localhost http allowed)")
return
}
stateBuf := make([]byte, 16)
if _, err := rand.Read(stateBuf); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate state")
return
}
state := hex.EncodeToString(stateBuf)
name := strings.TrimSpace(req.Name)
if name == "" {
db, _ := c.Get("db")
if d, ok := db.(*database.DB); ok {
name = strings.TrimSpace(settingValue(d, settingBrandProductName, "BRAND_NAME", ""))
}
if name == "" {
name = "Containr"
}
name = fmt.Sprintf("%s-%s", name, time.Now().UTC().Format("2006-01-02"))
}
callbackURL := base + "/git/github-app/callback"
manifest, err := json.Marshal(map[string]interface{}{
"name": name,
"url": base,
"hook_attributes": map[string]interface{}{
"url": base + "/api/git/github-app/webhook",
"active": true,
},
"redirect_url": callbackURL,
"callback_urls": []string{callbackURL},
"public": false,
"default_permissions": map[string]string{
"contents": "read",
"metadata": "read",
"pull_requests": "write",
},
"default_events": []string{"push", "pull_request"},
})
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to build manifest")
return
}
org := strings.TrimSpace(req.Organization)
createURL := fmt.Sprintf("https://github.com/settings/apps/new?state=%s", state)
if org != "" {
createURL = fmt.Sprintf("https://github.com/organizations/%s/settings/apps/new?state=%s", org, state)
}
c.JSON(http.StatusOK, gin.H{
"url": createURL,
"manifest": string(manifest),
"state": state,
})
}
// POST /admin/git/github-app/convert — exchanges the `code` GitHub appends to
// the manifest redirect for the real app credentials, then stores them.
func handleAdminGitHubAppConvert(c *gin.Context) {
var req struct {
Code string `json:"code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "code is required")
return
}
apiBase := strings.TrimSuffix(strings.TrimSpace(os.Getenv("GITHUB_APP_BASE_URL")), "/")
if apiBase == "" {
apiBase = "https://api.github.com"
}
request, err := http.NewRequest(http.MethodPost,
fmt.Sprintf("%s/app-manifests/%s/conversions", apiBase, strings.TrimSpace(req.Code)), nil)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to build conversion request")
return
}
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("X-GitHub-Api-Version", "2022-11-28")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(request)
if err != nil {
respondError(c, http.StatusBadGateway, "UPSTREAM", "failed to reach GitHub")
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
respondError(c, http.StatusBadGateway, "UPSTREAM",
fmt.Sprintf("GitHub conversion failed (%d)", resp.StatusCode))
return
}
var app struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
PEM string `json:"pem"`
WebhookSecret string `json:"webhook_secret"`
}
if err := json.Unmarshal(body, &app); err != nil || app.ID == 0 {
respondError(c, http.StatusBadGateway, "UPSTREAM", "invalid conversion response")
return
}
db := c.MustGet("db").(*database.DB)
for _, kv := range []struct {
key string
value string
secret bool
}{
{settingGitHubAppID, fmt.Sprintf("%d", app.ID), false},
{settingGitHubAppSlug, app.Slug, false},
{settingGitHubAppName, app.Name, false},
{settingGitHubAppClientID, app.ClientID, false},
{settingGitHubAppClientSecret, app.ClientSecret, true},
{settingGitHubAppPrivateKey, app.PEM, true},
{settingGitHubAppWebhookSecret, app.WebhookSecret, true},
} {
if err := setSetting(db, kv.key, kv.value, kv.secret); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to store app credentials")
return
}
}
LogAuditWithRequest(c, "github_app", fmt.Sprintf("%d", app.ID), "provision",
map[string]interface{}{"name": app.Name, "slug": app.Slug})
c.JSON(http.StatusOK, gin.H{
"app_id": app.ID,
"slug": app.Slug,
"name": app.Name,
})
}
// GET /admin/git/github-app — provisioning status for the settings UI.
func handleAdminGetGitHubApp(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
var stored string
source := "none"
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingGitHubAppID).Scan(&stored); err == nil && strings.TrimSpace(stored) != "" {
source = "settings"
} else if strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" {
source = "env"
}
c.JSON(http.StatusOK, gin.H{
"configured": githubAppConfigured(db),
"source": source,
"app_id": settingValue(db, settingGitHubAppID, "GITHUB_APP_ID", ""),
"slug": settingValue(db, settingGitHubAppSlug, "GITHUB_APP_SLUG", ""),
"name": settingValue(db, settingGitHubAppName, "", ""),
})
}
// POST /api/git/github-app/webhook — the single App-level webhook all
// installed repos deliver to. Verified against the provisioned webhook
// secret, then dispatched to matching services.
func handleGitHubAppWebhook(c *gin.Context) {
dbValue, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
db := dbValue.(*database.DB)
secret := strings.TrimSpace(settingValue(db, settingGitHubAppWebhookSecret, "GITHUB_APP_WEBHOOK_SECRET", ""))
if secret == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "GitHub App webhook not configured"})
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 4<<20))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read payload"})
return
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := mac.Sum(nil)
sig := strings.TrimPrefix(c.GetHeader("X-Hub-Signature-256"), "sha256=")
decoded, err := hex.DecodeString(sig)
if err != nil || len(decoded) == 0 || subtle.ConstantTimeCompare(decoded, expected) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"})
return
}
event := c.GetHeader("X-GitHub-Event")
if event != "push" {
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "event " + event})
return
}
var payload struct {
Ref string `json:"ref"`
After string `json:"after"`
Repository struct {
FullName string `json:"full_name"`
CloneURL string `json:"clone_url"`
} `json:"repository"`
HeadCommit struct {
ID string `json:"id"`
} `json:"head_commit"`
}
if err := json.Unmarshal(body, &payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
if !strings.HasPrefix(payload.Ref, "refs/heads/") {
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "not a branch push"})
return
}
branch := strings.TrimPrefix(payload.Ref, "refs/heads/")
commit := payload.HeadCommit.ID
if commit == "" {
commit = payload.After
}
cloneURL := payload.Repository.CloneURL
fullName := payload.Repository.FullName
if cloneURL == "" {
cloneURL = fmt.Sprintf("https://github.com/%s.git", fullName)
}
// Services tracking an App-connected repo store either the clone URL or
// owner/name — match both like the per-repo receiver does.
engineValue, _ := c.Get("deployment_engine")
engine, _ := engineValue.(*deployment.DeploymentEngine)
enqueued, err := dispatchPushToServices(c, db, engine, cloneURL, fullName, branch, commit, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"})
return
}
c.JSON(http.StatusAccepted, gin.H{
"received": true,
"branch": branch,
"deployments": enqueued,
})
}
+212
View File
@@ -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))
}
+245
View File
@@ -0,0 +1,245 @@
package api
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"net/http"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// Team invites: an admin creates a single-use link; the invitee registers
// through it even while public signup is closed. Tokens are random 32-byte
// values shared once — only their sha256 hash is stored.
type inviteJSON struct {
ID string `json:"id"`
Email *string `json:"email,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
}
func toInviteJSON(i sqlcdb.UserInvite) inviteJSON {
out := inviteJSON{ID: i.ID.String(), ExpiresAt: i.ExpiresAt}
if i.Email.Valid && i.Email.String != "" {
out.Email = &i.Email.String
}
if i.UsedAt.Valid {
out.UsedAt = &i.UsedAt.Time
}
if i.CreatedAt.Valid {
out.CreatedAt = &i.CreatedAt.Time
}
return out
}
func inviteTokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func generateInviteToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// POST /admin/invites — {email?, expires_in_hours?} → {invite, token, url}
func handleAdminCreateInvite(c *gin.Context) {
var req struct {
Email string `json:"email"`
ExpiresInHours *int `json:"expires_in_hours"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "invalid request body")
return
}
ttl := 24 * 7
if req.ExpiresInHours != nil {
if *req.ExpiresInHours < 1 || *req.ExpiresInHours > 24*90 {
respondError(c, http.StatusBadRequest, "VALIDATION", "expires_in_hours must be 1–2160")
return
}
ttl = *req.ExpiresInHours
}
token, err := generateInviteToken()
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate token")
return
}
userID, _ := requireAuthenticatedUserUUID(c)
params := sqlcdb.CreateUserInviteParams{
TokenHash: inviteTokenHash(token),
CreatedBy: uuid.NullUUID{UUID: userID, Valid: true},
ExpiresAt: time.Now().UTC().Add(time.Duration(ttl) * time.Hour),
}
if email := strings.ToLower(strings.TrimSpace(req.Email)); email != "" {
params.Email = sql.NullString{String: email, Valid: true}
}
db := c.MustGet("db").(*database.DB)
invite, err := sqlcdb.New(db.DB).CreateUserInvite(c.Request.Context(), params)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to create invite")
return
}
c.JSON(http.StatusCreated, gin.H{
"invite": toInviteJSON(invite),
"token": token,
"url": "/auth/accept-invite?token=" + token,
})
}
// GET /admin/invites
func handleAdminListInvites(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
invites, err := sqlcdb.New(db.DB).ListUserInvites(c.Request.Context())
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list invites")
return
}
out := make([]inviteJSON, 0, len(invites))
for _, i := range invites {
out = append(out, toInviteJSON(i))
}
c.JSON(http.StatusOK, gin.H{"invites": out})
}
// DELETE /admin/invites/:id — revoke an unused invite.
func handleAdminDeleteInvite(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid invite id")
return
}
db := c.MustGet("db").(*database.DB)
if err := sqlcdb.New(db.DB).DeleteUserInvite(c.Request.Context(), id); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to delete invite")
return
}
c.Status(http.StatusNoContent)
}
// GET /auth/invites/:token — public. Lets the accept page validate the link
// and pre-fill a bound email before the user commits to registering.
func handleGetInvite(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
invite, err := sqlcdb.New(db.DB).GetUserInviteByTokenHash(c.Request.Context(), inviteTokenHash(c.Param("token")))
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "invite not found")
return
}
expired := time.Now().After(invite.ExpiresAt)
var email *string
if invite.Email.Valid && invite.Email.String != "" {
email = &invite.Email.String
}
c.JSON(http.StatusOK, gin.H{
"valid": !invite.UsedAt.Valid && !expired,
"used": invite.UsedAt.Valid,
"expired": expired,
"email": email,
"expires_at": invite.ExpiresAt,
})
}
// POST /auth/accept-invite — {token, name, email, password}
func handleAcceptInvite(c *gin.Context) {
var req struct {
Token string `json:"token" binding:"required"`
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "token, name, email and password are required")
return
}
if len(req.Password) < 8 {
respondError(c, http.StatusBadRequest, "VALIDATION", "password must be at least 8 characters")
return
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
db := c.MustGet("db").(*database.DB)
q := sqlcdb.New(db.DB)
invite, err := q.GetUserInviteByTokenHash(c.Request.Context(), inviteTokenHash(req.Token))
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "invite not found")
return
}
if invite.UsedAt.Valid {
respondError(c, http.StatusGone, "INVITE_USED", "invite already used")
return
}
if time.Now().After(invite.ExpiresAt) {
respondError(c, http.StatusGone, "INVITE_EXPIRED", "invite expired")
return
}
if invite.Email.Valid && invite.Email.String != "" && invite.Email.String != req.Email {
respondError(c, http.StatusForbidden, "INVITE_EMAIL_MISMATCH", "invite is bound to a different email")
return
}
var existing int
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE email = $1`, req.Email).Scan(&existing); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "database error")
return
}
if existing > 0 {
respondError(c, http.StatusConflict, "CONFLICT", "user already exists")
return
}
if err := createBetterAuthUser(c, strings.TrimSpace(req.Name), req.Email, req.Password); err != nil && !errors.Is(err, errBetterAuthUserExists) {
respondError(c, http.StatusInternalServerError, "INTERNAL", err.Error())
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to hash password")
return
}
var user User
err = db.QueryRow(`
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, req.Email, string(hashedPassword), strings.TrimSpace(req.Name)).
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err != nil {
respondError(c, http.StatusConflict, "CONFLICT", "user already exists")
return
}
userUUID, _ := uuid.Parse(user.ID)
if err := q.MarkUserInviteUsed(c.Request.Context(), sqlcdb.MarkUserInviteUsedParams{
ID: invite.ID,
UsedBy: uuid.NullUUID{UUID: userUUID, Valid: userUUID != uuid.Nil},
}); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to consume invite")
return
}
jwtSecret := c.MustGet("jwt_secret").(string)
token, err := generateJWT(user.ID, user.Email, jwtSecret)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate token")
return
}
c.JSON(http.StatusCreated, AuthResponse{Token: token, User: user})
}
+496
View File
@@ -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 {
+419 -258
View File
@@ -2,7 +2,12 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/deployqueue"
"context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -26,11 +31,77 @@ 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"`
} }
// previewRowShape is the common column set the generated preview rows
// carry — ListPreviewEnvironmentsForProjectRow and GetPreviewEnvironmentRow
// share it.
type previewRowShape struct {
ID uuid.UUID
ProjectID uuid.UUID
ServiceID uuid.UUID
PreviewServiceID uuid.NullUUID
BranchName string
PrNumber sql.NullInt32
Environment string
Status string
Url sql.NullString
ExpiresAt time.Time
CreatedAt sql.NullTime
UpdatedAt sql.NullTime
SvcID uuid.NullUUID
ServiceName sql.NullString
ServiceType sql.NullString
}
func previewFromRow(r previewRowShape) PreviewEnvironment {
env := PreviewEnvironment{
ID: r.ID,
ProjectID: r.ProjectID,
ServiceID: r.ServiceID,
BranchName: r.BranchName,
Environment: r.Environment,
Status: r.Status,
URL: r.Url.String,
CreatedAt: r.CreatedAt.Time,
UpdatedAt: r.UpdatedAt.Time,
}
if r.PreviewServiceID.Valid {
id := r.PreviewServiceID.UUID
env.PreviewServiceID = &id
}
if r.PrNumber.Valid {
n := int(r.PrNumber.Int32)
env.PRNumber = &n
}
if !r.ExpiresAt.IsZero() {
t := r.ExpiresAt
env.ExpiresAt = &t
}
if r.SvcID.Valid {
env.Service = &Service{
ID: r.SvcID.UUID,
Name: r.ServiceName.String,
Type: r.ServiceType.String,
}
}
return env
}
func prNumberSQL(n *int) sql.NullInt32 {
if n == nil {
return sql.NullInt32{}
}
return sql.NullInt32{Int32: int32(*n), Valid: true}
}
// CreatePreviewEnvironmentRequest represents a request to create a preview environment // CreatePreviewEnvironmentRequest represents a request to create a preview environment
type CreatePreviewEnvironmentRequest struct { type CreatePreviewEnvironmentRequest struct {
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
@@ -77,51 +148,21 @@ 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 := sqlcdb.New(db.(*database.DB).DB).ListPreviewEnvironmentsForProject(c.Request.Context(), projectID)
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
WHERE pe.project_id = $1
ORDER BY pe.created_at DESC`,
projectID,
)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve preview environments"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve preview environments"})
return return
} }
defer rows.Close()
var environments []PreviewEnvironment environments := make([]PreviewEnvironment, 0, len(rows))
for rows.Next() { for _, r := range rows {
var env PreviewEnvironment environments = append(environments, previewFromRow(previewRowShape{
var serviceID sql.NullString ID: r.ID, ProjectID: r.ProjectID, ServiceID: r.ServiceID,
var serviceName sql.NullString PreviewServiceID: r.PreviewServiceID, BranchName: r.BranchName,
var serviceType sql.NullString PrNumber: r.PrNumber, Environment: r.Environment, Status: r.Status,
Url: r.Url, ExpiresAt: r.ExpiresAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
err := rows.Scan( SvcID: r.SvcID, ServiceName: r.ServiceName, ServiceType: r.ServiceType,
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber, }))
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
&serviceID, &serviceName, &serviceType,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan preview environment"})
return
}
if serviceID.Valid {
parsedServiceID, parseErr := uuid.Parse(serviceID.String)
if parseErr == nil {
env.Service = &Service{
ID: parsedServiceID,
Name: serviceName.String,
Type: serviceType.String,
}
}
}
environments = append(environments, env)
} }
c.JSON(http.StatusOK, gin.H{"preview_environments": environments}) c.JSON(http.StatusOK, gin.H{"preview_environments": environments})
@@ -162,48 +203,37 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
return return
} }
// Check if project exists and user has access ctx := c.Request.Context()
var project Project q := sqlcdb.New(db.(*database.DB).DB)
err = db.(*database.DB).QueryRow(
"SELECT id, name, owner_id FROM projects WHERE id = $1",
req.ProjectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
// Check if project exists and user has access
project, err := q.GetProjectBrief(ctx, req.ProjectID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return return
} }
// Check if user owns the project // Check if user owns the project
if project.OwnerID != userID.(string) && !contextIsAdmin(c) { if project.OwnerID.String() != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
// Check if service exists and belongs to the project // Check if service exists and belongs to the project
var service Service service, err := q.GetServiceTypeBrief(ctx, sqlcdb.GetServiceTypeBriefParams{
err = db.(*database.DB).QueryRow( ID: req.ServiceID, ProjectID: req.ProjectID})
"SELECT id, name, COALESCE(type, service_type, '') FROM services WHERE id = $1 AND project_id = $2",
req.ServiceID, req.ProjectID,
).Scan(&service.ID, &service.Name, &service.Type)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found or doesn't belong to this project"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found or doesn't belong to this project"})
return return
} }
// Check if preview environment already exists for this branch and service // Check if preview environment already exists for this branch and service
var count int count, err := q.CountActivePreviewsForBranch(ctx, sqlcdb.CountActivePreviewsForBranchParams{
err = db.(*database.DB).QueryRow( ServiceID: req.ServiceID, BranchName: req.BranchName})
"SELECT COUNT(*) FROM preview_environments WHERE service_id = $1 AND branch_name = $2 AND status NOT IN ('expired', 'stopped')",
req.ServiceID, req.BranchName,
).Scan(&count)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing preview environment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing preview environment"})
return return
} }
if count > 0 { if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Preview environment already exists for this branch and service"}) c.JSON(http.StatusConflict, gin.H{"error": "Preview environment already exists for this branch and service"})
return return
@@ -215,87 +245,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 := q.UpdateServicePreviewBranch(ctx, sqlcdb.UpdateServicePreviewBranchParams{
GitBranch: sql.NullString{String: req.BranchName, Valid: true}, ID: cloneID}); err != nil {
_ = q.DeleteServiceByID(ctx, 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 := q.DeleteAllServiceDomains(ctx, cloneID); err != nil {
_ = q.DeleteServiceByID(ctx, cloneID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure preview domains"})
return
}
previewHost := resolvePreviewHost(db.(*database.DB), cloneID, envName)
if previewHost != "" {
_ = q.CreatePreviewDomain(ctx, sqlcdb.CreatePreviewDomainParams{
ServiceID: cloneID, Domain: 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 = q.CreatePreviewEnvironment(ctx, sqlcdb.CreatePreviewEnvironmentParams{
_, err = db.(*database.DB).Exec( ID: env.ID,
`INSERT INTO preview_environments ProjectID: env.ProjectID,
(id, project_id, service_id, branch_name, pr_number, environment, ServiceID: env.ServiceID,
status, url, expires_at, created_at, updated_at) PreviewServiceID: uuid.NullUUID{UUID: cloneID, Valid: true},
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, BranchName: env.BranchName,
env.ID, env.ProjectID, env.ServiceID, env.BranchName, env.PRNumber, PrNumber: prNumberSQL(env.PRNumber),
env.Environment, env.Status, env.URL, env.ExpiresAt, env.CreatedAt, env.UpdatedAt, Environment: env.Environment,
) Status: env.Status,
Url: sql.NullString{String: env.URL, Valid: env.URL != ""},
ExpiresAt: *env.ExpiresAt,
CreatedAt: sql.NullTime{Time: env.CreatedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: env.UpdatedAt, Valid: true},
})
if err != nil { if err != nil {
_ = q.DeleteServiceByID(ctx, 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 = q.InsertPendingDeployment(ctx, sqlcdb.InsertPendingDeploymentParams{
sanitizedBranch = strings.ReplaceAll(sanitizedBranch, "_", "-") ID: deploymentID,
version := fmt.Sprintf("preview-%s-%d", sanitizedBranch, time.Now().Unix()) ServiceID: cloneID,
startedAt := time.Now().UTC() Version: "preview-" + env.Environment,
completedAt := startedAt CommitHash: sql.NullString{String: req.BranchName, Valid: true},
})
_, deployErr := db.(*database.DB).Exec( if err != nil {
`INSERT INTO deployments _ = q.DeletePreviewEnvironment(ctx, env.ID)
(id, service_id, version, commit_hash, status, started_at, completed_at, deployment_log, created_at, updated_at) _ = q.DeleteServiceByID(ctx, cloneID)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())`,
deploymentID,
req.ServiceID,
version,
req.BranchName,
"running",
startedAt,
completedAt,
fmt.Sprintf("Preview environment %s activated for branch %s", env.Environment, req.BranchName),
)
if deployErr != nil {
_, _ = db.(*database.DB).Exec(
`UPDATE preview_environments
SET status = 'failed', updated_at = NOW()
WHERE id = $1`,
env.ID,
)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to provision preview deployment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to provision preview deployment"})
return return
} }
if strings.TrimSpace(env.URL) == "" {
env.URL = fmt.Sprintf("https://%s.preview.containr.local", env.Environment)
}
env.Status = "running"
env.UpdatedAt = time.Now().UTC()
env.DeploymentID = &deploymentID env.DeploymentID = &deploymentID
_, err = db.(*database.DB).Exec( if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
`UPDATE preview_environments if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
SET status = $1, url = $2, updated_at = $3 cloneService, loadErr := loadServiceForDeploy(db.(*database.DB), cloneID)
WHERE id = $4`, if loadErr == nil {
env.Status, engineInstance := engine
env.URL, d := DeploymentModel{ID: deploymentID, ServiceID: cloneID, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now()}
env.UpdatedAt, getDeployQueue(c).Enqueue(cloneID, deployqueue.Job{
env.ID, DeploymentID: deploymentID,
) Run: func(jctx context.Context) {
if err != nil { runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, cloneService, CreateDeploymentRequest{Branch: req.BranchName, Trigger: "preview"}, userID.(string))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to finalize preview environment"}) },
return })
}
}
} }
LogAuditWithRequest(c, "service", req.ServiceID.String(), "preview.create", map[string]interface{}{
"preview_environment_id": env.ID.String(),
"preview_service_id": cloneID.String(),
"branch": req.BranchName,
})
c.JSON(http.StatusCreated, gin.H{"preview_environment": env}) c.JSON(http.StatusCreated, gin.H{"preview_environment": env})
} }
@@ -316,43 +370,20 @@ func handleGetPreviewEnvironment(c *gin.Context) {
// Public when the parent project is approved; otherwise owner/member/admin. // Public when the parent project is approved; otherwise owner/member/admin.
userID := optionalUserUUID(c) userID := optionalUserUUID(c)
var env PreviewEnvironment r, err := sqlcdb.New(db.(*database.DB).DB).GetPreviewEnvironment(
var serviceID sql.NullString c.Request.Context(), sqlcdb.GetPreviewEnvironmentParams{
var serviceName sql.NullString ID: envID, OwnerID: userID, Column3: contextIsAdmin(c)})
var serviceType sql.NullString
err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
envID, userID, contextIsAdmin(c),
).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
&serviceID, &serviceName, &serviceType,
)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
return return
} }
env := previewFromRow(previewRowShape{
// Populate service info if available ID: r.ID, ProjectID: r.ProjectID, ServiceID: r.ServiceID,
if serviceID.Valid { PreviewServiceID: r.PreviewServiceID, BranchName: r.BranchName,
parsedServiceID, parseErr := uuid.Parse(serviceID.String) PrNumber: r.PrNumber, Environment: r.Environment, Status: r.Status,
if parseErr == nil { Url: r.Url, ExpiresAt: r.ExpiresAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
env.Service = &Service{ SvcID: r.SvcID, ServiceName: r.ServiceName, ServiceType: r.ServiceType,
ID: parsedServiceID, })
Name: serviceName.String,
Type: serviceType.String,
}
}
}
c.JSON(http.StatusOK, gin.H{"preview_environment": env}) c.JSON(http.StatusOK, gin.H{"preview_environment": env})
} }
@@ -385,24 +416,21 @@ func handleUpdatePreviewEnvironment(c *gin.Context) {
} }
// Check if preview environment exists and user has access // Check if preview environment exists and user has access
var existingEnv PreviewEnvironment uid, _ := uuid.Parse(fmt.Sprint(userID))
err = db.(*database.DB).QueryRow( q := sqlcdb.New(db.(*database.DB).DB)
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number, r, err := q.GetPreviewEnvironmentForWrite(
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at c.Request.Context(), sqlcdb.GetPreviewEnvironmentForWriteParams{
FROM preview_environments pe ID: envID, OwnerID: uid, Column3: contextIsAdmin(c)})
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
envID, userID, contextIsAdmin(c),
).Scan(
&existingEnv.ID, &existingEnv.ProjectID, &existingEnv.ServiceID, &existingEnv.BranchName,
&existingEnv.PRNumber, &existingEnv.Environment, &existingEnv.Status, &existingEnv.URL,
&existingEnv.ExpiresAt, &existingEnv.CreatedAt, &existingEnv.UpdatedAt,
)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
return return
} }
existingEnv := previewFromRow(previewRowShape{
ID: r.ID, ProjectID: r.ProjectID, ServiceID: r.ServiceID,
BranchName: r.BranchName, PrNumber: r.PrNumber, Environment: r.Environment,
Status: r.Status, Url: r.Url, ExpiresAt: r.ExpiresAt,
CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
})
// Update fields if provided // Update fields if provided
if req.Status != "" { if req.Status != "" {
@@ -422,12 +450,17 @@ func handleUpdatePreviewEnvironment(c *gin.Context) {
existingEnv.UpdatedAt = time.Now() existingEnv.UpdatedAt = time.Now()
// Update preview environment in database // Update preview environment in database
_, err = db.(*database.DB).Exec( expiresAt := time.Time{}
`UPDATE preview_environments if existingEnv.ExpiresAt != nil {
SET status = $1, url = $2, expires_at = $3, updated_at = $4 expiresAt = *existingEnv.ExpiresAt
WHERE id = $5`, }
existingEnv.Status, existingEnv.URL, existingEnv.ExpiresAt, existingEnv.UpdatedAt, existingEnv.ID, err = q.UpdatePreviewEnvironment(c.Request.Context(), sqlcdb.UpdatePreviewEnvironmentParams{
) Status: existingEnv.Status,
Url: sql.NullString{String: existingEnv.URL, Valid: existingEnv.URL != ""},
ExpiresAt: expiresAt,
UpdatedAt: sql.NullTime{Time: existingEnv.UpdatedAt, Valid: true},
ID: existingEnv.ID,
})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment"})
@@ -459,49 +492,34 @@ func handleDeletePreviewEnvironment(c *gin.Context) {
return return
} }
// Check if preview environment exists and user has access ctx := c.Request.Context()
var projectOwnerID string q := sqlcdb.New(db.(*database.DB).DB)
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1`,
envID,
).Scan(&projectOwnerID)
// Check if preview environment exists and user has access
projectOwnerID, err := q.GetPreviewEnvironmentOwner(ctx, envID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
return return
} }
// Check if user owns the project // Check if user owns the project
if projectOwnerID != userID.(string) && !contextIsAdmin(c) { if projectOwnerID.String() != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return return
} }
cleanupID := uuid.New() // Tear down the clone's runtime and row — the preview is the clone.
cleanupStartedAt := time.Now().UTC() if cloneID, err := q.GetPreviewServiceID(ctx, envID); err == nil && cloneID.Valid {
cleanupCompletedAt := cleanupStartedAt if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
_, _ = db.(*database.DB).Exec( removeServiceRuntime(ctx, db.(*database.DB), engine, cloneID.UUID)
`INSERT INTO deployments }
(id, service_id, version, status, started_at, completed_at, deployment_log, created_at, updated_at) }
VALUES ($1, (SELECT service_id FROM preview_environments WHERE id = $2), $3, $4, $5, $6, $7, NOW(), NOW())`, _ = q.DeleteServiceByID(ctx, cloneID.UUID)
cleanupID, }
envID,
"preview-cleanup",
"rolled_back",
cleanupStartedAt,
cleanupCompletedAt,
"Preview environment resources cleaned up",
)
// Delete preview environment // Delete preview environment
_, err = db.(*database.DB).Exec( err = q.DeletePreviewEnvironment(ctx, envID)
"DELETE FROM preview_environments WHERE id = $1",
envID,
)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete preview environment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete preview environment"})
@@ -539,22 +557,30 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
return return
} }
// Get preview environment details ctx := c.Request.Context()
var env PreviewEnvironment q := sqlcdb.New(db.(*database.DB).DB)
err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
envID, userID, contextIsAdmin(c),
).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status,
)
// Get preview environment details
uid, _ := uuid.Parse(fmt.Sprint(userID))
r, err := q.GetPreviewEnvironmentForPromote(
ctx, sqlcdb.GetPreviewEnvironmentForPromoteParams{
ID: envID, OwnerID: uid, Column3: contextIsAdmin(c)})
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
return return
} }
env := PreviewEnvironment{
ID: r.ID,
ProjectID: r.ProjectID,
ServiceID: r.ServiceID,
BranchName: r.BranchName,
Environment: r.Environment,
Status: r.Status,
}
if r.PreviewServiceID.Valid {
id := r.PreviewServiceID.UUID
env.PreviewServiceID = &id
}
// Check if preview environment is in a state that can be promoted // Check if preview environment is in a state that can be promoted
if env.Status != "running" { if env.Status != "running" {
@@ -562,39 +588,51 @@ 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 = q.InsertPendingDeployment(ctx, sqlcdb.InsertPendingDeploymentParams{
`INSERT INTO deployments (id, service_id, version, status, created_at, updated_at) ID: deploymentID,
VALUES ($1, $2, $3, $4, NOW(), NOW())`, ServiceID: env.ServiceID,
deploymentID, env.ServiceID, promotionVersion, "pending", Version: promotionVersion,
) CommitHash: sql.NullString{String: env.BranchName, Valid: true},
})
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 {
previewStatus := "stopped" if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
if req.CreateBackup { engineInstance := engine
previewStatus = "expired" 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))
},
})
}
} }
if _, err := db.(*database.DB).Exec( // Promotion retires the preview — the clone's containers and row go.
`UPDATE preview_environments if env.PreviewServiceID != nil {
SET status = $1, updated_at = NOW() if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
WHERE id = $2`, if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
previewStatus, env.ID, removeServiceRuntime(ctx, db.(*database.DB), engine, *env.PreviewServiceID)
); err != nil { }
}
_ = q.DeleteServiceByID(ctx, *env.PreviewServiceID)
}
if err := q.MarkPreviewEnvironmentStatus(ctx, sqlcdb.MarkPreviewEnvironmentStatusParams{
Status: "stopped", ID: env.ID}); 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 +646,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",
}, },
}) })
} }
@@ -637,44 +675,46 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
return return
} }
ctx := c.Request.Context()
q := sqlcdb.New(db.(*database.DB).DB)
uid, _ := uuid.Parse(fmt.Sprint(userID))
// Find expired preview environments for user's projects // Find expired preview environments for user's projects
rows, err := db.(*database.DB).Query( rows, err := q.ListExpiredPreviewsForUser(ctx, sqlcdb.ListExpiredPreviewsForUserParams{
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment OwnerID: uid, Column2: contextIsAdmin(c)})
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`,
userID, contextIsAdmin(c),
)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find expired preview environments"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find expired preview environments"})
return return
} }
defer rows.Close()
var expiredEnvs []PreviewEnvironment expiredEnvs := make([]PreviewEnvironment, 0, len(rows))
for rows.Next() { for _, r := range rows {
var env PreviewEnvironment env := PreviewEnvironment{
err := rows.Scan( ID: r.ID, ProjectID: r.ProjectID, ServiceID: r.ServiceID,
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, BranchName: r.BranchName, Environment: r.Environment,
) }
if err != nil { if r.PreviewServiceID.Valid {
continue id := r.PreviewServiceID.UUID
env.PreviewServiceID = &id
} }
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 if err := q.MarkPreviewEnvironmentStatus(ctx, sqlcdb.MarkPreviewEnvironmentStatusParams{
_, err := db.(*database.DB).Exec( Status: "expired", ID: env.ID}); err != nil {
"UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1",
env.ID,
)
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(ctx, db.(*database.DB), engine, *env.PreviewServiceID)
}
}
_ = q.DeleteServiceByID(ctx, *env.PreviewServiceID)
}
cleanupCount++ cleanupCount++
} }
@@ -684,3 +724,124 @@ 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 ""
}
q := sqlcdb.New(db.DB)
ctx := context.Background()
brief, err := q.GetServicePlacementBrief(ctx, cloneID)
if err != nil {
return ""
}
nodeID, spread := brief.NodeID, brief.Spread
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 serviceForDeployFromRow(r sqlcdb.GetServiceForDeployRow) Service {
return Service{
ID: r.ID, ProjectID: r.ProjectID, Name: r.Name, Type: r.Type.String,
Status: r.Status.String, Image: r.Image.String, Command: r.Command.String,
Environment: r.Environment.String, GitRepo: r.GitRepo.String,
GitBranch: r.GitBranch.String, BuildPath: r.BuildPath.String,
CPU: r.Cpu.String, Memory: r.Memory.String,
Replicas: int(r.Replicas), Port: int(r.Port), Domain: r.Domain,
HealthCheckPath: r.HealthcheckPath, RestartPolicy: r.RestartPolicy,
Builder: r.Builder, CPUReserve: r.CpuReserve, MemoryReserve: r.MemoryReserve,
StaticBuildCmd: r.StaticBuildCmd, StaticDir: r.StaticDir,
CreatedAt: r.CreatedAt.Time, UpdatedAt: r.UpdatedAt.Time,
}
}
func loadServiceForDeploy(db *database.DB, serviceID uuid.UUID) (Service, error) {
r, err := sqlcdb.New(db.DB).GetServiceForDeploy(context.Background(), serviceID)
if err != nil {
return Service{}, err
}
return serviceForDeployFromRow(r), nil
}
// 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) {
q := sqlcdb.New(db.DB)
rows, err := q.ListSweepablePreviews(ctx)
if err != nil {
return
}
var ids []uuid.UUID
for _, r := range rows {
ids = append(ids, r.ID)
if r.PreviewServiceID.Valid {
removeServiceRuntime(ctx, db, engine, r.PreviewServiceID.UUID)
_ = q.DeleteServiceByID(ctx, r.PreviewServiceID.UUID)
}
}
for _, id := range ids {
_ = q.MarkPreviewEnvironmentStatus(ctx, sqlcdb.MarkPreviewEnvironmentStatusParams{
Status: "expired", ID: 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) {
_ = sqlcdb.New(db.DB).SyncPreviewStatuses(ctx)
}
@@ -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)
}
}
+2
View File
@@ -153,6 +153,7 @@ func handleCreateProject(c *gin.Context) {
return return
} }
LogAuditWithRequest(c, "project", projectRow.ID.String(), "project.create", map[string]interface{}{"name": req.Name})
c.JSON(http.StatusCreated, mapSQLCProject(projectRow)) c.JSON(http.StatusCreated, mapSQLCProject(projectRow))
} }
@@ -340,6 +341,7 @@ func handleDeleteProject(c *gin.Context) {
return return
} }
LogAuditWithRequest(c, "project", projectID.String(), "project.delete", nil)
c.JSON(http.StatusOK, gin.H{"message": "Project deleted successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Project deleted successfully"})
} }
+60
View File
@@ -5,6 +5,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"path/filepath"
"time" "time"
"containr/internal/build" "containr/internal/build"
@@ -43,6 +44,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,6 +111,17 @@ 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).
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)
@@ -249,6 +266,16 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// replaces session auth. Providers POST to /api/git/webhooks/:id. // replaces session auth. Providers POST to /api/git/webhooks/:id.
router.POST("/api/git/webhooks/:id", handleGitWebhookPush) router.POST("/api/git/webhooks/:id", handleGitWebhookPush)
// GitHub App-level webhook: installed repos deliver to this single
// endpoint, verified against the provisioned webhook secret.
router.POST("/api/git/github-app/webhook", handleGitHubAppWebhook)
// Public API gateway data plane. Lives outside /api/v1 so each service's
// route_prefix (/g/<slug>) maps cleanly; auth is the caller's api_key,
// not a session.
router.Any("/g/:slug", handleGatewayProxy)
router.Any("/g/:slug/*path", handleGatewayProxy)
v1 := router.Group("/api/v1") v1 := router.Group("/api/v1")
{ {
// Public routes (no authentication required) // Public routes (no authentication required)
@@ -259,10 +286,17 @@ 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
// even while public signup is closed.
public.GET("/auth/invites/:token", handleGetInvite)
public.POST("/auth/accept-invite", handleAcceptInvite)
// 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
@@ -274,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)
@@ -321,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)
@@ -330,12 +366,19 @@ 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("/projects/:id/activity", handleGetProjectActivity)
// Instance announcements shown to all signed-in users // Instance announcements shown to all signed-in users
authed.GET("/banners/active", handleListActiveBanners) authed.GET("/banners/active", handleListActiveBanners)
@@ -374,6 +417,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.POST("/deployments/:id/rollback", handleRollbackDeployment) authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
authed.POST("/deployments/:id/cancel", handleCancelDeployment) authed.POST("/deployments/:id/cancel", handleCancelDeployment)
// Interactive terminal — WebSocket → docker exec pty.
authed.GET("/services/:id/terminal", handleServiceTerminal)
// Environment variables — secrets, owner/admin only. // Environment variables — secrets, owner/admin only.
authed.GET("/services/:id/variables", handleGetVariables) authed.GET("/services/:id/variables", handleGetVariables)
authed.PUT("/services/:id/variables", handleUpdateVariables) authed.PUT("/services/:id/variables", handleUpdateVariables)
@@ -437,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)
@@ -455,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)
@@ -472,6 +521,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
admin.GET("/admin/overview", handleAdminOverview) admin.GET("/admin/overview", handleAdminOverview)
admin.GET("/admin/users", handleAdminListUsers) admin.GET("/admin/users", handleAdminListUsers)
admin.PATCH("/admin/users/:id", handleAdminSetUserAdmin) admin.PATCH("/admin/users/:id", handleAdminSetUserAdmin)
admin.POST("/admin/users/:id/impersonate", handleAdminImpersonateUser)
admin.PATCH("/admin/projects/:id", handleAdminSetProjectApproval) admin.PATCH("/admin/projects/:id", handleAdminSetProjectApproval)
admin.POST("/users", handleCreateUser) admin.POST("/users", handleCreateUser)
@@ -495,6 +545,16 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
admin.POST("/admin/banners", handleAdminCreateBanner) admin.POST("/admin/banners", handleAdminCreateBanner)
admin.PATCH("/admin/banners/:id", handleAdminUpdateBanner) admin.PATCH("/admin/banners/:id", handleAdminUpdateBanner)
admin.DELETE("/admin/banners/:id", handleAdminDeleteBanner) admin.DELETE("/admin/banners/:id", handleAdminDeleteBanner)
// GitHub App self-provisioning (manifest flow)
admin.GET("/admin/git/github-app", handleAdminGetGitHubApp)
admin.POST("/admin/git/github-app/manifest", handleAdminGitHubAppManifest)
admin.POST("/admin/git/github-app/convert", handleAdminGitHubAppConvert)
// Team invites — single-use registration links
admin.GET("/admin/invites", handleAdminListInvites)
admin.POST("/admin/invites", handleAdminCreateInvite)
admin.DELETE("/admin/invites/:id", handleAdminDeleteInvite)
admin.GET("/audit-logs", handleGetAuditLogs) admin.GET("/audit-logs", handleGetAuditLogs)
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs) admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
+101 -8
View File
@@ -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))
} }
+19 -1
View File
@@ -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),
+78 -78
View File
@@ -1,11 +1,14 @@
package api package api
import ( import (
"context"
"database/sql"
"net/http" "net/http"
"strings" "strings"
"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"
@@ -53,11 +56,8 @@ func handleCloneService(c *gin.Context) {
if name == "" { if name == "" {
name = source.Name + "-copy" name = source.Name + "-copy"
} }
var count int count, _ := sqlcdb.New(db.(*database.DB).DB).CountServicesByName(
_ = db.(*database.DB).QueryRow( c.Request.Context(), sqlcdb.CountServicesByNameParams{ProjectID: targetProjectID, Name: name})
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
targetProjectID, name,
).Scan(&count)
if count > 0 { if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in target project", "code": "CONFLICT"}) c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in target project", "code": "CONFLICT"})
return return
@@ -67,68 +67,74 @@ func handleCloneService(c *gin.Context) {
if req.Environment != "" { if req.Environment != "" {
environment = req.Environment environment = req.Environment
} }
if environment == "" {
environment = "production"
}
environmentID, err := getProjectEnvironmentID(db.(*database.DB), targetProjectID, environment)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
return
}
maintenance, basicAuth := serviceAccess(db.(*database.DB), source.ID) newID, err := cloneServiceRow(db.(*database.DB), source.ID, targetProjectID, name, environment)
newID := uuid.New()
now := time.Now()
// string() so pq sends text — []byte would go as bytea and fail ::jsonb.
volumesJSON := string(loadServiceVolumesJSON(db.(*database.DB), source.ID))
_, err = db.(*database.DB).Exec(
`INSERT INTO services
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
build_command, start_command, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, volumes, maintenance_mode, basic_auth_users,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
created_at, updated_at)
SELECT $1, $2, $3, $4, service_type, source_type, source_url, image_name,
build_command, start_command, type, 'stopped', image, command, $5,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, $6::jsonb, $7, $8,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
$9, $9
FROM services WHERE id = $10`,
newID, targetProjectID, name, environmentID, environment, volumesJSON,
maintenance, basicAuth, now, source.ID,
)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
return return
} }
if _, err := db.(*database.DB).Exec(
`INSERT INTO service_domains (service_id, domain, is_default, cert_type)
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
newID, source.ID,
); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service domains"})
return
}
// Variable values are copied as stored — ciphertext stays ciphertext.
if _, err := db.(*database.DB).Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
newID, source.ID,
); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service variables"})
return
}
LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name}) 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"}) 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) {
ctx := context.Background()
q := sqlcdb.New(db.DB)
sourceEnv, err := q.GetServiceEnvironment(ctx, sourceID)
if err != nil {
return uuid.Nil, err
}
if environment == "" {
environment = sourceEnv
}
if environment == "" {
environment = "production"
}
environmentID, err := getProjectEnvironmentID(db, targetProjectID, environment)
if err != nil {
return uuid.Nil, err
}
maintenance, basicAuth := serviceAccess(db, sourceID)
newID := uuid.New()
now := time.Now()
err = q.CloneServiceRow(ctx, sqlcdb.CloneServiceRowParams{
ID: newID,
ProjectID: targetProjectID,
Name: name,
EnvironmentID: environmentID,
Environment: sql.NullString{String: environment, Valid: true},
MaintenanceMode: maintenance,
BasicAuthUsers: basicAuth,
CreatedAt: sql.NullTime{Time: now, Valid: true},
ID_2: sourceID,
})
if err != nil {
return uuid.Nil, err
}
if err := q.CloneServiceDomains(ctx, sqlcdb.CloneServiceDomainsParams{
ServiceID: newID, ServiceID_2: sourceID}); err != nil {
_ = q.DeleteServiceByID(ctx, newID)
return uuid.Nil, err
}
// Variable values are copied as stored — ciphertext stays ciphertext.
if err := q.CloneServiceVariables(ctx, sqlcdb.CloneServiceVariablesParams{
ServiceID: newID, ServiceID_2: sourceID}); err != nil {
_ = q.DeleteServiceByID(ctx, newID)
return uuid.Nil, err
}
return newID, nil
}
type MoveServiceRequest struct { type MoveServiceRequest struct {
ProjectID uuid.UUID `json:"project_id" binding:"required"` ProjectID uuid.UUID `json:"project_id" binding:"required"`
} }
@@ -169,11 +175,8 @@ func handleMoveService(c *gin.Context) {
return return
} }
var count int count, _ := sqlcdb.New(db.(*database.DB).DB).CountServicesByName(
_ = db.(*database.DB).QueryRow( c.Request.Context(), sqlcdb.CountServicesByNameParams{ProjectID: req.ProjectID, Name: service.Name})
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
req.ProjectID, service.Name,
).Scan(&count)
if count > 0 { if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "A service with this name already exists in the target project", "code": "CONFLICT"}) c.JSON(http.StatusConflict, gin.H{"error": "A service with this name already exists in the target project", "code": "CONFLICT"})
return return
@@ -189,15 +192,18 @@ func handleMoveService(c *gin.Context) {
return return
} }
res, err := db.(*database.DB).Exec( n, err := sqlcdb.New(db.(*database.DB).DB).MoveServiceProject(
`UPDATE services SET project_id = $1, environment_id = $2, updated_at = $3 WHERE id = $4`, c.Request.Context(), sqlcdb.MoveServiceProjectParams{
req.ProjectID, environmentID, time.Now(), serviceID, ProjectID: req.ProjectID,
) EnvironmentID: environmentID,
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: serviceID,
})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to move service"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to move service"})
return return
} }
if n, _ := res.RowsAffected(); n == 0 { if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found", "code": "NOT_FOUND"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found", "code": "NOT_FOUND"})
return return
} }
@@ -211,16 +217,10 @@ func ownsProject(db *database.DB, projectID uuid.UUID, userID string, isAdmin bo
if isAdmin { if isAdmin {
return true return true
} }
var owner string uid, err := uuid.Parse(userID)
err := db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, projectID).Scan(&owner) if err != nil {
return err == nil && owner == userID return false
}
// loadServiceVolumesJSON returns the raw stored mounts for a verbatim copy.
func loadServiceVolumesJSON(db *database.DB, serviceID uuid.UUID) []byte {
var raw []byte
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil || len(raw) == 0 {
return []byte("[]")
} }
return raw owner, err := sqlcdb.New(db.DB).GetProjectOwner(context.Background(), projectID)
return err == nil && owner == uid
} }
+303 -7
View File
@@ -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,8 +50,21 @@ 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:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"` // NodeID pins the service to a node agent ("" = local Docker host,
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` // resolved "auto" picks the least-loaded online agent at write time).
NodeID string `json:"node_id,omitempty" db:"-"`
NodeName string `json:"node_name,omitempty" db:"-"`
// Spread distributes replicas across every online, schedulable agent.
// Mutually exclusive with an explicit NodeID pin.
Spread bool `json:"spread" db:"-"`
// PlacementTags restrict auto/spread candidates to agents carrying all
// listed tags. An explicit pin overrides them — pin + tags is rejected.
PlacementTags []string `json:"placement_tags,omitempty" db:"-"`
// TraefikLabels holds per-service middleware overrides —
// "middlewares.<name>.<type>.<field>" → value. Lazy-read.
TraefikLabels map[string]string `json:"traefik_labels,omitempty" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
// loadServiceSleep fills the sleep-mode columns, which live outside the // loadServiceSleep fills the sleep-mode columns, which live outside the
@@ -64,6 +79,151 @@ func loadServiceSleep(db *database.DB, s *Service) {
} }
} }
// serviceNodeID reads the pin column directly — kept out of the wide service
// SELECTs so pre-migration schemas keep working.
func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
var id sql.NullString
if err := db.QueryRow(`SELECT node_id FROM services WHERE id = $1`, serviceID).Scan(&id); err == nil {
return id.String
}
return ""
}
// serviceSpread reads the spread column with the same lazy pattern.
func serviceSpread(db *database.DB, serviceID uuid.UUID) bool {
var spread bool
if err := db.QueryRow(`SELECT COALESCE(spread, false) FROM services WHERE id = $1`, serviceID).Scan(&spread); err == nil {
return spread
}
return false
}
// servicePlacementTags reads the placement_tags column with the lazy pattern.
func servicePlacementTags(db *database.DB, serviceID uuid.UUID) []string {
var raw []byte
if err := db.QueryRow(`SELECT placement_tags FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
return nil
}
var tags []string
if err := json.Unmarshal(raw, &tags); err != nil {
return nil
}
return tags
}
// normalizePlacementTags trims, lowercases, and dedupes tag input. Tags are
// matched verbatim against node_agents.tags.
func normalizePlacementTags(tags []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(tags))
for _, t := range tags {
t = strings.ToLower(strings.TrimSpace(t))
if t == "" || seen[t] {
continue
}
seen[t] = true
out = append(out, t)
}
return out
}
// traefikLabelKey matches "middlewares.<name>.<type>" with an optional
// ".<subkey>" tail — e.g. "middlewares.rl.ratelimit.average".
var traefikLabelKey = regexp.MustCompile(`^middlewares\.([A-Za-z0-9][A-Za-z0-9_-]{0,39})\.([A-Za-z]+)(\.[A-Za-z0-9_.*+-]+)?$`)
// traefikMiddlewareTypes are the Traefik middleware kinds a service may
// define. Router/service/provider-level keys are never accepted — those
// would let a tenant hijack someone else's routing.
var traefikMiddlewareTypes = map[string]bool{
"addprefix": true, "basicauth": true, "buffering": true, "chain": true,
"circuitbreaker": true, "compress": true, "digestauth": true,
"errors": true, "forwardauth": true, "grpcweb": true, "headers": true,
"inflightreq": true, "ipallowlist": true, "ipwhitelist": true,
"passtlsclientcert": true, "ratelimit": true, "redirectregex": true,
"redirectscheme": true, "replacepath": true, "replacepathregex": true,
"retry": true, "stripprefix": true, "stripprefixregex": true,
}
// normalizeTraefikLabels validates a raw override map. Returns the cleaned
// map (middleware type lowercased — Traefik label keys are canonical-lower)
// or an error naming the offending key.
func normalizeTraefikLabels(raw map[string]string) (map[string]string, error) {
if len(raw) > 32 {
return nil, fmt.Errorf("traefik_labels: at most 32 entries (got %d)", len(raw))
}
out := make(map[string]string, len(raw))
names := map[string]bool{}
for k, v := range raw {
m := traefikLabelKey.FindStringSubmatch(k)
if m == nil {
return nil, fmt.Errorf("traefik_labels: invalid key %q — want middlewares.<name>.<type>[.<field>]", k)
}
mwType := strings.ToLower(m[2])
if !traefikMiddlewareTypes[mwType] {
return nil, fmt.Errorf("traefik_labels: unsupported middleware type %q", m[2])
}
if len(v) == 0 {
return nil, fmt.Errorf("traefik_labels: %q has an empty value", k)
}
if len(v) > 512 || strings.ContainsAny(v, "\r\n") {
return nil, fmt.Errorf("traefik_labels: %q value must be a single line under 512 chars", k)
}
out["middlewares."+m[1]+"."+mwType+m[3]] = v
names[m[1]] = true
}
if len(names) > 16 {
return nil, fmt.Errorf("traefik_labels: at most 16 middleware names")
}
return out, nil
}
// serviceTraefikLabels lazy-reads the traefik_labels column.
func serviceTraefikLabels(db *database.DB, serviceID uuid.UUID) map[string]string {
var raw []byte
if err := db.QueryRow(`SELECT traefik_labels FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
return nil
}
var labels map[string]string
if err := json.Unmarshal(raw, &labels); err != nil || len(labels) == 0 {
return nil
}
return labels
}
// traefikLabelsJSON marshals an override map for the jsonb column.
func traefikLabelsJSON(labels map[string]string) []byte {
if len(labels) == 0 {
return []byte("{}")
}
raw, _ := json.Marshal(labels)
return raw
}
// tagsJSON marshals a tag list for a jsonb column; empty stays an array, not null.
func tagsJSON(tags []string) []byte {
if len(tags) == 0 {
return []byte("[]")
}
raw, _ := json.Marshal(tags)
return raw
}
// loadServiceNode fills the pin + spread + placement tags + the agent's
// display name for responses.
func loadServiceNode(db *database.DB, s *Service) {
var name sql.NullString
s.NodeID = serviceNodeID(db, s.ID)
s.Spread = serviceSpread(db, s.ID)
s.PlacementTags = servicePlacementTags(db, s.ID)
s.TraefikLabels = serviceTraefikLabels(db, s.ID)
if s.NodeID == "" {
return
}
if err := db.QueryRow(`SELECT name FROM node_agents WHERE id = $1`, s.NodeID).Scan(&name); err == nil {
s.NodeName = name.String
}
}
// ServiceVolume is a volume/bind mount applied to every replica. // 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")
}
}
+78
View File
@@ -0,0 +1,78 @@
package api
// First-run setup wizard state. GET /setup/status is public so the web
// shell can redirect fresh installs before a session exists; the flag
// lives in app_settings so it survives restarts.
import (
"net/http"
"strings"
"containr/internal/database"
"github.com/gin-gonic/gin"
)
const settingSetupCompleted = "setup_completed"
// setupComplete resolves the flag. Installs that predate the wizard
// (users + projects already exist) auto-complete — nobody who has been
// running Containr for months should get a welcome screen.
func setupComplete(db *database.DB, hasUsers bool) bool {
if strings.EqualFold(settingValue(db, settingSetupCompleted, "", ""), "true") {
return true
}
if !hasUsers {
return false
}
var projects int
if err := db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&projects); err == nil && projects > 0 {
return true
}
return false
}
// GET /api/v1/setup/status — public: fresh installs hit it before any
// session exists.
func handleSetupStatus(c *gin.Context) {
db, ok := c.MustGet("db").(*database.DB)
if !ok {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Database connection not available")
return
}
count, err := countLocalUsers(db)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to inspect setup state")
return
}
hasUsers := count > 0
completed := setupComplete(db, hasUsers)
tunnelToken, _ := resolvedTunnelToken(db)
c.JSON(http.StatusOK, gin.H{
"has_users": hasUsers,
"setup_completed": completed,
"needs_setup": !completed,
"has_github_app": githubAppConfigured(db),
"has_tunnel": tunnelToken != "",
"signup_enabled": signupEnabled(db),
})
}
// POST /api/v1/setup/complete — marks the wizard done. Open to any
// authenticated user: the first account is the owner, and the flag only
// suppresses a banner.
func handleSetupComplete(c *gin.Context) {
if _, ok := requireAuthenticatedUserID(c); !ok {
return
}
db, ok := c.MustGet("db").(*database.DB)
if !ok {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Database connection not available")
return
}
if err := setSetting(db, settingSetupCompleted, "true", false); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to save setup state")
return
}
c.JSON(http.StatusOK, gin.H{"message": "Setup completed"})
}
+171 -33
View File
@@ -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 {
@@ -256,9 +346,13 @@ func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client,
} }
id, err := dc.CreateContainer(ctx, docker.ContainerConfig{ id, err := dc.CreateContainer(ctx, docker.ContainerConfig{
Name: name, Name: name,
Image: wakeImage, Image: wakeImage,
Cmd: []string{"sh", "-c", cmd}, Cmd: []string{"sh", "-c", cmd},
Env: []string{
"WAKE_URLS=" + strings.Join(wakeURLs, " "),
"WAKE_PAGE=" + page,
},
Labels: labels, Labels: labels,
Networks: endpoints, Networks: endpoints,
}) })
@@ -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,
} }
go putToSleep(context.Background(), db, dc, engine, svc, apiPortFromContext(c)) q := sqlcdb.New(db.DB)
if len(remoteLiveContainers(c.Request.Context(), q, service.ID.String())) > 0 {
go putToSleepRemote(context.Background(), db, q, svc, apiPortFromContext(c))
} else {
go putToSleep(context.Background(), db, dc, engine, svc, apiPortFromContext(c))
}
c.JSON(http.StatusAccepted, gin.H{"status": "sleeping"}) c.JSON(http.StatusAccepted, gin.H{"status": "sleeping"})
} }
+131 -2
View File
@@ -11,6 +11,8 @@ import (
"log" "log"
"math/big" "math/big"
"net/http" "net/http"
"os"
"path/filepath"
"regexp" "regexp"
"sort" "sort"
"strconv" "strconv"
@@ -22,6 +24,7 @@ import (
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue" "containr/internal/deployqueue"
"containr/internal/secrets" "containr/internal/secrets"
"containr/internal/source"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -227,7 +230,9 @@ func resolveStaticExpr(value string, userVars map[string]string, unresolved *[]s
if val, ok := userVars[key]; ok { if val, ok := userVars[key]; ok {
return val return val
} }
if sub[2] != "" { // {{VAR:-x}} supplies a default; {{VAR:-}} declares an explicitly
// empty one. Both resolve — only a bare {{VAR}} is unresolved.
if strings.Contains(m, ":-") {
return sub[2] return sub[2]
} }
if unresolved != nil { if unresolved != nil {
@@ -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)
}
+135 -15
View File
@@ -21,18 +21,20 @@ import (
) )
type ServiceTemplate struct { type ServiceTemplate struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"` Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"` Description string `json:"description" db:"description"`
Category string `json:"category" db:"category"` Category string `json:"category" db:"category"`
Logo string `json:"logo" db:"logo"` Logo string `json:"logo" db:"logo"`
Config string `json:"config" db:"config"` Config string `json:"config" db:"config"`
Variables string `json:"variables" db:"variables"` Variables string `json:"variables" db:"variables"`
IsOfficial bool `json:"is_official" db:"is_official"` IsOfficial bool `json:"is_official" db:"is_official"`
OwnerID string `json:"owner_id,omitempty" db:"owner_id"` OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
IsPublic bool `json:"is_public" db:"is_public"` IsPublic bool `json:"is_public" db:"is_public"`
CreatedAt time.Time `json:"created_at" db:"created_at"` // Source marks catalog origin: "" (local/official) or "registry".
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` Source string `json:"source,omitempty" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
type TemplateConfig struct { type TemplateConfig struct {
@@ -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,11 +211,18 @@ 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) {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"}) // 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"})
return
}
templateRow = row
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
return return
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
return
} }
template := mapSQLCTemplate(templateRow) template := mapSQLCTemplate(templateRow)
@@ -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
} }
+33 -1
View File
@@ -1,6 +1,9 @@
package api package api
import "testing" import (
"encoding/json"
"testing"
)
func TestNormalizeTemplateServiceType(t *testing.T) { func TestNormalizeTemplateServiceType(t *testing.T) {
valid := []string{"web", "worker", "database", "cron"} valid := []string{"web", "worker", "database", "cron"}
@@ -71,3 +74,32 @@ func TestResolveTemplateRuntimeImage(t *testing.T) {
t.Fatalf("expected unknown runtime passthrough, got %q", got) t.Fatalf("expected unknown runtime passthrough, got %q", got)
} }
} }
func TestSeedTemplatesResolveCleanly(t *testing.T) {
for _, tpl := range SeedTemplates() {
var config TemplateConfig
if err := json.Unmarshal([]byte(tpl.Config), &config); err != nil {
t.Fatalf("%s: invalid config JSON: %v", tpl.ID, err)
}
var variables []TemplateVariable
if err := json.Unmarshal([]byte(tpl.Variables), &variables); err != nil {
t.Fatalf("%s: invalid variables JSON: %v", tpl.ID, err)
}
if len(config.Services) == 0 {
continue // legacy single-service template
}
sorted, err := topoSortServices(config.Services)
if err != nil {
t.Fatalf("%s: topo sort: %v", tpl.ID, err)
}
if err := validateTemplateGraph(sorted); err != nil {
t.Fatalf("%s: graph validation: %v", tpl.ID, err)
}
// User vars left empty — every expression must still resolve via
// defaults, secrets, or service refs.
graph := resolveTemplateGraph(sorted, map[string]string{})
if len(graph.unresolved) > 0 {
t.Fatalf("%s: unresolved expressions: %v", tpl.ID, graph.unresolved)
}
}
}
+212
View File
@@ -0,0 +1,212 @@
package api
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"sort"
"strings"
"sync"
"time"
"containr/internal/database"
"containr/internal/docker"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
// Interactive terminal: a WebSocket bridged to a `docker exec` pty in the
// service's container. Protocol is JSON text frames both ways:
// client → server: {"type":"stdin","data":"..."} | {"type":"resize","cols":N,"rows":N}
// server → client: {"type":"stdout","data":"..."} | {"type":"exit","code":N} | {"type":"error","message":"..."}
// Shell preference: $SHELL, then bash, then sh.
type terminalFrame struct {
Type string `json:"type"`
Data string `json:"data,omitempty"`
Cols uint `json:"cols,omitempty"`
Rows uint `json:"rows,omitempty"`
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
func handleServiceTerminal(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid service id")
return
}
projectID, found := serviceProjectID(db, serviceID)
if !found {
respondError(c, http.StatusNotFound, "NOT_FOUND", "service not found")
return
}
if _, allowed := projectManageAccess(c, db, projectID); !allowed {
respondError(c, http.StatusNotFound, "NOT_FOUND", "service not found")
return
}
dockerClient, _ := c.Get("docker_client")
client, ok := dockerClient.(*docker.Client)
if !ok || client == nil {
respondError(c, http.StatusServiceUnavailable, "DOCKER_UNAVAILABLE", "Docker is not configured")
return
}
ctx := c.Request.Context()
containers, err := client.ListContainersFiltered(ctx, filters.NewArgs(
filters.Arg("label", "containr.service="+serviceID.String()),
), false)
if err != nil || len(containers) == 0 {
respondError(c, http.StatusConflict, "NO_CONTAINER", "service has no running container")
return
}
// Prefer a running container. With several matches, deterministically
// pick the first `containr-<service>-<n>` replica by name — that is the
// actual workload naming; auxiliary containers get ignored.
target := ""
sort.Slice(containers, func(i, j int) bool { return containers[i].Names[0] < containers[j].Names[0] })
for _, ctr := range containers {
if ctr.State == "running" && strings.HasPrefix(ctr.Names[0], "/containr-") {
target = ctr.ID
break
}
}
if target == "" {
for _, ctr := range containers {
if ctr.State == "running" {
target = ctr.ID
break
}
}
}
if target == "" {
target = containers[0].ID
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
send := func(f terminalFrame) bool {
return conn.WriteJSON(f) == nil
}
// Pick a shell that actually exists — distroless images may have none.
// `command -v` can't probe without a shell, so start each candidate and
// watch for the OCI "no such file" failure.
var stream types.HijackedResponse
var execID string
attached := false
for _, candidate := range []string{"/bin/bash", "/bin/sh", "sh"} {
exec, err := client.ExecCreate(ctx, target, docker.ExecConfig{
Cmd: []string{candidate},
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
Env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"},
})
if err != nil {
continue
}
s, err := client.ExecAttach(ctx, exec.ID)
if err != nil {
continue
}
if execStartFailed(ctx, client, exec.ID, s) {
s.Close()
continue
}
stream, execID, attached = s, exec.ID, true
break
}
if !attached {
send(terminalFrame{Type: "error", Message: "no shell available in this container image"})
return
}
defer stream.Close()
// The client sends a resize frame with its real size right after
// connecting; start at a sane default meanwhile.
_ = client.ExecResize(ctx, execID, 80, 24)
var wsWrite sync.Mutex
writeJSON := func(f terminalFrame) {
wsWrite.Lock()
defer wsWrite.Unlock()
_ = conn.WriteJSON(f)
}
// Container → client.
go func() {
buf := make([]byte, 8192)
for {
n, err := stream.Reader.Read(buf)
if n > 0 {
writeJSON(terminalFrame{Type: "stdout", Data: string(buf[:n])})
}
if err != nil {
if err != io.EOF {
log.Printf("terminal: exec stream ended: %v", err)
}
inspect, ierr := client.ExecInspect(context.Background(), execID)
code := 0
if ierr == nil {
code = inspect.ExitCode
}
writeJSON(terminalFrame{Type: "exit", Code: code})
_ = conn.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "session ended"),
time.Now().Add(time.Second))
return
}
}
}()
// Client → container.
for {
_, raw, err := conn.ReadMessage()
if err != nil {
return
}
var f terminalFrame
if err := json.Unmarshal(raw, &f); err != nil {
continue
}
switch f.Type {
case "stdin":
if _, err := stream.Conn.Write([]byte(f.Data)); err != nil {
return
}
case "resize":
_ = client.ExecResize(ctx, execID, f.Cols, f.Rows)
case "ping":
writeJSON(terminalFrame{Type: "pong"})
}
}
}
// execStartFailed reports whether an attached exec failed to launch
// (missing binary → non-zero exit, not running). The OCI failure takes a
// moment to surface, so a short grace period elapses before judging.
func execStartFailed(ctx context.Context, client *docker.Client, execID string, _ types.HijackedResponse) bool {
select {
case <-ctx.Done():
return true
case <-time.After(500 * time.Millisecond):
}
inspect, err := client.ExecInspect(ctx, execID)
if err != nil {
return false
}
return !inspect.Running && inspect.ExitCode != 0
}
+30
View File
@@ -31,3 +31,33 @@ func TestWebhookEventMatches(t *testing.T) {
} }
} }
} }
func TestValidWebhookURL(t *testing.T) {
cases := []struct {
name string
url string
wantErr bool
}{
{"https", "https://hooks.example.com/wh/abc", false},
{"http", "http://internal.lan:8080/notify", false},
{"whitespace trimmed", " https://example.com/hook ", false},
{"with query", "https://example.com/hook?token=x", false},
{"ftp rejected", "ftp://files.example.com/x", true},
{"no scheme", "example.com/hook", true},
{"bare scheme", "https://", true},
{"empty", "", true},
{"javascript rejected", "javascript:alert(1)", true},
{"host empty", "http:///path-only", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validWebhookURL(tc.url)
if tc.wantErr && err == nil {
t.Errorf("validWebhookURL(%q) = nil, want error", tc.url)
}
if !tc.wantErr && err != nil {
t.Errorf("validWebhookURL(%q) = %v, want nil", tc.url, err)
}
})
}
}
+5 -1
View File
@@ -539,12 +539,16 @@ func createBuildContext(sourcePath, dockerfileContent string) (io.ReadCloser, er
return nil, err return nil, err
} }
// Add source files to tar // Add source files to tar. The checkout's .git metadata is never build
// context — it bloats the tar and ships clone URLs to remote daemons.
err = filepath.Walk(sourcePath, func(file string, fi os.FileInfo, err error) error { err = filepath.Walk(sourcePath, func(file string, fi os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
if fi.IsDir() { if fi.IsDir() {
if fi.Name() == ".git" {
return filepath.SkipDir
}
return nil return nil
} }
+71
View File
@@ -3,6 +3,8 @@ package build
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"os"
"path/filepath" "path/filepath"
"time" "time"
@@ -92,6 +94,75 @@ func (bm *BuildManager) Build(ctx context.Context, req *types.BuildRequest) (*ty
} }
} }
// PackageContext produces the same build-context tar Build would hand the
// local daemon, without building it. Node agents download the tar and run
// docker build themselves — the Dockerfile lands at the tar root.
func (bm *BuildManager) PackageContext(ctx context.Context, req *types.BuildRequest) (io.ReadCloser, error) {
if req.BuildType == "" {
detectedType, err := bm.DetectBuildType(ctx, req.SourcePath)
if err != nil {
return nil, fmt.Errorf("failed to detect build type: %w", err)
}
req.BuildType = string(detectedType)
}
var dockerfileContent string
switch BuildType(req.BuildType) {
case BuildTypeDockerfile:
name, err := bm.dockerfileBuilder.DetectDockerfile(ctx, req.SourcePath)
if err != nil {
return nil, fmt.Errorf("failed to detect Dockerfile: %w", err)
}
path := filepath.Join(req.SourcePath, name)
if optimized, err := bm.dockerfileBuilder.OptimizeDockerfile(ctx, path); err == nil {
path = optimized
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read Dockerfile: %w", err)
}
dockerfileContent = string(data)
case BuildTypeRailpack:
if err := bm.railpackBuilder.DetectRailpack(ctx, req.SourcePath); err != nil {
return nil, fmt.Errorf("Railpack cannot build this project: %w", err)
}
content, err := bm.railpackBuilder.generateDockerfile(ctx, req.SourcePath, &RailpackConfig{
BuildCmd: req.BuildCommand,
StartCmd: req.StartCommand,
Env: req.Environment,
})
if err != nil {
return nil, fmt.Errorf("failed to generate Dockerfile with Railpack: %w", err)
}
dockerfileContent = content
case BuildTypeNixpacks:
plan, err := bm.nixpacksBuilder.GeneratePlan(ctx, req.SourcePath, &NixpacksConfig{
BuildCmd: req.BuildCommand,
StartCmd: req.StartCommand,
Env: req.Environment,
})
if err != nil {
return nil, fmt.Errorf("failed to generate build plan: %w", err)
}
path, err := bm.nixpacksBuilder.generateDockerfile(plan)
if err != nil {
return nil, fmt.Errorf("failed to generate Dockerfile: %w", err)
}
defer os.Remove(path)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read generated Dockerfile: %w", err)
}
dockerfileContent = string(data)
case BuildTypeStatic:
dockerfileContent = staticDockerfile
default:
return nil, fmt.Errorf("unsupported build type for remote packaging: %s", req.BuildType)
}
return createBuildContext(req.SourcePath, dockerfileContent)
}
// buildPrebuilt handles prebuilt image deployments // buildPrebuilt handles prebuilt image deployments
func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) { func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
if req.PrebuiltImage == "" { if req.PrebuiltImage == "" {
+9 -4
View File
@@ -476,17 +476,22 @@ func (n *NixpacksBuilder) Build(ctx context.Context, req *types.BuildRequest) (*
} }
// Create temporary Dockerfile // Create temporary Dockerfile
dockerfile, err := n.generateDockerfile(plan) dockerfilePath, err := n.generateDockerfile(plan)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate Dockerfile: %w", err) return nil, fmt.Errorf("failed to generate Dockerfile: %w", err)
} }
defer os.Remove(dockerfile) defer os.Remove(dockerfilePath)
// Build Docker image // Build Docker image
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag) imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
// Create build context tar // Create build context tar — createBuildContext takes the Dockerfile
buildCtx, err := createBuildContext(req.SourcePath, dockerfile) // contents, not its path.
dockerfileData, err := os.ReadFile(dockerfilePath)
if err != nil {
return nil, fmt.Errorf("failed to read generated Dockerfile: %w", err)
}
buildCtx, err := createBuildContext(req.SourcePath, string(dockerfileData))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create build context: %w", err) return nil, fmt.Errorf("failed to create build context: %w", err)
} }
@@ -0,0 +1,67 @@
package commands
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
var (
activitySeverity string
activityCategory string
activityResource string
activityProject string
activityLimit int
)
// ActivityCmd shows the enriched activity feed (audit events with
// severity/category/label).
var ActivityCmd = &cobra.Command{
Use: "activity",
Short: "Activity feed — enriched audit events",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
params := []string{fmt.Sprintf("limit=%d", activityLimit)}
if activitySeverity != "" {
params = append(params, "severity="+activitySeverity)
}
if activityCategory != "" {
params = append(params, "category="+activityCategory)
}
if activityResource != "" {
params = append(params, "resource="+activityResource)
}
if activityProject != "" {
params = append(params, "project_id="+activityProject)
}
data, err := c.Do("GET", "/activity?"+strings.Join(params, "&"), nil)
if err != nil {
return err
}
items, err := unwrapList(data, "activity")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, e := range items {
rows = append(rows, []string{
str(e, "severity"), str(e, "category"), str(e, "label"),
str(e, "user_email"), relTime(e, "created_at"),
})
}
printRows(data, []string{"SEVERITY", "CATEGORY", "EVENT", "ACTOR", "WHEN"}, rows)
return nil
},
}
func init() {
ActivityCmd.Flags().StringVar(&activitySeverity, "severity", "", "filter: info|success|warning|error")
ActivityCmd.Flags().StringVar(&activityCategory, "category", "", "filter by category")
ActivityCmd.Flags().StringVar(&activityResource, "resource", "", "filter by resource type")
ActivityCmd.Flags().StringVar(&activityProject, "project", "", "filter by project id")
ActivityCmd.Flags().IntVar(&activityLimit, "limit", 50, "max events")
}
+85 -1
View File
@@ -56,6 +56,84 @@ var adminUsersCmd = &cobra.Command{
}, },
} }
var adminImpersonateCmd = &cobra.Command{
Use: "impersonate <user-id>",
Short: "Mint a 15-minute token acting as a user (audit-logged)",
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", "/admin/users/"+args[0]+"/impersonate", map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
fmt.Fprintln(cmd.ErrOrStderr(), "\nUse it with: CONTAINR_TOKEN=<token> containr <command>")
return nil
},
}
var adminGitHubAppCmd = &cobra.Command{
Use: "github-app",
Short: "GitHub App self-provisioning",
}
var adminGitHubAppStatusCmd = &cobra.Command{
Use: "status",
Short: "Show GitHub App provisioning status",
RunE: simpleGet("/admin/git/github-app"),
}
var adminGitHubAppManifestCmd = &cobra.Command{
Use: "manifest",
Short: "Build the manifest to create the instance GitHub App",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
req := map[string]interface{}{"base_url": ghAppBaseURL}
if ghAppOrg != "" {
req["organization"] = ghAppOrg
}
if ghAppName != "" {
req["name"] = ghAppName
}
data, err := c.Do("POST", "/admin/git/github-app/manifest", req)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var adminGitHubAppConvertCmd = &cobra.Command{
Use: "convert <code>",
Short: "Exchange the manifest callback code for app credentials",
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", "/admin/git/github-app/convert", map[string]interface{}{"code": args[0]})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var (
ghAppBaseURL string
ghAppOrg string
ghAppName string
)
var adminSettingsCmd = &cobra.Command{ var adminSettingsCmd = &cobra.Command{
Use: "settings", Use: "settings",
Short: "Show platform settings", Short: "Show platform settings",
@@ -353,7 +431,13 @@ func init() {
securityScanCmd.Flags().String("type", "configuration", "dependency|configuration|comprehensive") securityScanCmd.Flags().String("type", "configuration", "dependency|configuration|comprehensive")
securityScanCmd.Flags().String("service", "", "limit scan to one service") securityScanCmd.Flags().String("service", "", "limit scan to one service")
AdminCmd.AddCommand(adminOverviewCmd, adminUsersCmd, adminSettingsCmd, adminAuditCmd) adminGitHubAppManifestCmd.Flags().StringVar(&ghAppBaseURL, "base-url", "", "public base URL of this instance (required)")
adminGitHubAppManifestCmd.Flags().StringVar(&ghAppOrg, "org", "", "create the app under this organization")
adminGitHubAppManifestCmd.Flags().StringVar(&ghAppName, "name", "", "app name (defaults to product name + date)")
adminGitHubAppManifestCmd.MarkFlagRequired("base-url")
adminGitHubAppCmd.AddCommand(adminGitHubAppStatusCmd, adminGitHubAppManifestCmd, adminGitHubAppConvertCmd)
AdminCmd.AddCommand(adminOverviewCmd, adminUsersCmd, adminSettingsCmd, adminAuditCmd, adminImpersonateCmd, adminGitHubAppCmd)
GatewayCmd.AddCommand(gatewayServicesCmd, gatewayKeysCmd, gatewayAnalyticsCmd) GatewayCmd.AddCommand(gatewayServicesCmd, gatewayKeysCmd, gatewayAnalyticsCmd)
HACmd.AddCommand(haStatusCmd, haAlertsCmd, haHealthCmd, haPoliciesCmd, haEnableCmd, haDisableCmd, haFailoverCmd) HACmd.AddCommand(haStatusCmd, haAlertsCmd, haHealthCmd, haPoliciesCmd, haEnableCmd, haDisableCmd, haFailoverCmd)
ScalingCmd.AddCommand(scalingStatusCmd, scalingPoliciesCmd, scalingServicesCmd, scalingScaleCmd) ScalingCmd.AddCommand(scalingStatusCmd, scalingPoliciesCmd, scalingServicesCmd, scalingScaleCmd)
@@ -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)
}
+104
View File
@@ -0,0 +1,104 @@
package commands
import (
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/gorilla/websocket"
"github.com/spf13/cobra"
"golang.org/x/term"
)
// ShellCmd opens an interactive shell inside a service's running container.
var ShellCmd = &cobra.Command{
Use: "shell <service-id>",
Short: "Interactive shell inside the service container",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
serviceID := args[0]
cli, err := client()
if err != nil {
return err
}
base := strings.TrimRight(cli.BaseURL, "/")
wsURL := strings.Replace(base, "http", "ws", 1) + "/services/" + serviceID + "/terminal"
header := http.Header{}
header.Set("Authorization", "Bearer "+cli.Token)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, header)
if err != nil {
return fmt.Errorf("terminal: %w", err)
}
defer conn.Close()
fd := int(os.Stdin.Fd())
raw := term.IsTerminal(fd)
var oldState *term.State
if raw {
oldState, err = term.MakeRaw(fd)
if err == nil {
defer term.Restore(fd, oldState)
}
sendSize := func() {
if w, h, err := term.GetSize(fd); err == nil {
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
}
}
sendSize()
defer watchResize(sendSize)()
}
done := make(chan struct{})
// stdin → ws
go func() {
buf := make([]byte, 4096)
for {
n, err := os.Stdin.Read(buf)
if n > 0 {
if werr := conn.WriteJSON(map[string]interface{}{
"type": "stdin", "data": string(buf[:n]),
}); werr != nil {
return
}
}
if err != nil {
return
}
}
}()
// ws → stdout
go func() {
defer close(done)
for {
var frame struct {
Type string `json:"type"`
Data string `json:"data"`
Code int `json:"code"`
Message string `json:"message"`
}
if err := conn.ReadJSON(&frame); err != nil {
return
}
switch frame.Type {
case "stdout":
_, _ = os.Stdout.WriteString(frame.Data)
case "exit":
fmt.Fprintf(os.Stderr, "\r\n[session ended — exit %d]\r\n", frame.Code)
return
case "error":
fmt.Fprintf(os.Stderr, "\r\nerror: %s\r\n", frame.Message)
return
}
}
}()
select {
case <-done:
case <-time.After(24 * time.Hour):
}
return nil
},
}
@@ -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,92 @@
package commands
import (
"github.com/spf13/cobra"
)
var inviteEmail string
var inviteTTL int
// InvitesCmd manages team invite links (admin).
var InvitesCmd = &cobra.Command{
Use: "invites",
Short: "Manage team invite links (admin)",
}
var inviteListCmd = &cobra.Command{
Use: "list",
Short: "List invites",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/admin/invites", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "invites")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, i := range items {
state := "open"
if _, used := i["used_at"]; used && i["used_at"] != nil {
state = "used"
}
rows = append(rows, []string{
str(i, "id"), str(i, "email"), str(i, "expires_at"), state,
})
}
printRows(data, []string{"ID", "EMAIL", "EXPIRES", "STATE"}, rows)
return nil
},
}
var inviteCreateCmd = &cobra.Command{
Use: "create",
Short: "Create an invite link (--email to bind, --hours for TTL)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
req := map[string]interface{}{}
if cmd.Flags().Changed("email") {
req["email"] = inviteEmail
}
if cmd.Flags().Changed("hours") {
req["expires_in_hours"] = inviteTTL
}
data, err := c.Do("POST", "/admin/invites", req)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var inviteRevokeCmd = &cobra.Command{
Use: "revoke <id>",
Short: "Revoke an invite",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !Confirm("Revoke invite " + 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", "/admin/invites/"+args[0], nil)
return err
},
}
func init() {
inviteCreateCmd.Flags().StringVar(&inviteEmail, "email", "", "bind invite to this email")
inviteCreateCmd.Flags().IntVar(&inviteTTL, "hours", 168, "invite lifetime in hours (1-2160)")
InvitesCmd.AddCommand(inviteListCmd, inviteCreateCmd, inviteRevokeCmd)
}
@@ -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)
}
}
}
+166 -1
View File
@@ -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)
} }
+83 -3
View File
@@ -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
+28 -1
View File
@@ -194,6 +194,32 @@ var templatesImportComposeCmd = &cobra.Command{
}, },
} }
var templatesImportGitCmd = &cobra.Command{
Use: "import-git <repo> [path]",
Short: "Fetch a compose file from a git repo and convert it into a template graph config",
Long: "repo accepts a clone URL, ssh remote, or owner/repo shorthand resolved via connected git providers (private repos included). path defaults to compose.yaml/yml or docker-compose.yml/yaml. --ref selects a branch or commit.",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
body := map[string]string{"repo": args[0]}
if len(args) > 1 {
body["path"] = args[1]
}
if v, _ := cmd.Flags().GetString("ref"); v != "" {
body["ref"] = v
}
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/templates/import/git", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesDeployGraphCmd = &cobra.Command{ var templatesDeployGraphCmd = &cobra.Command{
Use: "deploy-graph <project-id> <config.json|->", Use: "deploy-graph <project-id> <config.json|->",
Short: "Deploy an ad-hoc service graph (v2 config with services[]) without a stored template", Short: "Deploy an ad-hoc service graph (v2 config with services[]) without a stored template",
@@ -257,5 +283,6 @@ func init() {
templatesDeployCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)") templatesDeployCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
templatesPlanCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)") templatesPlanCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
templatesDeployGraphCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)") templatesDeployGraphCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd, templatesPlanCmd, templatesImportComposeCmd, templatesDeployGraphCmd) templatesImportGitCmd.Flags().String("ref", "", "git branch or commit (default: remote HEAD)")
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd, templatesPlanCmd, templatesImportComposeCmd, templatesImportGitCmd, templatesDeployGraphCmd)
} }
+5
View File
@@ -89,8 +89,13 @@ func init() {
rootCmd.AddCommand(commands.NotificationsCmd) rootCmd.AddCommand(commands.NotificationsCmd)
rootCmd.AddCommand(commands.WebhooksCmd) rootCmd.AddCommand(commands.WebhooksCmd)
rootCmd.AddCommand(commands.OperationsCmd) rootCmd.AddCommand(commands.OperationsCmd)
rootCmd.AddCommand(commands.ActivityCmd)
rootCmd.AddCommand(commands.ShellCmd)
rootCmd.AddCommand(commands.BannersCmd) rootCmd.AddCommand(commands.BannersCmd)
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
}
@@ -0,0 +1,536 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: deployments.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const completeDeployment = `-- name: CompleteDeployment :exec
UPDATE deployments
SET status = $1, error = $2, completed_at = $3, updated_at = $3
WHERE id = $4
`
type CompleteDeploymentParams struct {
Status sql.NullString `json:"status"`
Error sql.NullString `json:"error"`
CompletedAt sql.NullTime `json:"completed_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) CompleteDeployment(ctx context.Context, arg CompleteDeploymentParams) error {
_, err := q.db.ExecContext(ctx, completeDeployment,
arg.Status,
arg.Error,
arg.CompletedAt,
arg.ID,
)
return err
}
const countAccessibleDeployments = `-- name: CountAccessibleDeployments :one
SELECT COUNT(*)
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
`
type CountAccessibleDeploymentsParams struct {
OwnerID uuid.UUID `json:"owner_id"`
Column2 bool `json:"column_2"`
}
func (q *Queries) CountAccessibleDeployments(ctx context.Context, arg CountAccessibleDeploymentsParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countAccessibleDeployments, arg.OwnerID, arg.Column2)
var count int64
err := row.Scan(&count)
return count, err
}
const countDeploymentsForService = `-- name: CountDeploymentsForService :one
SELECT COUNT(*) FROM deployments WHERE service_id = $1
`
func (q *Queries) CountDeploymentsForService(ctx context.Context, serviceID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, countDeploymentsForService, serviceID)
var count int64
err := row.Scan(&count)
return count, err
}
const failDeployment = `-- name: FailDeployment :exec
UPDATE deployments
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
WHERE id = $3
`
type FailDeploymentParams struct {
Error sql.NullString `json:"error"`
CompletedAt sql.NullTime `json:"completed_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) FailDeployment(ctx context.Context, arg FailDeploymentParams) error {
_, err := q.db.ExecContext(ctx, failDeployment, arg.Error, arg.CompletedAt, arg.ID)
return err
}
const getDeploymentAccess = `-- name: GetDeploymentAccess :one
SELECT d.status, d.service_id, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1
`
type GetDeploymentAccessRow struct {
Status sql.NullString `json:"status"`
ServiceID uuid.UUID `json:"service_id"`
OwnerID uuid.UUID `json:"owner_id"`
}
func (q *Queries) GetDeploymentAccess(ctx context.Context, id uuid.UUID) (GetDeploymentAccessRow, error) {
row := q.db.QueryRowContext(ctx, getDeploymentAccess, id)
var i GetDeploymentAccessRow
err := row.Scan(&i.Status, &i.ServiceID, &i.OwnerID)
return i, err
}
const getDeploymentForRollback = `-- name: GetDeploymentForRollback :one
SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1
`
type GetDeploymentForRollbackRow struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
CommitHash sql.NullString `json:"commit_hash"`
Status sql.NullString `json:"status"`
ImageName sql.NullString `json:"image_name"`
ImageTag sql.NullString `json:"image_tag"`
BuildLog sql.NullString `json:"build_log"`
RuntimeLog sql.NullString `json:"runtime_log"`
Error sql.NullString `json:"error"`
StartedAt sql.NullTime `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
OwnerID uuid.UUID `json:"owner_id"`
}
func (q *Queries) GetDeploymentForRollback(ctx context.Context, id uuid.UUID) (GetDeploymentForRollbackRow, error) {
row := q.db.QueryRowContext(ctx, getDeploymentForRollback, id)
var i GetDeploymentForRollbackRow
err := row.Scan(
&i.ID,
&i.ServiceID,
&i.CommitHash,
&i.Status,
&i.ImageName,
&i.ImageTag,
&i.BuildLog,
&i.RuntimeLog,
&i.Error,
&i.StartedAt,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.OwnerID,
)
return i, err
}
const getDeploymentWithProject = `-- name: GetDeploymentWithProject :one
SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.id AS project_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1
`
type GetDeploymentWithProjectRow struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
CommitHash sql.NullString `json:"commit_hash"`
Status sql.NullString `json:"status"`
ImageName sql.NullString `json:"image_name"`
ImageTag sql.NullString `json:"image_tag"`
BuildLog sql.NullString `json:"build_log"`
RuntimeLog sql.NullString `json:"runtime_log"`
Error sql.NullString `json:"error"`
StartedAt sql.NullTime `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) GetDeploymentWithProject(ctx context.Context, id uuid.UUID) (GetDeploymentWithProjectRow, error) {
row := q.db.QueryRowContext(ctx, getDeploymentWithProject, id)
var i GetDeploymentWithProjectRow
err := row.Scan(
&i.ID,
&i.ServiceID,
&i.CommitHash,
&i.Status,
&i.ImageName,
&i.ImageTag,
&i.BuildLog,
&i.RuntimeLog,
&i.Error,
&i.StartedAt,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ProjectID,
)
return i, err
}
const getServiceForDeployWithOwner = `-- name: GetServiceForDeployWithOwner :one
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) AS replicas, COALESCE(s.port, 0) AS port,
COALESCE(s.domain, '') AS domain, COALESCE(s.healthcheck_path, '') AS healthcheck_path,
COALESCE(s.restart_policy, 'unless-stopped') AS restart_policy,
COALESCE(s.builder, 'auto') AS builder, COALESCE(s.cpu_reserve, '') AS cpu_reserve,
COALESCE(s.memory_reserve, '') AS memory_reserve, COALESCE(s.static_build_cmd, '') AS static_build_cmd,
COALESCE(s.static_dir, '') AS static_dir,
s.created_at, s.updated_at, p.owner_id
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1
`
type GetServiceForDeployWithOwnerRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Type sql.NullString `json:"type"`
Status sql.NullString `json:"status"`
Image sql.NullString `json:"image"`
Command sql.NullString `json:"command"`
Environment sql.NullString `json:"environment"`
GitRepo sql.NullString `json:"git_repo"`
GitBranch sql.NullString `json:"git_branch"`
BuildPath sql.NullString `json:"build_path"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullString `json:"memory"`
Replicas int32 `json:"replicas"`
Port int32 `json:"port"`
Domain string `json:"domain"`
HealthcheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
Builder string `json:"builder"`
CpuReserve string `json:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd"`
StaticDir string `json:"static_dir"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
OwnerID uuid.UUID `json:"owner_id"`
}
func (q *Queries) GetServiceForDeployWithOwner(ctx context.Context, id uuid.UUID) (GetServiceForDeployWithOwnerRow, error) {
row := q.db.QueryRowContext(ctx, getServiceForDeployWithOwner, id)
var i GetServiceForDeployWithOwnerRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Type,
&i.Status,
&i.Image,
&i.Command,
&i.Environment,
&i.GitRepo,
&i.GitBranch,
&i.BuildPath,
&i.Cpu,
&i.Memory,
&i.Replicas,
&i.Port,
&i.Domain,
&i.HealthcheckPath,
&i.RestartPolicy,
&i.Builder,
&i.CpuReserve,
&i.MemoryReserve,
&i.StaticBuildCmd,
&i.StaticDir,
&i.CreatedAt,
&i.UpdatedAt,
&i.OwnerID,
)
return i, err
}
const getServicePublishedPort = `-- name: GetServicePublishedPort :one
SELECT published_port FROM services WHERE id = $1
`
func (q *Queries) GetServicePublishedPort(ctx context.Context, id uuid.UUID) (int32, error) {
row := q.db.QueryRowContext(ctx, getServicePublishedPort, id)
var published_port int32
err := row.Scan(&published_port)
return published_port, err
}
const insertDeployment = `-- name: InsertDeployment :exec
INSERT INTO deployments
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`
type InsertDeploymentParams struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Version string `json:"version"`
CommitHash sql.NullString `json:"commit_hash"`
Status sql.NullString `json:"status"`
ImageName sql.NullString `json:"image_name"`
ImageTag sql.NullString `json:"image_tag"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) InsertDeployment(ctx context.Context, arg InsertDeploymentParams) error {
_, err := q.db.ExecContext(ctx, insertDeployment,
arg.ID,
arg.ServiceID,
arg.Version,
arg.CommitHash,
arg.Status,
arg.ImageName,
arg.ImageTag,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const listDeploymentsForService = `-- name: ListDeploymentsForService :many
SELECT id, service_id, commit_hash, status, image_name, image_tag,
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
FROM deployments
WHERE service_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
type ListDeploymentsForServiceParams struct {
ServiceID uuid.UUID `json:"service_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListDeploymentsForServiceRow struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
CommitHash sql.NullString `json:"commit_hash"`
Status sql.NullString `json:"status"`
ImageName sql.NullString `json:"image_name"`
ImageTag sql.NullString `json:"image_tag"`
BuildLog sql.NullString `json:"build_log"`
RuntimeLog sql.NullString `json:"runtime_log"`
Error sql.NullString `json:"error"`
StartedAt sql.NullTime `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) ListDeploymentsForService(ctx context.Context, arg ListDeploymentsForServiceParams) ([]ListDeploymentsForServiceRow, error) {
rows, err := q.db.QueryContext(ctx, listDeploymentsForService, arg.ServiceID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListDeploymentsForServiceRow{}
for rows.Next() {
var i ListDeploymentsForServiceRow
if err := rows.Scan(
&i.ID,
&i.ServiceID,
&i.CommitHash,
&i.Status,
&i.ImageName,
&i.ImageTag,
&i.BuildLog,
&i.RuntimeLog,
&i.Error,
&i.StartedAt,
&i.CompletedAt,
&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 listRecentAccessibleDeployments = `-- name: ListRecentAccessibleDeployments :many
SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status,
COALESCE(d.image_name, '') AS image_name, d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
ORDER BY d.created_at DESC
LIMIT $3 OFFSET $4
`
type ListRecentAccessibleDeploymentsParams struct {
OwnerID uuid.UUID `json:"owner_id"`
Column2 bool `json:"column_2"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListRecentAccessibleDeploymentsRow struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectName string `json:"project_name"`
Status sql.NullString `json:"status"`
ImageName string `json:"image_name"`
StartedAt sql.NullTime `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
CreatedAt sql.NullTime `json:"created_at"`
}
func (q *Queries) ListRecentAccessibleDeployments(ctx context.Context, arg ListRecentAccessibleDeploymentsParams) ([]ListRecentAccessibleDeploymentsRow, error) {
rows, err := q.db.QueryContext(ctx, listRecentAccessibleDeployments,
arg.OwnerID,
arg.Column2,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRecentAccessibleDeploymentsRow{}
for rows.Next() {
var i ListRecentAccessibleDeploymentsRow
if err := rows.Scan(
&i.ID,
&i.ServiceID,
&i.ServiceName,
&i.ProjectName,
&i.Status,
&i.ImageName,
&i.StartedAt,
&i.CompletedAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setDeploymentStatus = `-- name: SetDeploymentStatus :exec
UPDATE deployments SET status = $1, updated_at = $2 WHERE id = $3
`
type SetDeploymentStatusParams struct {
Status sql.NullString `json:"status"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetDeploymentStatus(ctx context.Context, arg SetDeploymentStatusParams) error {
_, err := q.db.ExecContext(ctx, setDeploymentStatus, arg.Status, arg.UpdatedAt, arg.ID)
return err
}
const setServiceStatus = `-- name: SetServiceStatus :exec
UPDATE services SET status = $1, updated_at = $2 WHERE id = $3
`
type SetServiceStatusParams struct {
Status sql.NullString `json:"status"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetServiceStatus(ctx context.Context, arg SetServiceStatusParams) error {
_, err := q.db.ExecContext(ctx, setServiceStatus, arg.Status, arg.UpdatedAt, arg.ID)
return err
}
const syncDeploymentProgress = `-- name: SyncDeploymentProgress :exec
UPDATE deployments
SET status = $1,
image_name = $2,
image_tag = $3,
build_log = $4,
runtime_log = $5,
error = $6,
started_at = $7,
completed_at = $8,
updated_at = $9
WHERE id = $10
`
type SyncDeploymentProgressParams struct {
Status sql.NullString `json:"status"`
ImageName sql.NullString `json:"image_name"`
ImageTag sql.NullString `json:"image_tag"`
BuildLog sql.NullString `json:"build_log"`
RuntimeLog sql.NullString `json:"runtime_log"`
Error sql.NullString `json:"error"`
StartedAt sql.NullTime `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SyncDeploymentProgress(ctx context.Context, arg SyncDeploymentProgressParams) error {
_, err := q.db.ExecContext(ctx, syncDeploymentProgress,
arg.Status,
arg.ImageName,
arg.ImageTag,
arg.BuildLog,
arg.RuntimeLog,
arg.Error,
arg.StartedAt,
arg.CompletedAt,
arg.UpdatedAt,
arg.ID,
)
return err
}
@@ -0,0 +1,242 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: domains.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
const clearServiceDomainDefault = `-- name: ClearServiceDomainDefault :exec
UPDATE service_domains SET is_default = false WHERE service_id = $1
`
func (q *Queries) ClearServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, clearServiceDomainDefault, serviceID)
return err
}
const countServiceDomains = `-- name: CountServiceDomains :one
SELECT COUNT(*) FROM service_domains WHERE service_id = $1
`
func (q *Queries) CountServiceDomains(ctx context.Context, serviceID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, countServiceDomains, serviceID)
var count int64
err := row.Scan(&count)
return count, err
}
const createServiceDomain = `-- name: CreateServiceDomain :one
INSERT INTO service_domains (service_id, domain, is_default)
VALUES ($1, $2, $3)
RETURNING id, service_id, domain, is_default, cert_type, cert_status, created_at
`
type CreateServiceDomainParams struct {
ServiceID uuid.UUID `json:"service_id"`
Domain string `json:"domain"`
IsDefault bool `json:"is_default"`
}
type CreateServiceDomainRow struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Domain string `json:"domain"`
IsDefault bool `json:"is_default"`
CertType string `json:"cert_type"`
CertStatus string `json:"cert_status"`
CreatedAt time.Time `json:"created_at"`
}
func (q *Queries) CreateServiceDomain(ctx context.Context, arg CreateServiceDomainParams) (CreateServiceDomainRow, error) {
row := q.db.QueryRowContext(ctx, createServiceDomain, arg.ServiceID, arg.Domain, arg.IsDefault)
var i CreateServiceDomainRow
err := row.Scan(
&i.ID,
&i.ServiceID,
&i.Domain,
&i.IsDefault,
&i.CertType,
&i.CertStatus,
&i.CreatedAt,
)
return i, err
}
const deleteServiceDomain = `-- name: DeleteServiceDomain :execrows
DELETE FROM service_domains WHERE id = $1 AND service_id = $2
`
type DeleteServiceDomainParams struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
}
func (q *Queries) DeleteServiceDomain(ctx context.Context, arg DeleteServiceDomainParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteServiceDomain, arg.ID, arg.ServiceID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getServiceAccess = `-- name: GetServiceAccess :one
SELECT maintenance_mode, basic_auth_users FROM services WHERE id = $1
`
type GetServiceAccessRow struct {
MaintenanceMode bool `json:"maintenance_mode"`
BasicAuthUsers string `json:"basic_auth_users"`
}
func (q *Queries) GetServiceAccess(ctx context.Context, id uuid.UUID) (GetServiceAccessRow, error) {
row := q.db.QueryRowContext(ctx, getServiceAccess, id)
var i GetServiceAccessRow
err := row.Scan(&i.MaintenanceMode, &i.BasicAuthUsers)
return i, err
}
const getServiceDomainDefault = `-- name: GetServiceDomainDefault :one
SELECT domain FROM service_domains WHERE service_id = $1 AND is_default
`
func (q *Queries) GetServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getServiceDomainDefault, serviceID)
var domain string
err := row.Scan(&domain)
return domain, err
}
const getServiceDomainFirst = `-- name: GetServiceDomainFirst :one
SELECT domain FROM service_domains WHERE service_id = $1 ORDER BY created_at ASC LIMIT 1
`
func (q *Queries) GetServiceDomainFirst(ctx context.Context, serviceID uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getServiceDomainFirst, serviceID)
var domain string
err := row.Scan(&domain)
return domain, err
}
const getServicePlacementBrief = `-- name: GetServicePlacementBrief :one
SELECT name, node_id, COALESCE(spread, false) AS spread FROM services WHERE id = $1
`
type GetServicePlacementBriefRow struct {
Name string `json:"name"`
NodeID sql.NullString `json:"node_id"`
Spread bool `json:"spread"`
}
func (q *Queries) GetServicePlacementBrief(ctx context.Context, id uuid.UUID) (GetServicePlacementBriefRow, error) {
row := q.db.QueryRowContext(ctx, getServicePlacementBrief, id)
var i GetServicePlacementBriefRow
err := row.Scan(&i.Name, &i.NodeID, &i.Spread)
return i, err
}
const listServiceDomains = `-- name: ListServiceDomains :many
SELECT id, service_id, domain, is_default, cert_type, cert_status, last_checked_at, created_at
FROM service_domains
WHERE service_id = $1
ORDER BY is_default DESC, created_at ASC
`
func (q *Queries) ListServiceDomains(ctx context.Context, serviceID uuid.UUID) ([]ServiceDomain, error) {
rows, err := q.db.QueryContext(ctx, listServiceDomains, serviceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ServiceDomain{}
for rows.Next() {
var i ServiceDomain
if err := rows.Scan(
&i.ID,
&i.ServiceID,
&i.Domain,
&i.IsDefault,
&i.CertType,
&i.CertStatus,
&i.LastCheckedAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const serviceWriteAccess = `-- name: ServiceWriteAccess :one
SELECT EXISTS(
SELECT 1 FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool)
) AS allowed
`
type ServiceWriteAccessParams struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Column3 bool `json:"column_3"`
}
func (q *Queries) ServiceWriteAccess(ctx context.Context, arg ServiceWriteAccessParams) (bool, error) {
row := q.db.QueryRowContext(ctx, serviceWriteAccess, arg.ID, arg.OwnerID, arg.Column3)
var allowed bool
err := row.Scan(&allowed)
return allowed, err
}
const setServiceDomainDefault = `-- name: SetServiceDomainDefault :execrows
UPDATE service_domains SET is_default = (id = $1) WHERE service_id = $2
`
type SetServiceDomainDefaultParams struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
}
func (q *Queries) SetServiceDomainDefault(ctx context.Context, arg SetServiceDomainDefaultParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setServiceDomainDefault, arg.ID, arg.ServiceID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const setServiceLegacyDomain = `-- name: SetServiceLegacyDomain :exec
UPDATE services SET domain = $1 WHERE id = $2
`
type SetServiceLegacyDomainParams struct {
Domain string `json:"domain"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) SetServiceLegacyDomain(ctx context.Context, arg SetServiceLegacyDomainParams) error {
_, err := q.db.ExecContext(ctx, setServiceLegacyDomain, arg.Domain, arg.ID)
return err
}
const touchServiceDomainChecked = `-- name: TouchServiceDomainChecked :exec
UPDATE service_domains SET last_checked_at = NOW() WHERE id = $1
`
func (q *Queries) TouchServiceDomainChecked(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, touchServiceDomainChecked, id)
return err
}
@@ -0,0 +1,134 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: invites.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
const createUserInvite = `-- name: CreateUserInvite :one
INSERT INTO user_invites (token_hash, email, created_by, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING user_invites.id, user_invites.token_hash, user_invites.email, user_invites.created_by, user_invites.expires_at, user_invites.used_by, user_invites.used_at, user_invites.created_at
`
type CreateUserInviteParams struct {
TokenHash string `json:"token_hash"`
Email sql.NullString `json:"email"`
CreatedBy uuid.NullUUID `json:"created_by"`
ExpiresAt time.Time `json:"expires_at"`
}
func (q *Queries) CreateUserInvite(ctx context.Context, arg CreateUserInviteParams) (UserInvite, error) {
row := q.db.QueryRowContext(ctx, createUserInvite,
arg.TokenHash,
arg.Email,
arg.CreatedBy,
arg.ExpiresAt,
)
var i UserInvite
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Email,
&i.CreatedBy,
&i.ExpiresAt,
&i.UsedBy,
&i.UsedAt,
&i.CreatedAt,
)
return i, err
}
const deleteUserInvite = `-- name: DeleteUserInvite :exec
DELETE FROM user_invites
WHERE id = $1
`
func (q *Queries) DeleteUserInvite(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteUserInvite, id)
return err
}
const getUserInviteByTokenHash = `-- name: GetUserInviteByTokenHash :one
SELECT user_invites.id, user_invites.token_hash, user_invites.email, user_invites.created_by, user_invites.expires_at, user_invites.used_by, user_invites.used_at, user_invites.created_at
FROM user_invites
WHERE token_hash = $1
`
func (q *Queries) GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error) {
row := q.db.QueryRowContext(ctx, getUserInviteByTokenHash, tokenHash)
var i UserInvite
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Email,
&i.CreatedBy,
&i.ExpiresAt,
&i.UsedBy,
&i.UsedAt,
&i.CreatedAt,
)
return i, err
}
const listUserInvites = `-- name: ListUserInvites :many
SELECT user_invites.id, user_invites.token_hash, user_invites.email, user_invites.created_by, user_invites.expires_at, user_invites.used_by, user_invites.used_at, user_invites.created_at
FROM user_invites
ORDER BY created_at DESC
`
func (q *Queries) ListUserInvites(ctx context.Context) ([]UserInvite, error) {
rows, err := q.db.QueryContext(ctx, listUserInvites)
if err != nil {
return nil, err
}
defer rows.Close()
items := []UserInvite{}
for rows.Next() {
var i UserInvite
if err := rows.Scan(
&i.ID,
&i.TokenHash,
&i.Email,
&i.CreatedBy,
&i.ExpiresAt,
&i.UsedBy,
&i.UsedAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markUserInviteUsed = `-- name: MarkUserInviteUsed :exec
UPDATE user_invites
SET used_by = $2, used_at = now()
WHERE id = $1 AND used_at IS NULL
`
type MarkUserInviteUsedParams struct {
ID uuid.UUID `json:"id"`
UsedBy uuid.NullUUID `json:"used_by"`
}
func (q *Queries) MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error {
_, err := q.db.ExecContext(ctx, markUserInviteUsed, arg.ID, arg.UsedBy)
return err
}
@@ -0,0 +1,623 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: metrics.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
)
const aggregateNodeMetrics = `-- name: AggregateNodeMetrics :many
SELECT date_bin($1::interval, timestamp, '2000-01-01'::timestamptz) AS bucket,
AVG(cpu_usage)::float8 AS avg_cpu,
AVG(memory_usage_percent)::float8 AS avg_memory,
AVG(storage_usage_percent)::float8 AS avg_storage
FROM node_metrics
WHERE node_id = $2 AND timestamp BETWEEN $3 AND $4
GROUP BY bucket
ORDER BY bucket ASC
`
type AggregateNodeMetricsParams struct {
Column1 int64 `json:"column_1"`
NodeID string `json:"node_id"`
Timestamp time.Time `json:"timestamp"`
Timestamp_2 time.Time `json:"timestamp_2"`
}
type AggregateNodeMetricsRow struct {
Bucket interface{} `json:"bucket"`
AvgCpu float64 `json:"avg_cpu"`
AvgMemory float64 `json:"avg_memory"`
AvgStorage float64 `json:"avg_storage"`
}
// Portable bucket bucketing (no TimescaleDB): floor epoch seconds into
// bucket-sized buckets, then back to timestamptz.
func (q *Queries) AggregateNodeMetrics(ctx context.Context, arg AggregateNodeMetricsParams) ([]AggregateNodeMetricsRow, error) {
rows, err := q.db.QueryContext(ctx, aggregateNodeMetrics,
arg.Column1,
arg.NodeID,
arg.Timestamp,
arg.Timestamp_2,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []AggregateNodeMetricsRow{}
for rows.Next() {
var i AggregateNodeMetricsRow
if err := rows.Scan(
&i.Bucket,
&i.AvgCpu,
&i.AvgMemory,
&i.AvgStorage,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listInstanceMetrics = `-- name: ListInstanceMetrics :many
SELECT instance_id, node_id, status, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count
FROM instance_metrics
WHERE service_id = $1 AND timestamp = $2
`
type ListInstanceMetricsParams struct {
ServiceID string `json:"service_id"`
Timestamp time.Time `json:"timestamp"`
}
type ListInstanceMetricsRow struct {
InstanceID string `json:"instance_id"`
NodeID sql.NullString `json:"node_id"`
Status sql.NullString `json:"status"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullInt64 `json:"memory"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
StartTime sql.NullTime `json:"start_time"`
LastSeen sql.NullTime `json:"last_seen"`
HealthStatus sql.NullString `json:"health_status"`
HealthLastCheck sql.NullTime `json:"health_last_check"`
HealthCheckCount sql.NullInt32 `json:"health_check_count"`
HealthFailureCount sql.NullInt32 `json:"health_failure_count"`
}
func (q *Queries) ListInstanceMetrics(ctx context.Context, arg ListInstanceMetricsParams) ([]ListInstanceMetricsRow, error) {
rows, err := q.db.QueryContext(ctx, listInstanceMetrics, arg.ServiceID, arg.Timestamp)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListInstanceMetricsRow{}
for rows.Next() {
var i ListInstanceMetricsRow
if err := rows.Scan(
&i.InstanceID,
&i.NodeID,
&i.Status,
&i.Cpu,
&i.Memory,
&i.NetworkBytesIn,
&i.NetworkBytesOut,
&i.NetworkPacketsIn,
&i.NetworkPacketsOut,
&i.NetworkConnectionsIn,
&i.NetworkConnectionsOut,
&i.NetworkErrorsIn,
&i.NetworkErrorsOut,
&i.StartTime,
&i.LastSeen,
&i.HealthStatus,
&i.HealthLastCheck,
&i.HealthCheckCount,
&i.HealthFailureCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listNodeMetrics = `-- name: ListNodeMetrics :many
SELECT node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15,
memory_total, memory_used, memory_available, memory_usage_percent,
storage_total, storage_used, storage_available, storage_usage_percent,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
coalesce(extract(epoch from uptime), 0)::bigint AS uptime_seconds,
processes, os, kernel, architecture
FROM node_metrics
WHERE node_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC
`
type ListNodeMetricsParams struct {
NodeID string `json:"node_id"`
Timestamp time.Time `json:"timestamp"`
Timestamp_2 time.Time `json:"timestamp_2"`
}
type ListNodeMetricsRow struct {
NodeID string `json:"node_id"`
Timestamp time.Time `json:"timestamp"`
CpuUsage sql.NullString `json:"cpu_usage"`
CpuCores sql.NullString `json:"cpu_cores"`
LoadAvg1 sql.NullString `json:"load_avg_1"`
LoadAvg5 sql.NullString `json:"load_avg_5"`
LoadAvg15 sql.NullString `json:"load_avg_15"`
MemoryTotal sql.NullInt64 `json:"memory_total"`
MemoryUsed sql.NullInt64 `json:"memory_used"`
MemoryAvailable sql.NullInt64 `json:"memory_available"`
MemoryUsagePercent sql.NullString `json:"memory_usage_percent"`
StorageTotal sql.NullInt64 `json:"storage_total"`
StorageUsed sql.NullInt64 `json:"storage_used"`
StorageAvailable sql.NullInt64 `json:"storage_available"`
StorageUsagePercent sql.NullString `json:"storage_usage_percent"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
UptimeSeconds int64 `json:"uptime_seconds"`
Processes sql.NullInt32 `json:"processes"`
Os sql.NullString `json:"os"`
Kernel sql.NullString `json:"kernel"`
Architecture sql.NullString `json:"architecture"`
}
func (q *Queries) ListNodeMetrics(ctx context.Context, arg ListNodeMetricsParams) ([]ListNodeMetricsRow, error) {
rows, err := q.db.QueryContext(ctx, listNodeMetrics, arg.NodeID, arg.Timestamp, arg.Timestamp_2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListNodeMetricsRow{}
for rows.Next() {
var i ListNodeMetricsRow
if err := rows.Scan(
&i.NodeID,
&i.Timestamp,
&i.CpuUsage,
&i.CpuCores,
&i.LoadAvg1,
&i.LoadAvg5,
&i.LoadAvg15,
&i.MemoryTotal,
&i.MemoryUsed,
&i.MemoryAvailable,
&i.MemoryUsagePercent,
&i.StorageTotal,
&i.StorageUsed,
&i.StorageAvailable,
&i.StorageUsagePercent,
&i.NetworkBytesIn,
&i.NetworkBytesOut,
&i.NetworkPacketsIn,
&i.NetworkPacketsOut,
&i.NetworkConnectionsIn,
&i.NetworkConnectionsOut,
&i.NetworkErrorsIn,
&i.NetworkErrorsOut,
&i.UptimeSeconds,
&i.Processes,
&i.Os,
&i.Kernel,
&i.Architecture,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listServiceMetrics = `-- name: ListServiceMetrics :many
SELECT service_id, service_name, project_id, timestamp,
requests_total, requests_success, requests_errors, requests_avg_latency,
requests_p95_latency, requests_p99_latency, requests_throughput,
errors_total, errors_rate, performance_response_time, performance_throughput,
performance_concurrency, performance_saturation, performance_utilization,
resource_cpu_usage, resource_memory_usage, resource_storage_usage,
resource_network_usage, resource_score
FROM service_metrics
WHERE service_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC
`
type ListServiceMetricsParams struct {
ServiceID string `json:"service_id"`
Timestamp time.Time `json:"timestamp"`
Timestamp_2 time.Time `json:"timestamp_2"`
}
type ListServiceMetricsRow struct {
ServiceID string `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectID string `json:"project_id"`
Timestamp time.Time `json:"timestamp"`
RequestsTotal sql.NullInt64 `json:"requests_total"`
RequestsSuccess sql.NullInt64 `json:"requests_success"`
RequestsErrors sql.NullInt64 `json:"requests_errors"`
RequestsAvgLatency sql.NullString `json:"requests_avg_latency"`
RequestsP95Latency sql.NullString `json:"requests_p95_latency"`
RequestsP99Latency sql.NullString `json:"requests_p99_latency"`
RequestsThroughput sql.NullString `json:"requests_throughput"`
ErrorsTotal sql.NullInt64 `json:"errors_total"`
ErrorsRate sql.NullString `json:"errors_rate"`
PerformanceResponseTime sql.NullString `json:"performance_response_time"`
PerformanceThroughput sql.NullString `json:"performance_throughput"`
PerformanceConcurrency sql.NullInt64 `json:"performance_concurrency"`
PerformanceSaturation sql.NullString `json:"performance_saturation"`
PerformanceUtilization sql.NullString `json:"performance_utilization"`
ResourceCpuUsage sql.NullString `json:"resource_cpu_usage"`
ResourceMemoryUsage sql.NullInt64 `json:"resource_memory_usage"`
ResourceStorageUsage sql.NullInt64 `json:"resource_storage_usage"`
ResourceNetworkUsage sql.NullInt64 `json:"resource_network_usage"`
ResourceScore sql.NullString `json:"resource_score"`
}
func (q *Queries) ListServiceMetrics(ctx context.Context, arg ListServiceMetricsParams) ([]ListServiceMetricsRow, error) {
rows, err := q.db.QueryContext(ctx, listServiceMetrics, arg.ServiceID, arg.Timestamp, arg.Timestamp_2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListServiceMetricsRow{}
for rows.Next() {
var i ListServiceMetricsRow
if err := rows.Scan(
&i.ServiceID,
&i.ServiceName,
&i.ProjectID,
&i.Timestamp,
&i.RequestsTotal,
&i.RequestsSuccess,
&i.RequestsErrors,
&i.RequestsAvgLatency,
&i.RequestsP95Latency,
&i.RequestsP99Latency,
&i.RequestsThroughput,
&i.ErrorsTotal,
&i.ErrorsRate,
&i.PerformanceResponseTime,
&i.PerformanceThroughput,
&i.PerformanceConcurrency,
&i.PerformanceSaturation,
&i.PerformanceUtilization,
&i.ResourceCpuUsage,
&i.ResourceMemoryUsage,
&i.ResourceStorageUsage,
&i.ResourceNetworkUsage,
&i.ResourceScore,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertInstanceMetrics = `-- name: UpsertInstanceMetrics :exec
INSERT INTO instance_metrics (
service_id, timestamp, instance_id, node_id, status, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21
)
ON CONFLICT (service_id, timestamp, instance_id) DO UPDATE SET
node_id = EXCLUDED.node_id,
status = EXCLUDED.status,
cpu = EXCLUDED.cpu,
memory = EXCLUDED.memory,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
network_connections_in = EXCLUDED.network_connections_in,
network_connections_out = EXCLUDED.network_connections_out,
network_errors_in = EXCLUDED.network_errors_in,
network_errors_out = EXCLUDED.network_errors_out,
start_time = EXCLUDED.start_time,
last_seen = EXCLUDED.last_seen,
health_status = EXCLUDED.health_status,
health_last_check = EXCLUDED.health_last_check,
health_check_count = EXCLUDED.health_check_count,
health_failure_count = EXCLUDED.health_failure_count
`
type UpsertInstanceMetricsParams struct {
ServiceID string `json:"service_id"`
Timestamp time.Time `json:"timestamp"`
InstanceID string `json:"instance_id"`
NodeID sql.NullString `json:"node_id"`
Status sql.NullString `json:"status"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullInt64 `json:"memory"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
StartTime sql.NullTime `json:"start_time"`
LastSeen sql.NullTime `json:"last_seen"`
HealthStatus sql.NullString `json:"health_status"`
HealthLastCheck sql.NullTime `json:"health_last_check"`
HealthCheckCount sql.NullInt32 `json:"health_check_count"`
HealthFailureCount sql.NullInt32 `json:"health_failure_count"`
}
func (q *Queries) UpsertInstanceMetrics(ctx context.Context, arg UpsertInstanceMetricsParams) error {
_, err := q.db.ExecContext(ctx, upsertInstanceMetrics,
arg.ServiceID,
arg.Timestamp,
arg.InstanceID,
arg.NodeID,
arg.Status,
arg.Cpu,
arg.Memory,
arg.NetworkBytesIn,
arg.NetworkBytesOut,
arg.NetworkPacketsIn,
arg.NetworkPacketsOut,
arg.NetworkConnectionsIn,
arg.NetworkConnectionsOut,
arg.NetworkErrorsIn,
arg.NetworkErrorsOut,
arg.StartTime,
arg.LastSeen,
arg.HealthStatus,
arg.HealthLastCheck,
arg.HealthCheckCount,
arg.HealthFailureCount,
)
return err
}
const upsertNodeMetrics = `-- name: UpsertNodeMetrics :exec
INSERT INTO node_metrics (
node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15,
memory_total, memory_used, memory_available, memory_usage_percent,
storage_total, storage_used, storage_available, storage_usage_percent,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
uptime, processes, os, kernel, architecture
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28
)
ON CONFLICT (node_id, timestamp) DO UPDATE SET
cpu_usage = EXCLUDED.cpu_usage,
cpu_cores = EXCLUDED.cpu_cores,
load_avg_1 = EXCLUDED.load_avg_1,
load_avg_5 = EXCLUDED.load_avg_5,
load_avg_15 = EXCLUDED.load_avg_15,
memory_total = EXCLUDED.memory_total,
memory_used = EXCLUDED.memory_used,
memory_available = EXCLUDED.memory_available,
memory_usage_percent = EXCLUDED.memory_usage_percent,
storage_total = EXCLUDED.storage_total,
storage_used = EXCLUDED.storage_used,
storage_available = EXCLUDED.storage_available,
storage_usage_percent = EXCLUDED.storage_usage_percent,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
network_connections_in = EXCLUDED.network_connections_in,
network_connections_out = EXCLUDED.network_connections_out,
network_errors_in = EXCLUDED.network_errors_in,
network_errors_out = EXCLUDED.network_errors_out,
uptime = EXCLUDED.uptime,
processes = EXCLUDED.processes,
os = EXCLUDED.os,
kernel = EXCLUDED.kernel,
architecture = EXCLUDED.architecture
`
type UpsertNodeMetricsParams struct {
NodeID string `json:"node_id"`
Timestamp time.Time `json:"timestamp"`
CpuUsage sql.NullString `json:"cpu_usage"`
CpuCores sql.NullString `json:"cpu_cores"`
LoadAvg1 sql.NullString `json:"load_avg_1"`
LoadAvg5 sql.NullString `json:"load_avg_5"`
LoadAvg15 sql.NullString `json:"load_avg_15"`
MemoryTotal sql.NullInt64 `json:"memory_total"`
MemoryUsed sql.NullInt64 `json:"memory_used"`
MemoryAvailable sql.NullInt64 `json:"memory_available"`
MemoryUsagePercent sql.NullString `json:"memory_usage_percent"`
StorageTotal sql.NullInt64 `json:"storage_total"`
StorageUsed sql.NullInt64 `json:"storage_used"`
StorageAvailable sql.NullInt64 `json:"storage_available"`
StorageUsagePercent sql.NullString `json:"storage_usage_percent"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
Uptime sql.NullInt64 `json:"uptime"`
Processes sql.NullInt32 `json:"processes"`
Os sql.NullString `json:"os"`
Kernel sql.NullString `json:"kernel"`
Architecture sql.NullString `json:"architecture"`
}
func (q *Queries) UpsertNodeMetrics(ctx context.Context, arg UpsertNodeMetricsParams) error {
_, err := q.db.ExecContext(ctx, upsertNodeMetrics,
arg.NodeID,
arg.Timestamp,
arg.CpuUsage,
arg.CpuCores,
arg.LoadAvg1,
arg.LoadAvg5,
arg.LoadAvg15,
arg.MemoryTotal,
arg.MemoryUsed,
arg.MemoryAvailable,
arg.MemoryUsagePercent,
arg.StorageTotal,
arg.StorageUsed,
arg.StorageAvailable,
arg.StorageUsagePercent,
arg.NetworkBytesIn,
arg.NetworkBytesOut,
arg.NetworkPacketsIn,
arg.NetworkPacketsOut,
arg.NetworkConnectionsIn,
arg.NetworkConnectionsOut,
arg.NetworkErrorsIn,
arg.NetworkErrorsOut,
arg.Uptime,
arg.Processes,
arg.Os,
arg.Kernel,
arg.Architecture,
)
return err
}
const upsertServiceMetrics = `-- name: UpsertServiceMetrics :exec
INSERT INTO service_metrics (
service_id, service_name, project_id, timestamp,
requests_total, requests_success, requests_errors, requests_avg_latency,
requests_p95_latency, requests_p99_latency, requests_throughput,
errors_total, errors_rate, performance_response_time, performance_throughput,
performance_concurrency, performance_saturation, performance_utilization,
resource_cpu_usage, resource_memory_usage, resource_storage_usage,
resource_network_usage, resource_score
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21, $22, $23
)
ON CONFLICT (service_id, timestamp) DO UPDATE SET
requests_total = EXCLUDED.requests_total,
requests_success = EXCLUDED.requests_success,
requests_errors = EXCLUDED.requests_errors,
requests_avg_latency = EXCLUDED.requests_avg_latency,
requests_p95_latency = EXCLUDED.requests_p95_latency,
requests_p99_latency = EXCLUDED.requests_p99_latency,
requests_throughput = EXCLUDED.requests_throughput,
errors_total = EXCLUDED.errors_total,
errors_rate = EXCLUDED.errors_rate,
performance_response_time = EXCLUDED.performance_response_time,
performance_throughput = EXCLUDED.performance_throughput,
performance_concurrency = EXCLUDED.performance_concurrency,
performance_saturation = EXCLUDED.performance_saturation,
performance_utilization = EXCLUDED.performance_utilization,
resource_cpu_usage = EXCLUDED.resource_cpu_usage,
resource_memory_usage = EXCLUDED.resource_memory_usage,
resource_storage_usage = EXCLUDED.resource_storage_usage,
resource_network_usage = EXCLUDED.resource_network_usage,
resource_score = EXCLUDED.resource_score
`
type UpsertServiceMetricsParams struct {
ServiceID string `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectID string `json:"project_id"`
Timestamp time.Time `json:"timestamp"`
RequestsTotal sql.NullInt64 `json:"requests_total"`
RequestsSuccess sql.NullInt64 `json:"requests_success"`
RequestsErrors sql.NullInt64 `json:"requests_errors"`
RequestsAvgLatency sql.NullString `json:"requests_avg_latency"`
RequestsP95Latency sql.NullString `json:"requests_p95_latency"`
RequestsP99Latency sql.NullString `json:"requests_p99_latency"`
RequestsThroughput sql.NullString `json:"requests_throughput"`
ErrorsTotal sql.NullInt64 `json:"errors_total"`
ErrorsRate sql.NullString `json:"errors_rate"`
PerformanceResponseTime sql.NullString `json:"performance_response_time"`
PerformanceThroughput sql.NullString `json:"performance_throughput"`
PerformanceConcurrency sql.NullInt64 `json:"performance_concurrency"`
PerformanceSaturation sql.NullString `json:"performance_saturation"`
PerformanceUtilization sql.NullString `json:"performance_utilization"`
ResourceCpuUsage sql.NullString `json:"resource_cpu_usage"`
ResourceMemoryUsage sql.NullInt64 `json:"resource_memory_usage"`
ResourceStorageUsage sql.NullInt64 `json:"resource_storage_usage"`
ResourceNetworkUsage sql.NullInt64 `json:"resource_network_usage"`
ResourceScore sql.NullString `json:"resource_score"`
}
func (q *Queries) UpsertServiceMetrics(ctx context.Context, arg UpsertServiceMetricsParams) error {
_, err := q.db.ExecContext(ctx, upsertServiceMetrics,
arg.ServiceID,
arg.ServiceName,
arg.ProjectID,
arg.Timestamp,
arg.RequestsTotal,
arg.RequestsSuccess,
arg.RequestsErrors,
arg.RequestsAvgLatency,
arg.RequestsP95Latency,
arg.RequestsP99Latency,
arg.RequestsThroughput,
arg.ErrorsTotal,
arg.ErrorsRate,
arg.PerformanceResponseTime,
arg.PerformanceThroughput,
arg.PerformanceConcurrency,
arg.PerformanceSaturation,
arg.PerformanceUtilization,
arg.ResourceCpuUsage,
arg.ResourceMemoryUsage,
arg.ResourceStorageUsage,
arg.ResourceNetworkUsage,
arg.ResourceScore,
)
return err
}
+145 -5
View File
@@ -136,11 +136,22 @@ type DatabaseService struct {
} }
type Deployment struct { type Deployment struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"` ServiceID uuid.UUID `json:"service_id"`
Status sql.NullString `json:"status"` Version string `json:"version"`
CreatedAt sql.NullTime `json:"created_at"` CommitHash sql.NullString `json:"commit_hash"`
UpdatedAt sql.NullTime `json:"updated_at"` ImageDigest sql.NullString `json:"image_digest"`
Status sql.NullString `json:"status"`
BuildLog sql.NullString `json:"build_log"`
DeploymentLog sql.NullString `json:"deployment_log"`
StartedAt sql.NullTime `json:"started_at"`
CompletedAt sql.NullTime `json:"completed_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ImageName sql.NullString `json:"image_name"`
ImageTag sql.NullString `json:"image_tag"`
RuntimeLog sql.NullString `json:"runtime_log"`
Error sql.NullString `json:"error"`
} }
type Environment struct { type Environment struct {
@@ -161,6 +172,31 @@ type EnvironmentVariable struct {
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type InstanceMetric struct {
ServiceID string `json:"service_id"`
Timestamp time.Time `json:"timestamp"`
InstanceID string `json:"instance_id"`
NodeID sql.NullString `json:"node_id"`
Status sql.NullString `json:"status"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullInt64 `json:"memory"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
StartTime sql.NullTime `json:"start_time"`
LastSeen sql.NullTime `json:"last_seen"`
HealthStatus sql.NullString `json:"health_status"`
HealthLastCheck sql.NullTime `json:"health_last_check"`
HealthCheckCount sql.NullInt32 `json:"health_check_count"`
HealthFailureCount sql.NullInt32 `json:"health_failure_count"`
CreatedAt sql.NullTime `json:"created_at"`
}
type NodeAgent struct { type NodeAgent struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -175,6 +211,42 @@ 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 NodeMetric struct {
NodeID string `json:"node_id"`
Timestamp time.Time `json:"timestamp"`
CpuUsage sql.NullString `json:"cpu_usage"`
CpuCores sql.NullString `json:"cpu_cores"`
LoadAvg1 sql.NullString `json:"load_avg_1"`
LoadAvg5 sql.NullString `json:"load_avg_5"`
LoadAvg15 sql.NullString `json:"load_avg_15"`
MemoryTotal sql.NullInt64 `json:"memory_total"`
MemoryUsed sql.NullInt64 `json:"memory_used"`
MemoryAvailable sql.NullInt64 `json:"memory_available"`
MemoryUsagePercent sql.NullString `json:"memory_usage_percent"`
StorageTotal sql.NullInt64 `json:"storage_total"`
StorageUsed sql.NullInt64 `json:"storage_used"`
StorageAvailable sql.NullInt64 `json:"storage_available"`
StorageUsagePercent sql.NullString `json:"storage_usage_percent"`
NetworkBytesIn sql.NullInt64 `json:"network_bytes_in"`
NetworkBytesOut sql.NullInt64 `json:"network_bytes_out"`
NetworkPacketsIn sql.NullInt64 `json:"network_packets_in"`
NetworkPacketsOut sql.NullInt64 `json:"network_packets_out"`
NetworkConnectionsIn sql.NullInt32 `json:"network_connections_in"`
NetworkConnectionsOut sql.NullInt32 `json:"network_connections_out"`
NetworkErrorsIn sql.NullInt64 `json:"network_errors_in"`
NetworkErrorsOut sql.NullInt64 `json:"network_errors_out"`
Uptime sql.NullInt64 `json:"uptime"`
Processes sql.NullInt32 `json:"processes"`
Os sql.NullString `json:"os"`
Kernel sql.NullString `json:"kernel"`
Architecture sql.NullString `json:"architecture"`
CreatedAt sql.NullTime `json:"created_at"`
} }
type Notification struct { type Notification struct {
@@ -189,6 +261,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"`
@@ -202,6 +285,21 @@ type OutboundWebhook struct {
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type PreviewEnvironment struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
BranchName string `json:"branch_name"`
PrNumber sql.NullInt32 `json:"pr_number"`
Environment string `json:"environment"`
Status string `json:"status"`
Url sql.NullString `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
PreviewServiceID uuid.NullUUID `json:"preview_service_id"`
}
type Project struct { type Project struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -273,6 +371,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 {
@@ -286,6 +388,33 @@ type ServiceDomain struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type ServiceMetric struct {
ServiceID string `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectID string `json:"project_id"`
Timestamp time.Time `json:"timestamp"`
RequestsTotal sql.NullInt64 `json:"requests_total"`
RequestsSuccess sql.NullInt64 `json:"requests_success"`
RequestsErrors sql.NullInt64 `json:"requests_errors"`
RequestsAvgLatency sql.NullString `json:"requests_avg_latency"`
RequestsP95Latency sql.NullString `json:"requests_p95_latency"`
RequestsP99Latency sql.NullString `json:"requests_p99_latency"`
RequestsThroughput sql.NullString `json:"requests_throughput"`
ErrorsTotal sql.NullInt64 `json:"errors_total"`
ErrorsRate sql.NullString `json:"errors_rate"`
PerformanceResponseTime sql.NullString `json:"performance_response_time"`
PerformanceThroughput sql.NullString `json:"performance_throughput"`
PerformanceConcurrency sql.NullInt64 `json:"performance_concurrency"`
PerformanceSaturation sql.NullString `json:"performance_saturation"`
PerformanceUtilization sql.NullString `json:"performance_utilization"`
ResourceCpuUsage sql.NullString `json:"resource_cpu_usage"`
ResourceMemoryUsage sql.NullInt64 `json:"resource_memory_usage"`
ResourceStorageUsage sql.NullInt64 `json:"resource_storage_usage"`
ResourceNetworkUsage sql.NullInt64 `json:"resource_network_usage"`
ResourceScore sql.NullString `json:"resource_score"`
CreatedAt sql.NullTime `json:"created_at"`
}
type ServiceTemplate struct { type ServiceTemplate struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -301,6 +430,17 @@ type ServiceTemplate struct {
UpdatedAt sql.NullTime `json:"updated_at"` UpdatedAt sql.NullTime `json:"updated_at"`
} }
type UserInvite struct {
ID uuid.UUID `json:"id"`
TokenHash string `json:"token_hash"`
Email sql.NullString `json:"email"`
CreatedBy uuid.NullUUID `json:"created_by"`
ExpiresAt time.Time `json:"expires_at"`
UsedBy uuid.NullUUID `json:"used_by"`
UsedAt sql.NullTime `json:"used_at"`
CreatedAt sql.NullTime `json:"created_at"`
}
type UserToken struct { type UserToken struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"` UserID uuid.UUID `json:"user_id"`
@@ -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
@@ -0,0 +1,635 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: preview_environments.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
const countActivePreviewsForBranch = `-- name: CountActivePreviewsForBranch :one
SELECT COUNT(*) FROM preview_environments
WHERE service_id = $1 AND branch_name = $2 AND status NOT IN ('expired', 'stopped')
`
type CountActivePreviewsForBranchParams struct {
ServiceID uuid.UUID `json:"service_id"`
BranchName string `json:"branch_name"`
}
func (q *Queries) CountActivePreviewsForBranch(ctx context.Context, arg CountActivePreviewsForBranchParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countActivePreviewsForBranch, arg.ServiceID, arg.BranchName)
var count int64
err := row.Scan(&count)
return count, err
}
const createPreviewDomain = `-- name: CreatePreviewDomain :exec
INSERT INTO service_domains (service_id, domain, is_default, cert_type) VALUES ($1, $2, true, 'tls')
`
type CreatePreviewDomainParams struct {
ServiceID uuid.UUID `json:"service_id"`
Domain string `json:"domain"`
}
func (q *Queries) CreatePreviewDomain(ctx context.Context, arg CreatePreviewDomainParams) error {
_, err := q.db.ExecContext(ctx, createPreviewDomain, arg.ServiceID, arg.Domain)
return err
}
const createPreviewEnvironment = `-- name: CreatePreviewEnvironment :exec
INSERT INTO preview_environments
(id, project_id, service_id, preview_service_id, branch_name, pr_number, environment,
status, url, expires_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`
type CreatePreviewEnvironmentParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
PreviewServiceID uuid.NullUUID `json:"preview_service_id"`
BranchName string `json:"branch_name"`
PrNumber sql.NullInt32 `json:"pr_number"`
Environment string `json:"environment"`
Status string `json:"status"`
Url sql.NullString `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) CreatePreviewEnvironment(ctx context.Context, arg CreatePreviewEnvironmentParams) error {
_, err := q.db.ExecContext(ctx, createPreviewEnvironment,
arg.ID,
arg.ProjectID,
arg.ServiceID,
arg.PreviewServiceID,
arg.BranchName,
arg.PrNumber,
arg.Environment,
arg.Status,
arg.Url,
arg.ExpiresAt,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const deleteAllServiceDomains = `-- name: DeleteAllServiceDomains :exec
DELETE FROM service_domains WHERE service_id = $1
`
func (q *Queries) DeleteAllServiceDomains(ctx context.Context, serviceID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteAllServiceDomains, serviceID)
return err
}
const deletePreviewEnvironment = `-- name: DeletePreviewEnvironment :exec
DELETE FROM preview_environments WHERE id = $1
`
func (q *Queries) DeletePreviewEnvironment(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deletePreviewEnvironment, id)
return err
}
const getPreviewEnvironment = `-- name: GetPreviewEnvironment :one
SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id AS svc_id, s.name AS service_name, s.type AS service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))
`
type GetPreviewEnvironmentParams struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Column3 bool `json:"column_3"`
}
type GetPreviewEnvironmentRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
PreviewServiceID uuid.NullUUID `json:"preview_service_id"`
BranchName string `json:"branch_name"`
PrNumber sql.NullInt32 `json:"pr_number"`
Environment string `json:"environment"`
Status string `json:"status"`
Url sql.NullString `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
SvcID uuid.NullUUID `json:"svc_id"`
ServiceName sql.NullString `json:"service_name"`
ServiceType sql.NullString `json:"service_type"`
}
func (q *Queries) GetPreviewEnvironment(ctx context.Context, arg GetPreviewEnvironmentParams) (GetPreviewEnvironmentRow, error) {
row := q.db.QueryRowContext(ctx, getPreviewEnvironment, arg.ID, arg.OwnerID, arg.Column3)
var i GetPreviewEnvironmentRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.ServiceID,
&i.PreviewServiceID,
&i.BranchName,
&i.PrNumber,
&i.Environment,
&i.Status,
&i.Url,
&i.ExpiresAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.SvcID,
&i.ServiceName,
&i.ServiceType,
)
return i, err
}
const getPreviewEnvironmentForPromote = `-- name: GetPreviewEnvironmentForPromote :one
SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.environment, pe.status
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)
`
type GetPreviewEnvironmentForPromoteParams struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Column3 bool `json:"column_3"`
}
type GetPreviewEnvironmentForPromoteRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
PreviewServiceID uuid.NullUUID `json:"preview_service_id"`
BranchName string `json:"branch_name"`
Environment string `json:"environment"`
Status string `json:"status"`
}
func (q *Queries) GetPreviewEnvironmentForPromote(ctx context.Context, arg GetPreviewEnvironmentForPromoteParams) (GetPreviewEnvironmentForPromoteRow, error) {
row := q.db.QueryRowContext(ctx, getPreviewEnvironmentForPromote, arg.ID, arg.OwnerID, arg.Column3)
var i GetPreviewEnvironmentForPromoteRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.ServiceID,
&i.PreviewServiceID,
&i.BranchName,
&i.Environment,
&i.Status,
)
return i, err
}
const getPreviewEnvironmentForWrite = `-- name: GetPreviewEnvironmentForWrite :one
SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)
`
type GetPreviewEnvironmentForWriteParams struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Column3 bool `json:"column_3"`
}
type GetPreviewEnvironmentForWriteRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
BranchName string `json:"branch_name"`
PrNumber sql.NullInt32 `json:"pr_number"`
Environment string `json:"environment"`
Status string `json:"status"`
Url sql.NullString `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) GetPreviewEnvironmentForWrite(ctx context.Context, arg GetPreviewEnvironmentForWriteParams) (GetPreviewEnvironmentForWriteRow, error) {
row := q.db.QueryRowContext(ctx, getPreviewEnvironmentForWrite, arg.ID, arg.OwnerID, arg.Column3)
var i GetPreviewEnvironmentForWriteRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.ServiceID,
&i.BranchName,
&i.PrNumber,
&i.Environment,
&i.Status,
&i.Url,
&i.ExpiresAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getPreviewEnvironmentOwner = `-- name: GetPreviewEnvironmentOwner :one
SELECT p.owner_id
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1
`
func (q *Queries) GetPreviewEnvironmentOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getPreviewEnvironmentOwner, id)
var owner_id uuid.UUID
err := row.Scan(&owner_id)
return owner_id, err
}
const getPreviewServiceID = `-- name: GetPreviewServiceID :one
SELECT preview_service_id FROM preview_environments WHERE id = $1
`
func (q *Queries) GetPreviewServiceID(ctx context.Context, id uuid.UUID) (uuid.NullUUID, error) {
row := q.db.QueryRowContext(ctx, getPreviewServiceID, id)
var preview_service_id uuid.NullUUID
err := row.Scan(&preview_service_id)
return preview_service_id, err
}
const getProjectBrief = `-- name: GetProjectBrief :one
SELECT id, name, owner_id FROM projects WHERE id = $1
`
type GetProjectBriefRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
OwnerID uuid.UUID `json:"owner_id"`
}
func (q *Queries) GetProjectBrief(ctx context.Context, id uuid.UUID) (GetProjectBriefRow, error) {
row := q.db.QueryRowContext(ctx, getProjectBrief, id)
var i GetProjectBriefRow
err := row.Scan(&i.ID, &i.Name, &i.OwnerID)
return i, err
}
const getServiceForDeploy = `-- name: GetServiceForDeploy :one
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) AS replicas, COALESCE(s.port, 0) AS port,
COALESCE(s.domain, '') AS domain, COALESCE(s.healthcheck_path, '') AS healthcheck_path,
COALESCE(s.restart_policy, 'unless-stopped') AS restart_policy,
COALESCE(s.builder, 'auto') AS builder, COALESCE(s.cpu_reserve, '') AS cpu_reserve,
COALESCE(s.memory_reserve, '') AS memory_reserve, COALESCE(s.static_build_cmd, '') AS static_build_cmd,
COALESCE(s.static_dir, '') AS static_dir,
s.created_at, s.updated_at
FROM services s
WHERE s.id = $1
`
type GetServiceForDeployRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Type sql.NullString `json:"type"`
Status sql.NullString `json:"status"`
Image sql.NullString `json:"image"`
Command sql.NullString `json:"command"`
Environment sql.NullString `json:"environment"`
GitRepo sql.NullString `json:"git_repo"`
GitBranch sql.NullString `json:"git_branch"`
BuildPath sql.NullString `json:"build_path"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullString `json:"memory"`
Replicas int32 `json:"replicas"`
Port int32 `json:"port"`
Domain string `json:"domain"`
HealthcheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
Builder string `json:"builder"`
CpuReserve string `json:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd"`
StaticDir string `json:"static_dir"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) GetServiceForDeploy(ctx context.Context, id uuid.UUID) (GetServiceForDeployRow, error) {
row := q.db.QueryRowContext(ctx, getServiceForDeploy, id)
var i GetServiceForDeployRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Type,
&i.Status,
&i.Image,
&i.Command,
&i.Environment,
&i.GitRepo,
&i.GitBranch,
&i.BuildPath,
&i.Cpu,
&i.Memory,
&i.Replicas,
&i.Port,
&i.Domain,
&i.HealthcheckPath,
&i.RestartPolicy,
&i.Builder,
&i.CpuReserve,
&i.MemoryReserve,
&i.StaticBuildCmd,
&i.StaticDir,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getServiceTypeBrief = `-- name: GetServiceTypeBrief :one
SELECT id, name, COALESCE(type, service_type, '') AS service_type FROM services WHERE id = $1 AND project_id = $2
`
type GetServiceTypeBriefParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
type GetServiceTypeBriefRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
ServiceType string `json:"service_type"`
}
func (q *Queries) GetServiceTypeBrief(ctx context.Context, arg GetServiceTypeBriefParams) (GetServiceTypeBriefRow, error) {
row := q.db.QueryRowContext(ctx, getServiceTypeBrief, arg.ID, arg.ProjectID)
var i GetServiceTypeBriefRow
err := row.Scan(&i.ID, &i.Name, &i.ServiceType)
return i, err
}
const insertPendingDeployment = `-- name: InsertPendingDeployment :exec
INSERT INTO deployments (id, service_id, version, commit_hash, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW())
`
type InsertPendingDeploymentParams struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Version string `json:"version"`
CommitHash sql.NullString `json:"commit_hash"`
}
func (q *Queries) InsertPendingDeployment(ctx context.Context, arg InsertPendingDeploymentParams) error {
_, err := q.db.ExecContext(ctx, insertPendingDeployment,
arg.ID,
arg.ServiceID,
arg.Version,
arg.CommitHash,
)
return err
}
const listExpiredPreviewsForUser = `-- name: ListExpiredPreviewsForUser :many
SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.environment
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'
`
type ListExpiredPreviewsForUserParams struct {
OwnerID uuid.UUID `json:"owner_id"`
Column2 bool `json:"column_2"`
}
type ListExpiredPreviewsForUserRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
PreviewServiceID uuid.NullUUID `json:"preview_service_id"`
BranchName string `json:"branch_name"`
Environment string `json:"environment"`
}
func (q *Queries) ListExpiredPreviewsForUser(ctx context.Context, arg ListExpiredPreviewsForUserParams) ([]ListExpiredPreviewsForUserRow, error) {
rows, err := q.db.QueryContext(ctx, listExpiredPreviewsForUser, arg.OwnerID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListExpiredPreviewsForUserRow{}
for rows.Next() {
var i ListExpiredPreviewsForUserRow
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.ServiceID,
&i.PreviewServiceID,
&i.BranchName,
&i.Environment,
); 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 listPreviewEnvironmentsForProject = `-- name: ListPreviewEnvironmentsForProject :many
SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id AS svc_id, s.name AS service_name, s.type AS service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
WHERE pe.project_id = $1
ORDER BY pe.created_at DESC
`
type ListPreviewEnvironmentsForProjectRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id"`
PreviewServiceID uuid.NullUUID `json:"preview_service_id"`
BranchName string `json:"branch_name"`
PrNumber sql.NullInt32 `json:"pr_number"`
Environment string `json:"environment"`
Status string `json:"status"`
Url sql.NullString `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
SvcID uuid.NullUUID `json:"svc_id"`
ServiceName sql.NullString `json:"service_name"`
ServiceType sql.NullString `json:"service_type"`
}
func (q *Queries) ListPreviewEnvironmentsForProject(ctx context.Context, projectID uuid.UUID) ([]ListPreviewEnvironmentsForProjectRow, error) {
rows, err := q.db.QueryContext(ctx, listPreviewEnvironmentsForProject, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListPreviewEnvironmentsForProjectRow{}
for rows.Next() {
var i ListPreviewEnvironmentsForProjectRow
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.ServiceID,
&i.PreviewServiceID,
&i.BranchName,
&i.PrNumber,
&i.Environment,
&i.Status,
&i.Url,
&i.ExpiresAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.SvcID,
&i.ServiceName,
&i.ServiceType,
); 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 listSweepablePreviews = `-- name: ListSweepablePreviews :many
SELECT id, preview_service_id FROM preview_environments
WHERE expires_at < NOW() AND status NOT IN ('expired', 'stopped')
`
type ListSweepablePreviewsRow struct {
ID uuid.UUID `json:"id"`
PreviewServiceID uuid.NullUUID `json:"preview_service_id"`
}
func (q *Queries) ListSweepablePreviews(ctx context.Context) ([]ListSweepablePreviewsRow, error) {
rows, err := q.db.QueryContext(ctx, listSweepablePreviews)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListSweepablePreviewsRow{}
for rows.Next() {
var i ListSweepablePreviewsRow
if err := rows.Scan(&i.ID, &i.PreviewServiceID); 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 markPreviewEnvironmentStatus = `-- name: MarkPreviewEnvironmentStatus :exec
UPDATE preview_environments SET status = $1, updated_at = NOW() WHERE id = $2
`
type MarkPreviewEnvironmentStatusParams struct {
Status string `json:"status"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) MarkPreviewEnvironmentStatus(ctx context.Context, arg MarkPreviewEnvironmentStatusParams) error {
_, err := q.db.ExecContext(ctx, markPreviewEnvironmentStatus, arg.Status, arg.ID)
return err
}
const syncPreviewStatuses = `-- name: SyncPreviewStatuses :exec
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
`
func (q *Queries) SyncPreviewStatuses(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, syncPreviewStatuses)
return err
}
const updatePreviewEnvironment = `-- name: UpdatePreviewEnvironment :exec
UPDATE preview_environments
SET status = $1, url = $2, expires_at = $3, updated_at = $4
WHERE id = $5
`
type UpdatePreviewEnvironmentParams struct {
Status string `json:"status"`
Url sql.NullString `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdatePreviewEnvironment(ctx context.Context, arg UpdatePreviewEnvironmentParams) error {
_, err := q.db.ExecContext(ctx, updatePreviewEnvironment,
arg.Status,
arg.Url,
arg.ExpiresAt,
arg.UpdatedAt,
arg.ID,
)
return err
}
const updateServicePreviewBranch = `-- name: UpdateServicePreviewBranch :exec
UPDATE services SET git_branch = $1, domain = '', updated_at = NOW() WHERE id = $2
`
type UpdateServicePreviewBranchParams struct {
GitBranch sql.NullString `json:"git_branch"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateServicePreviewBranch(ctx context.Context, arg UpdateServicePreviewBranchParams) error {
_, err := q.db.ExecContext(ctx, updateServicePreviewBranch, arg.GitBranch, arg.ID)
return err
}
@@ -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,15 +7,31 @@ 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 {
// Portable bucket bucketing (no TimescaleDB): floor epoch seconds into
// bucket-sized buckets, then back to timestamptz.
AggregateNodeMetrics(ctx context.Context, arg AggregateNodeMetricsParams) ([]AggregateNodeMetricsRow, error)
ClearServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) error
ClearServiceNodePins(ctx context.Context, nodeID sql.NullString) (int64, error)
CloneServiceDomains(ctx context.Context, arg CloneServiceDomainsParams) error
CloneServiceRow(ctx context.Context, arg CloneServiceRowParams) error
CloneServiceVariables(ctx context.Context, arg CloneServiceVariablesParams) error
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error) CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
CompleteDeployment(ctx context.Context, arg CompleteDeploymentParams) error
CountAccessibleDeployments(ctx context.Context, arg CountAccessibleDeploymentsParams) (int64, error)
CountActivePreviewsForBranch(ctx context.Context, arg CountActivePreviewsForBranchParams) (int64, 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)
CountDeploymentsForService(ctx context.Context, serviceID uuid.UUID) (int64, error)
CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error)
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
CountServiceDomains(ctx context.Context, serviceID uuid.UUID) (int64, error)
CountServicesByName(ctx context.Context, arg CountServicesByNameParams) (int64, error)
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error) CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error) CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
@@ -29,20 +45,33 @@ type Querier interface {
CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error
CreateNotification(ctx context.Context, arg CreateNotificationParams) error CreateNotification(ctx context.Context, arg CreateNotificationParams) error
CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error
CreatePreviewDomain(ctx context.Context, arg CreatePreviewDomainParams) error
CreatePreviewEnvironment(ctx context.Context, arg CreatePreviewEnvironmentParams) error
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
CreateServiceDomain(ctx context.Context, arg CreateServiceDomainParams) (CreateServiceDomainRow, error)
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
CreateUserInvite(ctx context.Context, arg CreateUserInviteParams) (UserInvite, error)
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error) CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error DeleteAgent(ctx context.Context, id string) error
DeleteAllServiceDomains(ctx context.Context, serviceID uuid.UUID) error
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
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
DeletePreviewEnvironment(ctx context.Context, id uuid.UUID) 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
DeleteServiceByID(ctx context.Context, id uuid.UUID) error
DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error
DeleteServiceDomain(ctx context.Context, arg DeleteServiceDomainParams) (int64, 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)
FailDeployment(ctx context.Context, arg FailDeploymentParams) error
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error) GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
GetAgent(ctx context.Context, id string) (NodeAgent, error) GetAgent(ctx context.Context, id string) (NodeAgent, error)
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
@@ -56,21 +85,47 @@ 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)
GetDeploymentAccess(ctx context.Context, id uuid.UUID) (GetDeploymentAccessRow, error)
GetDeploymentForRollback(ctx context.Context, id uuid.UUID) (GetDeploymentForRollbackRow, error)
GetDeploymentWithProject(ctx context.Context, id uuid.UUID) (GetDeploymentWithProjectRow, 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)
GetPreviewEnvironment(ctx context.Context, arg GetPreviewEnvironmentParams) (GetPreviewEnvironmentRow, error)
GetPreviewEnvironmentForPromote(ctx context.Context, arg GetPreviewEnvironmentForPromoteParams) (GetPreviewEnvironmentForPromoteRow, error)
GetPreviewEnvironmentForWrite(ctx context.Context, arg GetPreviewEnvironmentForWriteParams) (GetPreviewEnvironmentForWriteRow, error)
GetPreviewEnvironmentOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetPreviewServiceID(ctx context.Context, id uuid.UUID) (uuid.NullUUID, error)
GetProjectBrief(ctx context.Context, id uuid.UUID) (GetProjectBriefRow, 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)
GetProjectOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
GetServiceAccess(ctx context.Context, id uuid.UUID) (GetServiceAccessRow, error)
GetServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) (string, error)
GetServiceDomainFirst(ctx context.Context, serviceID uuid.UUID) (string, error)
GetServiceEnvironment(ctx context.Context, id uuid.UUID) (string, error)
GetServiceForDeploy(ctx context.Context, id uuid.UUID) (GetServiceForDeployRow, error)
GetServiceForDeployWithOwner(ctx context.Context, id uuid.UUID) (GetServiceForDeployWithOwnerRow, error)
GetServicePlacementBrief(ctx context.Context, id uuid.UUID) (GetServicePlacementBriefRow, error)
GetServicePublishedPort(ctx context.Context, id uuid.UUID) (int32, error)
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error) GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
GetServiceTypeBrief(ctx context.Context, arg GetServiceTypeBriefParams) (GetServiceTypeBriefRow, 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
InsertDeployment(ctx context.Context, arg InsertDeploymentParams) error
InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error)
InsertPendingDeployment(ctx context.Context, arg InsertPendingDeploymentParams) 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)
@@ -78,24 +133,59 @@ type Querier interface {
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error) ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error)
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
ListDeploymentsForService(ctx context.Context, arg ListDeploymentsForServiceParams) ([]ListDeploymentsForServiceRow, 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)
ListExpiredPreviewsForUser(ctx context.Context, arg ListExpiredPreviewsForUserParams) ([]ListExpiredPreviewsForUserRow, error)
ListInstanceMetrics(ctx context.Context, arg ListInstanceMetricsParams) ([]ListInstanceMetricsRow, error)
ListNodeMetrics(ctx context.Context, arg ListNodeMetricsParams) ([]ListNodeMetricsRow, error)
ListNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error)
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error) ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error) ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error)
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListPendingProjects(ctx context.Context) ([]Project, error) ListPendingProjects(ctx context.Context) ([]Project, error)
ListPreviewEnvironmentsForProject(ctx context.Context, projectID uuid.UUID) ([]ListPreviewEnvironmentsForProjectRow, 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)
ListRecentAccessibleDeployments(ctx context.Context, arg ListRecentAccessibleDeploymentsParams) ([]ListRecentAccessibleDeploymentsRow, 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)
ListServiceDomains(ctx context.Context, serviceID uuid.UUID) ([]ServiceDomain, error)
ListServiceMetrics(ctx context.Context, arg ListServiceMetricsParams) ([]ListServiceMetricsRow, error)
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
ListSweepablePreviews(ctx context.Context) ([]ListSweepablePreviewsRow, error)
ListUserInvites(ctx context.Context) ([]UserInvite, error)
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error) ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error)
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
MarkPreviewEnvironmentStatus(ctx context.Context, arg MarkPreviewEnvironmentStatusParams) error
MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error
MoveServiceProject(ctx context.Context, arg MoveServiceProjectParams) (int64, 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
ServiceWriteAccess(ctx context.Context, arg ServiceWriteAccessParams) (bool, 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
@@ -105,22 +195,42 @@ type Querier interface {
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
SetDeploymentStatus(ctx context.Context, arg SetDeploymentStatusParams) error
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error) SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
SetServiceDomainDefault(ctx context.Context, arg SetServiceDomainDefaultParams) (int64, error)
SetServiceLegacyDomain(ctx context.Context, arg SetServiceLegacyDomainParams) error
SetServiceStatus(ctx context.Context, arg SetServiceStatusParams) error
SyncDeploymentProgress(ctx context.Context, arg SyncDeploymentProgressParams) error
SyncPreviewStatuses(ctx context.Context) error
TouchAgentAuthToken(ctx context.Context, tokenHash string) error TouchAgentAuthToken(ctx context.Context, tokenHash string) error
TouchServiceDomainChecked(ctx context.Context, id uuid.UUID) error
TouchUserToken(ctx context.Context, id uuid.UUID) error TouchUserToken(ctx context.Context, id uuid.UUID) error
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
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
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error
UpdatePreviewEnvironment(ctx context.Context, arg UpdatePreviewEnvironmentParams) error
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error) UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
UpdateServicePreviewBranch(ctx context.Context, arg UpdateServicePreviewBranchParams) error
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
UpsertInstanceMetrics(ctx context.Context, arg UpsertInstanceMetricsParams) error
UpsertNodeMetrics(ctx context.Context, arg UpsertNodeMetricsParams) error
UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) error
UpsertServiceMetrics(ctx context.Context, arg UpsertServiceMetricsParams) error
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
} }
@@ -0,0 +1,158 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: service_ops.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const cloneServiceDomains = `-- name: CloneServiceDomains :exec
INSERT INTO service_domains (service_id, domain, is_default, cert_type)
SELECT $1, domain, is_default, cert_type FROM service_domains sd WHERE sd.service_id = $2
`
type CloneServiceDomainsParams struct {
ServiceID uuid.UUID `json:"service_id"`
ServiceID_2 uuid.UUID `json:"service_id_2"`
}
func (q *Queries) CloneServiceDomains(ctx context.Context, arg CloneServiceDomainsParams) error {
_, err := q.db.ExecContext(ctx, cloneServiceDomains, arg.ServiceID, arg.ServiceID_2)
return err
}
const cloneServiceRow = `-- name: CloneServiceRow :exec
INSERT INTO services
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
build_command, start_command, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, volumes, maintenance_mode, basic_auth_users,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
traefik_labels, created_at, updated_at)
SELECT $1, $2, $3, $4, service_type, source_type, source_url, image_name,
build_command, start_command, type, 'stopped', image, command, $5,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, volumes, $6, $7,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
traefik_labels, $8, $8
FROM services s WHERE s.id = $9
`
type CloneServiceRowParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
EnvironmentID uuid.UUID `json:"environment_id"`
Environment sql.NullString `json:"environment"`
MaintenanceMode bool `json:"maintenance_mode"`
BasicAuthUsers string `json:"basic_auth_users"`
CreatedAt sql.NullTime `json:"created_at"`
ID_2 uuid.UUID `json:"id_2"`
}
func (q *Queries) CloneServiceRow(ctx context.Context, arg CloneServiceRowParams) error {
_, err := q.db.ExecContext(ctx, cloneServiceRow,
arg.ID,
arg.ProjectID,
arg.Name,
arg.EnvironmentID,
arg.Environment,
arg.MaintenanceMode,
arg.BasicAuthUsers,
arg.CreatedAt,
arg.ID_2,
)
return err
}
const cloneServiceVariables = `-- name: CloneServiceVariables :exec
INSERT INTO environment_variables (id, service_id, key, value, is_secret)
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables ev WHERE ev.service_id = $2
`
type CloneServiceVariablesParams struct {
ServiceID uuid.UUID `json:"service_id"`
ServiceID_2 uuid.UUID `json:"service_id_2"`
}
func (q *Queries) CloneServiceVariables(ctx context.Context, arg CloneServiceVariablesParams) error {
_, err := q.db.ExecContext(ctx, cloneServiceVariables, arg.ServiceID, arg.ServiceID_2)
return err
}
const countServicesByName = `-- name: CountServicesByName :one
SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2
`
type CountServicesByNameParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
}
func (q *Queries) CountServicesByName(ctx context.Context, arg CountServicesByNameParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countServicesByName, arg.ProjectID, arg.Name)
var count int64
err := row.Scan(&count)
return count, err
}
const deleteServiceByID = `-- name: DeleteServiceByID :exec
DELETE FROM services WHERE id = $1
`
func (q *Queries) DeleteServiceByID(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteServiceByID, id)
return err
}
const getProjectOwner = `-- name: GetProjectOwner :one
SELECT owner_id FROM projects WHERE id = $1
`
func (q *Queries) GetProjectOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, getProjectOwner, id)
var owner_id uuid.UUID
err := row.Scan(&owner_id)
return owner_id, err
}
const getServiceEnvironment = `-- name: GetServiceEnvironment :one
SELECT COALESCE(environment, '') FROM services WHERE id = $1
`
func (q *Queries) GetServiceEnvironment(ctx context.Context, id uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getServiceEnvironment, id)
var environment string
err := row.Scan(&environment)
return environment, err
}
const moveServiceProject = `-- name: MoveServiceProject :execrows
UPDATE services SET project_id = $1, environment_id = $2, updated_at = $3 WHERE id = $4
`
type MoveServiceProjectParams struct {
ProjectID uuid.UUID `json:"project_id"`
EnvironmentID uuid.UUID `json:"environment_id"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) MoveServiceProject(ctx context.Context, arg MoveServiceProjectParams) (int64, error) {
result, err := q.db.ExecContext(ctx, moveServiceProject,
arg.ProjectID,
arg.EnvironmentID,
arg.UpdatedAt,
arg.ID,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
@@ -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")
}
}
+75 -1
View File
@@ -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 {
+151 -38
View File
@@ -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(
+22
View File
@@ -336,6 +336,13 @@ func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options
return resp, nil return resp, nil
} }
// SaveImage streams refs as a docker-save tarball. Remote nodes receive
// these through the artifact endpoint and docker load them — that is how
// locally-built rollback tags reach nodes without a registry.
func (c *Client) SaveImage(ctx context.Context, refs ...string) (io.ReadCloser, error) {
return c.cli.ImageSave(ctx, refs)
}
// RemoveImage removes an image // RemoveImage removes an image
func (c *Client) RemoveImage(ctx context.Context, imageID string, force bool) ([]image.DeleteResponse, error) { func (c *Client) RemoveImage(ctx context.Context, imageID string, force bool) ([]image.DeleteResponse, error) {
return c.cli.ImageRemove(ctx, imageID, image.RemoveOptions{ return c.cli.ImageRemove(ctx, imageID, image.RemoveOptions{
@@ -516,6 +523,21 @@ func (c *Client) ExecRun(ctx context.Context, containerID string, cmd []string)
return buf.String(), inspect.ExitCode, nil return buf.String(), inspect.ExitCode, nil
} }
// ExecAttach attaches to a running exec instance, returning the hijacked
// bidirectional stream. With Tty enabled the stream carries raw pty data
// (no stdcopy multiplexing).
func (c *Client) ExecAttach(ctx context.Context, execID string) (types.HijackedResponse, error) {
return c.cli.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{})
}
// ExecResize resizes the pty of a running exec instance.
func (c *Client) ExecResize(ctx context.Context, execID string, cols, rows uint) error {
return c.cli.ContainerExecResize(ctx, execID, container.ResizeOptions{
Height: rows,
Width: cols,
})
}
// GetImageInfo returns information about a Docker image // GetImageInfo returns information about a Docker image
func (c *Client) GetImageInfo(ctx context.Context, imageName string) (*ImageInfo, error) { func (c *Client) GetImageInfo(ctx context.Context, imageName string) (*ImageInfo, error) {
images, err := c.cli.ImageList(ctx, image.ListOptions{}) images, err := c.cli.ImageList(ctx, image.ListOptions{})
@@ -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")
}
}
+29 -2
View File
@@ -50,10 +50,15 @@ func init() {
register(spec{"containr_projects_create", "Create a project. Args: name (required), description.", "POST", "/projects", nil, []string{"name", "description"}, false}) register(spec{"containr_projects_create", "Create a project. Args: name (required), description.", "POST", "/projects", nil, []string{"name", "description"}, false})
register(spec{"containr_projects_delete", "Delete a project and all its services. Requires confirm:true.", "DELETE", "/projects/{id}", []string{"id"}, nil, true}) register(spec{"containr_projects_delete", "Delete a project and all its services. Requires confirm:true.", "DELETE", "/projects/{id}", []string{"id"}, nil, true})
// Environments
register(spec{"containr_environments_list", "List environments in a project with service counts", "GET", "/projects/{id}/environments", []string{"id"}, nil, false})
register(spec{"containr_environments_create", "Create an environment. Args: name (required — lowercase alphanumeric with dashes, 1-50 chars).", "POST", "/projects/{id}/environments", []string{"id"}, []string{"name"}, false})
register(spec{"containr_environments_delete", "Delete an empty environment. Requires confirm:true.", "DELETE", "/environments/{id}", []string{"id"}, nil, true})
// Services // Services
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false}) register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false}) register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir.", "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})
@@ -158,11 +168,28 @@ func init() {
register(spec{"containr_branding_get", "Get instance white-label branding (product name, logo, accent, links).", "GET", "/branding", nil, nil, false}) register(spec{"containr_branding_get", "Get instance white-label branding (product name, logo, accent, links).", "GET", "/branding", nil, nil, false})
register(spec{"containr_settings_update", "Update platform settings (admin). Args: signup_enabled (bool), cloudflare_tunnel_token (string, empty clears), branding (object: product_name, logo_url, favicon_url, accent_color, docs_url, support_url — empty string resets a field).", "PUT", "/settings", nil, []string{"signup_enabled", "cloudflare_tunnel_token", "branding"}, false}) register(spec{"containr_settings_update", "Update platform settings (admin). Args: signup_enabled (bool), cloudflare_tunnel_token (string, empty clears), branding (object: product_name, logo_url, favicon_url, accent_color, docs_url, support_url — empty string resets a field).", "PUT", "/settings", nil, []string{"signup_enabled", "cloudflare_tunnel_token", "branding"}, false})
register(spec{"containr_invites_list", "List team invite links (admin).", "GET", "/admin/invites", nil, nil, false})
register(spec{"containr_invites_create", "Create a team invite link (admin). Args: email (optional bind), expires_in_hours (default 168). Returns the one-time token + accept URL.", "POST", "/admin/invites", nil, []string{"email", "expires_in_hours"}, false})
register(spec{"containr_invites_revoke", "Revoke a team invite (admin). Args: id. Requires confirm=true.", "DELETE", "/admin/invites/{id}", []string{"id"}, nil, true})
register(spec{"containr_admin_impersonate", "Mint a 15-minute bearer token acting as a user (admin, audit-logged). Args: id (user id).", "POST", "/admin/users/{id}/impersonate", []string{"id"}, nil, false})
register(spec{"containr_github_app_status", "Show GitHub App provisioning status (admin).", "GET", "/admin/git/github-app", nil, nil, false})
register(spec{"containr_github_app_manifest", "Build a GitHub App manifest for self-provisioning (admin). Args: base_url (required, https public URL), name (optional), organization (optional).", "POST", "/admin/git/github-app/manifest", nil, []string{"base_url", "name", "organization"}, false})
register(spec{"containr_github_app_convert", "Exchange the GitHub manifest callback code for app credentials (admin). Args: code.", "POST", "/admin/git/github-app/convert", nil, []string{"code"}, false})
register(spec{"containr_activity_list", "Activity feed — enriched audit events (severity/category/label). Args: severity, category, resource, project_id, since, page, limit.", "GET", "/activity", nil, []string{"severity", "category", "resource", "project_id", "since", "page", "limit"}, false})
register(spec{"containr_project_activity", "Per-project activity timeline. Args: id (project id), page, limit.", "GET", "/projects/{id}/activity", []string{"id"}, []string{"page", "limit"}, false})
// Nodes / agent tokens (admin) // Nodes / agent tokens (admin)
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false}) register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false})
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false}) register(spec{"containr_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})
+259 -332
View File
@@ -4,293 +4,286 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"strconv"
"sync" "sync"
"time" "time"
"containr/internal/database/sqlcdb"
_ "github.com/lib/pq" _ "github.com/lib/pq"
) )
// PostgreSQLMetricsStorage implements MetricsStorage using PostgreSQL // PostgreSQLMetricsStorage implements MetricsStorage using PostgreSQL
// through sqlc-generated queries (sqlc/queries/metrics.sql).
//
// Per-container node metrics are deliberately not persisted: the
// container_metrics table belongs to the container_instances model and
// has no place for the node/container samples collected here. Node and
// service/instance rows map cleanly, so those are stored.
type PostgreSQLMetricsStorage struct { type PostgreSQLMetricsStorage struct {
db *sql.DB q *sqlcdb.Queries
} }
// NewPostgreSQLMetricsStorage creates a new PostgreSQL metrics storage // NewPostgreSQLMetricsStorage creates a new PostgreSQL metrics storage
func NewPostgreSQLMetricsStorage(db *sql.DB) *PostgreSQLMetricsStorage { func NewPostgreSQLMetricsStorage(db *sql.DB) *PostgreSQLMetricsStorage {
return &PostgreSQLMetricsStorage{db: db} return &PostgreSQLMetricsStorage{q: sqlcdb.New(db)}
} }
func numStr(v float64) sql.NullString {
return sql.NullString{String: strconv.FormatFloat(v, 'f', -1, 64), Valid: true}
}
func int64N(v int64) sql.NullInt64 {
return sql.NullInt64{Int64: v, Valid: true}
}
func int32N(v int64) sql.NullInt32 {
return sql.NullInt32{Int32: int32(v), Valid: true}
}
func strN(v string) sql.NullString {
return sql.NullString{String: v, Valid: v != ""}
}
func timeN(t time.Time) sql.NullTime {
return sql.NullTime{Time: t, Valid: !t.IsZero()}
}
func f64v(n sql.NullString) float64 {
f, _ := strconv.ParseFloat(n.String, 64)
return f
}
func i64v(n sql.NullInt64) int64 { return n.Int64 }
func i32v(n sql.NullInt32) int64 { return int64(n.Int32) }
// StoreNodeMetrics stores node metrics in the database // StoreNodeMetrics stores node metrics in the database
func (s *PostgreSQLMetricsStorage) StoreNodeMetrics(ctx context.Context, metrics *NodeMetrics) error { func (s *PostgreSQLMetricsStorage) StoreNodeMetrics(ctx context.Context, metrics *NodeMetrics) error {
query := ` err := s.q.UpsertNodeMetrics(ctx, sqlcdb.UpsertNodeMetricsParams{
INSERT INTO node_metrics ( NodeID: metrics.NodeID,
node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15, Timestamp: metrics.Timestamp,
memory_total, memory_used, memory_available, memory_usage_percent, CpuUsage: numStr(metrics.CPU.UsagePercent),
storage_total, storage_used, storage_available, storage_usage_percent, CpuCores: numStr(metrics.CPU.UsageCores),
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out, LoadAvg1: numStr(metrics.CPU.LoadAverage1),
network_connections_in, network_connections_out, network_errors_in, network_errors_out, LoadAvg5: numStr(metrics.CPU.LoadAverage5),
uptime, processes, os, kernel, architecture LoadAvg15: numStr(metrics.CPU.LoadAverage15),
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28) MemoryTotal: int64N(metrics.Memory.Total),
ON CONFLICT (node_id, timestamp) DO UPDATE SET MemoryUsed: int64N(metrics.Memory.Used),
cpu_usage = EXCLUDED.cpu_usage, MemoryAvailable: int64N(metrics.Memory.Available),
cpu_cores = EXCLUDED.cpu_cores, MemoryUsagePercent: numStr(metrics.Memory.UsagePercent),
load_avg_1 = EXCLUDED.load_avg_1, StorageTotal: int64N(metrics.Storage.Total),
load_avg_5 = EXCLUDED.load_avg_5, StorageUsed: int64N(metrics.Storage.Used),
load_avg_15 = EXCLUDED.load_avg_15, StorageAvailable: int64N(metrics.Storage.Available),
memory_total = EXCLUDED.memory_total, StorageUsagePercent: numStr(metrics.Storage.UsagePercent),
memory_used = EXCLUDED.memory_used, NetworkBytesIn: int64N(metrics.Network.BytesIn),
memory_available = EXCLUDED.memory_available, NetworkBytesOut: int64N(metrics.Network.BytesOut),
memory_usage_percent = EXCLUDED.memory_usage_percent, NetworkPacketsIn: int64N(metrics.Network.PacketsIn),
storage_total = EXCLUDED.storage_total, NetworkPacketsOut: int64N(metrics.Network.PacketsOut),
storage_used = EXCLUDED.storage_used, NetworkConnectionsIn: int32N(metrics.Network.ConnectionsIn),
storage_available = EXCLUDED.storage_available, NetworkConnectionsOut: int32N(metrics.Network.ConnectionsOut),
storage_usage_percent = EXCLUDED.storage_usage_percent, NetworkErrorsIn: int64N(metrics.Network.ErrorsIn),
network_bytes_in = EXCLUDED.network_bytes_in, NetworkErrorsOut: int64N(metrics.Network.ErrorsOut),
network_bytes_out = EXCLUDED.network_bytes_out, // INTERVAL param — Postgres casts the bigint as seconds.
network_packets_in = EXCLUDED.network_packets_in, Uptime: int64N(int64(metrics.System.Uptime.Seconds())),
network_packets_out = EXCLUDED.network_packets_out, Processes: sql.NullInt32{Int32: int32(metrics.System.Processes), Valid: true},
network_connections_in = EXCLUDED.network_connections_in, Os: strN(metrics.System.OS),
network_connections_out = EXCLUDED.network_connections_out, Kernel: strN(metrics.System.Kernel),
network_errors_in = EXCLUDED.network_errors_in, Architecture: strN(metrics.System.Architecture),
network_errors_out = EXCLUDED.network_errors_out, })
uptime = EXCLUDED.uptime,
processes = EXCLUDED.processes,
os = EXCLUDED.os,
kernel = EXCLUDED.kernel,
architecture = EXCLUDED.architecture
`
_, err := s.db.ExecContext(ctx, query,
metrics.NodeID, metrics.Timestamp, metrics.CPU.UsagePercent, metrics.CPU.UsageCores,
metrics.CPU.LoadAverage1, metrics.CPU.LoadAverage5, metrics.CPU.LoadAverage15,
metrics.Memory.Total, metrics.Memory.Used, metrics.Memory.Available, metrics.Memory.UsagePercent,
metrics.Storage.Total, metrics.Storage.Used, metrics.Storage.Available, metrics.Storage.UsagePercent,
metrics.Network.BytesIn, metrics.Network.BytesOut, metrics.Network.PacketsIn, metrics.Network.PacketsOut,
metrics.Network.ConnectionsIn, metrics.Network.ConnectionsOut, metrics.Network.ErrorsIn, metrics.Network.ErrorsOut,
metrics.System.Uptime, metrics.System.Processes, metrics.System.OS, metrics.System.Kernel, metrics.System.Architecture,
)
if err != nil { if err != nil {
return fmt.Errorf("failed to store node metrics: %w", err) return fmt.Errorf("failed to store node metrics: %w", err)
} }
// Store container metrics
for _, container := range metrics.Containers {
if err := s.storeContainerMetrics(ctx, metrics.NodeID, metrics.Timestamp, container); err != nil {
return fmt.Errorf("failed to store container metrics: %w", err)
}
}
return nil return nil
} }
// StoreServiceMetrics stores service metrics in the database // StoreServiceMetrics stores service metrics in the database
func (s *PostgreSQLMetricsStorage) StoreServiceMetrics(ctx context.Context, metrics *ServiceMetrics) error { func (s *PostgreSQLMetricsStorage) StoreServiceMetrics(ctx context.Context, metrics *ServiceMetrics) error {
query := ` err := s.q.UpsertServiceMetrics(ctx, sqlcdb.UpsertServiceMetricsParams{
INSERT INTO service_metrics ( ServiceID: metrics.ServiceID,
service_id, service_name, project_id, timestamp, ServiceName: metrics.ServiceName,
requests_total, requests_success, requests_errors, requests_avg_latency, ProjectID: metrics.ProjectID,
requests_p95_latency, requests_p99_latency, requests_throughput, Timestamp: metrics.Timestamp,
errors_total, errors_rate, performance_response_time, performance_throughput, RequestsTotal: int64N(metrics.Requests.Total),
performance_concurrency, performance_saturation, performance_utilization, RequestsSuccess: int64N(metrics.Requests.Success),
resource_cpu_usage, resource_memory_usage, resource_storage_usage, RequestsErrors: int64N(metrics.Requests.Errors),
resource_network_usage, resource_score RequestsAvgLatency: numStr(metrics.Requests.AvgLatency),
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23) RequestsP95Latency: numStr(metrics.Requests.P95Latency),
ON CONFLICT (service_id, timestamp) DO UPDATE SET RequestsP99Latency: numStr(metrics.Requests.P99Latency),
requests_total = EXCLUDED.requests_total, RequestsThroughput: numStr(metrics.Requests.Throughput),
requests_success = EXCLUDED.requests_success, ErrorsTotal: int64N(metrics.Errors.Total),
requests_errors = EXCLUDED.requests_errors, ErrorsRate: numStr(metrics.Errors.Rate),
requests_avg_latency = EXCLUDED.requests_avg_latency, PerformanceResponseTime: numStr(metrics.Performance.ResponseTime),
requests_p95_latency = EXCLUDED.requests_p95_latency, PerformanceThroughput: numStr(metrics.Performance.Throughput),
requests_p99_latency = EXCLUDED.requests_p99_latency, PerformanceConcurrency: int64N(metrics.Performance.Concurrency),
requests_throughput = EXCLUDED.requests_throughput, PerformanceSaturation: numStr(metrics.Performance.Saturation),
errors_total = EXCLUDED.errors_total, PerformanceUtilization: numStr(metrics.Performance.Utilization),
errors_rate = EXCLUDED.errors_rate, ResourceCpuUsage: numStr(metrics.Resources.CPUUsage),
performance_response_time = EXCLUDED.performance_response_time, ResourceMemoryUsage: int64N(metrics.Resources.MemoryUsage),
performance_throughput = EXCLUDED.performance_throughput, ResourceStorageUsage: int64N(metrics.Resources.StorageUsage),
performance_concurrency = EXCLUDED.performance_concurrency, ResourceNetworkUsage: int64N(metrics.Resources.NetworkUsage),
performance_saturation = EXCLUDED.performance_saturation, ResourceScore: numStr(metrics.Resources.ResourceScore),
performance_utilization = EXCLUDED.performance_utilization, })
resource_cpu_usage = EXCLUDED.resource_cpu_usage,
resource_memory_usage = EXCLUDED.resource_memory_usage,
resource_storage_usage = EXCLUDED.resource_storage_usage,
resource_network_usage = EXCLUDED.resource_network_usage,
resource_score = EXCLUDED.resource_score
`
_, err := s.db.ExecContext(ctx, query,
metrics.ServiceID, metrics.ServiceName, metrics.ProjectID, metrics.Timestamp,
metrics.Requests.Total, metrics.Requests.Success, metrics.Requests.Errors,
metrics.Requests.AvgLatency, metrics.Requests.P95Latency, metrics.Requests.P99Latency,
metrics.Requests.Throughput, metrics.Errors.Total, metrics.Errors.Rate,
metrics.Performance.ResponseTime, metrics.Performance.Throughput,
metrics.Performance.Concurrency, metrics.Performance.Saturation, metrics.Performance.Utilization,
metrics.Resources.CPUUsage, metrics.Resources.MemoryUsage, metrics.Resources.StorageUsage,
metrics.Resources.NetworkUsage, metrics.Resources.ResourceScore,
)
if err != nil { if err != nil {
return fmt.Errorf("failed to store service metrics: %w", err) return fmt.Errorf("failed to store service metrics: %w", err)
} }
// Store instance metrics // The service row must land first — instance_metrics references
// service_metrics(service_id, timestamp).
for _, instance := range metrics.Instances { for _, instance := range metrics.Instances {
if err := s.storeInstanceMetrics(ctx, metrics.ServiceID, metrics.Timestamp, instance); err != nil { if err := s.storeInstanceMetrics(ctx, metrics.ServiceID, metrics.Timestamp, instance); err != nil {
return fmt.Errorf("failed to store instance metrics: %w", err) return fmt.Errorf("failed to store instance metrics: %w", err)
} }
} }
return nil return nil
} }
// GetNodeMetrics retrieves node metrics from the database // GetNodeMetrics retrieves node metrics from the database
func (s *PostgreSQLMetricsStorage) GetNodeMetrics(ctx context.Context, nodeID string, from, to time.Time) ([]*NodeMetrics, error) { func (s *PostgreSQLMetricsStorage) GetNodeMetrics(ctx context.Context, nodeID string, from, to time.Time) ([]*NodeMetrics, error) {
query := ` rows, err := s.q.ListNodeMetrics(ctx, sqlcdb.ListNodeMetricsParams{
SELECT node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15, NodeID: nodeID,
memory_total, memory_used, memory_available, memory_usage_percent, Timestamp: from,
storage_total, storage_used, storage_available, storage_usage_percent, Timestamp_2: to,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out, })
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
uptime, processes, os, kernel, architecture
FROM node_metrics
WHERE node_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC
`
rows, err := s.db.QueryContext(ctx, query, nodeID, from, to)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query node metrics: %w", err) return nil, fmt.Errorf("failed to query node metrics: %w", err)
} }
defer rows.Close()
var metrics []*NodeMetrics var metrics []*NodeMetrics
for rows.Next() { for _, r := range rows {
var m NodeMetrics metrics = append(metrics, &NodeMetrics{
err := rows.Scan( NodeID: r.NodeID,
&m.NodeID, &m.Timestamp, &m.CPU.UsagePercent, &m.CPU.UsageCores, Timestamp: r.Timestamp,
&m.CPU.LoadAverage1, &m.CPU.LoadAverage5, &m.CPU.LoadAverage15, CPU: CPUMetrics{
&m.Memory.Total, &m.Memory.Used, &m.Memory.Available, &m.Memory.UsagePercent, UsagePercent: f64v(r.CpuUsage),
&m.Storage.Total, &m.Storage.Used, &m.Storage.Available, &m.Storage.UsagePercent, UsageCores: f64v(r.CpuCores),
&m.Network.BytesIn, &m.Network.BytesOut, &m.Network.PacketsIn, &m.Network.PacketsOut, LoadAverage1: f64v(r.LoadAvg1),
&m.Network.ConnectionsIn, &m.Network.ConnectionsOut, &m.Network.ErrorsIn, &m.Network.ErrorsOut, LoadAverage5: f64v(r.LoadAvg5),
&m.System.Uptime, &m.System.Processes, &m.System.OS, &m.System.Kernel, &m.System.Architecture, LoadAverage15: f64v(r.LoadAvg15),
) },
if err != nil { Memory: MemoryMetrics{
return nil, fmt.Errorf("failed to scan node metrics: %w", err) Total: i64v(r.MemoryTotal),
} Used: i64v(r.MemoryUsed),
Available: i64v(r.MemoryAvailable),
// Get container metrics for this timestamp UsagePercent: f64v(r.MemoryUsagePercent),
containers, err := s.getContainerMetrics(ctx, nodeID, m.Timestamp) },
if err != nil { Storage: StorageMetrics{
return nil, fmt.Errorf("failed to get container metrics: %w", err) Total: i64v(r.StorageTotal),
} Used: i64v(r.StorageUsed),
m.Containers = containers Available: i64v(r.StorageAvailable),
UsagePercent: f64v(r.StorageUsagePercent),
metrics = append(metrics, &m) },
Network: NetworkMetrics{
BytesIn: i64v(r.NetworkBytesIn),
BytesOut: i64v(r.NetworkBytesOut),
PacketsIn: i64v(r.NetworkPacketsIn),
PacketsOut: i64v(r.NetworkPacketsOut),
ConnectionsIn: i32v(r.NetworkConnectionsIn),
ConnectionsOut: i32v(r.NetworkConnectionsOut),
ErrorsIn: i64v(r.NetworkErrorsIn),
ErrorsOut: i64v(r.NetworkErrorsOut),
},
System: SystemMetrics{
Uptime: time.Duration(r.UptimeSeconds) * time.Second,
Processes: int(r.Processes.Int32),
OS: r.Os.String,
Kernel: r.Kernel.String,
Architecture: r.Architecture.String,
},
})
} }
return metrics, nil return metrics, nil
} }
// GetServiceMetrics retrieves service metrics from the database // GetServiceMetrics retrieves service metrics from the database
func (s *PostgreSQLMetricsStorage) GetServiceMetrics(ctx context.Context, serviceID string, from, to time.Time) ([]*ServiceMetrics, error) { func (s *PostgreSQLMetricsStorage) GetServiceMetrics(ctx context.Context, serviceID string, from, to time.Time) ([]*ServiceMetrics, error) {
query := ` rows, err := s.q.ListServiceMetrics(ctx, sqlcdb.ListServiceMetricsParams{
SELECT service_id, service_name, project_id, timestamp, ServiceID: serviceID,
requests_total, requests_success, requests_errors, requests_avg_latency, Timestamp: from,
requests_p95_latency, requests_p99_latency, requests_throughput, Timestamp_2: to,
errors_total, errors_rate, performance_response_time, performance_throughput, })
performance_concurrency, performance_saturation, performance_utilization,
resource_cpu_usage, resource_memory_usage, resource_storage_usage,
resource_network_usage, resource_score
FROM service_metrics
WHERE service_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC
`
rows, err := s.db.QueryContext(ctx, query, serviceID, from, to)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query service metrics: %w", err) return nil, fmt.Errorf("failed to query service metrics: %w", err)
} }
defer rows.Close()
var metrics []*ServiceMetrics var metrics []*ServiceMetrics
for rows.Next() { for _, r := range rows {
var m ServiceMetrics m := &ServiceMetrics{
err := rows.Scan( ServiceID: r.ServiceID,
&m.ServiceID, &m.ServiceName, &m.ProjectID, &m.Timestamp, ServiceName: r.ServiceName,
&m.Requests.Total, &m.Requests.Success, &m.Requests.Errors, ProjectID: r.ProjectID,
&m.Requests.AvgLatency, &m.Requests.P95Latency, &m.Requests.P99Latency, Timestamp: r.Timestamp,
&m.Requests.Throughput, &m.Errors.Total, &m.Errors.Rate, Requests: RequestMetrics{
&m.Performance.ResponseTime, &m.Performance.Throughput, Total: i64v(r.RequestsTotal),
&m.Performance.Concurrency, &m.Performance.Saturation, &m.Performance.Utilization, Success: i64v(r.RequestsSuccess),
&m.Resources.CPUUsage, &m.Resources.MemoryUsage, &m.Resources.StorageUsage, Errors: i64v(r.RequestsErrors),
&m.Resources.NetworkUsage, &m.Resources.ResourceScore, AvgLatency: f64v(r.RequestsAvgLatency),
) P95Latency: f64v(r.RequestsP95Latency),
if err != nil { P99Latency: f64v(r.RequestsP99Latency),
return nil, fmt.Errorf("failed to scan service metrics: %w", err) Throughput: f64v(r.RequestsThroughput),
},
Errors: ErrorMetrics{
Total: i64v(r.ErrorsTotal),
Rate: f64v(r.ErrorsRate),
},
Performance: PerformanceMetrics{
ResponseTime: f64v(r.PerformanceResponseTime),
Throughput: f64v(r.PerformanceThroughput),
Concurrency: i64v(r.PerformanceConcurrency),
Saturation: f64v(r.PerformanceSaturation),
Utilization: f64v(r.PerformanceUtilization),
},
Resources: ResourceMetrics{
CPUUsage: f64v(r.ResourceCpuUsage),
MemoryUsage: i64v(r.ResourceMemoryUsage),
StorageUsage: i64v(r.ResourceStorageUsage),
NetworkUsage: i64v(r.ResourceNetworkUsage),
ResourceScore: f64v(r.ResourceScore),
},
} }
// Get instance metrics for this timestamp instances, err := s.getInstanceMetrics(ctx, serviceID, r.Timestamp)
instances, err := s.getInstanceMetrics(ctx, serviceID, m.Timestamp)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get instance metrics: %w", err) return nil, fmt.Errorf("failed to get instance metrics: %w", err)
} }
m.Instances = instances m.Instances = instances
metrics = append(metrics, m)
metrics = append(metrics, &m)
} }
return metrics, nil return metrics, nil
} }
// GetAggregatedMetrics retrieves aggregated metrics based on a query // GetAggregatedMetrics retrieves aggregated metrics based on a query.
// Bucketing uses date_bin (PostgreSQL 14+) — no TimescaleDB needed.
func (s *PostgreSQLMetricsStorage) GetAggregatedMetrics(ctx context.Context, query MetricsQuery) (*AggregatedMetrics, error) { func (s *PostgreSQLMetricsStorage) GetAggregatedMetrics(ctx context.Context, query MetricsQuery) (*AggregatedMetrics, error) {
// This is a simplified implementation
// In a real system, you'd build dynamic SQL based on the query
var timeSeries []TimeSeriesPoint var timeSeries []TimeSeriesPoint
var summary map[string]MetricSummary var summary map[string]MetricSummary
switch query.Type { switch query.Type {
case "node": case "node":
// Aggregate node metrics seconds := int64(query.Interval.Seconds())
nodeQuery := ` if seconds <= 0 {
SELECT seconds = 300
time_bucket($1, timestamp) AS bucket, }
AVG(cpu_usage) as avg_cpu, rows, err := s.q.AggregateNodeMetrics(ctx, sqlcdb.AggregateNodeMetricsParams{
AVG(memory_usage_percent) as avg_memory, Column1: seconds,
AVG(storage_usage_percent) as avg_storage NodeID: query.ID,
FROM node_metrics Timestamp: query.From,
WHERE node_id = $2 AND timestamp BETWEEN $3 AND $4 Timestamp_2: query.To,
GROUP BY bucket })
ORDER BY bucket ASC
`
rows, err := s.db.QueryContext(ctx, nodeQuery, query.Interval, query.ID, query.From, query.To)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query aggregated node metrics: %w", err) return nil, fmt.Errorf("failed to query aggregated node metrics: %w", err)
} }
defer rows.Close() for _, r := range rows {
bucket, _ := r.Bucket.(time.Time)
for rows.Next() { timeSeries = append(timeSeries, TimeSeriesPoint{
var bucket time.Time
var avgCPU, avgMemory, avgStorage float64
if err := rows.Scan(&bucket, &avgCPU, &avgMemory, &avgStorage); err != nil {
return nil, fmt.Errorf("failed to scan aggregated metrics: %w", err)
}
point := TimeSeriesPoint{
Timestamp: bucket, Timestamp: bucket,
Values: map[string]float64{ Values: map[string]float64{
"cpu_usage": avgCPU, "cpu_usage": r.AvgCpu,
"memory_usage": avgMemory, "memory_usage": r.AvgMemory,
"storage_usage": avgStorage, "storage_usage": r.AvgStorage,
}, },
} })
timeSeries = append(timeSeries, point)
} }
// Calculate summary statistics
summary = map[string]MetricSummary{ summary = map[string]MetricSummary{
"cpu_usage": calculateSummary(timeSeries, "cpu_usage"), "cpu_usage": calculateSummary(timeSeries, "cpu_usage"),
"memory_usage": calculateSummary(timeSeries, "memory_usage"), "memory_usage": calculateSummary(timeSeries, "memory_usage"),
@@ -305,137 +298,69 @@ func (s *PostgreSQLMetricsStorage) GetAggregatedMetrics(ctx context.Context, que
}, nil }, nil
} }
// Helper methods
func (s *PostgreSQLMetricsStorage) storeContainerMetrics(ctx context.Context, nodeID string, timestamp time.Time, container ContainerMetrics) error {
query := `
INSERT INTO container_metrics (
node_id, timestamp, container_id, name, state, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out, start_time
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (node_id, timestamp, container_id) DO UPDATE SET
name = EXCLUDED.name,
state = EXCLUDED.state,
cpu = EXCLUDED.cpu,
memory = EXCLUDED.memory,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
start_time = EXCLUDED.start_time
`
_, err := s.db.ExecContext(ctx, query,
nodeID, timestamp, container.ContainerID, container.Name, container.State,
container.CPU, container.Memory, container.Network.BytesIn, container.Network.BytesOut,
container.Network.PacketsIn, container.Network.PacketsOut, container.StartTime,
)
return err
}
func (s *PostgreSQLMetricsStorage) storeInstanceMetrics(ctx context.Context, serviceID string, timestamp time.Time, instance InstanceMetrics) error { func (s *PostgreSQLMetricsStorage) storeInstanceMetrics(ctx context.Context, serviceID string, timestamp time.Time, instance InstanceMetrics) error {
query := ` return s.q.UpsertInstanceMetrics(ctx, sqlcdb.UpsertInstanceMetricsParams{
INSERT INTO instance_metrics ( ServiceID: serviceID,
service_id, timestamp, instance_id, node_id, status, cpu, memory, Timestamp: timestamp,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out, InstanceID: instance.InstanceID,
network_connections_in, network_connections_out, network_errors_in, network_errors_out, NodeID: strN(instance.NodeID),
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count Status: strN(instance.Status),
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) Cpu: numStr(instance.CPU),
ON CONFLICT (service_id, timestamp, instance_id) DO UPDATE SET Memory: int64N(instance.Memory),
node_id = EXCLUDED.node_id, NetworkBytesIn: int64N(instance.Network.BytesIn),
status = EXCLUDED.status, NetworkBytesOut: int64N(instance.Network.BytesOut),
cpu = EXCLUDED.cpu, NetworkPacketsIn: int64N(instance.Network.PacketsIn),
memory = EXCLUDED.memory, NetworkPacketsOut: int64N(instance.Network.PacketsOut),
network_bytes_in = EXCLUDED.network_bytes_in, NetworkConnectionsIn: int32N(instance.Network.ConnectionsIn),
network_bytes_out = EXCLUDED.network_bytes_out, NetworkConnectionsOut: int32N(instance.Network.ConnectionsOut),
network_packets_in = EXCLUDED.network_packets_in, NetworkErrorsIn: int64N(instance.Network.ErrorsIn),
network_packets_out = EXCLUDED.network_packets_out, NetworkErrorsOut: int64N(instance.Network.ErrorsOut),
network_connections_in = EXCLUDED.network_connections_in, StartTime: timeN(instance.StartTime),
network_connections_out = EXCLUDED.network_connections_out, LastSeen: timeN(instance.LastSeen),
network_errors_in = EXCLUDED.network_errors_in, HealthStatus: strN(instance.Health.Status),
network_errors_out = EXCLUDED.network_errors_out, HealthLastCheck: timeN(instance.Health.LastCheck),
start_time = EXCLUDED.start_time, HealthCheckCount: sql.NullInt32{Int32: int32(instance.Health.CheckCount), Valid: true},
last_seen = EXCLUDED.last_seen, HealthFailureCount: sql.NullInt32{Int32: int32(instance.Health.FailureCount), Valid: true},
health_status = EXCLUDED.health_status, })
health_last_check = EXCLUDED.health_last_check,
health_check_count = EXCLUDED.health_check_count,
health_failure_count = EXCLUDED.health_failure_count
`
_, err := s.db.ExecContext(ctx, query,
serviceID, timestamp, instance.InstanceID, instance.NodeID, instance.Status,
instance.CPU, instance.Memory, instance.Network.BytesIn, instance.Network.BytesOut,
instance.Network.PacketsIn, instance.Network.PacketsOut, instance.Network.ConnectionsIn,
instance.Network.ConnectionsOut, instance.Network.ErrorsIn, instance.Network.ErrorsOut,
instance.StartTime, instance.LastSeen, instance.Health.Status, instance.Health.LastCheck,
instance.Health.CheckCount, instance.Health.FailureCount,
)
return err
}
func (s *PostgreSQLMetricsStorage) getContainerMetrics(ctx context.Context, nodeID string, timestamp time.Time) ([]ContainerMetrics, error) {
query := `
SELECT container_id, name, state, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out, start_time
FROM container_metrics
WHERE node_id = $1 AND timestamp = $2
`
rows, err := s.db.QueryContext(ctx, query, nodeID, timestamp)
if err != nil {
return nil, err
}
defer rows.Close()
var containers []ContainerMetrics
for rows.Next() {
var c ContainerMetrics
err := rows.Scan(
&c.ContainerID, &c.Name, &c.State, &c.CPU, &c.Memory,
&c.Network.BytesIn, &c.Network.BytesOut, &c.Network.PacketsIn, &c.Network.PacketsOut, &c.StartTime,
)
if err != nil {
return nil, err
}
containers = append(containers, c)
}
return containers, nil
} }
func (s *PostgreSQLMetricsStorage) getInstanceMetrics(ctx context.Context, serviceID string, timestamp time.Time) ([]InstanceMetrics, error) { func (s *PostgreSQLMetricsStorage) getInstanceMetrics(ctx context.Context, serviceID string, timestamp time.Time) ([]InstanceMetrics, error) {
query := ` rows, err := s.q.ListInstanceMetrics(ctx, sqlcdb.ListInstanceMetricsParams{
SELECT instance_id, node_id, status, cpu, memory, ServiceID: serviceID,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out, Timestamp: timestamp,
network_connections_in, network_connections_out, network_errors_in, network_errors_out, })
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count
FROM instance_metrics
WHERE service_id = $1 AND timestamp = $2
`
rows, err := s.db.QueryContext(ctx, query, serviceID, timestamp)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var instances []InstanceMetrics var instances []InstanceMetrics
for rows.Next() { for _, r := range rows {
var i InstanceMetrics instances = append(instances, InstanceMetrics{
err := rows.Scan( InstanceID: r.InstanceID,
&i.InstanceID, &i.NodeID, &i.Status, &i.CPU, &i.Memory, NodeID: r.NodeID.String,
&i.Network.BytesIn, &i.Network.BytesOut, &i.Network.PacketsIn, &i.Network.PacketsOut, Status: r.Status.String,
&i.Network.ConnectionsIn, &i.Network.ConnectionsOut, &i.Network.ErrorsIn, &i.Network.ErrorsOut, CPU: f64v(r.Cpu),
&i.StartTime, &i.LastSeen, &i.Health.Status, &i.Health.LastCheck, &i.Health.CheckCount, &i.Health.FailureCount, Memory: i64v(r.Memory),
) Network: NetworkMetrics{
if err != nil { BytesIn: i64v(r.NetworkBytesIn),
return nil, err BytesOut: i64v(r.NetworkBytesOut),
} PacketsIn: i64v(r.NetworkPacketsIn),
instances = append(instances, i) PacketsOut: i64v(r.NetworkPacketsOut),
ConnectionsIn: i32v(r.NetworkConnectionsIn),
ConnectionsOut: i32v(r.NetworkConnectionsOut),
ErrorsIn: i64v(r.NetworkErrorsIn),
ErrorsOut: i64v(r.NetworkErrorsOut),
},
StartTime: r.StartTime.Time,
LastSeen: r.LastSeen.Time,
Health: HealthMetrics{
Status: r.HealthStatus.String,
LastCheck: r.HealthLastCheck.Time,
CheckCount: int(r.HealthCheckCount.Int32),
FailureCount: int(r.HealthFailureCount.Int32),
},
})
} }
return instances, nil return instances, nil
} }
@@ -544,7 +469,9 @@ func (s *InMemoryMetricsStorage) GetServiceMetrics(ctx context.Context, serviceI
} }
func (s *InMemoryMetricsStorage) GetAggregatedMetrics(ctx context.Context, query MetricsQuery) (*AggregatedMetrics, error) { func (s *InMemoryMetricsStorage) GetAggregatedMetrics(ctx context.Context, query MetricsQuery) (*AggregatedMetrics, error) {
// Simplified implementation s.mu.RLock()
defer s.mu.RUnlock()
return &AggregatedMetrics{ return &AggregatedMetrics{
Query: query, Query: query,
TimeSeries: []TimeSeriesPoint{}, TimeSeries: []TimeSeriesPoint{},
@@ -0,0 +1,130 @@
package metrics
import (
"context"
"database/sql"
"os"
"testing"
"time"
)
// Live round-trip against a real Postgres — set METRICS_TEST_DSN (or
// DATABASE_URL) to enable. Skipped by default so unit runs stay hermetic.
func testDSN(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("METRICS_TEST_DSN")
if dsn == "" {
dsn = os.Getenv("DATABASE_URL")
}
if dsn == "" {
t.Skip("METRICS_TEST_DSN not set")
}
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.PingContext(context.Background()); err != nil {
t.Fatalf("ping: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestPostgresStorageRoundTrip(t *testing.T) {
db := testDSN(t)
s := NewPostgreSQLMetricsStorage(db)
ctx := context.Background()
ts := time.Now().UTC().Truncate(time.Second)
nodeID := "test-node-" + ts.Format("150405")
svcID := "test-svc-" + ts.Format("150405")
// Clean slate + teardown
db.ExecContext(ctx, `DELETE FROM instance_metrics WHERE service_id = $1`, svcID)
db.ExecContext(ctx, `DELETE FROM service_metrics WHERE service_id = $1`, svcID)
db.ExecContext(ctx, `DELETE FROM node_metrics WHERE node_id = $1`, nodeID)
t.Cleanup(func() {
db.ExecContext(ctx, `DELETE FROM instance_metrics WHERE service_id = $1`, svcID)
db.ExecContext(ctx, `DELETE FROM service_metrics WHERE service_id = $1`, svcID)
db.ExecContext(ctx, `DELETE FROM node_metrics WHERE node_id = $1`, nodeID)
})
node := &NodeMetrics{
NodeID: nodeID,
Timestamp: ts,
CPU: CPUMetrics{UsagePercent: 42.5, UsageCores: 1.7, LoadAverage1: 0.5, LoadAverage5: 0.4, LoadAverage15: 0.3},
Memory: MemoryMetrics{Total: 16 << 30, Used: 8 << 30, Available: 8 << 30, UsagePercent: 50.0},
Storage: StorageMetrics{Total: 100 << 30, Used: 40 << 30, Available: 60 << 30, UsagePercent: 40.0},
Network: NetworkMetrics{BytesIn: 1000, BytesOut: 2000, PacketsIn: 10, PacketsOut: 20, ConnectionsIn: 3, ConnectionsOut: 4, ErrorsIn: 0, ErrorsOut: 1},
System: SystemMetrics{Uptime: 3600 * time.Second, Processes: 120, OS: "linux", Kernel: "6.8", Architecture: "amd64"},
}
if err := s.StoreNodeMetrics(ctx, node); err != nil {
t.Fatalf("store node: %v", err)
}
// Upsert idempotent on the same key.
if err := s.StoreNodeMetrics(ctx, node); err != nil {
t.Fatalf("store node (repeat): %v", err)
}
svc := &ServiceMetrics{
ServiceID: svcID,
ServiceName: "svc",
ProjectID: "proj",
Timestamp: ts,
Requests: RequestMetrics{Total: 100, Success: 95, Errors: 5, AvgLatency: 12.5, P95Latency: 80, P99Latency: 120, Throughput: 3.3},
Errors: ErrorMetrics{Total: 5, Rate: 0.05},
Performance: PerformanceMetrics{ResponseTime: 12.5, Throughput: 3.3, Concurrency: 2, Saturation: 0.4, Utilization: 0.55},
Resources: ResourceMetrics{CPUUsage: 42.5, MemoryUsage: 512 << 20, StorageUsage: 1 << 30, NetworkUsage: 3000, ResourceScore: 0.7},
Instances: []InstanceMetrics{{
InstanceID: "inst-1",
NodeID: nodeID,
Status: "running",
CPU: 42.5,
Memory: 512 << 20,
Network: NetworkMetrics{BytesIn: 1000, BytesOut: 2000},
StartTime: ts.Add(-time.Hour),
LastSeen: ts,
Health: HealthMetrics{Status: "healthy", LastCheck: ts, CheckCount: 10, FailureCount: 0},
}},
}
if err := s.StoreServiceMetrics(ctx, svc); err != nil {
t.Fatalf("store service: %v", err)
}
from, to := ts.Add(-time.Minute), ts.Add(time.Minute)
gotNodes, err := s.GetNodeMetrics(ctx, nodeID, from, to)
if err != nil {
t.Fatalf("get nodes: %v", err)
}
if len(gotNodes) != 1 {
t.Fatalf("want 1 node row, got %d", len(gotNodes))
}
if got := gotNodes[0]; got.CPU.UsagePercent != 42.5 || got.System.Processes != 120 || got.System.Uptime != time.Hour {
t.Errorf("node round-trip mismatch: %+v", got)
}
gotSvcs, err := s.GetServiceMetrics(ctx, svcID, from, to)
if err != nil {
t.Fatalf("get services: %v", err)
}
if len(gotSvcs) != 1 {
t.Fatalf("want 1 service row, got %d", len(gotSvcs))
}
if gotSvcs[0].Requests.P99Latency != 120 {
t.Errorf("p99 mismatch: %v", gotSvcs[0].Requests.P99Latency)
}
if len(gotSvcs[0].Instances) != 1 || gotSvcs[0].Instances[0].Health.Status != "healthy" {
t.Errorf("instance join mismatch: %+v", gotSvcs[0].Instances)
}
agg, err := s.GetAggregatedMetrics(ctx, MetricsQuery{Type: "node", ID: nodeID, From: from, To: to, Interval: 5 * time.Minute})
if err != nil {
t.Fatalf("aggregate: %v", err)
}
if len(agg.TimeSeries) != 1 || agg.TimeSeries[0].Values["cpu_usage"] != 42.5 {
t.Errorf("aggregate mismatch: %+v", agg.TimeSeries)
}
if agg.Summary["cpu_usage"].Avg != 42.5 {
t.Errorf("summary mismatch: %+v", agg.Summary)
}
}
+10
View File
@@ -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
+190
View File
@@ -0,0 +1,190 @@
// Package source materializes git checkouts for builds. Services reference
// a repository by URL (or owner/repo); deployments fetch the exact branch or
// commit into an isolated workdir, then point the builders at build_path
// inside it. Credentials travel through GIT_ASKPASS so tokens never land in
// argv, .git/config, logs, or the build context.
package source
import (
"context"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Spec describes one checkout.
type Spec struct {
CloneURL string // https URL (NormalizeCloneURL output); local paths allowed for tests
Branch string // ref to fetch when Commit is empty
Commit string // optional SHA pinned over Branch
BuildPath string // subdirectory inside the repo; "" or "." = repo root
Token string // optional http credential
User string // credential username; defaults per provider in ResolveUser
}
// NormalizeCloneURL converts the forms services store into an https clone
// URL: ssh remotes (git@host:org/repo, ssh://git@host/org/repo) map onto
// https so token auth works uniformly. A bare "owner/repo" is returned
// unchanged — the caller resolves the host through git_repositories.
func NormalizeCloneURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("empty repository reference")
}
// scp-style ssh remote: [email protected]:org/repo(.git)
if strings.HasPrefix(raw, "git@") && strings.Contains(raw, ":") {
host := raw[len("git@"):strings.Index(raw, ":")]
path := strings.TrimPrefix(raw[strings.Index(raw, ":")+1:], "/")
return "https://" + host + "/" + path, nil
}
if strings.HasPrefix(raw, "ssh://") {
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("invalid ssh remote %q", raw)
}
return "https://" + u.Host + u.Path, nil
}
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
return raw, nil
}
if strings.Contains(raw, "://") {
return "", fmt.Errorf("unsupported repository scheme in %q", raw)
}
if strings.Count(raw, "/") == 1 && !strings.ContainsAny(raw, " @:") {
return raw, nil // owner/repo — resolved against git_repositories upstream
}
return "", fmt.Errorf("unsupported repository reference %q", raw)
}
// ValidateRemoteURL rejects non-http(s) clones before a deploy accepts them.
// Local checkouts (tests, mounted workspaces) bypass this via Checkout.
func ValidateRemoteURL(cloneURL string) error {
if strings.HasPrefix(cloneURL, "http://") || strings.HasPrefix(cloneURL, "https://") {
return nil
}
return fmt.Errorf("clone URL must be http(s), got %q", cloneURL)
}
// Checkout fetches spec into destDir/repo and returns the directory the
// builders should consume (checkout root + build_path). Callers
// RemoveAll(destDir) — the checkout is a per-deployment workspace, never
// shared. The askpass helper lives outside repo so it can't leak into the
// build context.
func Checkout(ctx context.Context, destDir string, spec Spec) (string, error) {
repoDir := filepath.Join(destDir, "repo")
if err := os.MkdirAll(repoDir, 0o755); err != nil {
return "", err
}
env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
if spec.Token != "" {
// Askpass keeps the token out of argv and .git/config.
askpass := filepath.Join(destDir, ".git-askpass")
script := "#!/bin/sh\ncase \"$1\" in\n*sername*) printf '%s' \"$CONTAINR_GIT_USER\" ;;\n*) printf '%s' \"$CONTAINR_GIT_TOKEN\" ;;\nesac\n"
if err := os.WriteFile(askpass, []byte(script), 0o700); err != nil {
return "", fmt.Errorf("write askpass: %w", err)
}
user := spec.User
if user == "" {
user = "x-access-token"
}
env = append(env,
"GIT_ASKPASS="+askpass,
"CONTAINR_GIT_USER="+user,
"CONTAINR_GIT_TOKEN="+spec.Token,
)
}
run := func(args ...string) error {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = repoDir
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git %s: %s", args[0], strings.TrimSpace(string(out)))
}
return nil
}
if err := run("init", "-q"); err != nil {
return "", err
}
if err := run("remote", "add", "origin", spec.CloneURL); err != nil {
return "", err
}
ref := spec.Commit
if ref == "" {
ref = spec.Branch
}
if ref == "" {
ref = "HEAD"
}
// Cheap path first, widening on failure: dumb transports reject
// --depth outright, and some hosts refuse direct SHA fetches so a
// pinned commit may only be reachable through its branch.
attempts := [][]string{
{"fetch", "--depth", "1", "origin", ref},
{"fetch", "origin", ref},
}
if spec.Commit != "" {
branchRef := spec.Branch
if branchRef == "" {
branchRef = "HEAD"
}
attempts = append(attempts,
[]string{"fetch", "--depth", "100", "origin", branchRef},
[]string{"fetch", "origin", branchRef},
)
}
var fetchErr error
for _, args := range attempts {
if fetchErr = run(args...); fetchErr == nil {
break
}
}
if fetchErr != nil {
return "", fetchErr
}
if err := run("checkout", "-q", spec.CommitOrFetchHead()); err != nil {
return "", err
}
return ResolveBuildPath(repoDir, spec.BuildPath)
}
// CommitOrFetchHead is the checkout target: the pinned commit or FETCH_HEAD.
func (s Spec) CommitOrFetchHead() string {
if s.Commit != "" {
return s.Commit
}
return "FETCH_HEAD"
}
// ResolveBuildPath maps build_path onto the checkout and rejects escapes —
// a path like ../../etc must never leave the workspace.
func ResolveBuildPath(checkoutDir, buildPath string) (string, error) {
bp := strings.TrimSpace(buildPath)
if bp == "" || bp == "." {
return checkoutDir, nil
}
if filepath.IsAbs(bp) {
return "", fmt.Errorf("build_path must be relative, got %q", bp)
}
clean := filepath.Clean(bp)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("build_path escapes the checkout: %q", bp)
}
resolved := filepath.Join(checkoutDir, clean)
info, err := os.Stat(resolved)
if err != nil {
return "", fmt.Errorf("build_path %q not found in checkout", bp)
}
if !info.IsDir() {
return "", fmt.Errorf("build_path %q is not a directory", bp)
}
return resolved, nil
}
+152
View File
@@ -0,0 +1,152 @@
package source
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestNormalizeCloneURL(t *testing.T) {
cases := []struct {
in, want string
err bool
}{
{"https://github.com/org/repo.git", "https://github.com/org/repo.git", false},
{"[email protected]:org/repo.git", "https://github.com/org/repo.git", false},
{"ssh://[email protected]/org/repo.git", "https://gitlab.example.com/org/repo.git", false},
{"org/repo", "org/repo", false},
{" https://gitlab.com/a/b.git ", "https://gitlab.com/a/b.git", false},
{"", "", true},
{"ftp://example.com/repo", "", true},
{"weird string", "", true},
}
for _, c := range cases {
got, err := NormalizeCloneURL(c.in)
if c.err {
if err == nil {
t.Errorf("%q: expected error, got %q", c.in, got)
}
continue
}
if err != nil {
t.Errorf("%q: unexpected error %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("%q: got %q, want %q", c.in, got, c.want)
}
}
}
func TestValidateRemoteURL(t *testing.T) {
if err := ValidateRemoteURL("https://github.com/o/r.git"); err != nil {
t.Errorf("https should pass: %v", err)
}
if err := ValidateRemoteURL("file:///tmp/repo"); err == nil {
t.Error("file:// should be rejected")
}
if err := ValidateRemoteURL("/tmp/repo"); err == nil {
t.Error("local path should be rejected")
}
}
// initRepo creates a small git repo with a branch and returns its path.
func initRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
run := func(args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %s", args, out)
}
}
run("init", "-q", "-b", "main")
if err := os.WriteFile(filepath.Join(dir, "app.txt"), []byte("v1"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "sub", "inner.txt"), []byte("sub"), 0o644); err != nil {
t.Fatal(err)
}
run("add", ".")
run("commit", "-q", "-m", "first")
return dir
}
func TestCheckoutBranch(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
repo := initRepo(t)
dest := t.TempDir()
dir, err := Checkout(context.Background(), dest, Spec{CloneURL: repo, Branch: "main"})
if err != nil {
t.Fatalf("checkout: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "app.txt")); err != nil {
t.Errorf("expected app.txt in checkout: %v", err)
}
}
func TestCheckoutBuildPath(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
repo := initRepo(t)
dest := t.TempDir()
dir, err := Checkout(context.Background(), dest, Spec{CloneURL: repo, Branch: "main", BuildPath: "sub"})
if err != nil {
t.Fatalf("checkout: %v", err)
}
if filepath.Base(dir) != "sub" {
t.Errorf("expected sub build path, got %s", dir)
}
if _, err := os.Stat(filepath.Join(dir, "inner.txt")); err != nil {
t.Errorf("expected inner.txt under build path: %v", err)
}
}
func TestCheckoutCommit(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
repo := initRepo(t)
out, err := exec.Command("git", "-C", repo, "rev-parse", "HEAD").Output()
if err != nil {
t.Fatal(err)
}
sha := string(out[:len(out)-1])
dest := t.TempDir()
dir, err := Checkout(context.Background(), dest, Spec{CloneURL: repo, Commit: sha, Branch: "main"})
if err != nil {
t.Fatalf("checkout commit: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "app.txt")); err != nil {
t.Errorf("expected app.txt in pinned checkout: %v", err)
}
}
func TestResolveBuildPathEscapes(t *testing.T) {
root := t.TempDir()
if _, err := ResolveBuildPath(root, "../../etc"); err == nil {
t.Error("escape should be rejected")
}
if _, err := ResolveBuildPath(root, "/abs/path"); err == nil {
t.Error("absolute path should be rejected")
}
if _, err := ResolveBuildPath(root, "missing"); err == nil {
t.Error("missing dir should be rejected")
}
if got, err := ResolveBuildPath(root, "."); err != nil || got != root {
t.Errorf("dot should resolve to root, got %q err=%v", got, err)
}
}
@@ -0,0 +1,19 @@
-- +goose Up
-- Team invites: admin-generated single-use links that let a new user
-- register even while public signup is closed. Tokens are sha256-hashed
-- at rest like PATs.
CREATE TABLE IF NOT EXISTS user_invites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash varchar(64) NOT NULL UNIQUE,
email varchar(255),
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
expires_at timestamptz NOT NULL,
used_by uuid REFERENCES users(id) ON DELETE SET NULL,
used_at timestamptz,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_user_invites_expires ON user_invites(expires_at);
-- +goose Down
DROP TABLE IF EXISTS user_invites;
@@ -0,0 +1,15 @@
-- +goose Up
ALTER TABLE audit_logs
ADD COLUMN IF NOT EXISTS severity TEXT NOT NULL DEFAULT 'info',
ADD COLUMN IF NOT EXISTS category TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS label TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_audit_logs_feed
ON audit_logs (created_at DESC) INCLUDE (severity, category, resource, action);
-- +goose Down
DROP INDEX IF EXISTS idx_audit_logs_feed;
ALTER TABLE audit_logs
DROP COLUMN IF EXISTS severity,
DROP COLUMN IF EXISTS category,
DROP COLUMN IF EXISTS label;
@@ -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;
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more