Compare commits

...
106 Commits
Author SHA1 Message Date
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
Tomáš DvořákandDevin b47ced0135 feat(branding): white-label — public /branding, admin write, shell theming (#41)
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>
2026-10-09 10:22:58 +02:00
Tomáš DvořákandDevin 9408464f12 feat(banners): instance announcements — admin CRUD, top bar, CLI (#39)
banners table (title/body/level/active/dismissible/schedule window).
GET /banners/active for signed-in users; admin CRUD under /admin/banners.
BannerBar renders above the header with per-banner dismissal
(localStorage); admin console gains an Announcements section;
`containr banners` manages them and `auth status` prints active ones.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 10:10:18 +02:00
Tomáš Dvořák 954a1d1f80 Merge pull request #38 from Dvorinka/feat/operations-page
feat(ops): operations page — live jobs, queue depth, failures
2026-10-09 09:57:22 +02:00
Tomáš Dvořák cf7afd2ba7 Merge pull request #37 from Dvorinka/feat/outbound-webhooks
feat: outbound webhooks — signed event delivery
2026-10-09 09:56:45 +02:00
Tomas DvorakandDevin f524d95894 feat(ops): /operations endpoint + page — live jobs, queue depth, failures
GET /api/v1/operations aggregates active deployments, per-service
deploy-queue depth via Queue.Snapshot(), 24h deployment failures,
cron runs and backups scoped to the caller's projects (admins see all).

Frontend /operations page (Operate nav) shows in-flight count, queued
depth, failures and backup stats with live refetch while deploys are
active. CLI `containr operations` + MCP containr_operations_get.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 09:51:38 +02:00
Tomas Dvorak f499678964 feat: outbound webhooks — signed event delivery + delivery log
Every audited action now emits a "resource.action" event (bare actions
get resource-qualified; already-qualified actions like service.clone
pass through). Webhooks subscribe per-user with exact names, resource.*
wildcards, or *.

- schema: outbound_webhooks + webhook_deliveries (sqlc + goose)
- dispatcher: 4 workers, 512-queue, 3 attempts with backoff, 10s timeout;
  deliveries record status/http-code/body(4KiB)/duration
- signing: X-Containr-Signature sha256=<hmac> over raw body;
  X-Containr-Event + X-Containr-Delivery headers; custom headers per hook
- api: CRUD + /deliveries + /test (ping) under authed group
- fix: SetAuditDB never called — LogAudit was a silent no-op at every
  call site; now wired at boot
- coverage: service create/delete audited (previously uninstrumented)
- cli: containr webhooks list|create|update|delete|deliveries|test
- mcp: 6 tools, delete requires confirm
- frontend: /settings/webhooks page — list/create/expand-deliveries/
  test/disable/delete, secret-shown-once banner; nav + account menu
- openapi + regenerated types; CLI.md + docs snapshot; roadmap ticks
2026-10-09 09:44:08 +02:00
Tomáš Dvořák 64cbf82dd8 Merge pull request #36 from Dvorinka/feat/compose-import-project
feat: direct docker-compose import into projects
2026-10-09 09:43:15 +02:00
Tomas Dvorak 5c2f2861bd feat: direct docker-compose import into projects
POST /projects/:id/import-compose deploys a compose file straight into a
project — no template row required. Well-known DB images become managed
databases attached to the project network under their service name as DNS
alias; other services deploy in dependency order via deployTemplateGraph.

- api: extract composeToSpecs converter (shared by the template
  import-compose preview and the new project endpoint); fix dropped env
  warnings (pointer receiver); replace insertion sort with sort.Strings
- cascade: database_services.project_id links graph-provisioned DBs to
  their project so project delete tears down runtime + row (previously
  leaked container + network)
- cli: new `containr import` group, `import compose <project> <file|->`
- mcp: containr_projects_import_compose tool
- openapi + regenerated frontend types; docs snapshot + CLI.md
- frontend: compose import modal on the project workspace toolbar
- tests: composeToSpecs conversion, ordering, db-image map, warnings
2026-10-09 09:33:38 +02:00
Tomáš Dvořák dca8aaa7b3 Merge pull request #35 from Dvorinka/fix/mobile-auth-gate
fix(mobile): gate authed routes — cold-start redbox fix
2026-10-09 05:49:29 +02:00
Tomas DvorakandDevin 6946357a7b fix(mobile): gate authed routes behind Stack.Protected — cold-start redbox
'/' redirected to /(tabs) unconditionally; tab screens call useApi()
which throws 'not signed in' during render — before AuthGate's effect
redirect could run. Sign-out hit the same throw.

- index redirects based on session state (api ? tabs : login)
- authed screens wrapped in Stack.Protected guard={!!api}
- found by HP-WIN11 emulator visual pass

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 05:45:44 +02:00
Tomáš Dvořák a0952faf25 Merge pull request #31 from Dvorinka/feat/template-catalog
feat(templates): catalog expansion — 5 new managed-DB stacks
2026-10-09 05:30:52 +02:00
Tomas Dvorak 999d719377 Merge remote-tracking branch 'origin/main' into feat/template-catalog 2026-10-09 05:30:27 +02:00
Tomáš Dvořák 8d682583ff Merge pull request #30 from Dvorinka/feat/env-doctor
feat(services): env doctor — empty vars, bad refs, unreadable secrets
2026-10-09 05:30:22 +02:00
Tomas Dvorak c80fbc766d Merge remote-tracking branch 'origin/main' into feat/env-doctor 2026-10-09 05:29:58 +02:00
Tomáš Dvořák 99ee4e79cf Merge pull request #29 from Dvorinka/feat/sleep-mode
feat(services): sleep mode — scale-to-zero on idle, wake on traffic
2026-10-09 05:29:53 +02:00
Tomas Dvorak f729ef2c82 Merge remote-tracking branch 'origin/main' into feat/sleep-mode 2026-10-09 05:29:29 +02:00
Tomáš Dvořák f90fc5f5ad Merge pull request #28 from Dvorinka/feat/templates-v2
feat(templates): v2 — multi-service graph templates, expressions, compose import
2026-10-09 05:29:24 +02:00
Tomas Dvorak f68225a97d Merge remote-tracking branch 'origin/main' into feat/templates-v2 2026-10-09 05:29:01 +02:00
Tomáš Dvořák 7536443610 Merge pull request #27 from Dvorinka/feat/backup-import
feat(databases): backup import — upload .tar.gz as a restore point
2026-10-09 05:28:55 +02:00
Tomas Dvorak 68da4236fa Merge remote-tracking branch 'origin/main' into feat/backup-import
# Conflicts:
#	docs/guides/CLI.md
2026-10-09 05:28:31 +02:00
Tomáš Dvořák b9441f3e11 Merge pull request #26 from Dvorinka/feat/backup-targets
feat(databases): S3-compatible backup targets — offsite archives + remote restore
2026-10-09 05:28:26 +02:00
Tomas Dvorak 605a43455d Merge remote-tracking branch 'origin/main' into feat/backup-targets 2026-10-09 05:28:03 +02:00
Tomáš Dvořák c7256954c2 Merge pull request #25 from Dvorinka/feat/db-public-port
feat: public_port for managed databases — opt-in 0.0.0.0 bind
2026-10-09 05:27:00 +02:00
Tomas Dvorak a50260e857 fix: resolve ROADMAP2 merge markers 2026-10-09 05:21:09 +02:00
Tomas Dvorak 3441acbc37 fix: resolve ROADMAP2 merge markers 2026-10-09 05:21:09 +02:00
Tomas Dvorak c5e0561add fix: resolve ROADMAP2 merge markers 2026-10-09 05:21:08 +02:00
Tomas Dvorak 1ef5ea5f7f fix: resolve ROADMAP2 merge markers 2026-10-09 05:21:08 +02:00
Tomas Dvorak d26452d97e fix: resolve ROADMAP2 merge markers 2026-10-09 05:21:08 +02:00
Tomas Dvorak 164a39c9c6 fix: resolve ROADMAP2 merge markers 2026-10-09 05:21:08 +02:00
Tomas Dvorak 63f65c78cc Merge remote-tracking branch 'origin/main' into feat/template-catalog
# Conflicts:
#	ROADMAP2.md
2026-10-09 05:20:47 +02:00
Tomas Dvorak d5960fdfff Merge remote-tracking branch 'origin/main' into feat/env-doctor
# Conflicts:
#	ROADMAP2.md
2026-10-09 05:20:47 +02:00
Tomas Dvorak 31c04ad51d Merge remote-tracking branch 'origin/main' into feat/sleep-mode
# Conflicts:
#	ROADMAP2.md
2026-10-09 05:20:46 +02:00
Tomas Dvorak 03f6dcc80d Merge remote-tracking branch 'origin/main' into feat/templates-v2
# Conflicts:
#	ROADMAP2.md
2026-10-09 05:20:46 +02:00
Tomas Dvorak 1a4ee99f5b Merge remote-tracking branch 'origin/main' into feat/backup-import
# Conflicts:
#	ROADMAP2.md
2026-10-09 05:20:46 +02:00
Tomas Dvorak 264f619be5 Merge remote-tracking branch 'origin/main' into feat/backup-targets
# Conflicts:
#	ROADMAP2.md
2026-10-09 05:20:46 +02:00
Tomas Dvorak 11525194b0 Merge remote-tracking branch 'origin/main' into feat/db-public-port 2026-10-09 05:20:45 +02:00
Tomáš Dvořák 9e3994e937 Merge pull request #34 from Dvorinka/feat/mobile-app
feat(mobile): Containr companion app (Expo)
2026-10-09 05:20:17 +02:00
Tomáš Dvořák 355eb2c9ab Merge pull request #24 from Dvorinka/feat/external-databases
feat: external databases — register, probe, encrypted credentials
2026-10-09 05:19:31 +02:00
Tomas Dvorak b5a1a8cc21 Merge remote-tracking branch 'origin/main' into feat/template-catalog
# Conflicts:
#	docs/guides/CLI.md
2026-10-09 05:12:38 +02:00
Tomas Dvorak 91f40a652d Merge remote-tracking branch 'origin/main' into feat/env-doctor
# Conflicts:
#	docs/guides/CLI.md
2026-10-09 05:12:38 +02:00
Tomas Dvorak 2be1715015 Merge remote-tracking branch 'origin/main' into feat/mobile-app 2026-10-09 05:12:38 +02:00
Tomas Dvorak b32e63368a Merge remote-tracking branch 'origin/main' into feat/sleep-mode
# Conflicts:
#	docs/guides/CLI.md
2026-10-09 05:12:37 +02:00
Tomas Dvorak f1a0f2189d Merge remote-tracking branch 'origin/main' into feat/templates-v2
# Conflicts:
#	docs/guides/CLI.md
2026-10-09 05:12:37 +02:00
Tomas Dvorak e3676bd89a Merge remote-tracking branch 'origin/main' into feat/backup-import
# Conflicts:
#	docs/guides/CLI.md
2026-10-09 05:12:37 +02:00
Tomas Dvorak 71c7d2b9f0 Merge remote-tracking branch 'origin/main' into feat/backup-targets
# Conflicts:
#	docs/guides/CLI.md
2026-10-09 05:12:19 +02:00
Tomas Dvorak e67a39436c Merge remote-tracking branch 'origin/main' into feat/external-databases 2026-10-09 05:12:01 +02:00
Tomas Dvorak 2c3d9fc123 Merge remote-tracking branch 'origin/main' into feat/db-public-port
# Conflicts:
#	docs/guides/CLI.md
2026-10-09 05:11:56 +02:00
Tomas DvorakandDevin 925199633b feat(mobile): Containr companion app — Expo/RN thin client
Phone app v1 (Phase I): PAT auth against any instance, secure-store
credentials, dark UI matching the design system.

- Overview: service status counts, per-project health dots, deploy feed
- Services/Projects: status badges, drill-down detail
- Service detail: start/stop/restart/redeploy/sleep/wake, env-doctor
  issues, deployment history + rollback
- Logs: 300-line tail, 5s poll, stderr highlighted
- Databases: status, start/stop/restart, backup-now
- Settings: server info, disconnect

CI: mobile job (tsc + expo lint) gated on a dedicated app/mobile paths
filter so non-mobile PRs skip it entirely.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 05:08:48 +02:00
Tomas Dvorak 1054679ca2 Merge branch 'feat/shared-vars' into feat/mobile-app 2026-10-09 01:17:57 +02:00
Tomas Dvorak ace68da188 Merge remote-tracking branch 'origin/main' into feat/template-catalog 2026-10-09 01:04:41 +02:00
Tomas Dvorak 2d626f8ee8 Merge remote-tracking branch 'origin/main' into feat/env-doctor 2026-10-09 01:04:41 +02:00
Tomas Dvorak 6d3aff6d58 Merge remote-tracking branch 'origin/main' into feat/sleep-mode 2026-10-09 01:04:41 +02:00
Tomas Dvorak c60c31693b Merge remote-tracking branch 'origin/main' into feat/templates-v2 2026-10-09 01:04:41 +02:00
Tomas Dvorak 9fd34482e3 Merge remote-tracking branch 'origin/main' into feat/backup-import 2026-10-09 01:04:41 +02:00
Tomas Dvorak 541a4719d4 Merge remote-tracking branch 'origin/main' into feat/backup-targets 2026-10-09 01:04:41 +02:00
Tomas Dvorak dd1bbaf5a2 Merge remote-tracking branch 'origin/main' into feat/external-databases 2026-10-09 01:04:41 +02:00
Tomas Dvorak f22a464ad0 Merge remote-tracking branch 'origin/main' into feat/db-public-port 2026-10-09 01:04:41 +02:00
Tomas Dvorak f5f1ff34a0 Merge remote-tracking branch 'origin/main' into feat/template-catalog
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:19 +02:00
Tomas Dvorak bac317e0e6 Merge remote-tracking branch 'origin/main' into feat/env-doctor
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:17 +02:00
Tomas Dvorak 26d585ffb7 Merge remote-tracking branch 'origin/main' into feat/sleep-mode
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:16 +02:00
Tomas Dvorak f0df61b52e Merge remote-tracking branch 'origin/main' into feat/templates-v2
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:14 +02:00
Tomas Dvorak ffe66d7b3d Merge remote-tracking branch 'origin/main' into feat/backup-import
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:13 +02:00
Tomas Dvorak 121b33f36a Merge remote-tracking branch 'origin/main' into feat/backup-targets
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:11 +02:00
Tomas Dvorak 8a8b903cdf Merge remote-tracking branch 'origin/main' into feat/db-public-port
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:09 +02:00
Tomas Dvorak f83ad81f73 Merge remote-tracking branch 'origin/main' into feat/external-databases
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:08 +02:00
Tomas Dvorak be83467cc9 Merge branch 'feat/env-doctor' into feat/template-catalog 2026-10-09 00:46:36 +02:00
Tomas Dvorak c99b2df419 Merge branch 'feat/sleep-mode' into feat/env-doctor 2026-10-09 00:46:34 +02:00
Tomas DvorakandDevin a26c103eb4 fix(ui): add sleeping/waking to ServiceStatus union + sleeping badge class
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 00:46:32 +02:00
Tomas Dvorak 9f8eaed386 Merge branch 'feat/env-doctor' into feat/template-catalog 2026-10-09 00:37:10 +02:00
Tomas Dvorak 3360eedc29 Merge branch 'feat/sleep-mode' into feat/env-doctor 2026-10-09 00:37:09 +02:00
Tomas DvorakandDevin 79fb73afc8 fix(ui): sleep form — draft keyed by service id, no setState-in-effect
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 00:37:07 +02:00
Tomas DvorakandDevin 99a25cb10e feat(templates): catalog v2 — 5 new managed-DB stacks + seed validation test
Ghost+MySQL, Gitea+PostgreSQL, Nextcloud+PG+Redis, Paperless+PG+Redis,
Plausible+PG+ClickHouse. TestOfficialTemplatesAllParse parses every
seed, topo-sorts graphs, and dry-resolves with no unresolved refs.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 00:33:27 +02:00
Tomas DvorakandDevin d135e5fd4b feat(services): env doctor — surface empty vars, bad refs, unreadable secrets
GET /services/:id/env-check (read scope) reports, without exposing values:
- unresolved: ${{service.KEY}} refs that don't match a sibling service/env
- empty: variables with blank values
- unreadable: secrets whose ciphertext fails decryption (key rotation)

install.sh now generates SECRETS_KEY explicitly (was falling back to
JWT_SECRET — a rotation would orphan ciphertext). .env.example documents it.

Surfaces: CLI `services env-check`, MCP containr_services_env_check
(90 tools), OpenAPI path, and a warning banner in the service Variables
panel listing each issue.

Verified live: injected a bogus ${{nosuchservice.PORT}} var plus an
unreadable ciphertext row; the endpoint flagged both, ok=false.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 00:27:23 +02:00
Tomas DvorakandDevin dbc87bf34c feat(services): sleep mode — scale-to-zero on idle, wake on traffic
Services gain sleep_enabled + sleep_idle_minutes. A 30s sweeper tracks
container NetworkIO deltas; a flat counter for the configured window
scales the service to zero (status=sleeping, workload containers removed).

While slept, a busybox wake container takes over the service's Traefik
host rule at priority 100: it serves a self-reloading "waking" page and
calls the unauthenticated idempotent GET/POST /api/v1/internal/wake/:id,
which reconciles the service back to running. Wake placeholders carry a
dedicated label (containr.wake-for) so runtime state never counts them;
the sweeper prunes them once the workload is up. The wake URL resolves
the API via its container name when containerized, else the edge
network's IPAM gateway — bare-metal installs need HOST=0.0.0.0 for the
callback.

Surfaces: PUT fields on services, POST /services/:id/sleep|wake,
CLI --sleep/--sleep-idle + services sleep|wake, 2 MCP tools (89 total),
OpenAPI paths/schema, and a Settings > Sleep card with toggle, idle
minutes, and manual sleep/wake plus a Sleeping badge.

Verified live: idle sweep scaled whoami-web to zero; the wake container
served the page on the edge network and its callback woke the service
(container recreated, status running, placeholder pruned).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 00:22:28 +02:00
Tomas DvorakandDevin b4baa853b2 feat(templates): v2 — multi-service graph templates, expressions, compose import
Config v2 adds services[] to TemplateConfig: members declare key, type
(web|worker|cron|database), runtime, environment, volumes and depends_on.
Deploy topo-sorts the graph, creates databases first (managed DBs attached
to the project network under their key as DNS alias), then services with
resolved env, and enqueues a real deployment per service.

Expressions: {{secret}}, {{secret(N)}}, {{random:N}}, {{VAR}},
{{VAR:-default}} and {{service.<key>.<prop>}} with props host, port, url,
user, password, database. Credential-bearing refs (password/url) mark the
consuming variable secret — stored encrypted, masked in plan output.
Single-service template secret vars are now encrypted at rest too.

New endpoints: POST /templates/:id/plan (dry-resolve with missing/
unresolved reporting), POST /templates/import/compose (compose YAML →
v2 config; known db images become managed databases, build: flagged in
warnings), POST /templates/deploy (ad-hoc graph deploy — the compose
import companion).

Surfaces: CLI gains templates plan/import-compose/deploy-graph plus
--name/--var on deploy; 3 new MCP tools (88 total); OpenAPI + generated
frontend types; TemplatesPage shows stack members, deploys graphs without
a name field, renders the created resource list, and ships an
Import Compose modal feeding the template editor.

Seeded: WordPress + MySQL and n8n + PostgreSQL graph templates.

Partial-failure cleanup: resources created by a failed graph deploy are
removed (rows, containers, volumes).

Verified live: wordpress-stack deploy created a managed MySQL attached to
the project network as `db`; an inline pg+redis+nginx graph deployed with
DATABASE_URL=postgresql://app:<gen>@pg:5432/shop resolvable in-container.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 23:42:08 +02:00
Tomas DvorakandDevin 15045b8dd3 feat(databases): backup import — upload .tar.gz as a restore point
D5. POST /databases/:id/backups/import accepts a gzipped tar archive
(multipart `file` field or raw application/gzip body, 2 GiB ceiling),
stages it to a temp file, streams it into the backup volume via the
started holder container, and registers a completed backup row — a
first-class restore point that also ships offsite when a target is
assigned. External databases rejected.

Also fixes `containr databases restore` — it never sent database_id,
which the API requires, so every CLI restore failed validation.

CLI: databases import-backup <id> <file|-> (stdin support via new
client.DoRaw). UI: Import button in the backups row. OpenAPI + docs.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 23:07:27 +02:00
Tomas DvorakandDevin 109f79268f docs: refresh ROADMAP2 — mark shipped phases, add sleep-scale/install/env-doctor/CI-CD/phone-app scope
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 22:59:42 +02:00
Tomas DvorakandDevin 6f43bbf8f6 feat(databases): S3-compatible backup targets — offsite archives + remote restore
Phase D3. Managed databases can ship backup archives to a configured
S3-compatible destination (AWS S3, MinIO, Garage, …) while the local
copy remains intact.

- backup_targets table: owner-scoped endpoint/bucket/region/prefix +
  encrypted access/secret keys (enc:v1:, never returned, has_credentials)
- create/update probe bucket access before storing; empty keys on update
  keep stored credentials; delete refused (409) while referenced
- databases.backup_target_id assigns a target; external DBs rejected
- backup pipeline uploads via a holder container over docker cp streams;
  remote failure keeps the local archive (logged), row gets remote:true
- restore prefers the local archive; on a missing local copy it downloads
  the remote object into the backup volume first
- holder containers must be started before archive ops — docker cp does
  not resolve volume mounts on never-started (Created) containers, and
  removing the source mid-stream stalls the reader without EOF
- CLI: backup-targets list|add|update|remove|test + databases update
  --backup-target; MCP: 5 new tools; Settings UI card + per-database
  target picker + offsite badge on backups; OpenAPI + llms.txt + CLI.md

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 22:54:36 +02:00
Tomas DvorakandDevin b7131965b1 feat: public_port for managed databases — opt-in 0.0.0.0 bind
Managed databases have always published their port on 127.0.0.1 only.
public_port opts into binding on all interfaces so the database is
reachable off-host. Toggling via PUT recreates the container — the
named data volume survives.

- create accepts public_port; PUT /databases/{id} toggles it
- external rows reject the flag (no container exists)
- CLI: databases create --public, databases update --public/--public=false
- MCP: databases_update tool; UI: create checkbox + per-row toggle

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 22:19:29 +02:00
Tomas DvorakandDevin 5dc9b5f115 feat: external databases — register, probe, encrypted credentials
Databases hosted outside Containr can now be registered alongside
managed ones. The connection is probed before storing (protocol-level
for postgres/redis-family via real drivers, TCP dial for the rest),
credentials are encrypted at rest and never returned, and external rows
skip managed lifecycle actions, backups, and Docker metrics.

- POST /databases/register-external — probe + create
- POST /databases/test-connection — ad-hoc probe (ok/latency_ms/error)
- POST /databases/{id}/test-connection — re-probe stored credentials
- provider/external fields on database services; privileged reads get a
  composed connection URL with loopback hosts rewritten to the caller's
- CLI: databases register + test-connection; MCP: 3 new tools
- UI: managed/external toggle in the create modal, external badge and
  host display, per-row test button, backups section hidden
- Migration widens the plan CHECK to admit 'external'

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 22:09:42 +02:00
191 changed files with 39779 additions and 748 deletions

No files matched your search

+17
View File
@@ -39,10 +39,24 @@ REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0
# Application Configuration
# In production this must be a strong value with at least 32 characters.
JWT_SECRET=SuperSecretJWTKey123456789!@#$%^&*()
# Encrypts secret env vars at rest. Defaults to JWT_SECRET when unset;
# setting it explicitly survives a JWT_SECRET rotation.
SECRETS_KEY=
# Shared secret for node-agent registration/heartbeat auth.
CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
# Optional rotation list (comma-separated). When set, this takes precedence.
# 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
# Optional build-time API URL overrides. Leave empty for the default
# same-origin setup - the frontend nginx proxies /api to the backend, which
@@ -155,3 +169,6 @@ DOCKER_GID=0
# Optional: trust reverse proxy CIDR (default local Docker bridge used by Traefik)
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
+44 -1
View File
@@ -24,6 +24,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
mobile: ${{ steps.filter.outputs.mobile }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
@@ -37,6 +38,8 @@ jobs:
- 'install.sh'
- '.github/workflows/**'
- 'docs/api/**'
mobile:
- 'app/mobile/**'
infra:
name: Infra (Compose + Scripts)
@@ -121,6 +124,34 @@ jobs:
- name: Test
run: npm test
mobile:
name: Mobile (Expo)
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.mobile == 'true' || github.event_name != 'pull_request'
defaults:
run:
working-directory: app/mobile
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: app/mobile/package-lock.json
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npx tsc --noEmit
- name: Lint
run: npx expo lint
smoke:
name: Compose boot smoke
runs-on: ubuntu-latest
@@ -132,11 +163,13 @@ jobs:
uses: actions/checkout@v4
- name: Boot stack
# .github/compose.ci.yml swaps every docker.io ref for
# mirror.gcr.io — anonymous Hub pulls 429 on shared CI IPs.
run: |
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN
docker compose -f infra/docker-compose.yml up -d --build postgres dragonfly backend frontend traefik
docker compose -f infra/docker-compose.yml -f .github/compose.ci.yml up -d --build postgres dragonfly backend frontend traefik
- name: Wait for backend health
run: |
@@ -173,7 +206,13 @@ jobs:
uses: actions/checkout@v4
- name: Setup Docker Buildx
# Mirror docker.io through gcr — shared CI IPs hit anonymous
# rate limits on base-image metadata resolution otherwise.
uses: docker/setup-buildx-action@v3
with:
config-inline: |
[registry."docker.io"]
mirrors = ["mirror.gcr.io"]
- name: Login to GHCR
if: github.event_name != 'pull_request'
@@ -217,6 +256,10 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta-backend.outputs.tags }}
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-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
- **Admin console** (`/admin`) — platform stats, project approval/edit/delete, user admin management
- **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references
- **Deployments & builds** — Docker deploys with history, image rollback, live build status
- **Deployments & builds** — Docker deploys with history, image rollback, live build status; git-sourced services build directly on remote node agents (no registry required)
- **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page
- **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App
- **Managed databases** — create PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Dragonfly, or ClickHouse directly from `/databases`, with backup/restore
+327 -147
View File
@@ -86,15 +86,15 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
| Multi-tenant organisations + custom RBAC (per-resource CRUD, create-limits, read-scope) | 🟡 `is_admin` + `project_members` | Defer full RBAC; design PAT scopes role-compatible |
| Team invite links/emails | 🟡 manual user create only | `POST /invites` → link join |
| User API keys → full REST API | ❌ **the big gap** — session/JWT only | PATs (Phase A) |
| Outbound webhooks (collection events, HMAC, custom headers) | ❌ | `webhooks` table + delivery log |
| 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 |
| 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 |
| **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` | — |
| Dynamic client-side filter framework (535-line `filter.utils`) | 🟡 audit page filters only | Reuse pattern for tables |
| Ops "bubble" (queue monitor, terminal, sync) | ❌ | Operations page |
| 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` + interactive terminal | Done |
#### Agent surface
| 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** |
| MCP | ❌ | none — same leap |
| Railway migrator script | ❌ | `containr migrate railway` |
| GitHub App **manifest flow** (instance self-provisions the App) | 🟡 env-configured App | `POST /git/github-app/manifest` flow |
| fork-sync webhook deploy trigger | ❌ | ~20 lines on receiver |
| GitHub App **manifest flow** (instance self-provisions the App) | ✅ manifest + convert + App webhook | Done |
| fork-sync webhook deploy trigger | ✅ sync/repository_dispatch/workflow_run | Done |
| Azure DevOps provider | ❌ | Optional (dflow does token-clone only) |
---
@@ -121,16 +121,16 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
- `openapi.yaml` stays the single source of truth; UI, CLI, MCP are thin
clients.
### Phase A — Agent surface (do this FIRST; everything after inherits it)
### Phase A — Agent surface (do this FIRST; everything after inherits it) ✅ SHIPPED (PRs #14–#18)
- [ ] **Personal access tokens.** `user_tokens` table — `id`, `user_id`,
- [x] **Personal access tokens.** `user_tokens` table — `id`, `user_id`,
`name`, `key_prefix` (`cnp_…`), `key_hash` (sha256), `scopes`
(`read`/`write`/`admin`, later resource-scoped), `expires_at`,
`last_used_at`. Model on `agent_auth_tokens` (raw shown once, hash stored).
`POST/GET/DELETE /api/v1/user/tokens`; `middleware.Auth` accepts
`Bearer cnp_…`; Settings → Access tokens UI with one-time display +
copyable CLI/MCP config snippets.
- [ ] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client:
- [x] **CLI v2.** Current `cmd/cli` is auth + projects only. Full client:
- `auth login [--token] [--url] [--profile]` (multi-instance profiles).
- Resources: `projects services deploy logs exec variables databases
cron templates nodes scaling ha security gateway notifications
@@ -143,16 +143,16 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable
0/1/2/3/4 (ok/err/not-found/unauthorized/conflict).
- GoReleaser → GH releases + `install.sh`; `version` command.
- Conventions from docsync/envdiff/cix/repolint.
- [ ] **MCP server** (`cmd/mcp`, same module, stdio). Tools mirror the
- [x] **MCP server** (`cmd/mcp`, same module, stdio). Tools mirror the
CLI: `list_projects create_service deploy get_logs set_variable
restart rollback create_database trigger_cron deploy_template exec
get_metrics security_scan …`. Auth: `CONTAINR_URL` + `CONTAINR_TOKEN`
or shared `~/.containr/config`. `containr mcp serve` + install snippets
for Devin/Claude/Cursor.
- [ ] **API agent ergonomics.** `Idempotency-Key` on mutating endpoints;
- [x] **API agent ergonomics.** `Idempotency-Key` on mutating endpoints;
uniform `{error, code, details}` envelope (audit bare-string returns);
cursor pagination on growing lists; name-or-id lookup everywhere.
- [ ] **Agent docs.** Root `AGENTS.md`, `/docs` serves `llms.txt` +
- [x] **Agent docs.** Root `AGENTS.md`, `/docs` serves `llms.txt` +
`llms-full.txt`, MCP tool list generated from OpenAPI where possible.
**Gate**: an agent holding only a PAT registers a project, deploys from a
@@ -161,179 +161,359 @@ for a human.
---
## 3. Phase B — Deployment pipeline maturity
## 3. Phase B — Deployment pipeline maturity ✅ SHIPPED (PR #19)
dflow's one real architectural edge. Containr deploys are fire-and-forget
goroutines; this fixes correctness *and* unlocks the ops surface.
- [ ] **Deploy job queue.** Lightweight internal queue (Postgres
`deployment_queue` table + single worker loop — no new infra; Redis
exists but don't add BullMQ-style sprawl). Serialize per service;
optional per-node serialization. Status gains `queued`.
- [ ] **Cancel deployments.** Route `POST /deployments/:id/cancel` →
engine `CancelDeployment` (exists, unrouted). Queued jobs cancel
cleanly; building jobs cancel context where safe.
- [ ] **Cache modes.** `?cache=full` on redeploy → rebuild from existing
image layers vs clean pull/checkout. Webhook-triggered deploys default
to no-cache (dflow convention — prevents stale-layer surprises).
- [ ] **Queued→building→running status + persisted step logs.** Already
mostly there; add `queued`, stream over existing `/ws`.
- [ ] **Env var apply → redeploy.** Saving variables asks "restart to
apply?" (or `--apply` flag in CLI); backend triggers redeploy honoring
running state. Currently rows update but containers keep stale env —
silent-drift bug class, not just UX.
- [ ] **Encrypt variables at rest.** `environment_variables.value` is
plaintext today (verified). Envelope-encrypt `is_secret` values
(AES-GCM, key from `JWT_SECRET` or dedicated `SECRETS_KEY`); dflow
encrypts every var, we encrypt secrets — migration = transparent
encrypt-on-write + decrypt-on-read.
- [x] **Deploy job queue.** Lightweight internal queue — per-service
FIFO in `internal/deployqueue`; status gains `queued`.
- [x] **Cancel deployments.** `POST /deployments/:id/cancel` — queued
jobs cancel immediately, active jobs cancel context; `cancelled` status.
- [x] **Cache modes.** `no_cache` on deploys; webhook-triggered deploys
default to no-cache (dflow convention — prevents stale-layer surprises).
- [x] **Queued→building→running status + persisted step logs.** `queued`
state lands; streaming over existing log surface.
- [x] **Env var apply → redeploy.** `--redeploy` flag on variable writes;
backend requeues honoring running state.
- [x] **Encrypt variables at rest.** `is_secret` values encrypted
AES-GCM `enc:v1:` (key from `SECRETS_KEY`); masked-echo preserves
ciphertext.
## 4. Phase C — Service configuration parity
## 4. Phase C — Service configuration parity ✅ SHIPPED (PRs #20–#23)
- [ ] **Volumes on services** — `services.volumes` jsonb
`[{volume|host_path, container_path, read_only}]`; engine accepts
`VolumeMounts` already. Volumes tab + orphan-volume listing
(`docker volume ls -f dangling`) with delete/attach.
- [ ] **Multi-domain** — `service_domains(service_id, domain, is_default,
cert_type, status, last_checked_at)`. Domains tab: add/remove/
set-default/DNS-status badge. Keep `services.domain` as derived
default for compat.
- [ ] **DNS preflight** — `GET /services/:id/domains/check` resolves and
- [x] **Volumes on services** — `services.volumes` jsonb
`[{volume|host_path, container_path, read_only}]`; Volumes tab +
admin volume inventory.
- [x] **Multi-domain** — `service_domains(service_id, domain, is_default,
cert_type, status, last_checked_at)`. Domains UI: add/remove/
set-default/DNS-status badge. `services.domain` stays derived default.
- [x] **DNS preflight** — `GET /services/:id/domains/check` resolves and
compares vs edge IP → `ok|wrong-target|pending`.
- [ ] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
- [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
no proxy code needed.
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with
allowlist validation (middlewares, headers, redirects).
- [ ] **Builder override** — `services.builder` enum
`auto|railpack|nixpacks|dockerfile|static`; fix hardcoded `nixpacks`
at `deployments.go:406`. Static builder = build + nginx sidecar.
- [ ] **Resource limits + reserves** — adopt dflow-style explicit units
(`"0.5"`/`"512m"`); add `cpu_reserve`/`memory_reserve` →
`NanoCPUs`/`Memory`/`MemoryReservation`; instance defaults in
`app_settings`.
- [ ] **Pre-deploy capacity check** — sum reservations vs node capacity
(agent telemetry); warn or block per policy flag.
- [ ] **Service clone** + **move between projects**.
- [ ] **Private registries** — `registry_credentials`
(server/username/secret, encrypted); `docker login` at pull time;
- [x] **Routing overrides** — `services.traefik_labels` jsonb with
allowlist validation. Keys `middlewares.<name>.<type>[.<field>]` scope
to the service router (`svc-<id>-<name>`), auto-attach after builtins;
router/service/provider-level keys rejected. API + CLI
(`--traefik-label`) + MCP + service page Access editor.
- [x] **Builder override** — `services.builder` enum
`auto|railpack|nixpacks|dockerfile|static`; static builder = build +
nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug.
- [x] **Resource limits + reserves** — `cpu`/`memory` caps +
`cpu_reserve`/`memory_reserve` → NanoCPUs/Memory/MemoryReservation;
instance defaults in `app_settings`.
- [x] **Pre-deploy capacity check** — reservations vs node capacity;
`app_settings.capacity_policy=block` rejects, default warns.
- [x] **Service clone** + **move between projects** — copies volumes,
domains, access gates, vars (ciphertext verbatim); audit-logged.
- [x] **Private registries** — `registry_credentials`
(server/username/secret, encrypted); pull auth resolved at deploy;
per-service registry link.
- [ ] **Service detail tabs to match dflow's surface**: add Domains,
Volumes, Proxy/access-controls, Backups sections to the service page
(Cron/Previews/Scaling already exist).
- [x] **Service detail tabs to match dflow's surface**: Domains,
Volumes, access-controls sections added to the service page.
## 5. Phase D — Data layer maturity
- [ ] **External DB registration** — `provider='managed'|'external'`,
conn fields, `POST /databases/test-connection`, same bind flow.
- [ ] **Public DB port expose** — opt-in Traefik TCP route or host port;
default stays loopback.
- [ ] **Offsite backups** — `backup_targets` (S3-compatible:
endpoint/bucket/keys encrypted); per-DB destination; ship archives via
utility container + s3 SDK; restore-from-S3.
- [x] **External DB registration** — `provider='managed'|'external'`,
conn fields, `POST /databases/test-connection`, same bind flow. (PR #24)
- [x] **Public DB port expose** — `public_port` opt-in `0.0.0.0` host
bind; default stays loopback. (PR #25)
- [x] **Offsite backups** — `backup_targets` (S3-compatible:
endpoint/bucket/keys encrypted); per-DB destination; archives shipped
via holder container + minio-go; restore-from-S3 fallback. (PR #26)
- [ ] **Cross-node DB migration** — `POST /databases/:id/migrate
{target_node_id}`: dump → stream transfer → provision → restore →
re-point bound services. Needs ≥2 nodes or local↔node.
- [ ] **Backup import** — upload archive → new restore point (pairs with
re-point bound services. Deferred until Phase F lands real second
nodes — nothing to migrate between today.
- [x] **Backup import** — `POST /databases/:id/backups/import` accepts a
`.tar.gz` (multipart `file` or raw body, 2 GiB cap, gzip-magic
validated), stores it in the backups volume via a holder container,
registers a completed restore point, and ships offsite when a backup
target is configured. `api/backuptargets.go`. (pairs with
migrators).
## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates)
- [ ] **Template = ordered service graph** — `services[]` entries with
- [x] **Template = ordered service graph** — `services[]` entries with
`{name, type, source, build, ports, volumes, variables, depends_on}`;
deploy order from dependency refs.
- [ ] **Expressions** — `${{service.KEY}}` (already the runtime ref
syntax — reuse), `{{secret(len,"charset")}}`, `{{random:int}}`,
`{{VAR:-default}}`; `POST /templates/:id/plan` returns the resolved
graph without deploying (agents love this).
- [ ] **Compose import** — `POST /templates/import/compose` (compose-go
parser → graph), `POST /templates/import/github` (compose file from
repo path). Implements `docs/superpowers/specs/2026-04-14-*`.
- [ ] **Graph deploy pipeline** — creates project + services in
dependency order, resolves vars, triggers builds; defined
partial-failure policy.
deploy order from dependency refs. `templategraph.go`,
`topoSortServices`.
- [x] **Expressions** — `{{service.KEY.prop}}` (host/port/user/password/
database/url), `{{secret}}`/`{{secret(N)}}`, `{{random:N}}`,
`{{VAR:-default}}` (incl. explicitly-empty defaults); `POST
/templates/:id/plan` returns the resolved graph without deploying.
- [x] **Compose import** — `POST /templates/import/compose` (yaml →
graph, known DB images become managed databases), `POST
/templates/import/git` (compose file fetched from any git repo — clone
URL, ssh remote, or owner/repo via connected providers, `ref` selects
branch/commit); `templates import-compose|import-git` CLI, MCP tools,
paste-or-git toggle in the template UI.
- [x] **Graph deploy pipeline** — creates services in dependency order,
resolves vars/secrets, triggers builds. `deployTemplateGraph`,
`POST /templates/deploy` (inline), `POST /projects/:id/import-compose`.
- [ ] **Visual template builder** — canvas mode: drop git/image/db
nodes, wire refs, set order, save/publish. Reuses existing canvas
primitives (dflow's `templates/compose` is exactly this).
- [ ] **Catalog growth** — convert `templates/*.md` (20 app guides) into
real graph templates; screenshots + badges per the old spec.
- [x] **Catalog growth** — seeded graph stacks for n8n, Gitea,
Nextcloud, Vaultwarden, Umami, Immich (Plausible already shipped).
`TestSeedTemplatesResolveCleanly` keeps every seed parseable,
topo-sortable, and fully resolved. Remaining single-service guides can
convert incrementally via the same endpoint.
**Gate**: paste n8n compose → 3-service project with generated secrets
and wired refs, one click or one CLI call.
## 7. Phase F — Nodes & infrastructure
- [ ] **SSH bootstrap install** — `containr nodes add --ssh user@host` +
UI flow: one SSH session runs agent install (binary + systemd +
enroll token), SSH closed forever. dflow onboarding UX, agent-model
security.
- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/
zt0 IPs at register; backend stores + displays. Optional
`TAILSCALE_AUTH_KEY` enroll-at-provision later.
- [ ] **Node detail page** — placed services, telemetry history, agent
version + self-upgrade, **drain/cordon**, remove.
- [ ] **Node housekeeping** — agent `prune` command (docker system
prune, bounded) + optional schedule (`setServerAutoCleanup` analog —
disk pressure is the #1 small-VPS killer).
- [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists):
pin/spread/affinity, prefer-lowest-usage; deploy engine routes to
`services.node_id` via agent commands (agent already runs containers).
- [ ] **Build-on-node** — agents currently `docker run` prebuilt images
only; extend agent to pull registry images (build stays central) —
private registry creds flow through Phase C.
- [x] **SSH bootstrap install** — `containr nodes add <user@host>` mints an
enroll token, SSHes once (agent/key/default keys), runs
`GET /api/agents/install.sh | bash` remotely, closes the session.
`--print` emits the one-liner for manual runs; the Usage page shows the
same command. Agent binaries ship in the image under `/agents` and are
served at `/api/agents/download/{linux-amd64,linux-arm64}` (install
script falls back to GH releases). `cli/commands/nodes_add.go`,
`api/assets/install-agent.sh`, `agents.go:ServeInstallScript`/`ServeAgentBinary`.
- [x] **Mesh awareness** — agent reports tailscale/netbird/wireguard/
zerotier interface IPs at register (`mesh` field, authoritative on
re-register), stored in `node_agents.metadata.mesh`, shown on the node
detail page. `TAILSCALE_AUTH_KEY` enroll-at-provision still open.
- [x] **Node detail page** — `/nodes/:id` (admin): heartbeat telemetry
(cpu/mem sparklines over 1h/24h/7d via `GET /agents/:id/metrics`),
containers, command history, rename, auto-prune toggle, prune-now,
remove. Drain/cordon and self-upgrade deferred — they need
`services.node_id` routing which lands with multi-node scheduling.
- [x] **Node housekeeping** — agent `prune` + `system_df` commands
(`docker system prune -af`, volumes excluded unless requested, optional
`--until`). `node_agents.auto_prune` + the health sweep enqueue a prune
at most once per 24h per node. Surfaces: `POST /agents/:id/prune`,
`nodes update --auto-prune` / `nodes prune [--volumes --until]` /
`nodes commands`, MCP `containr_nodes_update|prune|commands`
(confirm-gated), Usage-page per-node Prune button + auto toggle.
`cmd/agent/main.go`, `notification_producers.go:scheduleAutoPrune`.
- [🟡] **Multi-node scheduling** — phase 1 shipped: `services.node_id`
pin (explicit / `auto` = least-loaded online / `local` = clear),
`NodeRunner` seam in the deploy engine routes reconcile to the agent's
command queue (`create_container` with image/env/cmd/ports/volumes/
labels/limits), `container_instances` inventory per (agent, service,
replica), remote stop/start/restart/delete fan-out, `GET
/nodes/options`, `--node` on services create/update + MCP + OpenAPI +
service-detail Placement section. Drain/cordon shipped:
`node_agents.schedulable`, `POST /agents/:id/cordon|uncordon|drain`
(drain evicts remote containers via the agent queue + unpins services
so they redeploy locally), `nodes cordon|uncordon|drain` CLI +
MCP (drain confirm-gated) + node-detail page controls; `auto` and
explicit pins skip cordoned nodes. Spread placement shipped:
`services.spread` distributes replicas deterministically across every
online schedulable agent (replica i → agents[i mod n]), mutually
exclusive with a pin; `auto` is now resource-aware — least memory
utilisation, then cpu, then container count, with uninstrumented
agents sorting last. Lifecycle and `GET /runtime` are
inventory-driven, so remote replicas show up and tear down wherever
they run. Remote ingress shipped: the agent reports assigned host
ports in the `create_container` result, the backend renders a
Traefik file-provider config per remote service into
`TRAEFIK_DYNAMIC_DIR` (shared volume, `watch=true`) pointing the
loadbalancer at each replica's mesh IP + published port; route files
are rewritten on every remote reconcile and removed on teardown or
local placement. Agents report managed-container states at every
heartbeat — exits, OOMs, and crash loops land in inventory between
reconciles (tombstones never resurrect). Remote sleep/wake shipped:
heartbeats carry per-container net counters, idle remote services
lose replicas via agent remove + tombstones, and the route file
repoints at `/internal/wake-page/:id` on the backend — first hit
retriggers the wake reconcile, which recreates replicas and restores
real upstreams. Remote nodes run
registry-pulled images only — git builds are the remaining
documented ceiling. Tag affinity shipped: `node_agents.tags`
(operator-set via `PUT /agents/:id` / `nodes update --tags`) +
`services.placement_tags` — `auto` and `spread` only consider
online schedulable agents carrying every required tag, tags are
re-evaluated each reconcile (a tag change re-spreads replicas),
unsatisfiable requirements fail with a clear error, pin + tags
and local + tags are rejected, empty array clears. Surfaces:
`--placement-tags` on services create/update, `--tags` on
`nodes update`, MCP args, OpenAPI, node-detail tag editor +
service-detail placement-tags input. Remaining: build-on-node,
remote sleep/wake, spread weighting by real capacity.
- [x] **Agent self-upgrade** — `POST /agents/:id/upgrade` enqueues a
`self_upgrade` command carrying platform + sha256 + server version;
the agent downloads its replacement from `GET /agents/download/
:platform`, verifies the checksum, sanity-checks `--version`,
atomically swaps its own executable, reports the result, then
re-execs (`syscall.Exec`) — no supervisor needed. Agents now stamp
`main.version` via ldflags at image build and report it at
register/heartbeat (`node_agents.version` refreshes on re-register;
older agents keep their stored value). Surfaces: `nodes upgrade`
CLI, `containr_nodes_upgrade` MCP (confirm-gated), node-detail page
button (online-gated). Ceilings: linux amd64/arm64 only; agents on
other platforms get a `NO_BINARY` 409.
- [x] **Build-on-node** — private registry pulls + real builds shipped.
Git sources materialize via `internal/source` checkout (branch/commit,
`GIT_ASKPASS` credential injection, dumb-transport fallback), package
through `BuildManager.PackageContext`, ship as agent-token artifacts,
and `docker build` on the node (`build_image` command). Remote rollbacks
`docker save` → artifact → `load_image` on the node. Verified end-to-end
on a live agent (PR #60).
- [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/
OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module
(dflow's second provider), cloud-init → auto-enroll.
- [ ] **Global/wildcard domain per node** (`nodes.default_domain`) —
auto-domains for services on that node.
- [x] **Global/wildcard domain per node** (`node_agents.default_domain`) —
`PUT /agents/:id {default_domain}`; domainless services placed on the
node get `<name>.<default_domain>` (pinned → node's base; spread →
union of eligible nodes). `serviceDomainNames` resolves them so
routing, wake placeholders, and `public_url` all pick them up.
CLI `nodes update --domain`, MCP arg, node-detail editor.
## 8. Phase G — Platform polish
- [ ] **Outbound webhooks** — `webhooks(url, secret, events[], headers,
enabled)` + `webhook_deliveries` log; HMAC `X-Containr-Signature`;
events: `deployment.* service.* database.* security.* agent.*`.
- [ ] **Activity feed** — enrich `audit_logs` with `severity category
icon label metadata`; per-project timeline + global feed; TTL job.
- [ ] **Banners** — `banners(title, body, level, scope, active)`; top
bar in UI + surfaced to CLI on `auth status`.
- [ ] **White-label** — `app_settings.branding`: product name, logos,
favicon, accent override, docs/support links. `GET /api/v1/branding`
public + cached. **Containr/Vertice remains default;** this is for
self-hosters who rebrand their instance.
- [ ] **Operations page** — unified view of running/queued/failed jobs
(deploys, builds, backups, cron, scans, agent commands) — dflow's
bubble panel as a real page; flush/stuck-job tools once queue exists.
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`);
`nodes/:id/terminal` via agent (dflow's wetty without the extra
container).
- [ ] **Team invites** — `POST /invites` → signed link → join flow;
replaces manual-only user creation.
- [ ] **Impersonate user** — admin support tool, audit-logged.
- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest`
returns manifest + redirect; instance self-provisions its App
(dflow pattern — removes the env-setup barrier).
- [ ] **fork-sync trigger** — accept repo-sync events on webhook
receiver.
- [ ] **Dynamic filter framework** — port dflow's `filter.utils`
pattern (typed filter configs → URL state → client filtering) for
audit logs, deployments, notifications tables.
- [ ] **Notification producers round-out** — backups, agent offline,
upgrade available, security findings.
- [x] **Outbound webhooks** — `outbound_webhooks(url, secret, events[],
headers, enabled)` + `webhook_deliveries` log; HMAC
`X-Containr-Signature`; wildcard events on every audited action;
CLI + MCP + `/settings/webhooks` page with delivery log + test ping.
- [x] **Activity feed** — `audit_logs` enriched with `severity`
(`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/
dismissible/schedule window. `GET /banners/active` for signed-in
users; admin CRUD at `/admin/banners`. `BannerBar` renders above the
header (per-banner dismiss via localStorage); admin console manages
them; `containr banners` CLI + `auth status` surfaces active ones.
- [x] **White-label** — `app_settings` `branding.*` keys: product name,
logo/favicon URL, accent color, docs/support links. Public
`GET /api/v1/branding` (defaults = Containr); admin writes via
`PUT /settings`. `useBranding()` applies title/accent/favicon;
`BrandWordmark` replaces hardcoded marks; admin Platform section
edits all fields. MCP `containr_branding_get` + `settings_update`.
- [x] **Operations page** — `GET /operations` aggregates active
deployments, in-memory deploy-queue depth (`Queue.Snapshot()`),
24h failures, cron runs and backups scoped to the caller's projects.
UI at `/operations` (Operate nav) with live refetch; `containr
operations` CLI + `containr_operations_get` MCP. Remaining: agent
commands once node fleet lands; flush/stuck-job tools.
- [x] **Interactive terminal** — `GET /services/:id/terminal` upgrades
to a WebSocket bridged to a `docker exec` PTY (bash→sh fallback,
`TERM=xterm-256color`, resize frames). xterm.js Console section on the
service page; `containr shell <id>` bridges a local TTY; distroless
images get a clean "no shell" error. Remaining: `nodes/:id/terminal`
via agent.
- [x] **Team invites** — `POST /admin/invites` returns a one-time
link (sha256-hashed token at rest, optional email binding, TTL);
public `GET /auth/invites/:token` validates and
`POST /auth/accept-invite` registers — works while public signup is
closed. `/auth/accept-invite` page, admin manage/revoke UI,
`containr invites` CLI + MCP tools.
- [x] **Impersonate user** — `POST /admin/users/:id/impersonate`
mints a 15-minute bearer token (`impersonated_by` claim),
audit-logged; admin accounts are not impersonatable. CLI
`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) —
`services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper
polls container NetworkIO deltas; idle past threshold → workload
removed, status `sleeping`. Wake-on-traffic via per-service
placeholder container on the edge network carrying the same Traefik
host rule at higher priority — serves a waking page, calls
`/internal/wake/:id`, exits; Traefik falls back once the app is up.
Verified `running → sleeping → waking → running` end to end.
- [x] **Env doctor** — `GET /services/:id/env-check` surfaces
unresolved `${{…}}` refs, empty values, and unreadable
(rotated-key) secrets; template `plan` endpoint reports missing
required vars before deploy (PR #30). Frontend Variables section
shows the banner.
- [x] **Shared project variables** — `project_variables` table,
`GET/PUT /projects/:id/variables`, `${{shared.KEY}}` refs resolved
at deploy/reconcile, secrets `enc:v1:` at rest and masked (PR #32).
- [x] **One-click install** — `install.sh` (curl|sh): detects docker,
writes compose + .env with generated secrets incl. `SECRETS_KEY`,
pulls images, runs migrations, prints admin URL. GHCR images mean
installs never build from source.
- [x] **CI/CD v2** — required checks (Go test/vet, frontend
lint/build/test, compose boot smoke), `dorny/paths-filter` gate so
docs-only PRs skip heavyweight jobs, and release on main/tag:
conventional-commit version bump → tag → GHCR images (backend,
frontend) → cross-platform CLI + MCP binaries → SHA-256 checksums
→ GitHub Release → Trivy scans (PR #33).
- [ ] **Custom roles/RBAC** — defer until demanded; PAT scopes designed
forward-compatible now.
## 9. Phase H — Migration & ecosystem
- [ ] **Railway migrator** — `containr migrate railway --token --project
--dry-run`: Railway GraphQL → services/vars/domains → recreate via
PAT. Landing-page pitch: "off Railway in one command."
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST
export or Mongo dump: servers→nodes, projects, services(+vars/
domains/volumes), databases, templates. Onboards dflow refugees.
- [ ] **`containr import compose`** — Phase-E parser straight to a
project (no template intermediate).
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL`
fetch/publish; bundled catalog works offline (dflow's self-contained
principle — nothing phones home).
- [x] **Railway migrator** — `containr migrate railway --token --project
--environment --into --dry-run`: Railway GraphQL (backboard) →
services (git-repo/image + command + replicas), variables, custom +
service domains → recreates via the Containr API. Project tokens and
account tokens both supported. `cli/commands/migrate.go`.
- [x] **dflow importer** — `containr migrate dflow --url --api-key
--project --into --dry-run`: pages the Payload REST collections
(users API-Key auth) → services (app→git/web, docker→image,
database→typed image + DATABASE_URL for external providers),
variables (empty encrypted values flagged), bind volumes, domains.
Soft-deleted services excluded. `cli/commands/migrate_dflow.go`.
- [x] **`containr import compose`** — `POST /projects/:id/import-compose`
deploys a compose file straight into a project; CLI `import compose`,
MCP tool, workspace import modal; project delete cascades to
graph-provisioned managed databases (`database_services.project_id`).
- [x] **Template registry** — `CONTAINR_TEMPLATE_REGISTRY_URL` merges a
remote Containr catalog into `GET /templates` as `source:"registry"`
(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
- [x] **Phone app (v1)** — `app/mobile`, Expo/React Native thin client
over the agent-shaped API; dark UI mirroring the design system. PAT
auth against any instance URL, stored in platform secure storage.
- **Dashboard** — service status counts (running/sleeping/failed/
stopped), per-project service health dots, recent deployments feed.
- **Operate** — start/stop/restart/redeploy, sleep/wake, rollback to
a prior deployment, database start/stop/restart/backup-now.
- **Observe** — polling log tail (300 lines, 5s refresh), per-service
deployment history, env-doctor issues surfaced on the service view.
- [x] **env var edit + domain management + replica scaling** (PR #61 —
mobile v1.5: variables editor, domains screen, scale + sleep
settings, notifications tab). **cron execution history** still open.
- [ ] **Push notifications** — zero-infra path = self-hosted
ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional.
- [ ] **Offline resilience** — cached last-known state + unreachable
banner.
- [ ] **Status/offline resilience** — app + CLI degrade gracefully when
the instance is unreachable (cached last-known state, clear banner).
## 10. Carried-over debt (ROADMAP.md items still open)
+14 -5
View File
@@ -1,5 +1,7 @@
# Build Go API binary
FROM golang:1.26-alpine AS go-builder
ARG GO_IMAGE=golang:1.26-alpine
ARG NODE_IMAGE=node:22-alpine
FROM ${GO_IMAGE} AS go-builder
RUN apk add --no-cache git ca-certificates tzdata
@@ -9,10 +11,16 @@ COPY go.mod go.sum ./
RUN go mod download
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
FROM node:22-alpine AS auth-builder
FROM ${NODE_IMAGE} AS auth-builder
WORKDIR /auth
@@ -22,9 +30,9 @@ RUN npm ci --omit=dev
COPY auth/src ./src
# Final runtime image (Go API + Better Auth sidecar in one container)
FROM node:22-alpine
FROM ${NODE_IMAGE}
RUN apk --no-cache add ca-certificates tzdata wget
RUN apk --no-cache add ca-certificates tzdata wget git
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
@@ -33,6 +41,7 @@ WORKDIR /app
COPY --from=go-builder /app/main ./main
COPY --from=go-builder /app/migrations_goose ./migrations_goose
COPY --from=go-builder /agents /agents
COPY --from=auth-builder /auth ./auth
RUN chown -R appuser:appgroup /app
+476 -7
View File
@@ -3,14 +3,18 @@ package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
@@ -104,7 +108,15 @@ type registerResponse struct {
Status string `json:"status"`
}
// version is stamped at build time via -ldflags "-X main.version=…".
// The server reports it on upgrades so agents can show what they run.
var version = "dev"
func main() {
if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "version") {
fmt.Printf("containr-agent %s\n", version)
return
}
cfg, err := loadConfig()
if err != nil {
log.Fatal(err)
@@ -167,6 +179,9 @@ func register(ctx context.Context, client *http.Client, cfg agentConfig) (string
"ip_address": cfg.IP,
"port": cfg.Port,
"capabilities": detectCapabilities(),
"version": firstEnv("CONTAINR_AGENT_VERSION", version),
// Always send mesh so a removed interface clears stored state.
"mesh": detectMeshInterfaces(),
}
var response registerResponse
if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil {
@@ -188,11 +203,93 @@ func sendHeartbeat(ctx context.Context, client *http.Client, cfg agentConfig, ag
"container_count": dockerContainerCount(),
"system_load": readSystemLoad(),
"uptime": readSystemUptime(),
"version": firstEnv("CONTAINR_AGENT_VERSION", "dev"),
"version": firstEnv("CONTAINR_AGENT_VERSION", version),
"containers": managedContainerStates(),
}
return postJSON(ctx, client, cfg, "/api/agents/heartbeat", payload, nil)
}
// managedContainerStates lists every containr-managed container's docker
// state plus cumulative network counters — state keeps runtime honest,
// net bytes let the sleeper detect idle remote services.
func managedContainerStates() []map[string]interface{} {
out, err := runDocker("ps", "-a",
"--filter", "label=containr.managed=true",
"--format", "{{.Names}}\t{{.State}}")
if err != nil {
return nil
}
netIO := containerNetIO()
var states []map[string]interface{}
for _, line := range strings.Split(out, "\n") {
parts := strings.SplitN(line, "\t", 2)
if len(parts) != 2 || parts[0] == "" {
continue
}
entry := map[string]interface{}{"name": parts[0], "state": parts[1]}
if io, ok := netIO[parts[0]]; ok {
entry["rx_bytes"], entry["tx_bytes"] = io[0], io[1]
}
states = append(states, entry)
}
return states
}
// containerNetIO snapshots cumulative rx/tx per running container. Stats
// only covers running containers — stopped replicas report state without
// counters, which is all the sleeper needs.
func containerNetIO() map[string][2]int64 {
out, err := runDocker("stats", "--no-stream",
"--format", "{{.Name}}\t{{.NetIO}}")
if err != nil {
return nil
}
result := map[string][2]int64{}
for _, line := range strings.Split(out, "\n") {
parts := strings.SplitN(line, "\t", 2)
if len(parts) != 2 {
continue
}
io := strings.Split(parts[1], "/")
if len(io) != 2 {
continue
}
result[parts[0]] = [2]int64{parseHumanBytes(io[0]), parseHumanBytes(io[1])}
}
return result
}
// parseHumanBytes converts docker's human units ("1.2kB", "3.4MB") to bytes.
func parseHumanBytes(s string) int64 {
s = strings.TrimSpace(s)
var num float64
var unit string
if _, err := fmt.Sscanf(s, "%f%s", &num, &unit); err != nil {
return 0
}
switch strings.ToUpper(unit) {
case "B":
return int64(num)
case "KB":
return int64(num * 1e3)
case "MB":
return int64(num * 1e6)
case "GB":
return int64(num * 1e9)
case "TB":
return int64(num * 1e12)
case "KIB":
return int64(num * 1024)
case "MIB":
return int64(num * 1024 * 1024)
case "GIB":
return int64(num * 1024 * 1024 * 1024)
case "TIB":
return int64(num * 1024 * 1024 * 1024 * 1024)
}
return int64(num)
}
func processCommands(ctx context.Context, client *http.Client, cfg agentConfig, agentID string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/agents/%s/commands", cfg.APIURL, agentID), nil)
if err != nil {
@@ -214,7 +311,13 @@ func processCommands(ctx context.Context, client *http.Client, cfg agentConfig,
return err
}
for _, item := range list.Commands {
result, runErr := executeCommand(item)
var result, reexecPath string
var runErr error
if item.Type == "self_upgrade" {
result, reexecPath, runErr = selfUpgrade(cfg, item.Payload)
} else {
result, runErr = executeCommand(cfg, item)
}
status := "completed"
errText := ""
if runErr != nil {
@@ -230,11 +333,17 @@ func processCommands(ctx context.Context, client *http.Client, cfg agentConfig,
if err := postJSON(ctx, client, cfg, path, payload, nil); err != nil {
log.Printf("report command result failed: command=%s err=%v", item.ID, err)
}
// Re-exec AFTER the result posts — the new binary picks up the loop.
if reexecPath != "" {
if err := syscall.Exec(reexecPath, os.Args, os.Environ()); err != nil {
log.Fatalf("re-exec after upgrade failed: %v", err)
}
}
}
return nil
}
func executeCommand(item command) (string, error) {
func executeCommand(cfg agentConfig, item command) (string, error) {
switch item.Type {
case "create_container":
return createContainer(item.Payload)
@@ -245,12 +354,100 @@ func executeCommand(item command) (string, error) {
case "restart_container":
return runDocker("restart", commandTarget(item.Payload))
case "remove_container":
return runDocker("rm", "-f", commandTarget(item.Payload))
out, err := runDocker("rm", "-f", commandTarget(item.Payload))
// A missing container is the desired end state — report success.
if err != nil && strings.Contains(out, "No such container") {
return out, nil
}
return out, err
case "build_image":
return buildImage(cfg, item.Payload)
case "load_image":
return loadImage(cfg, item.Payload)
case "prune":
return dockerPrune(item.Payload)
case "system_df":
return runDocker("system", "df", "--format", "json")
default:
return "", fmt.Errorf("unsupported command type %q", item.Type)
}
}
// selfUpgrade downloads the agent binary the server bundles for this
// platform, verifies it, swaps it in atomically, and returns the path to
// re-exec. On any failure the running binary is left untouched.
func selfUpgrade(cfg agentConfig, payload map[string]interface{}) (string, string, error) {
platform := firstString(stringValue(payload["platform"]), runtime.GOOS+"-"+runtime.GOARCH)
exe, err := os.Executable()
if err != nil {
return "", "", fmt.Errorf("resolve own path: %w", err)
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
url := fmt.Sprintf("%s/api/agents/download/%s", cfg.APIURL, platform)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", "", err
}
resp, err := (&http.Client{Timeout: 2 * time.Minute}).Do(req)
if err != nil {
return "", "", fmt.Errorf("download %s: %w", platform, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("download %s: server returned %d", platform, resp.StatusCode)
}
newPath := exe + ".new"
out, err := os.OpenFile(newPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return "", "", fmt.Errorf("write %s: %w", newPath, err)
}
h := sha256.New()
if _, err := io.Copy(io.MultiWriter(out, h), resp.Body); err != nil {
out.Close()
os.Remove(newPath)
return "", "", fmt.Errorf("download write: %w", err)
}
out.Close()
if want := stringValue(payload["sha256"]); want != "" {
if got := hex.EncodeToString(h.Sum(nil)); got != want {
os.Remove(newPath)
return "", "", fmt.Errorf("sha256 mismatch: got %s want %s", got, want)
}
}
// Sanity: the new binary must at least report its version.
if verOut, err := exec.Command(newPath, "--version").CombinedOutput(); err != nil {
os.Remove(newPath)
return "", "", fmt.Errorf("new binary failed sanity check: %s", strings.TrimSpace(string(verOut)))
}
if err := os.Rename(newPath, exe); err != nil {
os.Remove(newPath)
return "", "", fmt.Errorf("swap binary: %w", err)
}
return fmt.Sprintf("upgraded to %s (%s)", firstString(stringValue(payload["version"]), "latest"), platform), exe, nil
}
// dockerPrune runs a bounded docker system prune. Volumes are excluded
// unless the payload explicitly opts in — a stray volume prune destroys
// database data.
func dockerPrune(payload map[string]interface{}) (string, error) {
args := []string{"system", "prune", "-af"}
if boolValue(payload["volumes"]) {
args = append(args, "--volumes")
}
if until := stringValue(payload["until"]); until != "" {
args = append(args, "--filter", fmt.Sprintf("until=%s", until))
}
return runDocker(args...)
}
func createContainer(payload map[string]interface{}) (string, error) {
container, ok := payload["container"].(map[string]interface{})
if !ok {
@@ -263,7 +460,38 @@ func createContainer(payload map[string]interface{}) (string, error) {
return "", errors.New("container name and image are required")
}
args := []string{"run", "-d", "--name", name, "--restart", "unless-stopped"}
// Private registry: log in for the pull, then log back out so the
// credential doesn't linger in the node's docker config.
if reg, ok := container["registry"].(map[string]interface{}); ok {
if user := stringValue(reg["username"]); user != "" {
server := firstString(stringValue(reg["server"]), registryHostOf(image))
login := exec.Command("docker", "login", server, "-u", user, "--password-stdin")
login.Stdin = strings.NewReader(stringValue(reg["password"]))
if out, err := login.CombinedOutput(); err != nil {
return "", fmt.Errorf("registry login %s: %s", server, strings.TrimSpace(string(out)))
}
defer func() { _, _ = runDocker("logout", server) }()
}
}
// Redeploys reuse the deterministic name — clear the stale container
// first so `run` never fails on a name conflict.
_, _ = runDocker("rm", "-f", name)
args := []string{"run", "-d", "--name", name}
restart := firstString(stringValue(container["restart_policy"]), "unless-stopped")
args = append(args, "--restart", restart)
if labels, ok := container["labels"].(map[string]interface{}); ok {
for key, value := range labels {
args = append(args, "--label", fmt.Sprintf("%s=%v", key, value))
}
}
if mem := int64Value(container["memory"]); mem > 0 {
args = append(args, "--memory", fmt.Sprintf("%d", mem))
}
if cpus := floatValue(container["cpus"]); cpus > 0 {
args = append(args, "--cpus", fmt.Sprintf("%g", cpus))
}
if env, ok := container["environment"].(map[string]interface{}); ok {
for key, value := range env {
args = append(args, "-e", fmt.Sprintf("%s=%v", key, value))
@@ -278,8 +506,14 @@ func createContainer(payload map[string]interface{}) (string, error) {
hostPort := intValue(port["host_port"])
containerPort := intValue(port["container_port"])
protocol := firstString(stringValue(port["protocol"]), "tcp")
if hostPort > 0 && containerPort > 0 {
if containerPort <= 0 {
continue
}
if hostPort > 0 {
args = append(args, "-p", fmt.Sprintf("%d:%d/%s", hostPort, containerPort, protocol))
} else {
// Ephemeral host port — docker picks the binding.
args = append(args, "-p", fmt.Sprintf("%d/%s", containerPort, protocol))
}
}
}
@@ -301,7 +535,165 @@ func createContainer(payload map[string]interface{}) (string, error) {
}
}
args = append(args, image)
return runDocker(args...)
if cmd, ok := container["command"].([]interface{}); ok {
for _, arg := range cmd {
args = append(args, stringValue(arg))
}
}
if _, err := runDocker(args...); err != nil {
return "", err
}
// docker run -d returns before the entrypoint fails — a crash-looping
// service would be reported healthy. Give it a moment, then verify.
time.Sleep(1500 * time.Millisecond)
state, _ := runDocker("inspect", "-f", "{{.State.Status}}", name)
if state != "running" {
logs, _ := runDocker("logs", "--tail", "5", name)
return "", fmt.Errorf("container %s is %q after start: %s", name, state, logs)
}
// Report the real host port bindings — ephemeral publishes pick a port
// the server can't guess, and remote ingress needs the actual mapping.
hostPorts := map[string]string{}
portsJSON, _ := runDocker("inspect", "-f", "{{json .NetworkSettings.Ports}}", name)
var bindings map[string][]struct {
HostIP string `json:"HostIp"`
HostPort string `json:"HostPort"`
}
if json.Unmarshal([]byte(portsJSON), &bindings) == nil {
for cport, addrs := range bindings {
for _, a := range addrs {
if a.HostPort != "" && a.HostPort != "0" {
hostPorts[cport] = a.HostPort
}
}
}
}
out, _ := json.Marshal(map[string]interface{}{"name": name, "host_ports": hostPorts})
return string(out), nil
}
// fetchArtifact downloads a server-side deploy artifact (build context or
// docker-save tarball) into a temp file the docker CLI consumes.
func fetchArtifact(cfg agentConfig, artifactID string) (*os.File, error) {
if len(artifactID) != 32 {
return nil, fmt.Errorf("invalid artifact id")
}
url := fmt.Sprintf("%s/api/agents/artifacts/%s", cfg.APIURL, artifactID)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Containr-Agent-Token", cfg.Token)
resp, err := (&http.Client{Timeout: 10 * time.Minute}).Do(req)
if err != nil {
return nil, fmt.Errorf("download artifact: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download artifact: server returned %d", resp.StatusCode)
}
f, err := os.CreateTemp("", "containr-artifact-*.tar")
if err != nil {
return nil, err
}
if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
os.Remove(f.Name())
return nil, fmt.Errorf("write artifact: %w", err)
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
f.Close()
os.Remove(f.Name())
return nil, err
}
return f, nil
}
// buildImage docker-builds a packaged context tar on this node. The context
// already carries its Dockerfile at the tar root, so `docker build -` can
// stream it straight from stdin. Result reports the image digest so the
// server records what each node actually produced.
func buildImage(cfg agentConfig, payload map[string]interface{}) (string, error) {
image := stringValue(payload["image"])
artifactID := stringValue(payload["artifact_id"])
if image == "" || artifactID == "" {
return "", errors.New("build_image requires image and artifact_id")
}
f, err := fetchArtifact(cfg, artifactID)
if err != nil {
return "", err
}
defer f.Close()
defer os.Remove(f.Name())
args := []string{"build", "-t", image}
if boolValue(payload["no_cache"]) {
args = append(args, "--no-cache")
}
// Bare --build-arg keys read their values from the process env — keeps
// token-bearing args out of the node's process list.
env := os.Environ()
if buildArgs, ok := payload["build_args"].(map[string]interface{}); ok {
for k, v := range buildArgs {
args = append(args, "--build-arg", k)
env = append(env, fmt.Sprintf("%s=%v", k, v))
}
}
args = append(args, "-")
cmd := exec.Command("docker", args...)
cmd.Stdin = f
cmd.Env = env
outBytes, err := cmd.CombinedOutput()
out := strings.TrimSpace(string(outBytes))
if err != nil {
return "", fmt.Errorf("docker build failed: %s", tailOutput(out, 4096))
}
digest, _ := runDocker("inspect", "-f", "{{.Id}}", image)
size, _ := runDocker("inspect", "-f", "{{.Size}}", image)
result, _ := json.Marshal(map[string]string{
"image": image, "id": digest, "size": size,
"log_tail": tailOutput(out, 4096),
})
return string(result), nil
}
// loadImage imports a docker-save tarball — rollbacks of locally-built tags
// reach remote nodes this way since no registry serves containr-* images.
func loadImage(cfg agentConfig, payload map[string]interface{}) (string, error) {
artifactID := stringValue(payload["artifact_id"])
if artifactID == "" {
return "", errors.New("load_image requires artifact_id")
}
f, err := fetchArtifact(cfg, artifactID)
if err != nil {
return "", err
}
defer f.Close()
defer os.Remove(f.Name())
out, err := runDockerStdin(f, "load")
if err != nil {
return "", fmt.Errorf("docker load failed: %s", tailOutput(out, 4096))
}
return out, nil
}
// tailOutput bounds command results — full docker build logs would swamp
// the agent_commands row.
func tailOutput(s string, max int) string {
if len(s) <= max {
return s
}
return s[len(s)-max:]
}
// registryHostOf mirrors the server's registryHost: first path segment with
// a "." or ":" is a registry host; bare names mean Docker Hub.
func registryHostOf(imageRef string) string {
parts := strings.Split(imageRef, "/")
if len(parts) > 1 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
return parts[0]
}
return "docker.io"
}
func commandTarget(payload map[string]interface{}) string {
@@ -354,6 +746,42 @@ func detectCapabilities() capabilities {
}
}
// detectMeshInterfaces reports overlay-network interface IPs (tailscale,
// netbird, zerotier, wireguard) so the backend can display reachable
// mesh addresses alongside the primary IP.
func detectMeshInterfaces() map[string]string {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
mesh := map[string]string{}
for _, iface := range ifaces {
name := strings.ToLower(iface.Name)
isMesh := strings.HasPrefix(name, "tailscale") || strings.HasPrefix(name, "ts.") ||
strings.HasPrefix(name, "netbird") || strings.HasPrefix(name, "wt") ||
strings.HasPrefix(name, "zt") || strings.HasPrefix(name, "wg")
if !isMesh || iface.Flags&net.FlagUp == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ip, _, err := net.ParseCIDR(addr.String())
if err != nil || ip.IsLoopback() {
continue
}
if v4 := ip.To4(); v4 != nil {
mesh[iface.Name] = v4.String()
} else if _, exists := mesh[iface.Name]; !exists {
mesh[iface.Name] = ip.String()
}
}
}
return mesh
}
func collectResources(ip string) nodeResources {
mem := readMemory()
storage := readStorage("/")
@@ -469,6 +897,15 @@ func runDocker(args ...string) (string, error) {
return strings.TrimSpace(string(output)), err
}
// runDockerStdin pipes stdin into docker — build contexts and saved image
// tarballs stream in without a second copy on disk.
func runDockerStdin(stdin io.Reader, args ...string) (string, error) {
cmd := exec.Command("docker", args...)
cmd.Stdin = stdin
output, err := cmd.CombinedOutput()
return strings.TrimSpace(string(output)), err
}
func dockerAvailable() bool {
_, err := exec.LookPath("docker")
if err != nil {
@@ -564,3 +1001,35 @@ func boolValue(value interface{}) bool {
typed, _ := value.(bool)
return typed
}
func int64Value(value interface{}) int64 {
switch typed := value.(type) {
case int64:
return typed
case int:
return int64(typed)
case float64:
return int64(typed)
case json.Number:
out, _ := typed.Int64()
return out
default:
return 0
}
}
func floatValue(value interface{}) float64 {
switch typed := value.(type) {
case float64:
return typed
case int:
return float64(typed)
case int64:
return float64(typed)
case json.Number:
out, _ := typed.Float64()
return out
default:
return 0
}
}
+12 -3
View File
@@ -13,6 +13,7 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/lib/pq v1.10.9
github.com/miekg/dns v1.1.72
github.com/minio/minio-go/v7 v7.0.95
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/pressly/goose/v3 v3.24.1
github.com/robfig/cron/v3 v3.0.1
@@ -23,6 +24,7 @@ require (
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.57.0
golang.org/x/term v0.46.0
gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -37,23 +39,28 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/minio/crc64nvme v1.0.2 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
@@ -63,8 +70,10 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
@@ -74,6 +83,7 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
@@ -97,7 +107,6 @@ require (
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.49.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
modernc.org/sqlite v1.39.0 // indirect
)
+21 -5
View File
@@ -46,6 +46,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -63,8 +65,8 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -82,9 +84,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -99,6 +104,12 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
@@ -130,6 +141,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -144,6 +157,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
@@ -182,6 +197,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
@@ -227,7 +244,6 @@ golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+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"})
}
// 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) {
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
+422 -29
View File
@@ -6,13 +6,17 @@ import (
"crypto/sha256"
"crypto/subtle"
"database/sql"
_ "embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@@ -26,16 +30,24 @@ import (
// NodeAgent represents a container orchestration agent
type NodeAgent struct {
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
IPAddress string `json:"ip_address"`
Port int `json:"port"`
Status string `json:"status"`
Version string `json:"version"`
Capabilities AgentCapabilities `json:"capabilities"`
Resources NodeResources `json:"resources"`
LastHeartbeat time.Time `json:"last_heartbeat"`
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
IPAddress string `json:"ip_address"`
Port int `json:"port"`
Status string `json:"status"`
Version string `json:"version"`
Capabilities AgentCapabilities `json:"capabilities"`
Resources NodeResources `json:"resources"`
LastHeartbeat time.Time `json:"last_heartbeat"`
AutoPrune bool `json:"auto_prune"`
Schedulable bool `json:"schedulable"`
// Tags are operator-set placement labels; services with placement_tags
// only schedule onto agents carrying every required tag.
Tags []string `json:"tags"`
// DefaultDomain is this node's base domain — services placed here with
// no explicit domains get <name>.<default_domain> automatically.
DefaultDomain string `json:"default_domain"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata"`
@@ -189,6 +201,17 @@ type AgentHeartbeat struct {
SystemLoad SystemLoad `json:"system_load"`
Uptime int64 `json:"uptime"`
Version string `json:"version"`
// Containers reports managed-container liveness — {name, state} per
// containr.managed container on the node — so remote replica status
// reflects reality between reconciles (exits, OOMs, crash loops).
Containers []AgentContainerState `json:"containers,omitempty"`
}
type AgentContainerState struct {
Name string `json:"name"`
State string `json:"state"`
RxBytes int64 `json:"rx_bytes,omitempty"`
TxBytes int64 `json:"tx_bytes,omitempty"`
}
type SystemLoad struct {
@@ -225,16 +248,45 @@ func unmarshalRaw(raw pqtype.NullRawMessage, dst interface{}) {
}
}
// ListNodeOptions returns the minimal id/name/status list used by the service
// placement picker — available to any authenticated user (no hostnames, IPs,
// or telemetry).
func (h *NodeAgentHandler) ListNodeOptions(c *gin.Context) {
rows, err := h.q.ListAgents(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list nodes"})
return
}
options := make([]gin.H, 0, len(rows))
for _, row := range rows {
var tags []string
if len(row.Tags) > 0 {
_ = json.Unmarshal(row.Tags, &tags)
}
options = append(options, gin.H{
"id": row.ID,
"name": row.Name,
"status": row.Status.String,
"tags": tags,
})
}
c.JSON(http.StatusOK, gin.H{"nodes": options})
}
func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
agent := NodeAgent{
ID: row.ID,
Name: row.Name,
Hostname: row.Hostname,
IPAddress: row.IpAddress,
Port: int(row.Port),
Status: row.Status.String,
Version: row.Version.String,
Metadata: map[string]interface{}{},
ID: row.ID,
Name: row.Name,
Hostname: row.Hostname,
IPAddress: row.IpAddress,
Port: int(row.Port),
Status: row.Status.String,
Version: row.Version.String,
AutoPrune: row.AutoPrune,
Schedulable: row.Schedulable,
Tags: []string{},
DefaultDomain: row.DefaultDomain,
Metadata: map[string]interface{}{},
}
if row.LastHeartbeat.Valid {
agent.LastHeartbeat = row.LastHeartbeat.Time
@@ -248,6 +300,9 @@ func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
unmarshalRaw(row.Capabilities, &agent.Capabilities)
unmarshalRaw(row.Resources, &agent.Resources)
unmarshalRaw(row.Metadata, &agent.Metadata)
if len(row.Tags) > 0 {
_ = json.Unmarshal(row.Tags, &agent.Tags)
}
return agent
}
@@ -326,7 +381,9 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
IPAddress string `json:"ip_address" binding:"required"`
Port int `json:"port" binding:"required"`
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
Version string `json:"version"`
AuthToken string `json:"auth_token"`
Mesh map[string]string `json:"mesh"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -341,24 +398,43 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
}
ctx := c.Request.Context()
metadata := map[string]interface{}{}
// req.Mesh is authoritative when present (newer agents always send it,
// possibly empty); nil means an older agent — keep any stored value.
if req.Mesh != nil {
metadata["mesh"] = req.Mesh
}
// Re-registering an existing host updates it in place.
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
Hostname: req.Hostname,
IpAddress: req.IPAddress,
}); err == nil {
if existing.Metadata.Valid && len(existing.Metadata.RawMessage) > 0 {
var prev map[string]interface{}
if err := json.Unmarshal(existing.Metadata.RawMessage, &prev); err == nil {
for k, v := range prev {
if _, ok := metadata[k]; !ok {
metadata[k] = v
}
}
}
}
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
ID: existing.ID,
Name: req.Name,
Hostname: existing.Hostname,
IpAddress: existing.IpAddress,
Port: int32(req.Port),
Status: sql.NullString{String: "connecting", Valid: true},
Version: existing.Version,
ID: existing.ID,
Name: req.Name,
Hostname: existing.Hostname,
IpAddress: existing.IpAddress,
Port: int32(req.Port),
Status: sql.NullString{String: "connecting", Valid: true},
// Older agents don't report a version — keep whatever's stored.
Version: sql.NullString{String: firstNonEmpty(req.Version, existing.Version.String), Valid: true},
Capabilities: rawJSON(req.Capabilities),
Resources: existing.Resources,
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
Metadata: existing.Metadata,
Metadata: rawJSON(metadata),
AutoPrune: existing.AutoPrune,
Schedulable: existing.Schedulable,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
@@ -404,11 +480,11 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
IpAddress: req.IPAddress,
Port: int32(req.Port),
Status: sql.NullString{String: "connecting", Valid: true},
Version: sql.NullString{},
Version: sql.NullString{String: req.Version, Valid: req.Version != ""},
Capabilities: rawJSON(req.Capabilities),
Resources: rawJSON(resources),
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
Metadata: rawJSON(map[string]interface{}{}),
Metadata: rawJSON(metadata),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
@@ -489,13 +565,46 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
Resources: rawJSON(agent.Resources),
LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()},
Metadata: rawJSON(agent.Metadata),
AutoPrune: agent.AutoPrune,
Schedulable: agent.Schedulable,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return
}
c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(updated)})
// Tags persist through their own column write — UpdateAgent keeps the
// register-path signature untouched.
resp := agentFromRow(updated)
if raw, ok := updates["default_domain"]; ok {
domain := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", raw)))
if domain != "" && !validHostname(domain) {
c.JSON(http.StatusBadRequest, gin.H{"error": "default_domain must be a valid hostname", "code": "VALIDATION"})
return
}
if err := h.q.SetAgentDefaultDomain(ctx, sqlcdb.SetAgentDefaultDomainParams{ID: id, DefaultDomain: domain}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update default domain"})
return
}
resp.DefaultDomain = domain
}
if raw, ok := updates["tags"]; ok {
var tags []string
if b, mErr := json.Marshal(raw); mErr == nil {
_ = json.Unmarshal(b, &tags)
}
tags = normalizePlacementTags(tags)
if err := h.q.SetAgentTags(ctx, sqlcdb.SetAgentTagsParams{
ID: id,
Column2: json.RawMessage(tagsJSON(tags)),
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent tags"})
return
}
resp.Tags = tags
}
c.JSON(http.StatusOK, gin.H{"agent": resp})
}
// applyAgentUpdates merges whitelisted keys from an arbitrary update map.
@@ -516,6 +625,8 @@ func applyAgentUpdates(agent *NodeAgent, updates map[string]interface{}) {
remarshal("capabilities", &agent.Capabilities)
remarshal("resources", &agent.Resources)
remarshal("metadata", &agent.Metadata)
remarshal("auto_prune", &agent.AutoPrune)
remarshal("schedulable", &agent.Schedulable)
}
// DeleteAgent removes an agent
@@ -581,6 +692,22 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
return
}
// Reconcile remote container liveness — exits and crash loops surface
// in inventory between reconciles. Best-effort, never fatal.
for _, rc := range heartbeat.Containers {
if rc.Name == "" || rc.State == "" {
continue
}
stateJSON, _ := json.Marshal(rc.State)
bytesJSON, _ := json.Marshal(rc.RxBytes + rc.TxBytes)
_ = h.q.UpdateContainerStateByName(ctx, sqlcdb.UpdateContainerStateByNameParams{
Name: rc.Name,
NodeAgentID: heartbeat.NodeAgentID,
Column3: stateJSON,
Column4: bytesJSON,
})
}
c.Status(http.StatusOK)
}
@@ -650,9 +777,51 @@ func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
h.updateContainerStatusAfterCommand(ctx, out)
}
// Command payloads can carry registry credentials — scrub them once the
// command is settled so secrets don't linger in the queue rows.
scrubCommandSecrets(ctx, h.q, commandID, command.Payload)
c.JSON(http.StatusOK, gin.H{"command": out})
}
// scrubCommandSecrets rewrites a completed command's payload with any
// credential-bearing fields blanked — registry passwords and build-time
// args (tokens like NPM_TOKEN ride along as build args). Best-effort — a
// failed scrub leaves the row.
func scrubCommandSecrets(ctx context.Context, q *sqlcdb.Queries, commandID string, payload pqtype.NullRawMessage) {
if !payload.Valid || len(payload.RawMessage) == 0 {
return
}
var doc map[string]interface{}
if err := json.Unmarshal(payload.RawMessage, &doc); err != nil {
return
}
dirty := false
if container, ok := doc["container"].(map[string]interface{}); ok {
if reg, ok := container["registry"].(map[string]interface{}); ok && reg["password"] != nil {
reg["password"] = "***"
dirty = true
}
}
for _, key := range []string{"build_args", "environment"} {
if args, ok := doc[key].(map[string]interface{}); ok && len(args) > 0 {
for k := range args {
args[k] = "***"
}
dirty = true
}
}
if !dirty {
return
}
if err := q.ScrubCommandPayload(ctx, sqlcdb.ScrubCommandPayloadParams{
ID: commandID,
Payload: rawJSON(doc),
}); err != nil {
log.Printf("agents: scrub command %s payload: %v", commandID, err)
}
}
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(ctx context.Context, command AgentCommand) {
row, err := h.q.GetContainer(ctx, *command.ContainerID)
if err != nil {
@@ -803,6 +972,103 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"container": container})
}
// PruneAgent enqueues a bounded `docker system prune -af` command. Volumes
// stay unless the caller explicitly opts in.
func (h *NodeAgentHandler) PruneAgent(c *gin.Context) {
agentID := c.Param("id")
if _, err := h.q.GetAgent(c.Request.Context(), agentID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
var req struct {
Until string `json:"until"` // e.g. "168h" — only prune objects older than this
Volumes bool `json:"volumes"` // destructive — off by default
}
_ = c.ShouldBindJSON(&req)
command, err := h.q.CreateCommand(c.Request.Context(), sqlcdb.CreateCommandParams{
ID: uuid.New().String(),
Type: "prune",
NodeAgentID: agentID,
Payload: rawJSON(map[string]interface{}{"until": req.Until, "volumes": req.Volumes}),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enqueue prune"})
return
}
c.JSON(http.StatusAccepted, gin.H{"command": command})
}
// UpgradeAgent enqueues a self_upgrade command: the agent downloads its
// replacement binary from this server's bundled /download endpoint,
// verifies the sha256, swaps it in, and re-execs. Fails fast when no
// bundled binary exists for the agent's architecture.
func (h *NodeAgentHandler) UpgradeAgent(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
agent, err := h.q.GetAgent(ctx, id)
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Agent not found")
return
}
if agent.Status.String != "online" {
respondError(c, http.StatusConflict, "NODE_OFFLINE", "node is offline — reconnect it before upgrading")
return
}
arch := "amd64"
var caps struct {
SupportedArchitectures []string `json:"supported_architectures"`
}
unmarshalRaw(agent.Capabilities, &caps)
if len(caps.SupportedArchitectures) > 0 && caps.SupportedArchitectures[0] != "" {
arch = caps.SupportedArchitectures[0]
}
platform := "linux-" + arch // agents only ship for linux today
dir := os.Getenv("CONTAINR_AGENT_BIN_DIR")
if dir == "" {
dir = "/agents"
}
path := filepath.Join(dir, "containr-agent-"+platform)
sum, err := sha256File(path)
if err != nil {
respondError(c, http.StatusConflict, "NO_BINARY",
fmt.Sprintf("no bundled agent binary for %s on this server", platform))
return
}
command, err := h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
ID: uuid.New().String(),
Type: "self_upgrade",
NodeAgentID: id,
Payload: rawJSON(map[string]interface{}{
"platform": platform,
"sha256": sum,
"version": Version,
}),
})
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to enqueue upgrade")
return
}
LogAuditWithRequest(c, "node", id, "upgrade", map[string]interface{}{"platform": platform, "version": Version})
c.JSON(http.StatusAccepted, gin.H{"command": command, "platform": platform, "version": Version})
}
func sha256File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// ExecuteCommand executes a command on an agent
func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
agentID := c.Param("id")
@@ -1214,9 +1480,49 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
agents.POST("/heartbeat", h.SendHeartbeat)
agents.GET("/:id/commands", h.GetPendingCommandsForAgent)
agents.POST("/:id/commands/:commandId/result", h.CompleteCommand)
// Bootstrap surface — the install script and agent binaries are
// public; the enroll token is the actual secret.
agents.GET("/install.sh", h.ServeInstallScript)
agents.GET("/download/:platform", h.ServeAgentBinary)
// Deploy artifacts (build contexts, image tarballs) — token-gated;
// the blobs are per-deployment source code, not public bootstrap.
agents.GET("/artifacts/:id", h.ServeArtifact)
}
}
//go:embed assets/install-agent.sh
var agentInstallScript string
// ServeInstallScript returns the agent bootstrap script. The script
// downloads the binary from this instance first, GH releases second.
func (h *NodeAgentHandler) ServeInstallScript(c *gin.Context) {
c.Data(http.StatusOK, "text/x-sh", []byte(agentInstallScript))
}
// ServeAgentBinary streams a bundled agent binary. Images ship the
// linux binaries under CONTAINR_AGENT_BIN_DIR (default /agents).
func (h *NodeAgentHandler) ServeAgentBinary(c *gin.Context) {
platform := c.Param("platform")
if platform != "linux-amd64" && platform != "linux-arm64" {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported platform"})
return
}
dir := os.Getenv("CONTAINR_AGENT_BIN_DIR")
if dir == "" {
dir = "/agents"
}
path := filepath.Join(dir, "containr-agent-"+platform)
info, err := os.Stat(path)
if err != nil || info.IsDir() {
c.JSON(http.StatusNotFound, gin.H{"error": "no bundled binary for " + platform})
return
}
c.Header("Content-Disposition", `attachment; filename="containr-agent"`)
c.File(path)
}
// SetupAdminRoutes registers dashboard/control agent routes. Command
// history and container details can leak sensitive output, so these stay
// admin-only rather than joining the public read group.
@@ -1243,8 +1549,95 @@ func (h *NodeAgentHandler) SetupAdminRoutes(router *gin.RouterGroup) {
agents.DELETE("/:id/containers/:containerId", h.ContainerAction)
agents.GET("/:id/metrics", h.GetAgentMetrics)
agents.POST("/:id/cordon", h.CordonAgent)
agents.POST("/:id/uncordon", h.UncordonAgent)
agents.POST("/:id/drain", h.DrainAgent)
agents.POST("/:id/upgrade", h.UpgradeAgent)
agents.POST("/:id/prune", h.PruneAgent)
agents.POST("/:id/commands", h.ExecuteCommand)
agents.GET("/:id/commands", h.GetAgentCommands)
agents.GET("/:id/commands/:commandId", h.GetCommandStatus)
}
}
// setSchedulable flips the node's scheduling gate. Cordoned nodes keep
// running their placed replicas but take no new pins or `auto` placements.
func (h *NodeAgentHandler) setSchedulable(c *gin.Context, schedulable bool) {
id := c.Param("id")
if _, err := h.q.GetAgent(c.Request.Context(), id); err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Agent not found")
return
}
if err := h.q.SetAgentSchedulable(c.Request.Context(), sqlcdb.SetAgentSchedulableParams{
ID: id, Schedulable: schedulable,
}); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to update node")
return
}
LogAuditWithRequest(c, "node", id, map[bool]string{true: "uncordon", false: "cordon"}[schedulable], nil)
c.JSON(http.StatusOK, gin.H{"id": id, "schedulable": schedulable})
}
// CordonAgent blocks new placements on the node (explicit pins and `auto`).
func (h *NodeAgentHandler) CordonAgent(c *gin.Context) { h.setSchedulable(c, false) }
// UncordonAgent re-opens the node for placement.
func (h *NodeAgentHandler) UncordonAgent(c *gin.Context) { h.setSchedulable(c, true) }
// DrainAgent cordons the node, removes every service container via the agent
// command queue, and clears the affected services' pins — they fall back to
// the local host on next deploy and can be re-pinned to another node.
// Requires the node to be online; an offline node can't acknowledge the
// removals.
func (h *NodeAgentHandler) DrainAgent(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
agent, err := h.q.GetAgent(ctx, id)
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Agent not found")
return
}
if agent.Status.String != "online" {
respondError(c, http.StatusConflict, "NODE_OFFLINE",
"node is not online — reconnect it, or remove the containers manually")
return
}
if err := h.q.SetAgentSchedulable(ctx, sqlcdb.SetAgentSchedulableParams{
ID: id, Schedulable: false,
}); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to cordon node")
return
}
runner := &agentNodeRunner{q: h.q}
rows, err := h.q.ListContainersForAgent(ctx, id)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list node containers")
return
}
services := map[string]bool{}
for _, row := range rows {
services[row.ServiceID] = true
}
var drained int
var firstErr error
for serviceID := range services {
if err := runner.RemoveService(ctx, serviceID, id); err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
drained += 1
}
unpinned, _ := h.q.ClearServiceNodePins(ctx, sql.NullString{String: id, Valid: true})
if firstErr != nil {
respondError(c, http.StatusBadGateway, "DRAIN_PARTIAL",
fmt.Sprintf("drained %d service(s), unpinned %d — one failed: %v", drained, unpinned, firstErr))
return
}
LogAuditWithRequest(c, "node", id, "drain", map[string]interface{}{
"services": drained, "unpinned": unpinned,
})
c.JSON(http.StatusOK, gin.H{"id": id, "schedulable": false, "services_drained": drained, "services_unpinned": unpinned})
}
+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"
+77 -7
View File
@@ -23,9 +23,67 @@ type AuditLog struct {
Details string `json:"details" db:"details"`
IPAddress string `json:"ip_address" db:"ip_address"`
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"`
}
// 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 {
OldValue interface{} `json:"old_value,omitempty"`
NewValue interface{} `json:"new_value,omitempty"`
@@ -42,21 +100,27 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in
detailsJSON, _ := json.Marshal(details)
resourceUUID := parseUUIDOrNil(resourceID)
userUUID := parseUUIDOrNil(userID)
severity, category, label := classifyAuditEvent(resource, action)
auditID := uuid.New().String()
_, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), time.Now().UTC(),
`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, $8, $9, $10)`,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), severity, category, label, time.Now().UTC(),
)
if err != nil {
}
emitWebhookEvent(userID, resource, resourceID, action, details)
}
func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) {
userID, _ := c.Get("user_id")
if details == nil {
details = map[string]interface{}{}
}
details["ip_address"] = c.ClientIP()
details["user_agent"] = c.GetHeader("User-Agent")
@@ -69,16 +133,19 @@ func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, de
}
userUUID := parseUUIDOrNil(userIDStr)
resourceUUID := parseUUIDOrNil(resourceID)
severity, category, label := classifyAuditEvent(resource, action)
auditID := uuid.New().String()
_, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, created_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9)`,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), time.Now().UTC(),
`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, $10, $11, $12)`,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), severity, category, label, time.Now().UTC(),
)
if err != nil {
}
emitWebhookEvent(userIDStr, resource, resourceID, action, details)
}
var auditDB *database.DB
@@ -151,6 +218,9 @@ func handleGetAuditLogs(c *gin.Context) {
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
@@ -169,7 +239,7 @@ func handleGetAuditLogs(c *gin.Context) {
var logs []AuditLog
for rows.Next() {
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 {
continue
}
+13
View File
@@ -546,6 +546,19 @@ func generateJWT(userID, email, secret string) (string, error) {
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
func ValidateJWT(tokenString, secret string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
+646
View File
@@ -0,0 +1,646 @@
package api
import (
"archive/tar"
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
"containr/internal/database/sqlcdb"
"containr/internal/docker"
"containr/internal/secrets"
"github.com/docker/docker/api/types/mount"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// BackupTarget is an S3-compatible archive destination. Secret keys are
// stored encrypted and never serialized — has_credentials signals presence.
type BackupTarget struct {
ID string `json:"id"`
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket"`
Region string `json:"region,omitempty"`
Prefix string `json:"prefix,omitempty"`
HasCredentials bool `json:"has_credentials"`
UseTLS bool `json:"use_tls"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
accessKey string `json:"-"`
secretKey string `json:"-"`
}
type backupTargetRequest struct {
Name string `json:"name"`
Endpoint string `json:"endpoint" binding:"required"`
Bucket string `json:"bucket" binding:"required"`
Region string `json:"region"`
Prefix string `json:"prefix"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
UseTLS *bool `json:"use_tls"`
}
func mapBackupTargetRow(row sqlcdb.BackupTarget) BackupTarget {
return BackupTarget{
ID: row.ID,
Name: row.Name,
Endpoint: row.Endpoint,
Bucket: row.Bucket,
Region: row.Region,
Prefix: row.Prefix,
HasCredentials: row.SecretKey != "",
UseTLS: row.UseTls,
CreatedAt: databaseNullTime(row.CreatedAt),
UpdatedAt: databaseNullTime(row.UpdatedAt),
accessKey: secrets.Decrypt(row.AccessKey),
secretKey: secrets.Decrypt(row.SecretKey),
}
}
// s3Client builds a client for the target. Endpoint is a bare host[:port] —
// scheme comes from use_tls, matching MinIO/Garage/AWS conventions.
func (t BackupTarget) s3Client() (*minio.Client, error) {
opts := &minio.Options{Secure: t.UseTLS, Region: t.Region}
if t.accessKey != "" || t.secretKey != "" {
opts.Creds = credentials.NewStaticV4(t.accessKey, t.secretKey, "")
} else {
opts.Creds = credentials.NewIAM("")
}
return minio.New(t.Endpoint, opts)
}
func normalizeBackupTargetRequest(req *backupTargetRequest) error {
req.Name = strings.TrimSpace(req.Name)
req.Endpoint = strings.TrimSpace(req.Endpoint)
req.Endpoint = strings.TrimPrefix(strings.TrimPrefix(req.Endpoint, "https://"), "http://")
req.Endpoint = strings.TrimSuffix(req.Endpoint, "/")
req.Bucket = strings.TrimSpace(req.Bucket)
req.Prefix = strings.Trim(strings.TrimSpace(req.Prefix), "/")
if req.Name == "" {
return errors.New("name is required")
}
if req.Endpoint == "" || req.Bucket == "" {
return errors.New("endpoint and bucket are required")
}
return nil
}
// probeBackupTarget verifies credentials and bucket access.
func probeBackupTarget(ctx context.Context, t BackupTarget) (time.Duration, error) {
client, err := t.s3Client()
if err != nil {
return 0, err
}
start := time.Now()
exists, err := client.BucketExists(ctx, t.Bucket)
if err != nil {
return 0, err
}
if !exists {
return 0, fmt.Errorf("bucket %q not found or not accessible", t.Bucket)
}
return time.Since(start), nil
}
func (h *DatabaseHandler) ListBackupTargets(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
rows, err := h.queries.ListBackupTargetsByUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list backup targets"})
return
}
targets := make([]BackupTarget, 0, len(rows))
for _, row := range rows {
t := mapBackupTargetRow(row)
t.accessKey, t.secretKey = "", ""
targets = append(targets, t)
}
c.JSON(http.StatusOK, gin.H{"backup_targets": targets})
}
func (h *DatabaseHandler) CreateBackupTarget(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
var req backupTargetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
if err := normalizeBackupTargetRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
useTLS := true
if req.UseTLS != nil {
useTLS = *req.UseTLS
}
target := BackupTarget{
ID: "bt_" + strings.ReplaceAll(uuid.NewString(), "-", "")[:12],
Name: req.Name,
Endpoint: req.Endpoint,
Bucket: req.Bucket,
Region: req.Region,
Prefix: req.Prefix,
UseTLS: useTLS,
accessKey: req.AccessKey,
secretKey: req.SecretKey,
}
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
if _, err := probeBackupTarget(probeCtx, target); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Connection check failed: %v", err), "code": "DEPENDENCY_UNAVAILABLE"})
return
}
enc := func(v string) string {
if v == "" {
return ""
}
return secrets.Encrypt(v)
}
now := sql.NullTime{Time: time.Now(), Valid: true}
if err := h.queries.CreateBackupTarget(c.Request.Context(), sqlcdb.CreateBackupTargetParams{
ID: target.ID,
UserID: userID,
Name: target.Name,
Endpoint: target.Endpoint,
Bucket: target.Bucket,
Region: target.Region,
Prefix: target.Prefix,
AccessKey: enc(req.AccessKey),
SecretKey: enc(req.SecretKey),
UseTls: useTLS,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create backup target"})
return
}
LogAudit(userID, "backup_target", target.ID, "create", map[string]interface{}{"name": req.Name, "endpoint": req.Endpoint, "bucket": req.Bucket})
target.accessKey, target.secretKey = "", ""
target.HasCredentials = req.SecretKey != ""
target.CreatedAt, target.UpdatedAt = now.Time, now.Time
c.JSON(http.StatusCreated, gin.H{"backup_target": target})
}
func (h *DatabaseHandler) UpdateBackupTarget(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
targetID := c.Param("id")
row, err := h.queries.GetBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.GetBackupTargetByIDAndUserParams{ID: targetID, UserID: userID})
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup target not found", "code": "NOT_FOUND"})
return
}
var req backupTargetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
if err := normalizeBackupTargetRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
useTLS := row.UseTls
if req.UseTLS != nil {
useTLS = *req.UseTLS
}
// Empty credentials keep the stored pair.
accessKey := row.AccessKey
secretKey := row.SecretKey
if req.AccessKey != "" || req.SecretKey != "" {
accessKey = secrets.Encrypt(req.AccessKey)
secretKey = secrets.Encrypt(req.SecretKey)
}
target := BackupTarget{
Endpoint: req.Endpoint,
Bucket: req.Bucket,
Region: req.Region,
Prefix: req.Prefix,
UseTLS: useTLS,
accessKey: secrets.Decrypt(accessKey),
secretKey: secrets.Decrypt(secretKey),
}
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
if _, err := probeBackupTarget(probeCtx, target); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Connection check failed: %v", err), "code": "DEPENDENCY_UNAVAILABLE"})
return
}
if err := h.queries.UpdateBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.UpdateBackupTargetByIDAndUserParams{
Name: req.Name,
Endpoint: req.Endpoint,
Bucket: req.Bucket,
Region: req.Region,
Prefix: req.Prefix,
AccessKey: accessKey,
SecretKey: secretKey,
UseTls: useTLS,
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: targetID,
UserID: userID,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update backup target"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Backup target updated"})
}
func (h *DatabaseHandler) DeleteBackupTarget(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
targetID := c.Param("id")
count, err := h.queries.CountDatabasesUsingBackupTarget(c.Request.Context(), sql.NullString{String: targetID, Valid: true})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check target usage"})
return
}
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("%d database(s) still back up to this target", count), "code": "CONFLICT"})
return
}
if err := h.queries.DeleteBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.DeleteBackupTargetByIDAndUserParams{ID: targetID, UserID: userID}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete backup target"})
return
}
LogAudit(userID, "backup_target", targetID, "delete", nil)
c.JSON(http.StatusOK, gin.H{"message": "Backup target deleted"})
}
// ImportBackup accepts a `.tar.gz` archive (multipart `file` field or raw
// body) and stores it as a restore point — the counterpart to offsite
// export, and the entry path for migrators.
func (h *DatabaseHandler) ImportBackup(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID, UserID: userID,
})
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
return
}
if row.Provider == "external" {
c.JSON(http.StatusBadRequest, gin.H{"error": "backup import applies to managed databases — external restores are not supported yet", "code": "VALIDATION"})
return
}
// Buffer to a temp file — tar headers need the size up front, and a
// streamed upload can't be rewound for the archive write.
src, _, err := openUploadStream(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
defer src.Close()
tmp, err := os.CreateTemp("", "containr-backup-import-*")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to stage upload"})
return
}
defer func() { tmp.Close(); os.Remove(tmp.Name()) }()
const maxImport = 2 << 30 // 2 GiB ceiling for staged archives
size, err := io.Copy(tmp, http.MaxBytesReader(c.Writer, src, maxImport))
if err != nil {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "archive too large or unreadable", "code": "VALIDATION"})
return
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read staged upload"})
return
}
magic := make([]byte, 2)
if _, err := tmp.Read(magic); err != nil || magic[0] != 0x1f || magic[1] != 0x8b {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive must be gzip-compressed (.tar.gz)", "code": "VALIDATION"})
return
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read staged upload"})
return
}
backupID := generateDatabaseBackupID(databaseID)
archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID))
if _, err := h.dockerClient.CreateVolume(c.Request.Context(), docker.VolumeConfig{Name: managedDatabaseBackupVolume}); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already exists") {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to prepare backup volume"})
return
}
holderID, err := h.createBackupVolumeHolder(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to open backup volume: %v", err)})
return
}
defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) }()
pr, pw := io.Pipe()
tw := tar.NewWriter(pw)
go func() {
err := tw.WriteHeader(&tar.Header{Name: archivePath, Mode: 0o644, Size: size})
if err == nil {
_, err = io.Copy(tw, tmp)
}
if cerr := tw.Close(); err == nil {
err = cerr
}
_ = pw.CloseWithError(err)
}()
if err := h.dockerClient.CopyToContainer(c.Request.Context(), holderID, "/backup", pr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to store archive: %v", err)})
return
}
now := sql.NullTime{Time: time.Now(), Valid: true}
if err := h.queries.CreateDatabaseBackup(c.Request.Context(), sqlcdb.CreateDatabaseBackupParams{
ID: backupID,
DatabaseID: databaseID,
Size: humanReadableBytes(size),
Status: "completed",
BackupPath: sql.NullString{String: archivePath, Valid: true},
CreatedAt: now,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register backup"})
return
}
_ = h.queries.SetDatabaseBackupStatusByID(c.Request.Context(), sqlcdb.SetDatabaseBackupStatusByIDParams{
Status: "completed",
Size: humanReadableBytes(size),
CompletedAt: now,
ID: backupID,
})
// Imported archives ship offsite too when a target is configured —
// the import should be a first-class restore point.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
if key, err := h.uploadBackupToTarget(ctx, row, archivePath, backupID); err != nil {
log.Printf("containr: imported backup %s remote upload failed: %v (local archive kept)", backupID, err)
} else if key != "" {
log.Printf("containr: imported backup %s shipped to %s", backupID, key)
}
}()
LogAudit(userID, "database", databaseID, "backup_import", map[string]interface{}{"backup_id": backupID, "size": size})
c.JSON(http.StatusCreated, gin.H{"backup_id": backupID, "size": humanReadableBytes(size), "status": "completed"})
}
// openUploadStream resolves the request body: multipart `file` field when
// present, otherwise the raw body (e.g. CLI pipe of a .tar.gz).
func openUploadStream(c *gin.Context) (io.ReadCloser, string, error) {
if fh, err := c.FormFile("file"); err == nil {
src, err := fh.Open()
return src, fh.Filename, err
}
if c.Request.Body == nil || c.Request.ContentLength == 0 {
return nil, "", errors.New("no archive uploaded — send multipart field 'file' or a raw .tar.gz body")
}
return c.Request.Body, "archive.tar.gz", nil
}
func (h *DatabaseHandler) TestBackupTarget(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
targetID := c.Param("id")
row, err := h.queries.GetBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.GetBackupTargetByIDAndUserParams{ID: targetID, UserID: userID})
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup target not found", "code": "NOT_FOUND"})
return
}
target := mapBackupTargetRow(row)
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
latency, probeErr := probeBackupTarget(probeCtx, target)
if probeErr != nil {
c.JSON(http.StatusOK, gin.H{"ok": false, "error": probeErr.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
}
// uploadBackupToTarget copies the archive out of the backup volume and puts
// it in the configured S3 target. Returns the object key, or "" when no
// target is configured.
func (h *DatabaseHandler) uploadBackupToTarget(ctx context.Context, db sqlcdb.DatabaseService, archivePath, backupID string) (string, error) {
if !db.BackupTargetID.Valid || db.BackupTargetID.String == "" {
return "", nil
}
targetRow, err := h.queries.GetBackupTargetByID(ctx, db.BackupTargetID.String)
if err != nil {
return "", fmt.Errorf("backup target lookup failed: %w", err)
}
target := mapBackupTargetRow(targetRow)
archive, err := h.readBackupArchive(ctx, archivePath)
if err != nil {
return "", fmt.Errorf("read archive: %w", err)
}
defer func() {
_, _ = io.Copy(io.Discard, archive)
_ = archive.Close()
}()
client, err := target.s3Client()
if err != nil {
return "", err
}
key := target.Prefix
if key != "" {
key += "/"
}
key += "databases/" + db.ID + "/" + sanitizeBackupArchivePath(archivePath)
_, err = client.PutObject(ctx, target.Bucket, key, archive, -1, minio.PutObjectOptions{ContentType: "application/gzip"})
if err != nil {
return "", fmt.Errorf("s3 upload: %w", err)
}
_ = h.queries.SetDatabaseBackupRemoteKeyByID(ctx, sqlcdb.SetDatabaseBackupRemoteKeyByIDParams{
RemoteKey: sql.NullString{String: key, Valid: true},
ID: backupID,
})
return key, nil
}
// readBackupArchive streams an archive out of the shared backup volume via a
// stopped holder container — the volume has no direct host path.
func (h *DatabaseHandler) readBackupArchive(ctx context.Context, archivePath string) (io.ReadCloser, error) {
holderID, err := h.createBackupVolumeHolder(ctx)
if err != nil {
return nil, err
}
reader, err := h.dockerClient.CopyFromContainer(ctx, holderID, "/backup/"+sanitizeBackupArchivePath(archivePath))
if err != nil {
_ = h.dockerClient.RemoveContainer(context.Background(), holderID, true)
return nil, err
}
// The holder must outlive the stream — deleting the source container while
// dockerd serves the archive stalls the read without an error.
return &archiveReader{tar: reader, cleanup: func() {
_ = h.dockerClient.RemoveContainer(context.Background(), holderID, true)
}}, nil
}
// downloadBackupToVolume fetches a remote archive back into the backup volume
// so the normal restore path can run unchanged.
func (h *DatabaseHandler) downloadBackupToVolume(ctx context.Context, db sqlcdb.DatabaseService, remoteKey, archivePath string) error {
targetRow, err := h.queries.GetBackupTargetByID(ctx, db.BackupTargetID.String)
if err != nil {
return fmt.Errorf("backup target lookup failed: %w", err)
}
target := mapBackupTargetRow(targetRow)
client, err := target.s3Client()
if err != nil {
return err
}
obj, err := client.GetObject(ctx, target.Bucket, remoteKey, minio.GetObjectOptions{})
if err != nil {
return err
}
defer obj.Close()
archive := sanitizeBackupArchivePath(archivePath)
if archive == "" {
return errors.New("backup path is required")
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
data, err := io.ReadAll(obj)
if err != nil {
return fmt.Errorf("s3 download: %w", err)
}
if err := tw.WriteHeader(&tar.Header{Name: archive, Mode: 0o644, Size: int64(len(data))}); err != nil {
return err
}
if _, err := tw.Write(data); err != nil {
return err
}
if err := tw.Close(); err != nil {
return err
}
holderID, err := h.createBackupVolumeHolder(ctx)
if err != nil {
return err
}
defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) }()
return h.dockerClient.CopyToContainer(ctx, holderID, "/backup", &buf)
}
// backupArchiveExists reports whether the archive is still in the local
// backup volume — a copy attempt on a holder container is the cheapest check.
func (h *DatabaseHandler) backupArchiveExists(ctx context.Context, archivePath string) (bool, error) {
holderID, err := h.createBackupVolumeHolder(ctx)
if err != nil {
return false, err
}
defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), holderID, true) }()
reader, err := h.dockerClient.CopyFromContainer(ctx, holderID, "/backup/"+sanitizeBackupArchivePath(archivePath))
if err != nil {
return false, nil
}
_ = reader.Close()
return true, nil
}
// createBackupVolumeHolder creates a container mounting the backup volume so
// archives can be copied in and out. It must be started — docker cp/archive
// only resolves volume mounts on containers that have run at least once.
func (h *DatabaseHandler) createBackupVolumeHolder(ctx context.Context) (string, error) {
if err := h.ensureImage(ctx, managedDatabaseBackupImage); err != nil {
return "", err
}
holderID, err := h.dockerClient.CreateContainer(ctx, docker.ContainerConfig{
Image: managedDatabaseBackupImage,
Cmd: []string{"true"},
RestartPolicy: "no",
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: managedDatabaseBackupVolume, Target: "/backup"}},
Labels: map[string]string{"containr.managed": "true", "containr.utility": "backup-io"},
NetworkMode: "none",
})
if err != nil {
return "", err
}
if err := h.dockerClient.StartContainer(ctx, holderID); err != nil {
_ = h.dockerClient.RemoveContainer(context.Background(), holderID, true)
return "", fmt.Errorf("failed to start backup holder: %w", err)
}
return holderID, nil
}
// archiveReader unwraps the tar stream CopyFromContainer returns.
type archiveReader struct {
tar io.ReadCloser
tr *tar.Reader
err error
cleanup func()
}
func (r *archiveReader) Read(p []byte) (int, error) {
if r.err != nil {
return 0, r.err
}
if r.tr == nil {
r.tr = tar.NewReader(r.tar)
// Skip to the first regular file entry.
for {
hdr, err := r.tr.Next()
if err != nil {
r.err = err
return 0, err
}
if hdr.Typeflag == tar.TypeReg {
break
}
}
}
n, err := r.tr.Read(p)
if err != nil {
r.err = err
}
return n, err
}
func (r *archiveReader) Close() error {
err := r.tar.Close()
if r.cleanup != nil {
r.cleanup()
r.cleanup = nil
}
return err
}
@@ -0,0 +1,60 @@
package api
import (
"archive/tar"
"bytes"
"io"
"testing"
)
func TestNormalizeBackupTargetRequest(t *testing.T) {
req := backupTargetRequest{
Name: " garage ",
Endpoint: "https://garage.local:3900/",
Bucket: " backups ",
Prefix: " /prod/dbs/ ",
}
if err := normalizeBackupTargetRequest(&req); err != nil {
t.Fatalf("normalize failed: %v", err)
}
if req.Endpoint != "garage.local:3900" {
t.Fatalf("endpoint not normalized: %q", req.Endpoint)
}
if req.Bucket != "backups" || req.Prefix != "prod/dbs" {
t.Fatalf("bucket/prefix not trimmed: %q %q", req.Bucket, req.Prefix)
}
for i, bad := range []backupTargetRequest{
{Endpoint: "e", Bucket: "b"}, // no name
{Name: "n", Bucket: "b"}, // no endpoint
{Name: "n", Endpoint: "e"}, // no bucket
} {
if err := normalizeBackupTargetRequest(&bad); err == nil {
t.Fatalf("case %d: expected error", i)
}
}
}
func TestArchiveReaderUnwrapsTar(t *testing.T) {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
payload := []byte("backup-bytes")
if err := tw.WriteHeader(&tar.Header{Name: "b.tar.gz", Mode: 0o644, Size: int64(len(payload))}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(payload); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
r := &archiveReader{tar: io.NopCloser(&buf)}
got, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read: %v", err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("payload mismatch: %q", got)
}
}
+225
View File
@@ -0,0 +1,225 @@
package api
import (
"database/sql"
"net/http"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Instance-wide announcement banners. Admins manage them under
// /admin/banners; every signed-in user reads the active set at
// /banners/active so the UI (and CLI) can surface announcements.
var bannerLevels = map[string]bool{"info": true, "success": true, "warning": true, "error": true}
// bannerJSON flattens sqlc nullable wrappers into plain API values.
type bannerJSON struct {
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"`
Active bool `json:"active"`
Dismissible bool `json:"dismissible"`
StartsAt *time.Time `json:"starts_at,omitempty"`
EndsAt *time.Time `json:"ends_at,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
}
func toBannerJSON(b sqlcdb.Banner) bannerJSON {
out := bannerJSON{
ID: b.ID.String(),
Title: b.Title,
Body: b.Body,
Level: b.Level,
Active: b.Active,
Dismissible: b.Dismissible,
}
if b.StartsAt.Valid {
out.StartsAt = &b.StartsAt.Time
}
if b.EndsAt.Valid {
out.EndsAt = &b.EndsAt.Time
}
if b.CreatedAt.Valid {
out.CreatedAt = &b.CreatedAt.Time
}
return out
}
func toBannerList(banners []sqlcdb.Banner) []bannerJSON {
out := make([]bannerJSON, 0, len(banners))
for _, b := range banners {
out = append(out, toBannerJSON(b))
}
return out
}
type bannerRequest struct {
Title *string `json:"title"`
Body *string `json:"body"`
Level *string `json:"level"`
Active *bool `json:"active"`
Dismissible *bool `json:"dismissible"`
StartsAt *time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at"`
}
func bannerQueries(c *gin.Context) *sqlcdb.Queries {
db := c.MustGet("db").(*database.DB)
return sqlcdb.New(db.DB)
}
func parseBannerID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid banner id")
return uuid.Nil, false
}
return id, true
}
func validBannerLevel(level string) bool { return bannerLevels[level] }
func handleListActiveBanners(c *gin.Context) {
banners, err := bannerQueries(c).ListActiveBanners(c.Request.Context())
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list banners")
return
}
c.JSON(http.StatusOK, gin.H{"banners": toBannerList(banners)})
}
func handleAdminListBanners(c *gin.Context) {
banners, err := bannerQueries(c).ListBanners(c.Request.Context())
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list banners")
return
}
c.JSON(http.StatusOK, gin.H{"banners": toBannerList(banners)})
}
func handleAdminCreateBanner(c *gin.Context) {
var req bannerRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "invalid request body")
return
}
if req.Title == nil || *req.Title == "" {
respondError(c, http.StatusBadRequest, "VALIDATION", "title is required")
return
}
level := "info"
if req.Level != nil {
if !validBannerLevel(*req.Level) {
respondError(c, http.StatusBadRequest, "VALIDATION", "level must be info, success, warning or error")
return
}
level = *req.Level
}
userID, _ := requireAuthenticatedUserUUID(c)
params := sqlcdb.CreateBannerParams{
Title: *req.Title,
Level: level,
Active: true,
Dismissible: true,
CreatedBy: uuid.NullUUID{UUID: userID, Valid: true},
}
if req.Body != nil {
params.Body = *req.Body
}
if req.Active != nil {
params.Active = *req.Active
}
if req.Dismissible != nil {
params.Dismissible = *req.Dismissible
}
if req.StartsAt != nil {
params.StartsAt = sql.NullTime{Time: *req.StartsAt, Valid: true}
}
if req.EndsAt != nil {
params.EndsAt = sql.NullTime{Time: *req.EndsAt, Valid: true}
}
banner, err := bannerQueries(c).CreateBanner(c.Request.Context(), params)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to create banner")
return
}
c.JSON(http.StatusCreated, toBannerJSON(banner))
}
func handleAdminUpdateBanner(c *gin.Context) {
id, ok := parseBannerID(c)
if !ok {
return
}
q := bannerQueries(c)
banner, err := q.GetBannerByID(c.Request.Context(), id)
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "banner not found")
return
}
var req bannerRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "invalid request body")
return
}
params := sqlcdb.UpdateBannerParams{
ID: id,
Title: banner.Title,
Body: banner.Body,
Level: banner.Level,
Active: banner.Active,
Dismissible: banner.Dismissible,
StartsAt: banner.StartsAt,
EndsAt: banner.EndsAt,
}
if req.Title != nil {
params.Title = *req.Title
}
if req.Body != nil {
params.Body = *req.Body
}
if req.Level != nil {
if !validBannerLevel(*req.Level) {
respondError(c, http.StatusBadRequest, "VALIDATION", "level must be info, success, warning or error")
return
}
params.Level = *req.Level
}
if req.Active != nil {
params.Active = *req.Active
}
if req.Dismissible != nil {
params.Dismissible = *req.Dismissible
}
if req.StartsAt != nil {
params.StartsAt = sql.NullTime{Time: *req.StartsAt, Valid: true}
}
if req.EndsAt != nil {
params.EndsAt = sql.NullTime{Time: *req.EndsAt, Valid: true}
}
updated, err := q.UpdateBanner(c.Request.Context(), params)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to update banner")
return
}
c.JSON(http.StatusOK, toBannerJSON(updated))
}
func handleAdminDeleteBanner(c *gin.Context) {
id, ok := parseBannerID(c)
if !ok {
return
}
if err := bannerQueries(c).DeleteBanner(c.Request.Context(), id); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to delete banner")
return
}
c.Status(http.StatusNoContent)
}
+245 -43
View File
@@ -2,8 +2,11 @@ package api
import (
"archive/tar"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/docker"
"containr/internal/secrets"
"context"
"crypto/rand"
"database/sql"
@@ -21,6 +24,8 @@ import (
"strings"
"time"
"github.com/google/uuid"
dockercontainer "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
@@ -51,12 +56,32 @@ type DatabaseService struct {
Region string `json:"region" db:"region"`
BackupSchedule string `json:"backup_schedule,omitempty"`
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
Provider string `json:"provider"` // managed | external
PublicPort bool `json:"public_port" db:"public_port"`
BackupTargetID string `json:"backup_target_id,omitempty"` // S3-compatible archive destination
External *DatabaseExternalRef `json:"external,omitempty"` // connection target for external DBs
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
ConnectionURL string `json:"connection_url"`
Metrics DatabaseMetrics `json:"metrics"`
Backups DatabaseBackupConfig `json:"backups"`
Settings DatabaseSettings `json:"settings"`
// externalPasswordEnc is the stored ciphertext — needed to compose the
// connection URL on privileged reads, never serialized.
externalPasswordEnc string `json:"-"`
}
// DatabaseExternalRef describes a registered external database's connection
// target. The password is stored encrypted and never returned — has_password
// signals presence.
type DatabaseExternalRef struct {
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
HasPassword bool `json:"has_password"`
SSL bool `json:"ssl"`
}
// DatabaseMetrics represents database performance metrics
@@ -86,6 +111,7 @@ type DatabaseBackup struct {
CreatedAt time.Time `json:"created_at"`
Size string `json:"size"`
Status string `json:"status"` // completed, failed, in_progress
Remote bool `json:"remote"` // archive also exists on the configured backup target
}
// DatabaseSettings represents database configuration
@@ -98,10 +124,11 @@ type DatabaseSettings struct {
// DatabaseCreateRequest represents a request to create a new database
type DatabaseCreateRequest struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
Plan string `json:"plan" binding:"required,oneof=hobby starter standard business"`
Region string `json:"region" binding:"required"`
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
Plan string `json:"plan" binding:"required,oneof=hobby starter standard business"`
Region string `json:"region" binding:"required"`
PublicPort bool `json:"public_port"`
}
// DatabaseUpdateRequest represents a request to update a database
@@ -109,6 +136,8 @@ type DatabaseUpdateRequest struct {
Name string `json:"name,omitempty"`
Plan string `json:"plan,omitempty"`
BackupSchedule *string `json:"backup_schedule,omitempty"`
PublicPort *bool `json:"public_port,omitempty"`
BackupTargetID *string `json:"backup_target_id,omitempty"` // empty string clears
}
// DatabaseActionRequest represents a request to perform database actions
@@ -272,10 +301,11 @@ func (h *DatabaseHandler) CreateDatabase(c *gin.Context) {
}
databaseID, dbName, dbType, err := h.createManagedDatabase(c.Request.Context(), userID, managedDatabaseCreateRequest{
Name: req.Name,
Type: req.Type,
Plan: req.Plan,
Region: req.Region,
Name: req.Name,
Type: req.Type,
Plan: req.Plan,
Region: req.Region,
PublicPort: req.PublicPort,
})
if err != nil {
switch {
@@ -303,7 +333,13 @@ type managedDatabaseCreateRequest struct {
Type string
Plan string
Region string
PublicPort bool
RuntimeVariables map[string]string
// ProjectID + NetworkAlias attach the container to a project network
// under Alias — set by graph-template deploys so sibling services can
// resolve the database by name.
ProjectID string
NetworkAlias string
}
func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID string, req managedDatabaseCreateRequest) (databaseID string, name string, dbType string, err error) {
@@ -345,17 +381,26 @@ func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID stri
now := time.Now()
version := h.getDefaultVersion(dbType)
var projectID uuid.NullUUID
if req.ProjectID != "" {
if parsed, parseErr := uuid.Parse(req.ProjectID); parseErr == nil {
projectID = uuid.NullUUID{UUID: parsed, Valid: true}
}
}
err = h.queries.CreateDatabaseService(ctx, sqlcdb.CreateDatabaseServiceParams{
ID: databaseID,
UserID: userID,
Name: name,
Type: dbType,
Status: "building",
Version: version,
Plan: plan,
Region: region,
CreatedAt: sql.NullTime{Time: now, Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
ID: databaseID,
UserID: userID,
Name: name,
Type: dbType,
Status: "building",
Version: version,
Plan: plan,
Region: region,
PublicPort: req.PublicPort,
ProjectID: projectID,
CreatedAt: sql.NullTime{Time: now, Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
})
if err != nil {
return "", "", "", fmt.Errorf("failed to create database service: %w", err)
@@ -370,10 +415,17 @@ func (h *DatabaseHandler) createManagedDatabaseAndProvision(ctx context.Context,
return "", err
}
go h.provisionDatabaseWithVariables(databaseID, name, dbType, req.RuntimeVariables)
go h.provisionDatabaseWithVariables(databaseID, name, dbType, req.RuntimeVariables, dbNetworkAttach{ProjectID: req.ProjectID, Alias: req.NetworkAlias})
return databaseID, nil
}
// dbNetworkAttach carries the optional project-network membership for a
// managed database provisioned by a graph template.
type dbNetworkAttach struct {
ProjectID string
Alias string
}
// UpdateDatabase updates a database service
func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
@@ -392,11 +444,64 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
req.Name = strings.TrimSpace(req.Name)
req.Plan = strings.TrimSpace(req.Plan)
if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil {
if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil && req.PublicPort == nil && req.BackupTargetID == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
return
}
if req.PublicPort != nil || req.BackupTargetID != nil {
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID,
UserID: userID,
})
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
return
}
if req.PublicPort != nil {
if row.Provider == "external" {
c.JSON(http.StatusBadRequest, gin.H{"error": "public_port applies to managed databases — external databases are already reachable", "code": "VALIDATION"})
return
}
if err := h.queries.SetDatabaseServicePublicPortByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseServicePublicPortByIDAndUserParams{
PublicPort: *req.PublicPort,
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: databaseID,
UserID: userID,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update public port"})
return
}
// Port bindings are baked into the container — recreate it so the
// new bind scope takes effect. The data volume survives.
if *req.PublicPort != row.PublicPort {
go h.reprovisionManagedDatabase(databaseID, row.Name, row.Type)
}
}
if req.BackupTargetID != nil {
if row.Provider == "external" {
c.JSON(http.StatusBadRequest, gin.H{"error": "backup targets apply to managed databases — external backups are not supported yet", "code": "VALIDATION"})
return
}
targetID := strings.TrimSpace(*req.BackupTargetID)
if targetID != "" {
if _, err := h.queries.GetBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.GetBackupTargetByIDAndUserParams{ID: targetID, UserID: userID}); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup target not found", "code": "NOT_FOUND"})
return
}
}
if err := h.queries.SetDatabaseBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseBackupTargetByIDAndUserParams{
BackupTargetID: sql.NullString{String: targetID, Valid: targetID != ""},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
ID: databaseID,
UserID: userID,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update backup target"})
return
}
}
}
if req.BackupSchedule != nil {
var schedule sql.NullString
var nextBackup sql.NullTime
@@ -467,21 +572,20 @@ func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) {
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID,
UserID: userID,
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
if h.dockerClient != nil {
if row.Provider != "external" && h.dockerClient != nil {
if err := h.deleteManagedDatabaseRuntime(databaseID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete runtime database"})
return
@@ -515,17 +619,24 @@ func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) {
return
}
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID,
UserID: userID,
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
if row.Provider == "external" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Lifecycle actions apply to managed databases only — external databases are run elsewhere",
"code": "VALIDATION",
})
return
}
@@ -619,6 +730,13 @@ func (h *DatabaseHandler) CreateBackup(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{"error": "Database is still provisioning"})
return
}
if row.Provider == "external" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Backups run against the managed dump pipeline — external database backups are not supported yet",
"code": "VALIDATION",
})
return
}
backupID := generateDatabaseBackupID(databaseID)
archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID))
@@ -659,17 +777,23 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
return
}
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
dbRow, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID,
UserID: userID,
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
if dbRow.Provider == "external" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Restores run against the managed dump pipeline — external database restores are not supported yet",
"code": "VALIDATION",
})
return
}
@@ -787,7 +911,7 @@ func (h *DatabaseHandler) DownloadBackup(c *gin.Context) {
}
func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics {
if h.dockerClient == nil {
if h.dockerClient == nil || db.Provider == "external" {
return h.generateMockMetrics()
}
@@ -918,6 +1042,7 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string
CreatedAt: createdAt,
Size: row.Size,
Status: row.Status,
Remote: row.RemoteKey.Valid && row.RemoteKey.String != "",
})
if lastBackup == nil && row.Status == "completed" {
t := createdAt
@@ -945,7 +1070,11 @@ func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
// resolveConnectionURL returns the stored connection URL for privileged
// viewers, or "" when the runtime has not produced one (not provisioned yet).
// External databases compose it from the registered fields on demand.
func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string {
if db.Provider == "external" && db.External != nil {
return externalConnectionURL(db.Type, db.External, secrets.Decrypt(db.externalPasswordEnc))
}
return strings.TrimSpace(db.ConnectionURL)
}
@@ -987,7 +1116,7 @@ func rewriteLoopbackURL(rawURL, host string) string {
}
func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService {
if h.dockerClient == nil {
if h.dockerClient == nil || db.Provider == "external" {
return db
}
@@ -1054,7 +1183,7 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
}
func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
return DatabaseService{
svc := DatabaseService{
ID: row.ID,
OwnerID: row.UserID,
Name: row.Name,
@@ -1065,10 +1194,25 @@ func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
Region: row.Region,
BackupSchedule: databaseNullString(row.BackupSchedule),
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
Provider: row.Provider,
PublicPort: row.PublicPort,
BackupTargetID: databaseNullString(row.BackupTargetID),
ConnectionURL: databaseNullString(row.ConnectionUrl),
CreatedAt: databaseNullTime(row.CreatedAt),
UpdatedAt: databaseNullTime(row.UpdatedAt),
}
if svc.Provider == "external" {
svc.External = &DatabaseExternalRef{
Host: databaseNullString(row.ExternalHost),
Port: int(row.ExternalPort.Int32),
Database: databaseNullString(row.ExternalName),
Username: databaseNullString(row.ExternalUsername),
HasPassword: row.ExternalPassword != "",
SSL: row.ExternalSsl,
}
svc.externalPasswordEnc = row.ExternalPassword
}
return svc
}
func databaseNullTime(value sql.NullTime) time.Time {
@@ -1230,10 +1374,21 @@ type databaseRuntimePlan struct {
}
func (h *DatabaseHandler) provisionDatabase(databaseID, databaseName, dbType string) {
h.provisionDatabaseWithVariables(databaseID, databaseName, dbType, nil)
h.provisionDatabaseWithVariables(databaseID, databaseName, dbType, nil, dbNetworkAttach{})
}
func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string) {
// reprovisionManagedDatabase recreates a managed container so changed
// runtime settings (public_port) take effect. The data volume persists.
func (h *DatabaseHandler) reprovisionManagedDatabase(databaseID, databaseName, dbType string) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if h.dockerClient != nil {
_ = h.dockerClient.RemoveContainer(ctx, managedDatabaseContainerName(databaseID), true)
}
h.provisionDatabase(databaseID, databaseName, dbType)
}
func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string, attach dbNetworkAttach) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
@@ -1242,7 +1397,7 @@ func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseNam
return
}
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables)
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables, attach)
if err != nil {
log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err)
_ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{})
@@ -1264,7 +1419,7 @@ func (h *DatabaseHandler) setDatabaseStatusAndConnection(databaseID, status stri
})
}
func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, databaseID, databaseName, dbType string, runtimeVariables map[string]string) (string, error) {
func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, databaseID, databaseName, dbType string, runtimeVariables map[string]string, attach dbNetworkAttach) (string, error) {
plan, err := buildDatabaseRuntimePlan(dbType, databaseName, runtimeVariables)
if err != nil {
return "", err
@@ -1289,8 +1444,12 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
}
}
hostIP := "127.0.0.1"
if row, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil && row.PublicPort {
hostIP = "0.0.0.0"
}
portBindings := nat.PortMap{
plan.Port: []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: ""}},
plan.Port: []nat.PortBinding{{HostIP: hostIP, HostPort: ""}},
}
exposedPorts := nat.PortSet{plan.Port: struct{}{}}
@@ -1348,6 +1507,17 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
return "", fmt.Errorf("failed to start container: %w", err)
}
// Graph-template deploys attach the database to the project network so
// sibling services resolve it by alias ({{service.<key>.host}}).
if attach.ProjectID != "" && attach.Alias != "" {
projectNet := deployment.ProjectNetworkName(attach.ProjectID)
if err := h.dockerClient.ConnectNetwork(ctx, projectNet, containerID, network.EndpointSettings{
Aliases: []string{attach.Alias},
}); err != nil {
log.Printf("containr: database %s attach to %s failed: %v", databaseID, projectNet, err)
}
}
hostPort, err := h.resolvePublishedHostPort(ctx, containerID, plan.Port)
if err != nil {
return "", err
@@ -1699,20 +1869,36 @@ func (h *DatabaseHandler) createBackupProcess(databaseID, backupID, backupPath s
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
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 {
_ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false)
notify("Database backup failed", "Backup could not start: docker unavailable.")
return
}
sizeLabel, err := h.snapshotDatabaseVolume(ctx, databaseID, backupPath)
if err != nil {
_ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false)
notify("Database backup failed", fmt.Sprintf("Snapshot failed: %v", err))
return
}
if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil {
if key, err := h.uploadBackupToTarget(ctx, dbRow, backupPath, backupID); err != nil {
log.Printf("containr: backup %s remote upload failed: %v (local archive kept)", backupID, err)
} else if key != "" {
log.Printf("containr: backup %s shipped to %s", backupID, key)
}
}
_ = h.setDatabaseBackupStatus(backupID, "completed", sizeLabel, true)
notify("Database backup completed", fmt.Sprintf("Backup archived (%s).", sizeLabel))
}
func (h *DatabaseHandler) restoreBackupProcess(databaseID, _ string, backupPath string) {
func (h *DatabaseHandler) restoreBackupProcess(databaseID, backupID string, backupPath string) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
@@ -1721,6 +1907,22 @@ func (h *DatabaseHandler) restoreBackupProcess(databaseID, _ string, backupPath
return
}
// Restore the archive from the configured S3 target when the local copy
// is gone (fresh node, pruned volume) — local takes precedence.
if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil && dbRow.BackupTargetID.Valid && dbRow.BackupTargetID.String != "" {
if ok, _ := h.backupArchiveExists(ctx, backupPath); !ok {
if b, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(ctx, sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{
ID: backupID, DatabaseID: databaseID, UserID: dbRow.UserID,
}); err == nil && b.RemoteKey.Valid && b.RemoteKey.String != "" {
if err := h.downloadBackupToVolume(ctx, dbRow, b.RemoteKey.String, backupPath); err != nil {
log.Printf("containr: remote restore fetch failed for backup %s: %v", backupID, err)
_ = h.setDatabaseStatus(databaseID, "error")
return
}
}
}
}
_ = h.setDatabaseStatus(databaseID, "building")
_ = h.stopManagedDatabase(databaseID)
+319
View File
@@ -0,0 +1,319 @@
package api
import (
"context"
"crypto/tls"
"database/sql"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"containr/internal/database/sqlcdb"
"containr/internal/secrets"
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
)
// externalDatabaseRequest is the shared payload for registering an external
// database and for ad-hoc connection tests.
type externalDatabaseRequest struct {
Name string `json:"name"`
Type string `json:"type" binding:"required"`
Host string `json:"host" binding:"required"`
Port int `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password"`
SSL bool `json:"ssl"`
}
var externalDefaultPorts = map[string]int{
"postgresql": 5432,
"mysql": 3306,
"mariadb": 3306,
"mongodb": 27017,
"redis": 6379,
"dragonfly": 6379,
"clickhouse": 9000,
}
// externalConnectionURL composes a DSN for display/bind use. The password is
// embedded only on privileged reads — the value is decrypted at that point.
func externalConnectionURL(dbType string, ext *DatabaseExternalRef, password string) string {
host := net.JoinHostPort(ext.Host, fmt.Sprintf("%d", ext.Port))
creds := ""
if ext.Username != "" {
creds = url.QueryEscape(ext.Username)
if password != "" {
creds += ":" + url.QueryEscape(password)
}
creds += "@"
}
switch dbType {
case "postgresql":
u := fmt.Sprintf("postgres://%s%s/%s", creds, host, ext.Database)
if ext.SSL {
u += "?sslmode=require"
}
return u
case "mysql", "mariadb":
return fmt.Sprintf("mysql://%s%s/%s", creds, host, ext.Database)
case "mongodb":
u := fmt.Sprintf("mongodb://%s%s/%s", creds, host, ext.Database)
if ext.SSL {
u += "?tls=true"
}
return u
case "redis", "dragonfly":
scheme := "redis"
if ext.SSL {
scheme = "rediss"
}
// Redis auth is password-only; username support is ACL-era.
if password != "" {
creds = ":" + url.QueryEscape(password) + "@"
if ext.Username != "" {
creds = url.QueryEscape(ext.Username) + creds
}
}
return fmt.Sprintf("%s://%s%s", scheme, creds, host)
case "clickhouse":
return fmt.Sprintf("clickhouse://%s%s/%s", creds, host, ext.Database)
}
return fmt.Sprintf("%s://%s%s", dbType, creds, host)
}
// probeExternalDatabase verifies reachability. postgres and redis-family get
// real protocol checks via the vendored drivers; everything else gets a TCP
// dial — enough to catch typos and firewall issues without new dependencies.
func probeExternalDatabase(ctx context.Context, req externalDatabaseRequest) (time.Duration, error) {
addr := net.JoinHostPort(req.Host, fmt.Sprintf("%d", req.Port))
dialer := &net.Dialer{Timeout: 5 * time.Second}
start := time.Now()
switch req.Type {
case "postgresql":
sslmode := "disable"
if req.SSL {
sslmode = "require"
}
dsn := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s&connect_timeout=5",
url.QueryEscape(req.Username), url.QueryEscape(req.Password), addr,
url.QueryEscape(req.Database), sslmode)
conn, err := sql.Open("postgres", dsn)
if err != nil {
return 0, err
}
defer conn.Close()
if err := conn.PingContext(ctx); err != nil {
return 0, err
}
case "redis", "dragonfly":
client := redis.NewClient(&redis.Options{
Addr: addr,
Username: req.Username,
Password: req.Password,
DialTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
TLSConfig: redisTLSConfig(req.SSL, req.Host),
})
defer client.Close()
if err := client.Ping(ctx).Err(); err != nil {
return 0, err
}
default:
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return 0, err
}
conn.Close()
}
return time.Since(start), nil
}
func redisTLSConfig(enabled bool, host string) *tls.Config {
if !enabled {
return nil
}
return &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
}
// handleTestDatabaseConnection probes arbitrary connection parameters — used
// by the register form's "test" button and by agents before registering.
func (h *DatabaseHandler) TestDatabaseConnection(c *gin.Context) {
if _, ok := requireAuthenticatedUserID(c); !ok {
return
}
var req externalDatabaseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
if err := normalizeExternalRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
latency, err := probeExternalDatabase(ctx, req)
if err != nil {
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
}
// TestDatabaseConnectionByID re-probes a registered external database using
// its stored (decrypted) credentials.
func (h *DatabaseHandler) TestDatabaseConnectionByID(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID,
UserID: userID,
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"})
return
}
if row.Provider != "external" {
c.JSON(http.StatusBadRequest, gin.H{"error": "test-connection applies to external databases", "code": "VALIDATION"})
return
}
req := externalDatabaseRequest{
Type: row.Type,
Host: databaseNullString(row.ExternalHost),
Port: int(row.ExternalPort.Int32),
Database: databaseNullString(row.ExternalName),
Username: databaseNullString(row.ExternalUsername),
Password: secrets.Decrypt(row.ExternalPassword),
SSL: row.ExternalSsl,
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
latency, probeErr := probeExternalDatabase(ctx, req)
if probeErr != nil {
c.JSON(http.StatusOK, gin.H{"ok": false, "error": probeErr.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
}
func normalizeExternalRequest(req *externalDatabaseRequest) error {
req.Type = normalizeDatabaseType(req.Type)
if _, ok := supportedDatabaseTypes[req.Type]; !ok {
return fmt.Errorf("%w: %s", errUnsupportedDatabaseType, req.Type)
}
req.Host = strings.TrimSpace(req.Host)
if req.Host == "" {
return errors.New("host is required")
}
if req.Port <= 0 {
req.Port = externalDefaultPorts[req.Type]
}
if req.Port <= 0 || req.Port > 65535 {
return errors.New("port must be 1-65535")
}
return nil
}
// RegisterExternalDatabase records a database hosted elsewhere — same list,
// same bind surface, no container. The connection is probed first so typos
// fail at registration, not at the first service bind.
func (h *DatabaseHandler) RegisterExternalDatabase(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
var req externalDatabaseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"})
return
}
if err := normalizeExternalRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
if _, err := probeExternalDatabase(probeCtx, req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Connection check failed: %v", err),
"code": "DEPENDENCY_UNAVAILABLE",
})
return
}
count, err := h.queries.CountDatabaseServicesByUserAndName(c.Request.Context(), sqlcdb.CountDatabaseServicesByUserAndNameParams{
UserID: userID,
Name: req.Name,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate database name"})
return
}
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "database name already exists", "code": "CONFLICT"})
return
}
databaseID := generateDatabaseID(req.Name)
now := sql.NullTime{Time: time.Now(), Valid: true}
encPassword := ""
if req.Password != "" {
encPassword = secrets.Encrypt(req.Password)
}
if err := h.queries.CreateExternalDatabaseService(c.Request.Context(), sqlcdb.CreateExternalDatabaseServiceParams{
ID: databaseID,
UserID: userID,
Name: req.Name,
Type: req.Type,
Version: "",
Plan: "external",
Region: "external",
ExternalHost: sql.NullString{String: req.Host, Valid: true},
ExternalPort: sql.NullInt32{Int32: int32(req.Port), Valid: true},
ExternalName: sql.NullString{String: req.Database, Valid: req.Database != ""},
ExternalUsername: sql.NullString{String: req.Username, Valid: req.Username != ""},
ExternalPassword: encPassword,
ExternalSsl: req.SSL,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register database"})
return
}
LogAudit(userID, "database", databaseID, "register-external", map[string]interface{}{
"name": req.Name,
"type": req.Type,
"host": req.Host,
})
c.JSON(http.StatusCreated, gin.H{
"id": databaseID,
"message": "External database registered",
"status": "running",
})
}
@@ -0,0 +1,97 @@
package api
import (
"context"
"net"
"testing"
)
func TestNormalizeExternalRequestDefaults(t *testing.T) {
req := externalDatabaseRequest{Type: "PostgreSQL", Host: " db.example.com ", Port: 0}
if err := normalizeExternalRequest(&req); err != nil {
t.Fatalf("normalize failed: %v", err)
}
if req.Type != "postgresql" {
t.Fatalf("type not normalized: %q", req.Type)
}
if req.Host != "db.example.com" {
t.Fatalf("host not trimmed: %q", req.Host)
}
if req.Port != 5432 {
t.Fatalf("default port = %d, want 5432", req.Port)
}
}
func TestNormalizeExternalRequestRejects(t *testing.T) {
cases := []externalDatabaseRequest{
{Type: "sqlite", Host: "x"}, // unsupported type
{Type: "postgresql", Host: " "}, // empty host
{Type: "postgresql", Host: "h", Port: 70000}, // out of range
}
for i, req := range cases {
if err := normalizeExternalRequest(&req); err == nil {
t.Fatalf("case %d: expected error", i)
}
}
}
func TestExternalConnectionURLMasking(t *testing.T) {
ext := &DatabaseExternalRef{
Host: "db.example.com",
Port: 5432,
Database: "appdb",
Username: "svc",
SSL: true,
}
u := externalConnectionURL("postgresql", ext, "s3cret")
if u != "postgres://svc:[email protected]:5432/appdb?sslmode=require" {
t.Fatalf("unexpected pg url: %s", u)
}
u = externalConnectionURL("postgresql", ext, "")
if u != "postgres://[email protected]:5432/appdb?sslmode=require" {
t.Fatalf("unexpected passwordless url: %s", u)
}
r := &DatabaseExternalRef{Host: "cache.internal", Port: 6379, SSL: true}
u = externalConnectionURL("redis", r, "pw")
if u != "rediss://:[email protected]:6379" {
t.Fatalf("unexpected redis url: %s", u)
}
}
func TestProbeExternalDatabaseTCP(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
port := ln.Addr().(*net.TCPAddr).Port
// TCP fallback path — mysql uses a plain dial.
latency, err := probeExternalDatabase(context.Background(), externalDatabaseRequest{
Type: "mysql", Host: "127.0.0.1", Port: port,
})
if err != nil {
t.Fatalf("probe should succeed against open listener: %v", err)
}
if latency <= 0 {
t.Fatalf("latency not recorded")
}
// Closed port must fail fast.
if _, err := probeExternalDatabase(context.Background(), externalDatabaseRequest{
Type: "mysql", Host: "127.0.0.1", Port: 1,
}); err == nil {
t.Fatalf("probe should fail against closed port")
}
}
func TestRedisTLSConfig(t *testing.T) {
if redisTLSConfig(false, "h") != nil {
t.Fatalf("disabled ssl must produce nil tls config")
}
cfg := redisTLSConfig(true, "db.example.com")
if cfg == nil || cfg.ServerName != "db.example.com" {
t.Fatalf("expected ServerName passthrough, got %+v", cfg)
}
}
+127 -17
View File
@@ -5,11 +5,15 @@ import (
"containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/docker"
"containr/internal/source"
"containr/internal/types"
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@@ -377,6 +381,23 @@ func runDeploymentAndSyncWithImage(
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
defer cancel()
// The checkout and any shipped artifacts die with this deployment.
var artifactIDs []string
checkoutRoot := filepath.Join(containrWorkDir, "repos", dbDeployment.ID.String())
defer func() {
_ = os.RemoveAll(checkoutRoot)
for _, id := range artifactIDs {
removeArtifact(id)
}
}()
failDeploy := func(msg string) {
failedAt := time.Now()
_, _ = db.Exec(
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
msg, failedAt, dbDeployment.ID,
)
}
sourcePath := strings.TrimSpace(service.BuildPath)
if sourcePath == "" {
sourcePath = "."
@@ -421,6 +442,33 @@ func runDeploymentAndSyncWithImage(
maintURL = maintenanceURL(db)
}
// Node-pinned and spread services dispatch to agent command queues.
nodeID := serviceNodeID(db, service.ID)
spread := serviceSpread(db, service.ID)
remote := nodeID != "" || spread
// Materialize the git checkout — local builds consume it directly;
// remote builds package it into a context artifact the node builds.
if service.GitRepo != "" && imageOverride == "" {
var ownerID string
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
branch := req.Branch
if branch == "" {
branch = service.GitBranch
}
cloneSpec, err := resolveGitCloneSpec(db, ownerID, service.GitRepo, branch, req.CommitHash, service.BuildPath)
if err != nil {
failDeploy("resolve git source: " + err.Error())
return
}
src, err := source.Checkout(ctx, checkoutRoot, cloneSpec)
if err != nil {
failDeploy("git checkout: " + err.Error())
return
}
sourcePath = src
}
deployReq := &deployment.DeploymentRequest{
ProjectID: service.ProjectID.String(),
ServiceID: service.ID.String(),
@@ -447,6 +495,8 @@ func runDeploymentAndSyncWithImage(
Maintenance: maintenanceMode,
MaintenanceURL: maintURL,
BasicAuthUsers: basicAuthUsers,
NodeID: nodeID,
Spread: spread,
},
Trigger: deployment.TriggerConfig{
Type: req.Trigger,
@@ -463,14 +513,36 @@ func runDeploymentAndSyncWithImage(
if image == "" {
image = service.Image
}
var ownerID string
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
auth := registryAuthFor(db, ownerID, image)
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "prebuilt",
PrebuiltImage: image,
PullUsername: auth.Username,
PullPassword: auth.Password,
if remote && strings.HasPrefix(image, "containr-") {
// Locally-built tag — no registry serves it. Ship the image
// itself as a docker-save artifact; the node loads it.
rc, err := engine.DockerClient().SaveImage(ctx, image)
if err != nil {
failDeploy("export rollback image: " + err.Error())
return
}
artifactID, err := putArtifact(rc)
_ = rc.Close()
if err != nil {
failDeploy("store rollback image artifact: " + err.Error())
return
}
artifactIDs = append(artifactIDs, artifactID)
deployReq.Config.RemoteLoad = &deployment.RemoteLoadSpec{ArtifactID: artifactID}
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "remote",
PrebuiltImage: image,
}
} else {
var ownerID string
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
auth := registryAuthFor(db, ownerID, image)
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "prebuilt",
PrebuiltImage: image,
PullUsername: auth.Username,
PullPassword: auth.Password,
}
}
} else {
// "auto" leaves BuildType empty so the manager detects it; the static
@@ -480,16 +552,54 @@ func runDeploymentAndSyncWithImage(
if service.Builder != "" && service.Builder != "auto" {
buildType = service.Builder
}
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: buildType,
SourcePath: sourcePath,
Branch: req.Branch,
Commit: req.CommitHash,
NoCache: req.NoCache,
}
buildArgs := map[string]string{}
buildCommand := ""
if buildType == "static" {
deployReq.BuildConfig.BuildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build")
deployReq.BuildConfig.BuildArgs = map[string]string{"STATIC_DIR": firstNonEmpty(service.StaticDir, "dist")}
buildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build")
buildArgs["STATIC_DIR"] = firstNonEmpty(service.StaticDir, "dist")
}
if remote {
// Package the context — the node docker-builds it itself.
packReq := &types.BuildRequest{
BuildType: buildType,
SourcePath: sourcePath,
BuildCommand: buildCommand,
BuildArgs: buildArgs,
Environment: env,
}
rc, err := engine.BuildManager().PackageContext(ctx, packReq)
if err != nil {
failDeploy("package build context: " + err.Error())
return
}
artifactID, err := putArtifact(rc)
_ = rc.Close()
if err != nil {
failDeploy("store build context artifact: " + err.Error())
return
}
artifactIDs = append(artifactIDs, artifactID)
deployReq.Config.RemoteBuild = &deployment.RemoteBuildSpec{
ArtifactID: artifactID,
BuildArgs: buildArgs,
NoCache: req.NoCache,
}
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "remote",
Branch: req.Branch,
Commit: req.CommitHash,
NoCache: req.NoCache,
}
} else {
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: buildType,
SourcePath: sourcePath,
BuildCommand: buildCommand,
BuildArgs: buildArgs,
Branch: req.Branch,
Commit: req.CommitHash,
NoCache: req.NoCache,
}
}
}
+68
View File
@@ -3,6 +3,7 @@ package api
import (
"context"
"database/sql"
"encoding/json"
"net"
"net/http"
"regexp"
@@ -10,6 +11,7 @@ import (
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -68,6 +70,72 @@ func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) [
if len(out) == 0 && fallback != "" {
out = []string{fallback}
}
if len(out) == 0 {
out = serviceAutoDomains(db, serviceID)
}
return out
}
// dnsLabel normalizes a service name into a DNS label: lowercase alnum
// plus single dashes, never leading/trailing, ≤63 chars.
func dnsLabel(name string) string {
var b strings.Builder
lastWasSep := false
for _, r := range strings.ToLower(strings.TrimSpace(name)) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastWasSep = false
continue
}
if !lastWasSep && b.Len() > 0 {
b.WriteByte('-')
lastWasSep = true
}
}
out := strings.Trim(b.String(), "-")
if len(out) > 63 {
out = strings.Trim(out[:63], "-")
}
return out
}
// serviceAutoDomains generates <service>.<node-default-domain> hostnames
// for services with no explicit domains. Pinned services take their node's
// base domain; spread services union every eligible node's base domain.
// Local services get nothing — there is no instance-level base domain yet.
func serviceAutoDomains(db *database.DB, serviceID uuid.UUID) []string {
var name string
var nodeID sql.NullString
var spread bool
if err := db.QueryRow(
`SELECT name, node_id, COALESCE(spread, false) FROM services WHERE id = $1`, serviceID,
).Scan(&name, &nodeID, &spread); err != nil {
return nil
}
label := dnsLabel(name)
if label == "" {
return nil
}
var bases []string
ctx := context.Background()
q := sqlcdb.New(db.DB)
switch {
case nodeID.Valid && nodeID.String != "":
if a, err := q.GetAgent(ctx, nodeID.String); err == nil && a.DefaultDomain != "" {
bases = []string{a.DefaultDomain}
}
case spread:
var err error
bases, err = q.ListSchedulableAgentDomains(ctx, json.RawMessage(tagsJSON(servicePlacementTags(db, serviceID))))
if err != nil {
bases = nil
}
}
out := make([]string, 0, len(bases))
for _, base := range bases {
out = append(out, label+"."+base)
}
return out
}
+26
View File
@@ -0,0 +1,26 @@
package api
import "testing"
func TestDNSLabel(t *testing.T) {
cases := map[string]string{
"my-app": "my-app",
"My App_2": "my-app-2",
" web ": "web",
"__weird__name": "weird-name",
"---": "",
"": "",
}
for in, want := range cases {
if got := dnsLabel(in); got != want {
t.Errorf("dnsLabel(%q) = %q, want %q", in, got, want)
}
}
}
func TestDNSLabelLengthCap(t *testing.T) {
long := "a" + string(make([]byte, 0)) + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
if got := dnsLabel(long); len(got) > 63 {
t.Fatalf("label over 63 chars: %d", len(got))
}
}
+5 -3
View File
@@ -133,7 +133,9 @@ func handleGetGitProviders(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 == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "GITHUB_APP_SLUG is not configured"})
return
@@ -1073,8 +1075,8 @@ func fetchBitbucketRepositories(providerAPIURL, accessToken string) ([]remoteGit
}
func getGitHubAppInstallationToken(baseURL string, installationID int64) (string, error) {
appID := strings.TrimSpace(os.Getenv("GITHUB_APP_ID"))
privateKeyPEM := strings.TrimSpace(os.Getenv("GITHUB_APP_PRIVATE_KEY"))
appID := platformSetting(settingGitHubAppID, "GITHUB_APP_ID")
privateKeyPEM := platformSetting(settingGitHubAppPrivateKey, "GITHUB_APP_PRIVATE_KEY")
if appID == "" || privateKeyPEM == "" {
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"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
@@ -90,11 +91,24 @@ func handleGitWebhookPush(c *gin.Context) {
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"})
return
}
branch := strings.TrimPrefix(payload.Ref, "refs/heads/")
if branch == "" {
branch = branchFilter
}
if branchFilter != "" && branchFilter != branch {
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "branch not watched"})
return
@@ -113,16 +127,40 @@ func handleGitWebhookPush(c *gin.Context) {
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,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
s.created_at, s.updated_at
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)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"})
return
return 0, err
}
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.CreatedAt, &s.UpdatedAt,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
return
return 0, err
}
services = append(services, s)
}
engineValue, _ := c.Get("deployment_engine")
engine, _ := engineValue.(*deployment.DeploymentEngine)
enqueued := 0
for _, service := range services {
now := time.Now()
@@ -160,9 +194,9 @@ func handleGitWebhookPush(c *gin.Context) {
}
if _, err := db.Exec(
`INSERT INTO deployments
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
(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)`,
d.ID, d.ServiceID, fmt.Sprintf("v%d", now.Unix()), d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
); err != nil {
continue
}
@@ -181,7 +215,11 @@ func handleGitWebhookPush(c *gin.Context) {
)
// Webhook pushes default to a clean build — stale layers are the
// 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{
DeploymentID: d.ID,
Run: func(jctx context.Context) {
@@ -199,12 +237,7 @@ func handleGitWebhookPush(c *gin.Context) {
}
enqueued++
}
c.JSON(http.StatusAccepted, gin.H{
"received": true,
"branch": branch,
"deployments": enqueued,
})
return enqueued, nil
}
// 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,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)
}
}
}
+158
View File
@@ -0,0 +1,158 @@
package api
import (
"net/http"
"time"
"containr/internal/database"
"containr/internal/deployqueue"
"github.com/gin-gonic/gin"
)
// GET /operations — a single cross-cutting view of platform work: active
// and recent deployments, the per-service deploy queue, recent cron runs,
// and recent backups. Scoped to the caller's projects (admins see all).
type operationDeployment struct {
ID string `json:"id"`
ServiceID string `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectName string `json:"project_name"`
Status string `json:"status"`
Image string `json:"image"`
Error *string `json:"error,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type operationCronRun struct {
ID string `json:"id"`
JobName string `json:"job_name"`
Schedule string `json:"schedule"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Error *string `json:"error,omitempty"`
}
type operationBackup struct {
ID string `json:"id"`
DatabaseID string `json:"database_id"`
DBName string `json:"database_name"`
Status string `json:"status"`
Size string `json:"size"`
CreatedAt time.Time `json:"created_at"`
Completed *time.Time `json:"completed_at,omitempty"`
}
func handleGetOperations(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
isAdmin := contextIsAdmin(c)
// Same visibility rule as the deployment list endpoint.
visibility := `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)`
active := []operationDeployment{}
rows, err := db.Query(
`SELECT d.id, d.service_id, s.name, p.name, d.status,
COALESCE(d.image_name, ''), d.error, 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 d.status IN ('queued','pending','building','deploying','rolling_back')
AND (`+visibility+`)
ORDER BY d.created_at ASC`, userID, isAdmin)
if err == nil {
defer rows.Close()
for rows.Next() {
var d operationDeployment
var sid string
_ = rows.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt)
d.ServiceID = sid
active = append(active, d)
}
}
recentFailed := []operationDeployment{}
rows2, err2 := db.Query(
`SELECT d.id, d.service_id, s.name, p.name, d.status,
COALESCE(d.image_name, ''), d.error, 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 d.status IN ('failed','cancelled','rolled_back')
AND d.updated_at > NOW() - INTERVAL '24 hours'
AND (`+visibility+`)
ORDER BY d.updated_at DESC
LIMIT 20`, userID, isAdmin)
if err2 == nil {
defer rows2.Close()
for rows2.Next() {
var d operationDeployment
var sid string
_ = rows2.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt)
d.ServiceID = sid
recentFailed = append(recentFailed, d)
}
}
cronRuns := []operationCronRun{}
rows3, err3 := db.Query(
`SELECT e.id, j.name, j.schedule, e.status, e.started_at, e.finished_at, e.error
FROM cron_executions e
JOIN cron_jobs j ON j.id = e.cron_job_id
JOIN projects p ON p.id = j.project_id
WHERE e.started_at > NOW() - INTERVAL '24 hours'
AND (`+visibility+`)
ORDER BY e.started_at DESC
LIMIT 30`, userID, isAdmin)
if err3 == nil {
defer rows3.Close()
for rows3.Next() {
var r operationCronRun
_ = rows3.Scan(&r.ID, &r.JobName, &r.Schedule, &r.Status, &r.StartedAt, &r.FinishedAt, &r.Error)
cronRuns = append(cronRuns, r)
}
}
backups := []operationBackup{}
rows4, err4 := db.Query(
`SELECT b.id, b.database_id, ds.name, b.status, b.size, b.created_at, b.completed_at
FROM database_backups b
JOIN database_services ds ON ds.id = b.database_id
WHERE b.created_at > NOW() - INTERVAL '24 hours'
AND (ds.user_id = $1 OR $2::bool)
ORDER BY b.created_at DESC
LIMIT 20`, userID, isAdmin)
if err4 == nil {
defer rows4.Close()
for rows4.Next() {
var b operationBackup
_ = rows4.Scan(&b.ID, &b.DatabaseID, &b.DBName, &b.Status, &b.Size, &b.CreatedAt, &b.Completed)
backups = append(backups, b)
}
}
var queue []deployqueue.ServiceQueueState
if queueValue, exists := c.Get("deploy_queue"); exists && queueValue != nil {
queue = queueValue.(*deployqueue.Queue).Snapshot()
}
if queue == nil {
queue = []deployqueue.ServiceQueueState{}
}
c.JSON(http.StatusOK, gin.H{
"active_deployments": active,
"recent_failures": recentFailed,
"cron_runs": cronRuns,
"backups": backups,
"deploy_queue": queue,
})
}
+25
View File
@@ -7,6 +7,7 @@ import (
"context"
"database/sql"
"errors"
"log"
"net/http"
"strings"
"time"
@@ -152,6 +153,7 @@ func handleCreateProject(c *gin.Context) {
return
}
LogAuditWithRequest(c, "project", projectRow.ID.String(), "project.create", map[string]interface{}{"name": req.Name})
c.JSON(http.StatusCreated, mapSQLCProject(projectRow))
}
@@ -297,6 +299,28 @@ func handleDeleteProject(c *gin.Context) {
return
}
// Managed databases provisioned into this project (graph templates,
// compose imports) carry project_id and must be torn down first — their
// containers are not labelled containr.project and hold references to the
// project network.
if dbHandlerValue, exists := c.Get("database_handler"); exists && dbHandlerValue != nil {
if dbHandler, ok := dbHandlerValue.(*DatabaseHandler); ok {
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
projectDBs, listErr := dbHandler.queries.ListDatabaseServicesByProject(ctx, uuid.NullUUID{UUID: projectID, Valid: true})
if listErr == nil {
for _, projectDB := range projectDBs {
if projectDB.Provider != "external" {
if err := dbHandler.deleteManagedDatabaseRuntime(projectDB.ID); err != nil {
log.Printf("delete project %s: database %s runtime removal failed: %v", projectID, projectDB.ID, err)
}
}
_ = dbHandler.queries.DeleteDatabaseServiceByID(ctx, projectDB.ID)
}
}
cancel()
}
}
// Remove live containers and the project network before dropping rows.
// Best-effort: Docker may be unavailable; DB cleanup proceeds regardless.
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
@@ -317,6 +341,7 @@ func handleDeleteProject(c *gin.Context) {
return
}
LogAuditWithRequest(c, "project", projectID.String(), "project.delete", nil)
c.JSON(http.StatusOK, gin.H{"message": "Project deleted successfully"})
}
+96
View File
@@ -5,6 +5,7 @@ import (
"log"
"net/http"
"os"
"path/filepath"
"time"
"containr/internal/build"
@@ -23,6 +24,11 @@ import (
)
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
// Audit + outbound webhook delivery need a package-level handle for
// call sites that don't carry a gin context.
SetAuditDB(db)
StartWebhookDispatcher()
// Expose Better Auth through backend so frontend can use a single backend origin.
setupAuthProxyRoutes(router, cfg, db)
@@ -38,6 +44,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
dockerClient = client
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
initArtifacts(filepath.Join("/tmp/containr-builds", "artifacts"))
purgeStaleArtifacts()
// Node-pinned services dispatch container lifecycle to the agent's
// command queue instead of the local Docker socket.
deploymentEngine.SetNodeRunner(newAgentNodeRunner(db))
// Restore managed sidecars (e.g. cloudflared) from saved settings.
syncManagedContainers(dockerClient, db)
}
@@ -98,6 +109,19 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Start the cron scheduler — executes due cron_jobs via docker exec.
StartCronScheduler(context.Background(), db, dockerClient)
// Sleep sweeper — scales idle services to zero and prunes wake containers.
StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port)
// 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
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
@@ -158,6 +182,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
c.Set("deployment_engine", deploymentEngine)
}
c.Set("deploy_queue", deployQueue)
c.Set("api_port", cfg.Port)
c.Set("scheduler", scheduler)
c.Set("metrics_collector", metricsCollector)
c.Set("auto_scaler", autoScaler)
@@ -240,6 +265,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// replaces session auth. Providers POST to /api/git/webhooks/:id.
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)
v1 := router.Group("/api/v1")
{
// Public routes (no authentication required)
@@ -249,6 +278,17 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.POST("/auth/login", handleLogin)
public.POST("/auth/register", handleRegister)
public.GET("/maintenance", handleMaintenancePage)
public.GET("/branding", handleGetBranding)
// 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
// non-destructive: it only reconciles the service back to running.
public.Any("/internal/wake/:id", handleServiceWake)
public.Any("/internal/wake-page/:id", handleServiceWakePage)
}
// Read routes — public browsing. OptionalAuth resolves a session when
@@ -271,6 +311,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
read.GET("/services/:id/deployments", handleGetDeployments)
read.GET("/services/:id/domains", handleListServiceDomains)
read.GET("/services/:id/domains/check", handleCheckServiceDomains)
read.GET("/services/:id/env-check", handleServiceEnvCheck)
read.GET("/deployments", handleGetRecentDeployments)
read.GET("/deployments/:id", handleGetDeployment)
@@ -319,11 +360,29 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// WebSocket endpoint
authed.GET("/ws", handleWebSocket)
// Cross-cutting ops view — active/queued/failed work
authed.GET("/operations", handleGetOperations)
authed.GET("/nodes/options", agentHandler.ListNodeOptions)
authed.GET("/activity", handleGetActivity)
authed.GET("/projects/:id/activity", handleGetProjectActivity)
// Instance announcements shown to all signed-in users
authed.GET("/banners/active", handleListActiveBanners)
// Outbound webhooks — signed event delivery to user HTTP endpoints
authed.GET("/webhooks", handleListOutboundWebhooks)
authed.POST("/webhooks", handleCreateOutboundWebhook)
authed.PATCH("/webhooks/:id", handleUpdateOutboundWebhook)
authed.DELETE("/webhooks/:id", handleDeleteOutboundWebhook)
authed.GET("/webhooks/:id/deliveries", handleListOutboundWebhookDeliveries)
authed.POST("/webhooks/:id/test", handleTestOutboundWebhook)
// Owned-resource mutations — handlers scope to the caller and
// admit admins. New projects land unapproved until an admin
// publishes them.
authed.POST("/projects", handleCreateProject)
authed.POST("/projects/:id/services", handleCreateService)
authed.POST("/projects/:id/import-compose", handleImportProjectCompose)
authed.PUT("/projects/:id", handleUpdateProject)
authed.DELETE("/projects/:id", handleDeleteProject)
@@ -335,6 +394,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.POST("/services/:id/start", handleServiceStart)
authed.POST("/services/:id/stop", handleServiceStop)
authed.POST("/services/:id/restart", handleServiceRestart)
authed.POST("/services/:id/sleep", handleServiceSleep)
authed.POST("/services/:id/wake", handleServiceWakeNow)
authed.POST("/services/:id/redeploy", handleServiceRedeploy)
authed.POST("/services/:id/clone", handleCloneService)
authed.POST("/services/:id/move", handleMoveService)
@@ -342,6 +403,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
authed.POST("/deployments/:id/cancel", handleCancelDeployment)
// Interactive terminal — WebSocket → docker exec pty.
authed.GET("/services/:id/terminal", handleServiceTerminal)
// Environment variables — secrets, owner/admin only.
authed.GET("/services/:id/variables", handleGetVariables)
authed.PUT("/services/:id/variables", handleUpdateVariables)
@@ -358,6 +422,13 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// One-off exec console (docker exec, 30s ceiling)
authed.POST("/services/:id/exec", handleExecInService)
// S3-compatible backup targets — owner-scoped, keys encrypted at rest.
authed.GET("/backup-targets", databaseHandler.ListBackupTargets)
authed.POST("/backup-targets", databaseHandler.CreateBackupTarget)
authed.PUT("/backup-targets/:id", databaseHandler.UpdateBackupTarget)
authed.DELETE("/backup-targets/:id", databaseHandler.DeleteBackupTarget)
authed.POST("/backup-targets/:id/test", databaseHandler.TestBackupTarget)
// Private-image registries — owner-scoped, credentials encrypted at rest.
authed.GET("/registries", handleListRegistries)
authed.POST("/registries", handleCreateRegistry)
@@ -387,11 +458,15 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.POST("/builds/plan", buildHandler.GetBuildPlan)
authed.POST("/databases", databaseHandler.CreateDatabase)
authed.POST("/databases/register-external", databaseHandler.RegisterExternalDatabase)
authed.POST("/databases/test-connection", databaseHandler.TestDatabaseConnection)
authed.POST("/databases/:id/test-connection", databaseHandler.TestDatabaseConnectionByID)
authed.PUT("/databases/:id", databaseHandler.UpdateDatabase)
authed.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
authed.POST("/databases/:id/backup", databaseHandler.CreateBackup)
authed.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
authed.POST("/databases/:id/backups/import", databaseHandler.ImportBackup)
authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
@@ -410,6 +485,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.PUT("/templates/:id", handleUpdateTemplate)
authed.DELETE("/templates/:id", handleDeleteTemplate)
authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
authed.POST("/templates/:id/plan", handlePlanTemplate)
authed.POST("/templates/import/compose", handleImportCompose)
authed.POST("/templates/import/git", handleImportGitCompose)
authed.POST("/templates/deploy", handleDeployInlineGraph)
authed.POST("/cron-jobs", handleCreateCronJob)
authed.PUT("/cron-jobs/:id", handleUpdateCronJob)
@@ -426,6 +505,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
admin.GET("/admin/overview", handleAdminOverview)
admin.GET("/admin/users", handleAdminListUsers)
admin.PATCH("/admin/users/:id", handleAdminSetUserAdmin)
admin.POST("/admin/users/:id/impersonate", handleAdminImpersonateUser)
admin.PATCH("/admin/projects/:id", handleAdminSetProjectApproval)
admin.POST("/users", handleCreateUser)
@@ -443,6 +523,22 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
admin.GET("/admin/volumes", handleAdminListVolumes)
admin.DELETE("/admin/volumes/:name", handleAdminDeleteVolume)
// Announcement banners
admin.GET("/admin/banners", handleAdminListBanners)
admin.POST("/admin/banners", handleAdminCreateBanner)
admin.PATCH("/admin/banners/:id", handleAdminUpdateBanner)
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/:resource/:id", handleGetResourceAuditLogs)
+210 -8
View File
@@ -18,6 +18,61 @@ import (
"github.com/google/uuid"
)
// remoteAgentsForService lists every agent holding inventory rows for the
// service — covers pinned placements, spread replicas, and leftovers from a
// since-cleared pin.
func remoteAgentsForService(ctx context.Context, db *database.DB, serviceID uuid.UUID) []string {
rows, err := db.Query(
`SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1`,
serviceID,
)
if err != nil {
return nil
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if rows.Scan(&id) == nil && id != "" {
ids = append(ids, id)
}
}
return ids
}
// serviceLifecycle routes a start/stop/restart action to every agent holding
// the service's replicas, then to the local Docker engine. Both run — a
// service can't be pinned and local at once, but stale containers from a
// cleared pin shouldn't survive a stop.
func serviceLifecycle(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, serviceID uuid.UUID, action string) error {
for _, agentID := range remoteAgentsForService(ctx, db, serviceID) {
if err := engine.ControlServiceOnNode(ctx, serviceID.String(), agentID, action); err != nil {
return err
}
}
switch action {
case "start":
return engine.StartService(ctx, serviceID.String())
case "stop":
return engine.StopService(ctx, serviceID.String())
case "restart":
if err := engine.StopService(ctx, serviceID.String()); err != nil {
return err
}
return engine.StartService(ctx, serviceID.String())
}
return fmt.Errorf("unsupported lifecycle action %q", action)
}
// removeServiceRuntime tears down replicas wherever they run — remote nodes
// (pinned or spread) and the local Docker host.
func removeServiceRuntime(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, serviceID uuid.UUID) {
for _, agentID := range remoteAgentsForService(ctx, db, serviceID) {
_ = engine.RemoveServiceContainersOnNode(ctx, serviceID.String(), agentID)
}
_ = engine.RemoveServiceContainers(ctx, serviceID.String())
}
// serviceRuntimeSpec builds the container spec for a service: stored env vars
// merged with ${{Service.KEY}} references resolved across the project.
func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpec, error) {
@@ -37,6 +92,9 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
Domain: service.Domain,
HealthPath: service.HealthCheckPath,
RestartPolicy: service.RestartPolicy,
NodeID: serviceNodeID(db, service.ID),
Spread: serviceSpread(db, service.ID),
PlacementTags: servicePlacementTags(db, service.ID),
}
// Best effort: reuse the host port from the last live deployment so the
// public URL survives restarts. Column exists post-migration; older DBs
@@ -63,6 +121,7 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
spec.Volumes = loadServiceVolumes(db, service.ID)
spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
spec.TraefikLabels = serviceTraefikLabels(db, service.ID)
if spec.Maintenance {
spec.MaintenanceURL = maintenanceURL(db)
}
@@ -341,6 +400,7 @@ func handleGetServiceRuntime(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
mergeRemoteRuntime(c.Request.Context(), engine, service.ID.String(), state)
engine.ProbeServiceHealth(c.Request.Context(), state, service.ProjectID.String(), service.Name, service.Port, service.HealthCheckPath)
persistPublishedPort(db, service.ID, state.Ports)
if host := requestHostname(c); host != "" {
@@ -384,7 +444,7 @@ func handleServiceStart(c *gin.Context) {
if !ok {
return
}
if err := engine.StartService(c.Request.Context(), service.ID.String()); err != nil {
if err := serviceLifecycle(c.Request.Context(), db, engine, service.ID, "start"); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
@@ -402,7 +462,7 @@ func handleServiceStop(c *gin.Context) {
if !ok {
return
}
if err := engine.StopService(c.Request.Context(), service.ID.String()); err != nil {
if err := serviceLifecycle(c.Request.Context(), db, engine, service.ID, "stop"); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
@@ -421,11 +481,7 @@ func handleServiceRestart(c *gin.Context) {
return
}
ctx := c.Request.Context()
if err := engine.StopService(ctx, service.ID.String()); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if err := engine.StartService(ctx, service.ID.String()); err != nil {
if err := serviceLifecycle(ctx, db, engine, service.ID, "restart"); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
@@ -535,6 +591,37 @@ func enqueueServiceRedeploy(c *gin.Context, db *database.DB, service Service) bo
return true
}
// mergeRemoteRuntime folds inventory-backed remote replica state into a
// local runtime view so pinned and spread services report their containers.
// Remote rows carry deterministic UUIDs, local ones Docker IDs — no overlap.
func mergeRemoteRuntime(ctx context.Context, engine *deployment.DeploymentEngine, serviceID string, state *deployment.RuntimeState) {
remote, err := engine.RemoteRuntimeState(ctx, serviceID)
if err != nil || remote == nil {
return
}
running := 0
for _, c := range state.Containers {
if c.State == "running" {
running++
}
}
for _, rc := range remote.Containers {
state.Containers = append(state.Containers, rc)
if rc.State == "running" {
running++
}
}
switch {
case len(state.Containers) == 0:
case running == 0:
state.Status = "stopped"
case running < state.Desired:
state.Status = "degraded"
default:
state.Status = "running"
}
}
// persistPublishedPort records the host port Docker actually bound so later
// redeploys can reuse it. Idempotent — only writes when the value changed.
func persistPublishedPort(db *database.DB, serviceID uuid.UUID, ports []uint16) {
@@ -558,7 +645,11 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
state, err := engine.RuntimeState(ctx, service.ID.String(), service.Replicas)
if err != nil || state == nil || len(state.Containers) == 0 {
if err != nil || state == nil {
return
}
mergeRemoteRuntime(ctx, engine, service.ID.String(), state)
if len(state.Containers) == 0 {
return
}
if state.Status != service.Status {
@@ -568,6 +659,8 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
persistPublishedPort(db, service.ID, state.Ports)
if service.Domain != "" {
service.PublicURL = "https://" + service.Domain
} else if auto := serviceAutoDomains(db, service.ID); len(auto) > 0 {
service.PublicURL = "https://" + auto[0]
} else if len(state.URLs) > 0 {
service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c))
}
@@ -641,3 +734,112 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
_, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID)
return nil
}
// handleServiceEnvCheck reports environment problems without exposing
// values: empty vars, ${{service.KEY}} refs that fail to resolve, and
// secrets whose ciphertext can't be decrypted (key rotation).
func handleServiceEnvCheck(c *gin.Context) {
dbVal, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
db := dbVal.(*database.DB)
service, ok := loadReadableService(c, db)
if !ok {
return
}
rows, err := db.Query(
`SELECT key, value, COALESCE(is_secret, false) FROM environment_variables WHERE service_id = $1`,
service.ID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
var unresolved, empty, unreadable []string
var raw []struct{ key, val string }
for rows.Next() {
var k, v string
var secret bool
if err := rows.Scan(&k, &v, &secret); err != nil {
continue
}
decrypted := secrets.Decrypt(v)
if secret && secrets.IsEncrypted(v) && decrypted == v {
// Ciphertext failed to decrypt — Decrypt returns the input.
unreadable = append(unreadable, k)
continue
}
if strings.TrimSpace(decrypted) == "" {
empty = append(empty, k)
}
raw = append(raw, struct{ key, val string }{k, decrypted})
}
// Project siblings for ${{name.KEY}} resolution.
siblingIDs := map[string]string{}
svcRows, err := db.Query(`SELECT id, name FROM services WHERE project_id = $1`, service.ProjectID)
if err == nil {
for svcRows.Next() {
var id, name string
if err := svcRows.Scan(&id, &name); err == nil {
siblingIDs[strings.ToLower(name)] = id
}
}
svcRows.Close()
}
resolvable := func(serviceName, key string) bool {
id, ok := siblingIDs[strings.ToLower(serviceName)]
if !ok {
return false
}
if strings.EqualFold(key, "HOST") {
return true
}
if strings.EqualFold(key, "PORT") {
var port int
if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil {
return port > 0
}
return false
}
var n int
if err := db.QueryRow(
`SELECT COUNT(*) FROM environment_variables WHERE service_id = $1 AND key = $2`,
id, key,
).Scan(&n); err == nil {
return n > 0
}
return false
}
for _, kv := range raw {
for _, match := range refVarPattern.FindAllStringSubmatch(kv.val, -1) {
if len(match) == 3 && !resolvable(match[1], match[2]) {
unresolved = append(unresolved, fmt.Sprintf("%s → %s", kv.key, match[0]))
}
}
}
if unresolved == nil {
unresolved = []string{}
}
if empty == nil {
empty = []string{}
}
if unreadable == nil {
unreadable = []string{}
}
okResult := len(unresolved) == 0 && len(empty) == 0 && len(unreadable) == 0
c.JSON(http.StatusOK, gin.H{
"ok": okResult,
"unresolved": unresolved,
"empty": empty,
"unreadable": unreadable,
})
}
+19 -1
View File
@@ -29,9 +29,27 @@ type SecurityHandler struct {
func NewSecurityHandler(db *database.DB, encryptionKey string) *SecurityHandler {
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{
db: db,
scanner: security.NewScanner(db),
scanner: scanner,
complianceManager: security.NewComplianceManager(db),
encryptionManager: encryptionManager,
dataRetentionManager: security.NewDataRetentionManager(encryptionManager),
+382 -48
View File
@@ -5,9 +5,11 @@ import (
"containr/internal/deployment"
"containr/internal/deployqueue"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"time"
@@ -31,23 +33,195 @@ type Service struct {
CPU string `json:"cpu" db:"cpu"`
Memory string `json:"memory" db:"memory"`
// Runtime spec
Replicas int `json:"replicas" db:"replicas"`
Port int `json:"port" db:"port"` // container port to expose
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB
Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
MaintenanceMode bool `json:"maintenance_mode" db:"-"`
BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes
Builder string `json:"builder" db:"builder"` // auto|railpack|nixpacks|dockerfile|static
CPUReserve string `json:"cpu_reserve,omitempty" db:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve,omitempty" db:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd,omitempty" db:"static_build_cmd"`
StaticDir string `json:"static_dir,omitempty" db:"static_dir"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
Replicas int `json:"replicas" db:"replicas"`
Port int `json:"port" db:"port"` // container port to expose
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB
Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
MaintenanceMode bool `json:"maintenance_mode" db:"-"`
BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes
Builder string `json:"builder" db:"builder"` // auto|railpack|nixpacks|dockerfile|static
CPUReserve string `json:"cpu_reserve,omitempty" db:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve,omitempty" db:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd,omitempty" db:"static_build_cmd"`
StaticDir string `json:"static_dir,omitempty" db:"static_dir"`
SleepEnabled bool `json:"sleep_enabled" db:"-"`
SleepIdleMinutes int `json:"sleep_idle_minutes" db:"-"`
// NodeID pins the service to a node agent ("" = local Docker host,
// resolved "auto" picks the least-loaded online agent at write time).
NodeID string `json:"node_id,omitempty" db:"-"`
NodeName string `json:"node_name,omitempty" db:"-"`
// Spread distributes replicas across every online, schedulable agent.
// Mutually exclusive with an explicit NodeID pin.
Spread bool `json:"spread" db:"-"`
// PlacementTags restrict auto/spread candidates to agents carrying all
// listed tags. An explicit pin overrides them — pin + tags is rejected.
PlacementTags []string `json:"placement_tags,omitempty" db:"-"`
// TraefikLabels holds per-service middleware overrides —
// "middlewares.<name>.<type>.<field>" → value. Lazy-read.
TraefikLabels map[string]string `json:"traefik_labels,omitempty" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// loadServiceSleep fills the sleep-mode columns, which live outside the
// wide service SELECTs. Missing columns (pre-migration) degrade to off.
func loadServiceSleep(db *database.DB, s *Service) {
var idle int
if err := db.QueryRow(
`SELECT COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15) FROM services WHERE id = $1`,
s.ID,
).Scan(&s.SleepEnabled, &idle); err == nil {
s.SleepIdleMinutes = idle
}
}
// 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.
@@ -127,33 +301,64 @@ type CreateServiceRequest struct {
MemoryReserve string `json:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd"`
StaticDir string `json:"static_dir"`
// SleepEnabled/SleepIdleMinutes opt the service into scale-to-zero at
// create time — previously update-only, which surprised API/CLI users.
SleepEnabled *bool `json:"sleep_enabled"`
SleepIdleMinutes *int `json:"sleep_idle_minutes"`
// NodeID pins placement to a node agent; "auto" picks the least-loaded
// online agent, "local"/"" runs on the Containr host.
NodeID string `json:"node_id"`
// Spread distributes replicas across every online, schedulable agent.
// Mutually exclusive with an explicit node pin.
Spread *bool `json:"spread"`
// PlacementTags restrict auto/spread candidates to agents carrying all
// listed tags. Mutually exclusive with an explicit node pin.
PlacementTags []string `json:"placement_tags"`
// TraefikLabels defines extra Traefik middlewares for this service's
// router — keys "middlewares.<name>.<type>[.<field>]", validated against
// an allowlist. Defined middlewares attach to the router automatically.
TraefikLabels map[string]string `json:"traefik_labels"`
}
// UpdateServiceRequest represents a request to update a service
type UpdateServiceRequest struct {
Name string `json:"name" binding:"omitempty,min=1,max=255"`
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
Image string `json:"image"`
Command string `json:"command"`
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
BuildPath string `json:"build_path"`
CPU string `json:"cpu"`
Memory string `json:"memory"`
Replicas *int `json:"replicas"`
Port *int `json:"port"`
Domain *string `json:"domain"`
HealthCheckPath *string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
Volumes *[]ServiceVolume `json:"volumes"`
MaintenanceMode *bool `json:"maintenance_mode"`
BasicAuth *[]BasicAuthCred `json:"basic_auth"`
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
CPUReserve *string `json:"cpu_reserve"`
MemoryReserve *string `json:"memory_reserve"`
StaticBuildCmd *string `json:"static_build_cmd"`
StaticDir *string `json:"static_dir"`
Name string `json:"name" binding:"omitempty,min=1,max=255"`
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
Image string `json:"image"`
Command string `json:"command"`
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
BuildPath string `json:"build_path"`
CPU string `json:"cpu"`
Memory string `json:"memory"`
Replicas *int `json:"replicas"`
Port *int `json:"port"`
Domain *string `json:"domain"`
HealthCheckPath *string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
Volumes *[]ServiceVolume `json:"volumes"`
MaintenanceMode *bool `json:"maintenance_mode"`
BasicAuth *[]BasicAuthCred `json:"basic_auth"`
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
CPUReserve *string `json:"cpu_reserve"`
MemoryReserve *string `json:"memory_reserve"`
StaticBuildCmd *string `json:"static_build_cmd"`
StaticDir *string `json:"static_dir"`
SleepEnabled *bool `json:"sleep_enabled"`
SleepIdleMinutes *int `json:"sleep_idle_minutes"`
// NodeID pins placement; pointer so "local"/"" explicitly clears the pin.
NodeID *string `json:"node_id"`
// Spread toggles multi-node replica distribution; pointer so false is
// a real update, not a missing field.
Spread *bool `json:"spread"`
// PlacementTags restrict auto/spread candidates to agents carrying all
// listed tags; pointer so [] clears the requirement. Mutually exclusive
// with an explicit node pin.
PlacementTags *[]string `json:"placement_tags"`
// TraefikLabels replaces the service's middleware overrides; pointer so
// {} clears them and nil leaves them untouched.
TraefikLabels *map[string]string `json:"traefik_labels"`
}
// handleGetServices retrieves all services for a project
@@ -201,9 +406,10 @@ func handleGetServices(c *gin.Context) {
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''),
COALESCE(memory_reserve, ''), COALESCE(static_build_cmd, ''),
COALESCE(static_dir, ''),
created_at, updated_at
FROM services
WHERE project_id = $1
COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15),
created_at, updated_at
FROM services
WHERE project_id = $1
ORDER BY created_at DESC`,
projectID,
)
@@ -223,6 +429,7 @@ func handleGetServices(c *gin.Context) {
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.SleepEnabled, &service.SleepIdleMinutes,
&service.CreatedAt, &service.UpdatedAt,
)
if err != nil {
@@ -235,6 +442,7 @@ func handleGetServices(c *gin.Context) {
// Reconcile stored status with live container state (no-op without Docker).
for i := range services {
liveServiceStatus(c, db.(*database.DB), &services[i])
loadServiceNode(db.(*database.DB), &services[i])
}
c.JSON(http.StatusOK, gin.H{"services": services})
@@ -354,6 +562,14 @@ func handleCreateService(c *gin.Context) {
if service.Builder == "static" && service.StaticDir == "" {
service.StaticDir = "dist"
}
if len(req.TraefikLabels) > 0 {
labels, err := normalizeTraefikLabels(req.TraefikLabels)
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
service.TraefikLabels = labels
}
for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
@@ -391,6 +607,39 @@ func handleCreateService(c *gin.Context) {
sourceType := inferServiceSourceType(service)
if req.SleepEnabled != nil {
service.SleepEnabled = *req.SleepEnabled
}
if req.SleepIdleMinutes != nil {
if *req.SleepIdleMinutes < 1 || *req.SleepIdleMinutes > 1440 {
c.JSON(http.StatusBadRequest, gin.H{"error": "sleep_idle_minutes must be 1-1440", "code": "VALIDATION"})
return
}
service.SleepIdleMinutes = *req.SleepIdleMinutes
}
if req.Spread != nil {
service.Spread = *req.Spread
}
service.PlacementTags = normalizePlacementTags(req.PlacementTags)
if req.NodeID != "" {
if service.Spread && req.NodeID != "auto" && req.NodeID != "local" {
c.JSON(http.StatusBadRequest, gin.H{"error": "spread and an explicit node pin are mutually exclusive", "code": "VALIDATION"})
return
}
if len(service.PlacementTags) > 0 && req.NodeID != "auto" {
c.JSON(http.StatusBadRequest, gin.H{"error": "placement tags only apply to remote placement — set node_id=\"auto\" or omit it", "code": "VALIDATION"})
return
}
nodeID, nodeErr := resolveNodePin(c.Request.Context(), db.(*database.DB), req.NodeID, service.PlacementTags)
if nodeErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": nodeErr.Error(), "code": "VALIDATION"})
return
}
if !service.Spread {
service.NodeID = nodeID
}
}
// Insert service into database
_, err = db.(*database.DB).Exec(
`INSERT INTO services
@@ -399,16 +648,24 @@ func handleCreateService(c *gin.Context) {
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, volumes,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
created_at, updated_at)
node_id, spread, placement_tags, sleep_enabled, sleep_idle_minutes,
traefik_labels, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
$27, $28, $29, $30, $31, $32, $33)`,
$27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39)`,
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
"", service.Command, service.Type, service.Status, service.Image, service.Command,
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
volumesJSON, service.Builder, service.CPUReserve, service.MemoryReserve,
service.StaticBuildCmd, service.StaticDir, service.CreatedAt, service.UpdatedAt,
service.StaticBuildCmd, service.StaticDir,
sql.NullString{String: service.NodeID, Valid: service.NodeID != ""},
service.Spread,
tagsJSON(service.PlacementTags),
service.SleepEnabled,
sql.NullInt32{Int32: int32(service.SleepIdleMinutes), Valid: service.SleepIdleMinutes > 0},
traefikLabelsJSON(service.TraefikLabels),
service.CreatedAt, service.UpdatedAt,
)
if err != nil {
@@ -436,6 +693,12 @@ func handleCreateService(c *gin.Context) {
}
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
LogAuditWithRequest(c, "service", service.ID.String(), "create", map[string]interface{}{
"name": service.Name, "type": service.Type, "project_id": service.ProjectID,
"node_id": service.NodeID,
})
loadServiceNode(db.(*database.DB), &service)
c.JSON(http.StatusCreated, gin.H{"service": service})
}
@@ -524,6 +787,8 @@ func handleGetService(c *gin.Context) {
})
}
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
loadServiceSleep(db.(*database.DB), &service)
loadServiceNode(db.(*database.DB), &service)
maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
service.MaintenanceMode = maintenance
service.BasicAuth = basicAuthUsernames(basicAuth)
@@ -665,6 +930,27 @@ func handleUpdateService(c *gin.Context) {
if existingService.Builder == "static" && existingService.StaticDir == "" {
existingService.StaticDir = "dist"
}
loadServiceSleep(db.(*database.DB), &existingService)
if req.SleepEnabled != nil {
existingService.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
}
existingService.SleepIdleMinutes = *req.SleepIdleMinutes
}
if req.TraefikLabels != nil {
labels, err := normalizeTraefikLabels(*req.TraefikLabels)
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
existingService.TraefikLabels = labels
} else {
existingService.TraefikLabels = serviceTraefikLabels(db.(*database.DB), serviceID)
}
for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
@@ -691,6 +977,44 @@ func handleUpdateService(c *gin.Context) {
volumesArg = volumesJSON
}
if req.Spread != nil {
existingService.Spread = *req.Spread
} else {
existingService.Spread = serviceSpread(db.(*database.DB), serviceID)
}
if req.PlacementTags != nil {
existingService.PlacementTags = normalizePlacementTags(*req.PlacementTags)
} else {
existingService.PlacementTags = servicePlacementTags(db.(*database.DB), serviceID)
}
if req.NodeID != nil {
nodeValue := strings.TrimSpace(*req.NodeID)
if existingService.Spread && nodeValue != "" && nodeValue != "auto" && nodeValue != "local" {
c.JSON(http.StatusBadRequest, gin.H{"error": "spread and an explicit node pin are mutually exclusive — set spread=false first", "code": "VALIDATION"})
return
}
if len(existingService.PlacementTags) > 0 && nodeValue != "" && nodeValue != "auto" {
c.JSON(http.StatusBadRequest, gin.H{"error": "service has placement tags — clear them (placement_tags=[]) before pinning or going local", "code": "VALIDATION"})
return
}
nodeID, nodeErr := resolveNodePin(c.Request.Context(), db.(*database.DB), nodeValue, existingService.PlacementTags)
if nodeErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": nodeErr.Error(), "code": "VALIDATION"})
return
}
existingService.NodeID = nodeID
} else {
existingService.NodeID = serviceNodeID(db.(*database.DB), serviceID)
}
if existingService.Spread && existingService.NodeID != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "service already has an explicit node pin — clear it (node_id=\"local\") before enabling spread", "code": "VALIDATION"})
return
}
if len(existingService.PlacementTags) > 0 && existingService.NodeID != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "service already has an explicit node pin — clear it (node_id=\"local\") before setting placement tags", "code": "VALIDATION"})
return
}
existingService.UpdatedAt = time.Now()
// Update service in database
@@ -701,7 +1025,9 @@ func handleUpdateService(c *gin.Context) {
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
builder = $19, cpu_reserve = $20, memory_reserve = $21,
static_build_cmd = $22, static_dir = $23
static_build_cmd = $22, static_dir = $23,
sleep_enabled = $24, sleep_idle_minutes = $25, node_id = $26, spread = $27,
placement_tags = $28::jsonb, traefik_labels = $29::jsonb
WHERE id = $18`,
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
@@ -711,6 +1037,11 @@ func handleUpdateService(c *gin.Context) {
existingService.UpdatedAt, volumesArg, existingService.ID,
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
existingService.StaticBuildCmd, existingService.StaticDir,
existingService.SleepEnabled, existingService.SleepIdleMinutes,
sql.NullString{String: existingService.NodeID, Valid: existingService.NodeID != ""},
existingService.Spread,
tagsJSON(existingService.PlacementTags),
traefikLabelsJSON(existingService.TraefikLabels),
)
if err != nil {
@@ -780,6 +1111,7 @@ func handleUpdateService(c *gin.Context) {
}
}
loadServiceNode(db.(*database.DB), &existingService)
c.JSON(http.StatusOK, gin.H{"service": existingService})
}
@@ -829,7 +1161,7 @@ func handleDeleteService(c *gin.Context) {
// Remove runtime containers before dropping the row.
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
_ = engine.RemoveServiceContainers(c.Request.Context(), serviceID.String())
removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, serviceID)
}
}
@@ -844,5 +1176,7 @@ func handleDeleteService(c *gin.Context) {
return
}
LogAuditWithRequest(c, "service", serviceID.String(), "delete", map[string]interface{}{})
c.JSON(http.StatusOK, gin.H{"message": "Service deleted successfully"})
}
@@ -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
@@ -23,10 +23,49 @@ const (
settingSignupEnabled = "signup_enabled"
settingCloudflareTunnelToken = "cloudflare_tunnel_token"
// White-label branding keys — unset fields fall back to Containr
// defaults. Self-hosters rebrand their instance via these.
settingBrandProductName = "branding.product_name"
settingBrandLogoURL = "branding.logo_url"
settingBrandFaviconURL = "branding.favicon_url"
settingBrandAccent = "branding.accent_color"
settingBrandDocsURL = "branding.docs_url"
settingBrandSupportURL = "branding.support_url"
cloudflaredContainerName = "containr-cloudflared"
cloudflaredImage = "cloudflare/cloudflared:latest"
)
// brandingConfig is the public white-label payload. Fields are always
// populated — defaults keep the instance Containr-branded.
type brandingConfig struct {
ProductName string `json:"product_name"`
LogoURL string `json:"logo_url"`
FaviconURL string `json:"favicon_url"`
AccentColor string `json:"accent_color"`
DocsURL string `json:"docs_url"`
SupportURL string `json:"support_url"`
}
func resolveBranding(db *database.DB) brandingConfig {
return brandingConfig{
ProductName: settingValue(db, settingBrandProductName, "BRAND_NAME", "Containr"),
LogoURL: settingValue(db, settingBrandLogoURL, "BRAND_LOGO_URL", ""),
FaviconURL: settingValue(db, settingBrandFaviconURL, "BRAND_FAVICON_URL", ""),
AccentColor: settingValue(db, settingBrandAccent, "BRAND_ACCENT_COLOR", ""),
DocsURL: settingValue(db, settingBrandDocsURL, "BRAND_DOCS_URL", ""),
SupportURL: settingValue(db, settingBrandSupportURL, "BRAND_SUPPORT_URL", ""),
}
}
// GET /branding — public so the login page and shell can brand themselves
// before a session exists.
func handleGetBranding(c *gin.Context) {
db, _ := c.Get("db")
database, _ := db.(*database.DB)
c.JSON(http.StatusOK, resolveBranding(database))
}
// settingValue resolves a setting: app_settings row first, env fallback,
// then the caller's default.
func settingValue(db *database.DB, key, envKey, fallback string) string {
@@ -110,6 +149,7 @@ func handleGetSettings(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"signup_enabled": signupEnabled(db),
"cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client),
"branding": resolveBranding(db),
})
}
@@ -122,6 +162,14 @@ func handleUpdateSettings(c *gin.Context) {
var req struct {
SignupEnabled *bool `json:"signup_enabled"`
CloudflareTunnelToken *string `json:"cloudflare_tunnel_token"`
Branding *struct {
ProductName *string `json:"product_name"`
LogoURL *string `json:"logo_url"`
FaviconURL *string `json:"favicon_url"`
AccentColor *string `json:"accent_color"`
DocsURL *string `json:"docs_url"`
SupportURL *string `json:"support_url"`
} `json:"branding"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -158,6 +206,36 @@ func handleUpdateSettings(c *gin.Context) {
}
}
if req.Branding != nil {
fields := []struct {
key string
value *string
}{
{settingBrandProductName, req.Branding.ProductName},
{settingBrandLogoURL, req.Branding.LogoURL},
{settingBrandFaviconURL, req.Branding.FaviconURL},
{settingBrandAccent, req.Branding.AccentColor},
{settingBrandDocsURL, req.Branding.DocsURL},
{settingBrandSupportURL, req.Branding.SupportURL},
}
for _, f := range fields {
if f.value == nil {
continue
}
v := strings.TrimSpace(*f.value)
var err error
if v == "" {
err = deleteSetting(db, f.key)
} else {
err = setSetting(db, f.key, v, false)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save branding"})
return
}
}
}
handleGetSettings(c)
}
+599
View File
@@ -0,0 +1,599 @@
package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/docker"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Sleep mode: services with sleep_enabled are scaled to zero after
// sleep_idle_minutes without inbound traffic. Idleness is measured as
// NetworkIO byte deltas across the service's containers — we have no
// per-request layer in front of Traefik, so counters are the signal.
//
// When a service sleeps its containers are removed and a tiny busybox
// "wake" container takes over the same Traefik host rule at a higher
// priority: it serves a reloading "waking" page and pings the unauthenticated
// internal wake endpoint, which reconciles the service back to running.
// Wake containers carry a dedicated label (not the service label) so
// RuntimeState and reconcile never count them as replicas; the sweeper
// prunes them once the workload is running again.
const (
wakeImage = "busybox:1.36"
wakeLabel = "containr.wake"
wakeForLabel = "containr.wake-for"
wakeSweepInterval = 30 * time.Second
)
type sleepTracker struct {
mu sync.Mutex
activity map[uuid.UUID]sleepActivity
}
type sleepActivity struct {
bytes uint64
idleSince time.Time
}
var tracker = &sleepTracker{activity: make(map[uuid.UUID]sleepActivity)}
// StartSleepSweeper polls enabled services and scales idle ones to zero.
func StartSleepSweeper(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, apiPort int) {
if db == nil || dc == nil || engine == nil {
return
}
go func() {
ticker := time.NewTicker(wakeSweepInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
sweepSleeping(ctx, db, dc, engine, apiPort)
pruneWakeContainers(ctx, db, dc, engine)
}
}
}()
log.Printf("sleep sweeper: running (interval %s)", wakeSweepInterval)
}
type sleepCandidate struct {
ID uuid.UUID
ProjectID uuid.UUID
Name string
Status string
IdleMinutes int
}
func sweepSleeping(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, apiPort int) {
rows, err := db.QueryContext(ctx,
`SELECT id, project_id, name, COALESCE(status,''), COALESCE(sleep_idle_minutes,15)
FROM services WHERE sleep_enabled AND COALESCE(status,'') IN ('running','deployed','degraded','starting')`)
if err != nil {
return
}
var candidates []sleepCandidate
for rows.Next() {
var s sleepCandidate
if err := rows.Scan(&s.ID, &s.ProjectID, &s.Name, &s.Status, &s.IdleMinutes); err == nil {
candidates = append(candidates, s)
}
}
rows.Close()
q := sqlcdb.New(db.DB)
for _, svc := range candidates {
// Remote replicas live in inventory, not the local docker host —
// idleness comes from heartbeat-reported net counters instead.
if remote := remoteLiveContainers(ctx, q, svc.ID.String()); len(remote) > 0 {
var total uint64
for _, row := range remote {
total += rowNetBytes(row)
}
if markIdle(svc.ID, total, time.Duration(svc.IdleMinutes)*time.Minute) {
putToSleepRemote(ctx, db, q, svc, apiPort)
}
continue
}
containers, err := engine.ListServiceContainers(ctx, svc.ID.String())
if err != nil || len(containers) == 0 {
continue
}
// Ignore wake containers — they're the placeholder, not the workload.
workload := containers[:0]
for _, c := range containers {
if c.Labels[wakeLabel] != "true" {
workload = append(workload, c)
}
}
if len(workload) == 0 {
continue
}
var total uint64
for _, c := range workload {
total += containerNetBytes(ctx, dc, c.ID)
}
if markIdle(svc.ID, total, time.Duration(svc.IdleMinutes)*time.Minute) {
putToSleep(ctx, db, dc, engine, svc, apiPort)
}
}
}
// remoteLiveContainers returns inventory rows whose replicas are alive on
// node agents — the signal that idleness must be measured remotely.
func remoteLiveContainers(ctx context.Context, q *sqlcdb.Queries, serviceID string) []sqlcdb.ListServiceContainersRow {
rows, err := q.ListServiceContainers(ctx, serviceID)
if err != nil {
return nil
}
live := rows[:0]
for _, row := range rows {
var st struct {
State string `json:"state"`
}
if err := json.Unmarshal(row.Status.RawMessage, &st); err == nil &&
st.State != "" && st.State != "removed" && st.State != "sleeping" {
live = append(live, row)
}
}
return live
}
// rowNetBytes reads the cumulative rx+tx the agent reported at its last
// heartbeat — 0 for rows written before the counter existed.
func rowNetBytes(row sqlcdb.ListServiceContainersRow) uint64 {
var res struct {
NetBytes uint64 `json:"net_bytes"`
}
_ = json.Unmarshal(row.Resources.RawMessage, &res)
return res.NetBytes
}
// putToSleepRemote removes the service's remote replicas via their agents,
// marks the service sleeping, and repoints the domain route at the wake
// endpoint so inbound traffic brings it back.
func putToSleepRemote(ctx context.Context, db *database.DB, q *sqlcdb.Queries, svc sleepCandidate, apiPort int) {
runner := newAgentNodeRunner(db)
for _, agentID := range remoteAgentsForService(ctx, db, svc.ID) {
if err := runner.ControlService(ctx, svc.ID.String(), agentID, "remove"); err != nil {
log.Printf("sleep: remote remove on %s failed: %v", agentID, err)
continue
}
_ = q.DeleteServiceContainersOnAgent(ctx, sqlcdb.DeleteServiceContainersOnAgentParams{
NodeAgentID: agentID,
ServiceID: svc.ID.String(),
})
}
tracker.mu.Lock()
delete(tracker.activity, svc.ID)
tracker.mu.Unlock()
_, _ = db.Exec(`UPDATE services SET status = 'sleeping', updated_at = $1 WHERE id = $2`, time.Now(), svc.ID)
log.Printf("sleep: %s (%s) remote replicas removed after idle timeout", svc.Name, svc.ID)
var domain string
_ = db.QueryRow(`SELECT COALESCE(domain,'') FROM services WHERE id = $1`, svc.ID).Scan(&domain)
domains := serviceDomainNames(db, svc.ID, domain)
if len(domains) > 0 {
writeWakeIngress(svc.ID.String(), domains, apiPort)
}
}
// markIdle returns true when the network counter stopped growing past the
// timeout. First sighting only sets the baseline — no idle without history.
func markIdle(id uuid.UUID, bytes uint64, timeout time.Duration) bool {
tracker.mu.Lock()
defer tracker.mu.Unlock()
a := tracker.activity[id]
if bytes != a.bytes || a.idleSince.IsZero() {
tracker.activity[id] = sleepActivity{bytes: bytes, idleSince: time.Now()}
return false
}
return time.Since(a.idleSince) >= timeout
}
func containerNetBytes(ctx context.Context, dc *docker.Client, id string) uint64 {
stats, err := dc.GetContainerStats(ctx, id, false)
if err != nil || stats == nil {
return 0
}
defer stats.Body.Close()
var snap struct {
Networks map[string]struct {
RxBytes uint64 `json:"rx_bytes"`
TxBytes uint64 `json:"tx_bytes"`
} `json:"networks"`
}
if err := json.NewDecoder(stats.Body).Decode(&snap); err != nil {
return 0
}
var total uint64
for _, n := range snap.Networks {
total += n.RxBytes + n.TxBytes
}
return total
}
// putToSleep removes the workload containers, marks the service sleeping,
// and installs the wake placeholder when the service has a public domain.
func putToSleep(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, svc sleepCandidate, apiPort int) {
containers, err := engine.ListServiceContainers(ctx, svc.ID.String())
if err != nil {
return
}
for _, c := range containers {
if c.Labels[wakeLabel] == "true" {
continue
}
_ = dc.StopContainer(ctx, c.ID, nil)
_ = dc.RemoveContainer(ctx, c.ID, true)
}
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) scaled to zero 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 {
return
}
if err := spawnWakeContainer(ctx, db, dc, engine, svc, domains, apiPort); err != nil {
log.Printf("sleep: wake placeholder for %s failed: %v", svc.Name, err)
}
}
// wakeCGIScript is installed as /cgi-bin/wake in the wake container. It
// fires the wake callback on the first reachable candidate URL, in the
// background, then serves the reloading placeholder page. Deliberately
// single-quote-free — the file is written through printf '%s'.
const wakeCGIScript = `#!/bin/sh
for u in $WAKE_URLS; do wget -q -O /dev/null --timeout=20 "$u" && break; done >/dev/null 2>&1 &
printf "Content-Type: text/html\r\n\r\n"
printf "%s" "$WAKE_PAGE"
`
// spawnWakeContainer runs a busybox httpd holding the service's Traefik
// host rule at higher priority. Inbound hits redirect to /cgi-bin/wake,
// which calls the internal wake endpoint and serves a reloading page
// until the reconcile removes the placeholder.
func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, svc sleepCandidate, domains []string, apiPort int) error {
edgeNet := ""
for _, n := range networkList(ctx, dc) {
if n == "containr-network" {
edgeNet = n
}
}
if edgeNet == "" {
return fmt.Errorf("no edge network")
}
router := "svc-" + svc.ID.String()[:8]
sort.Strings(domains)
rules := make([]string, 0, len(domains))
for _, d := range domains {
rules = append(rules, "Host(`"+d+"`)")
}
labels := map[string]string{
deployment.LabelManaged: "true",
deployment.LabelProject: svc.ProjectID.String(),
wakeForLabel: svc.ID.String(),
wakeLabel: "true",
"traefik.enable": "true",
"traefik.docker.network": edgeNet,
"traefik.http.routers." + router + ".rule": strings.Join(rules, " || "),
"traefik.http.routers." + router + ".entrypoints": "web",
"traefik.http.routers." + router + ".priority": "100",
"traefik.http.services." + router + ".loadbalancer.server.port": "80",
}
wakeURLs := wakeEndpointURLs(ctx, dc, apiPort, svc.ID, edgeNet)
page := `<!doctype html><html><head><meta http-equiv="refresh" content="4"><title>Waking up</title>` +
`<style>body{font-family:system-ui,sans-serif;background:#0b0d12;color:#dde2ea;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}` +
`.card{text-align:center;max-width:34rem;padding:2rem}h1{font-size:1.25rem;font-weight:600}p{color:#98a2b3}</style></head>` +
`<body><div class="card"><h1>Service is waking up</h1><p>This Containr service was sleeping and is resuming now. The page reloads automatically.</p></div></body></html>`
// The wake callback must fire when a request arrives, not at boot —
// firing unconditionally makes the service flap sleep→wake forever.
// index.html redirects to /cgi-bin/wake; the CGI fires the callback
// (first reachable URL wins) and serves the reloading page.
indexPage := `<!doctype html><html><head><meta http-equiv="refresh" content="0;url=/cgi-bin/wake"></head><body></body></html>`
// httpd without -f daemonizes, so PID1 ends on sleep infinity. URLs
// and the page travel via env so the CGI script needs no quoting.
cmd := fmt.Sprintf(
`mkdir -p /www/cgi-bin && printf '%%s' '%s' > /www/index.html && `+
`printf '%%s' '%s' > /www/cgi-bin/wake && chmod +x /www/cgi-bin/wake && `+
`printf '%%s\n' 'E404:/cgi-bin/wake' > /www/httpd.conf && `+
`httpd -p 80 -h /www && sleep infinity`,
strings.ReplaceAll(indexPage, "'", "'\\''"),
strings.ReplaceAll(wakeCGIScript, "'", "'\\''"))
endpoints := map[string]*network.EndpointSettings{edgeNet: {}}
if ownNet := dc.OwnNetworkName(ctx); ownNet != "" && ownNet != edgeNet {
endpoints[ownNet] = &network.EndpointSettings{}
}
// Pull is best-effort — the image almost always sits in cache.
_ = dc.PullImageWait(ctx, wakeImage, registry.AuthConfig{})
// A previous placeholder may linger as Exited — clear the name first.
name := fmt.Sprintf("containr-wake-%s", svc.ID.String()[:8])
if stale, err := dc.ListContainersFiltered(ctx, filters.NewArgs(
filters.Arg("name", "^/"+name+"$"),
), true); err == nil {
for _, s := range stale {
_ = dc.RemoveContainer(ctx, s.ID, true)
}
}
id, err := dc.CreateContainer(ctx, docker.ContainerConfig{
Name: name,
Image: wakeImage,
Cmd: []string{"sh", "-c", cmd},
Env: []string{
"WAKE_URLS=" + strings.Join(wakeURLs, " "),
"WAKE_PAGE=" + page,
},
Labels: labels,
Networks: endpoints,
})
if err != nil {
return err
}
return dc.StartContainer(ctx, id)
}
func networkList(ctx context.Context, dc *docker.Client) []string {
networks, err := dc.ListNetworks(ctx)
if err != nil {
return nil
}
names := make([]string, 0, len(networks))
for _, n := range networks {
names = append(names, n.Name)
}
return names
}
// wakeEndpointURLs builds ordered candidate URLs for the wake container to
// call, deduplicated. No single address covers every topology: a containerized
// API resolves by name on the shared network; Docker Desktop containers reach
// the host via host.docker.internal; on native Docker the edge network's
// gateway routes to host-published ports. busybox wget walks the chain until
// one answers.
func wakeEndpointURLs(ctx context.Context, dc *docker.Client, apiPort int, serviceID uuid.UUID, edgeNet string) []string {
var hosts []string
if dc.OwnNetworkName(ctx) != "" {
if self, err := dc.GetContainer(ctx, selfID()); err == nil {
if name := strings.TrimPrefix(self.Name, "/"); name != "" {
hosts = append(hosts, name)
}
}
}
hosts = append(hosts, "host.docker.internal")
if gw := networkGateway(ctx, dc, edgeNet); gw != "" {
hosts = append(hosts, gw)
}
if probe := dc.HostProbeHost(ctx); probe != "" {
hosts = append(hosts, probe)
}
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 {
// ListNetworks does not populate IPAM config — inspect is required.
return dc.NetworkGateway(ctx, name)
}
func selfID() string {
// Inside a container the hostname is the short container ID.
if hn, err := os.Hostname(); err == nil {
return hn
}
return ""
}
// pruneWakeContainers removes wake placeholders whose service is healthy
// again — belt & braces over the reconcile drift sweep.
func pruneWakeContainers(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine) {
containers, err := dc.ListContainersFiltered(ctx, filters.NewArgs(filters.Arg("label", wakeLabel+"=true")), true)
if err != nil {
return
}
for _, c := range containers {
svcID := c.Labels[wakeForLabel]
if svcID == "" {
continue
}
running, _ := engine.ListServiceContainers(ctx, svcID)
live := 0
for _, r := range running {
if r.State == "running" {
live++
}
}
var status string
_ = db.QueryRow(`SELECT COALESCE(status,'') FROM services WHERE id = $1`, svcID).Scan(&status)
if live > 0 || status == "stopped" || status == "" {
_ = dc.RemoveContainer(ctx, c.ID, true)
}
}
}
// handleServiceWake is the unauthenticated internal endpoint the wake
// container hits. It reconciles the service back to running. Waking is
// idempotent and non-destructive, so no token is required; the endpoint
// never mutates configuration.
func handleServiceWake(c *gin.Context) {
code, body := triggerServiceWake(c)
if code != 0 {
c.JSON(code, body)
}
}
// handleServiceWakePage is the remote ingress wake target — Traefik routes
// a sleeping remote service's domain here via the file provider. Every hit
// (re)triggers the wake reconcile and returns a reloading page.
func handleServiceWakePage(c *gin.Context) {
triggerServiceWake(c)
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, wakePageHTML)
}
const wakePageHTML = `<!doctype html><html><head><meta http-equiv="refresh" content="4"><title>Waking up</title>` +
`<style>body{font-family:system-ui,sans-serif;background:#0b0d12;color:#dde2ea;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}` +
`.card{text-align:center;max-width:34rem;padding:2rem}h1{font-size:1.25rem;font-weight:600}p{color:#98a2b3}</style></head>` +
`<body><div class="card"><h1>Service is waking up</h1><p>This Containr service was sleeping and is resuming now. The page reloads automatically.</p></div></body></html>`
// triggerServiceWake loads the service and reconciles it back to running
// in the background. Returns (0, nil) when a response was already written
// by an embedded handler path, else the JSON status to send.
func triggerServiceWake(c *gin.Context) (int, gin.H) {
dbVal, _ := c.Get("db")
db := dbVal.(*database.DB)
engineVal, _ := c.Get("deployment_engine")
engine, _ := engineVal.(*deployment.DeploymentEngine)
if engine == nil {
return http.StatusServiceUnavailable, gin.H{"error": "deployment engine unavailable"}
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
return http.StatusBadRequest, gin.H{"error": "invalid service id"}
}
var service Service
err = db.QueryRow(
`SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
COALESCE(s.cpu, ''), COALESCE(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`, id,
).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,
)
if err != nil {
return http.StatusNotFound, gin.H{"error": "service not found"}
}
if service.Status == "running" || service.Status == "deployed" {
return http.StatusOK, gin.H{"status": service.Status}
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
spec, err := serviceRuntimeSpec(db, service)
if err != nil {
log.Printf("wake: runtime spec for %s failed: %v", service.ID, err)
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
return
}
state, err := engine.ReconcileService(ctx, spec)
if err != nil {
log.Printf("wake: reconcile for %s failed: %v", service.ID, err)
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
return
}
status := "running"
if state != nil {
status = state.Status
persistPublishedPort(db, service.ID, state.Ports)
}
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
}()
return http.StatusAccepted, gin.H{"status": "waking"}
}
// handleServiceSleep puts a service to sleep immediately (owner+ only).
func handleServiceSleep(c *gin.Context) {
dbVal, _ := c.Get("db")
db := dbVal.(*database.DB)
engineVal, _ := c.Get("deployment_engine")
engine, _ := engineVal.(*deployment.DeploymentEngine)
dcVal, _ := c.Get("docker_client")
dc, _ := dcVal.(*docker.Client)
if engine == nil || dc == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "deployment engine unavailable"})
return
}
service, ok := loadOwnedService(c, db)
if !ok {
return
}
var port int
_ = db.QueryRow(`SELECT COALESCE(port,0) FROM services WHERE id = $1`, service.ID).Scan(&port)
svc := sleepCandidate{
ID: service.ID,
ProjectID: service.ProjectID,
Name: service.Name,
Status: service.Status,
}
q := sqlcdb.New(db.DB)
if len(remoteLiveContainers(c.Request.Context(), q, service.ID.String())) > 0 {
go putToSleepRemote(context.Background(), db, q, svc, apiPortFromContext(c))
} else {
go putToSleep(context.Background(), db, dc, engine, svc, apiPortFromContext(c))
}
c.JSON(http.StatusAccepted, gin.H{"status": "sleeping"})
}
// handleServiceWakeNow wakes a sleeping service on demand (owner+ only).
func handleServiceWakeNow(c *gin.Context) {
if _, ok := loadOwnedService(c, c.MustGet("db").(*database.DB)); !ok {
return
}
handleServiceWake(c)
}
// apiPortFromContext pulls the configured HTTP port for the wake callback URL.
func apiPortFromContext(c *gin.Context) int {
if v, exists := c.Get("api_port"); exists {
if p, ok := v.(int); ok {
return p
}
}
return 8080
}
+41
View File
@@ -0,0 +1,41 @@
package api
import (
"testing"
"time"
"github.com/google/uuid"
)
func TestMarkIdleTracksCounter(t *testing.T) {
id := uuid.New()
timeout := 50 * time.Millisecond
// First sighting sets the baseline only.
if markIdle(id, 100, timeout) {
t.Fatal("slept on first sighting")
}
// Counter growth resets the idle clock.
if markIdle(id, 200, timeout) {
t.Fatal("slept while counter grew")
}
// Flat counter within the window doesn't sleep.
if markIdle(id, 200, timeout) {
t.Fatal("slept before timeout")
}
time.Sleep(60 * time.Millisecond)
if !markIdle(id, 200, timeout) {
t.Fatal("did not sleep after timeout")
}
}
func TestMarkIdleZeroCounterSleeps(t *testing.T) {
id := uuid.New()
timeout := 20 * time.Millisecond
markIdle(id, 0, timeout)
time.Sleep(30 * time.Millisecond)
if !markIdle(id, 0, timeout) {
t.Fatal("zero-traffic service never slept")
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,167 @@
package api
import (
"os"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func composeToSpecsForTest(t *testing.T, y string) ([]TemplateServiceSpec, []string, error) {
t.Helper()
var file composeFileDef
if err := yaml.Unmarshal([]byte(y), &file); err != nil {
t.Fatalf("yaml: %v", err)
}
return composeToSpecs(&file)
}
func TestComposeToSpecsConvertsStack(t *testing.T) {
specs, warnings, err := composeToSpecsForTest(t, `services:
web:
image: traefik/whoami:latest
ports:
- "80"
environment:
GREETING: hello-compose
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: pgsecret123
POSTGRES_DB: appdb
`)
if err != nil {
t.Fatal(err)
}
if len(specs) != 2 {
t.Fatalf("want 2 specs, got %d", len(specs))
}
db, web := specs[0], specs[1]
if db.Key != "db" || db.Type != "database" || db.Runtime != "postgresql" {
t.Errorf("db spec wrong: %+v", db)
}
if db.Environment["POSTGRES_PASSWORD"] != "pgsecret123" {
t.Errorf("db env lost: %v", db.Environment)
}
if web.Key != "web" || web.Type != "web" || web.Runtime != "traefik/whoami:latest" {
t.Errorf("web spec wrong: %+v", web)
}
if web.Port != 80 {
t.Errorf("web port = %d, want 80", web.Port)
}
if len(web.DependsOn) != 1 || web.DependsOn[0] != "db" {
t.Errorf("web depends_on = %v, want [db]", web.DependsOn)
}
if len(warnings) != 0 {
t.Errorf("unexpected warnings: %v", warnings)
}
}
func TestComposeToSpecsDeterministicOrder(t *testing.T) {
y := `services:
zebra:
image: alpine
alpha:
image: alpine
`
for i := 0; i < 5; i++ {
specs, _, err := composeToSpecsForTest(t, y)
if err != nil {
t.Fatal(err)
}
if specs[0].Key != "alpha" || specs[1].Key != "zebra" {
t.Fatalf("unstable order: %v", specs)
}
}
}
func TestComposeToSpecsDatabaseImages(t *testing.T) {
cases := map[string]string{
"postgres:16": "postgresql",
"redis:7": "redis",
"mysql:8": "mysql",
"mariadb:11": "mariadb",
"mongo:7": "mongodb",
"clickhouse:24": "clickhouse",
}
for image, want := range cases {
specs, _, err := composeToSpecsForTest(t, "services:\n db:\n image: "+image+"\n")
if err != nil {
t.Fatalf("%s: %v", image, err)
}
if specs[0].Type != "database" || specs[0].Runtime != want {
t.Errorf("%s → %+v, want database/%s", image, specs[0], want)
}
}
}
func TestComposeToSpecsNoServices(t *testing.T) {
_, _, err := composeToSpecsForTest(t, "version: '3'\n")
if err == nil || !strings.Contains(err.Error(), "no services") {
t.Errorf("want no-services error, got %v", err)
}
}
func TestComposeToSpecsWarnings(t *testing.T) {
_, warnings, err := composeToSpecsForTest(t, `services:
app:
build: .
orphan:
image: alpine
environment:
- UNSET_VAR
`)
if err != nil {
t.Fatal(err)
}
joined := strings.Join(warnings, "\n")
if !strings.Contains(joined, "build contexts are not supported") {
t.Errorf("missing build warning: %v", warnings)
}
if !strings.Contains(joined, "UNSET_VAR") {
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,202 @@
package api
import (
"encoding/json"
"strings"
"testing"
)
func TestTopoSortServicesOrdersByDependsOn(t *testing.T) {
sorted, err := topoSortServices([]TemplateServiceSpec{
{Key: "web", DependsOn: []string{"db"}},
{Key: "db", Type: "database"},
{Key: "worker", DependsOn: []string{"web", "db"}},
})
if err != nil {
t.Fatalf("topoSortServices: %v", err)
}
if sorted[0].Key != "db" || sorted[1].Key != "web" || sorted[2].Key != "worker" {
t.Fatalf("unexpected order: %v", []string{sorted[0].Key, sorted[1].Key, sorted[2].Key})
}
}
func TestTopoSortServicesRejectsCycle(t *testing.T) {
_, err := topoSortServices([]TemplateServiceSpec{
{Key: "a", DependsOn: []string{"b"}},
{Key: "b", DependsOn: []string{"a"}},
})
if err == nil || !strings.Contains(err.Error(), "cycle") {
t.Fatalf("expected cycle error, got %v", err)
}
}
func TestTopoSortServicesRejectsUnknownDep(t *testing.T) {
_, err := topoSortServices([]TemplateServiceSpec{
{Key: "a", DependsOn: []string{"ghost"}},
})
if err == nil || !strings.Contains(err.Error(), "unknown key") {
t.Fatalf("expected unknown-key error, got %v", err)
}
}
func TestResolveStaticExpr(t *testing.T) {
unresolved := []string{}
var generated bool
v := resolveStaticExpr("{{secret}}", nil, &unresolved, &generated)
if len(v) != 32 || !generated {
t.Fatalf("secret: got %q generated=%v", v, generated)
}
v = resolveStaticExpr("{{secret(8)}}", nil, &unresolved, &generated)
if len(v) != 16 {
t.Fatalf("secret(8): got %q", v)
}
v = resolveStaticExpr("port-{{random:10}}", nil, &unresolved, &generated)
if !strings.HasPrefix(v, "port-") {
t.Fatalf("random: got %q", v)
}
v = resolveStaticExpr("{{DOMAIN:-example.com}}", nil, &unresolved, &generated)
if v != "example.com" {
t.Fatalf("var default: got %q", v)
}
v = resolveStaticExpr("{{DOMAIN}}", map[string]string{"DOMAIN": "x.io"}, &unresolved, &generated)
if v != "x.io" {
t.Fatalf("var override: got %q", v)
}
v = resolveStaticExpr("{{MISSING}}", nil, &unresolved, &generated)
if v != "" || len(unresolved) == 0 || unresolved[0] != "MISSING" {
t.Fatalf("missing var: got %q unresolved=%v", v, unresolved)
}
}
func TestResolveTemplateGraphDatabaseRefs(t *testing.T) {
sorted, err := topoSortServices([]TemplateServiceSpec{
{Key: "db", Type: "database", Runtime: "postgresql", Environment: map[string]string{
"POSTGRES_DB": "shop",
}},
{Key: "web", Type: "web", Runtime: "nginx", DependsOn: []string{"db"}, Environment: map[string]string{
"DATABASE_URL": "{{service.db.url}}",
"DB_HOST": "{{service.db.host}}",
"DB_PORT": "{{service.db.port}}",
"SESSION_TOKEN": "{{secret}}",
}},
})
if err != nil {
t.Fatalf("topo: %v", err)
}
graph := resolveTemplateGraph(sorted, nil)
if len(graph.unresolved) > 0 {
t.Fatalf("unresolved: %v", graph.unresolved)
}
web := graph.members[1]
if web.env["DB_HOST"] != "db" {
t.Fatalf("DB_HOST=%q", web.env["DB_HOST"])
}
if web.env["DB_PORT"] != "5432" {
t.Fatalf("DB_PORT=%q", web.env["DB_PORT"])
}
dbURL := web.env["DATABASE_URL"]
if !strings.HasPrefix(dbURL, "postgresql://") || !strings.Contains(dbURL, "@db:5432/shop") {
t.Fatalf("DATABASE_URL=%q", dbURL)
}
if len(web.env["SESSION_TOKEN"]) != 32 || !web.secretKeys["SESSION_TOKEN"] {
t.Fatalf("SESSION_TOKEN=%q secret=%v", web.env["SESSION_TOKEN"], web.secretKeys["SESSION_TOKEN"])
}
// Database member must carry the generated password so refs and the
// container agree.
db := graph.members[0]
if pw := db.env["POSTGRES_PASSWORD"]; len(pw) != 32 || !db.secretKeys["POSTGRES_PASSWORD"] {
t.Fatalf("POSTGRES_PASSWORD=%q secret=%v", pw, db.secretKeys["POSTGRES_PASSWORD"])
}
if !strings.Contains(dbURL, db.env["POSTGRES_PASSWORD"]) {
t.Fatalf("url %q does not carry the resolved password", dbURL)
}
}
func TestResolveTemplateGraphServiceRefs(t *testing.T) {
sorted, err := topoSortServices([]TemplateServiceSpec{
{Key: "api", Type: "web", Runtime: "ghcr.io/x/api:1", Port: 8080},
{Key: "front", Type: "web", Runtime: "ghcr.io/x/front:1", Port: 3000, DependsOn: []string{"api"}, Environment: map[string]string{
"API_URL": "{{service.api.url}}/v2",
}},
})
if err != nil {
t.Fatalf("topo: %v", err)
}
graph := resolveTemplateGraph(sorted, nil)
if got := graph.members[1].env["API_URL"]; got != "http://api:8080/v2" {
t.Fatalf("API_URL=%q", got)
}
}
func TestResolveTemplateGraphUnresolvedServiceProp(t *testing.T) {
sorted, _ := topoSortServices([]TemplateServiceSpec{
{Key: "web", Type: "web", Runtime: "nginx", Environment: map[string]string{
"X": "{{service.nope.host}}",
}},
})
graph := resolveTemplateGraph(sorted, nil)
if len(graph.unresolved) == 0 {
t.Fatal("expected unresolved ref")
}
}
func TestValidateTemplateGraph(t *testing.T) {
if err := validateTemplateGraph([]TemplateServiceSpec{
{Key: "db", Type: "database", Runtime: "postgresql"},
}); err != nil {
t.Fatalf("valid db spec rejected: %v", err)
}
if err := validateTemplateGraph([]TemplateServiceSpec{
{Key: "db", Type: "database", Runtime: "nonsense"},
}); err == nil {
t.Fatal("expected unsupported db engine error")
}
if err := validateTemplateGraph([]TemplateServiceSpec{
{Key: "web", Type: "web"},
}); err == nil {
t.Fatal("expected missing runtime error")
}
}
// TestOfficialTemplatesAllParse walks every seeded template and asserts the
// config parses, graph templates topo-sort + validate, and a dry-run resolve
// produces no unresolved refs (defaults cover all expressions).
func TestOfficialTemplatesAllParse(t *testing.T) {
for _, tpl := range SeedTemplates() {
var cfg TemplateConfig
if err := json.Unmarshal([]byte(tpl.Config), &cfg); err != nil {
t.Errorf("%s: config parse: %v", tpl.ID, err)
continue
}
if len(cfg.Services) == 0 {
continue
}
sorted, err := topoSortServices(cfg.Services)
if err != nil {
t.Errorf("%s: topo sort: %v", tpl.ID, err)
continue
}
if err := validateTemplateGraph(sorted); err != nil {
t.Errorf("%s: validate: %v", tpl.ID, err)
continue
}
var vars []TemplateVariable
if tpl.Variables != "" {
if err := json.Unmarshal([]byte(tpl.Variables), &vars); err != nil {
t.Errorf("%s: variables parse: %v", tpl.ID, err)
continue
}
}
userVars := map[string]string{}
for _, v := range vars {
if v.Default != "" {
userVars[v.Key] = v.Default
}
}
graph := resolveTemplateGraph(sorted, userVars)
if len(graph.unresolved) > 0 {
t.Errorf("%s: unresolved refs: %v", tpl.ID, graph.unresolved)
}
}
}
@@ -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)
}
+288 -21
View File
@@ -3,6 +3,8 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/secrets"
"context"
"database/sql"
"encoding/json"
@@ -19,21 +21,24 @@ import (
)
type ServiceTemplate struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Category string `json:"category" db:"category"`
Logo string `json:"logo" db:"logo"`
Config string `json:"config" db:"config"`
Variables string `json:"variables" db:"variables"`
IsOfficial bool `json:"is_official" db:"is_official"`
OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
IsPublic bool `json:"is_public" db:"is_public"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Category string `json:"category" db:"category"`
Logo string `json:"logo" db:"logo"`
Config string `json:"config" db:"config"`
Variables string `json:"variables" db:"variables"`
IsOfficial bool `json:"is_official" db:"is_official"`
OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
IsPublic bool `json:"is_public" db:"is_public"`
// Source marks catalog origin: "" (local/official) or "registry".
Source string `json:"source,omitempty" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type TemplateConfig struct {
Version int `json:"version,omitempty"`
Type string `json:"type"`
Runtime string `json:"runtime"`
BuildCommand string `json:"build_command"`
@@ -43,6 +48,9 @@ type TemplateConfig struct {
Environment map[string]string `json:"environment"`
Dockerfile string `json:"dockerfile,omitempty"`
NixpacksConfig map[string]string `json:"nixpacks_config,omitempty"`
// Services makes this a v2 graph template. When non-empty the flat
// fields above are ignored and deployTemplateGraph handles the deploy.
Services []TemplateServiceSpec `json:"services,omitempty"`
}
type TemplateVariable struct {
@@ -91,10 +99,24 @@ func handleGetTemplates(c *gin.Context) {
}
templates := make([]ServiceTemplate, 0, len(templateRows))
localIDs := make(map[string]bool, len(templateRows))
for _, row := range templateRows {
localIDs[row.ID] = true
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})
}
@@ -106,6 +128,11 @@ func handleGetTemplate(c *gin.Context) {
row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID)
if err != nil {
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"})
return
}
@@ -147,7 +174,7 @@ func handleCreateFromTemplate(c *gin.Context) {
var req struct {
ProjectID string `json:"project_id" binding:"required"`
Name string `json:"name" binding:"required"`
Name string `json:"name"`
Plan string `json:"plan,omitempty"`
Region string `json:"region,omitempty"`
Variables map[string]string `json:"variables"`
@@ -160,10 +187,6 @@ func handleCreateFromTemplate(c *gin.Context) {
req.ProjectID = strings.TrimSpace(req.ProjectID)
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
projectID, err := uuid.Parse(req.ProjectID)
if err != nil {
@@ -188,11 +211,18 @@ func handleCreateFromTemplate(c *gin.Context) {
templateRow, err := queries.GetServiceTemplateByID(ctx, templateID)
if err != nil {
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
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
return
}
template := mapSQLCTemplate(templateRow)
@@ -213,6 +243,51 @@ func handleCreateFromTemplate(c *gin.Context) {
return
}
if len(config.Services) > 0 {
var engine *deployment.DeploymentEngine
if v, exists := c.Get("deployment_engine"); exists && v != nil {
engine, _ = v.(*deployment.DeploymentEngine)
}
var dbHandler *DatabaseHandler
if v, exists := c.Get("database_handler"); exists {
dbHandler, _ = v.(*DatabaseHandler)
}
userVars := map[string]string{}
for _, v := range templateVars {
if override, ok := req.Variables[v.Key]; ok {
userVars[v.Key] = override
} else if v.Default != "" {
userVars[v.Key] = v.Default
}
}
for k, v := range req.Variables {
userVars[k] = v
}
created, fail := deployTemplateGraph(c, db, engine, dbHandler, projectID, userID,
config.Services, templateVars, userVars, req.Plan, req.Region, "template:"+templateID)
if fail != nil {
status := http.StatusBadRequest
if (*fail)["code"] == "CONFLICT" {
status = http.StatusConflict
}
c.JSON(status, *fail)
return
}
c.JSON(http.StatusCreated, gin.H{
"resource": "stack",
"created": created,
"message": fmt.Sprintf("Deployed %d resources from template %s", len(created), template.Name),
})
return
}
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
serviceType, err := normalizeTemplateServiceType(config.Type)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -351,11 +426,15 @@ func handleCreateFromTemplate(c *gin.Context) {
continue
}
stored := value
if secretKeys[key] {
stored = secrets.Encrypt(value)
}
err = txQueries.UpsertEnvironmentVariable(ctx, sqlcdb.UpsertEnvironmentVariableParams{
ID: uuid.New(),
ServiceID: serviceID,
Key: key,
Value: value,
Value: stored,
IsSecret: sql.NullBool{Bool: secretKeys[key], Valid: true},
CreatedAt: sql.NullTime{Time: now, Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
@@ -927,6 +1006,194 @@ func SeedTemplates() []ServiceTemplate {
Variables: `[]`,
IsOfficial: true,
},
// Multi-service stacks — v2 graph configs. Database members become
// managed databases attached to the project network under their key.
{
ID: "tpl-wordpress-stack",
Name: "WordPress + MySQL",
Description: "WordPress with a managed MySQL database on a private project network",
Category: "stack",
Logo: "https://cdn.simpleicons.org/wordpress",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"mysql","environment":{"MYSQL_DATABASE":"wordpress"}},
{"key":"web","name":"wordpress","type":"web","runtime":"wordpress:6-apache","port":80,"health_check":"/wp-login.php","depends_on":["db"],
"environment":{"WORDPRESS_DB_HOST":"{{service.db.host}}","WORDPRESS_DB_USER":"{{service.db.user}}","WORDPRESS_DB_PASSWORD":"{{service.db.password}}","WORDPRESS_DB_NAME":"{{service.db.database}}"}}]}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-n8n-stack",
Name: "n8n + PostgreSQL",
Description: "n8n workflow automation backed by a managed PostgreSQL database",
Category: "stack",
Logo: "https://cdn.simpleicons.org/n8n",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql"},
{"key":"n8n","type":"web","runtime":"n8nio/n8n:latest","port":5678,"health_check":"/healthz","depends_on":["db"],
"environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"{{service.db.port}}","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","N8N_ENCRYPTION_KEY":"{{secret}}"}}]}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-ghost-stack",
Name: "Ghost + MySQL",
Description: "Ghost publishing platform backed by a managed MySQL database",
Category: "stack",
Logo: "https://cdn.simpleicons.org/ghost",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"mysql","environment":{"MYSQL_DATABASE":"ghost"}},
{"key":"web","name":"ghost","type":"web","runtime":"ghost:5-alpine","port":2368,"depends_on":["db"],
"environment":{"url":"{{APP_URL:-http://localhost:2368}}","database__client":"mysql","database__connection__host":"{{service.db.host}}","database__connection__user":"{{service.db.user}}","database__connection__password":"{{service.db.password}}","database__connection__database":"{{service.db.database}}"}}]}`,
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-gitea-stack",
Name: "Gitea + PostgreSQL",
Description: "Self-hosted Git service backed by a managed PostgreSQL database",
Category: "stack",
Logo: "https://cdn.simpleicons.org/gitea",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"gitea"}},
{"key":"web","name":"gitea","type":"web","runtime":"gitea/gitea:1","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__SECRET_KEY":"{{secret}}","GITEA__security__INTERNAL_TOKEN":"{{secret(64)}}"}}]}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-nextcloud-stack",
Name: "Nextcloud + PostgreSQL + Redis",
Description: "Nextcloud with a managed PostgreSQL database and Redis cache",
Category: "stack",
Logo: "https://cdn.simpleicons.org/nextcloud",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"nextcloud"}},
{"key":"cache","type":"database","runtime":"redis"},
{"key":"web","name":"nextcloud","type":"web","runtime":"nextcloud:30-apache","port":80,"health_check":"/status.php","depends_on":["db","cache"],
"environment":{"POSTGRES_HOST":"{{service.db.host}}","POSTGRES_DB":"{{service.db.database}}","POSTGRES_USER":"{{service.db.user}}","POSTGRES_PASSWORD":"{{service.db.password}}","REDIS_HOST":"{{service.cache.host}}","REDIS_HOST_PASSWORD":"{{service.cache.password}}","NEXTCLOUD_ADMIN_USER":"admin","NEXTCLOUD_ADMIN_PASSWORD":"{{secret}}","NEXTCLOUD_TRUSTED_DOMAINS":"{{APP_DOMAIN:-localhost}}"}}]}`,
Variables: `[{"key":"APP_DOMAIN","label":"Trusted domain","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-paperless-stack",
Name: "Paperless-ngx + PostgreSQL + Redis",
Description: "Document management with managed PostgreSQL and Redis",
Category: "stack",
Logo: "https://cdn.simpleicons.org/paperlessngx",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"paperless"}},
{"key":"cache","type":"database","runtime":"redis"},
{"key":"web","name":"paperless","type":"web","runtime":"ghcr.io/paperless-ngx/paperless-ngx:latest","port":8000,"depends_on":["db","cache"],
"environment":{"PAPERLESS_DBHOST":"{{service.db.host}}","PAPERLESS_DBPORT":"{{service.db.port}}","PAPERLESS_DBNAME":"{{service.db.database}}","PAPERLESS_DBUSER":"{{service.db.user}}","PAPERLESS_DBPASS":"{{service.db.password}}","PAPERLESS_REDIS":"{{service.cache.url}}","PAPERLESS_ADMIN_USER":"admin","PAPERLESS_ADMIN_PASSWORD":"{{secret}}","PAPERLESS_SECRET_KEY":"{{secret(48)}}","PAPERLESS_URL":"{{APP_URL:-http://localhost:8000}}"}}]}`,
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-plausible-stack",
Name: "Plausible + PostgreSQL + ClickHouse",
Description: "Privacy-friendly analytics with managed PostgreSQL and ClickHouse",
Category: "stack",
Logo: "https://cdn.simpleicons.org/plausibleanalytics",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"plausible"}},
{"key":"events","type":"database","runtime":"clickhouse","environment":{"CLICKHOUSE_DB":"plausible"}},
{"key":"web","name":"plausible","type":"web","runtime":"ghcr.io/plausible/community-edition:v2","port":8000,"health_check":"/api/health","depends_on":["db","events"],
"environment":{"BASE_URL":"{{APP_URL:-http://localhost:8000}}","SECRET_KEY_BASE":"{{secret(64)}}","DATABASE_URL":"{{service.db.url}}","CLICKHOUSE_DATABASE_URL":"http://{{service.events.host}}:{{service.events.port}}/{{service.events.database}}","TOTP_VAULT_KEY":"{{secret(32)}}"}}]}`,
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-n8n",
Name: "n8n + PostgreSQL + Redis",
Description: "Workflow automation in queue mode with managed PostgreSQL and Redis",
Category: "stack",
Logo: "https://cdn.simpleicons.org/n8n",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"n8n","POSTGRES_USER":"n8n"},"volumes":[{"source":"n8n-db","target":"/var/lib/postgresql/data"}]},
{"key":"redis","type":"database","runtime":"redis"},
{"key":"n8n","type":"web","runtime":"docker.n8n.io/n8nio/n8n:latest","port":5678,"health_check":"/healthz","depends_on":["db","redis"],
"environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"5432","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","QUEUE_BULL_REDIS_HOST":"{{service.redis.host}}","QUEUE_BULL_REDIS_PASSWORD":"{{service.redis.password}}","EXECUTIONS_MODE":"queue","N8N_ENCRYPTION_KEY":"{{secret(32)}}","N8N_HOST":"{{APP_URL:-http://localhost:5678}}","N8N_EDITOR_BASE_URL":"{{APP_URL:-http://localhost:5678}}"},
"volumes":[{"source":"n8n-data","target":"/home/node/.n8n"}]},
{"key":"worker","name":"n8n-worker","type":"worker","runtime":"docker.n8n.io/n8nio/n8n:latest","start_command":"worker","depends_on":["n8n","db","redis"],
"environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"5432","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","QUEUE_BULL_REDIS_HOST":"{{service.redis.host}}","QUEUE_BULL_REDIS_PASSWORD":"{{service.redis.password}}","EXECUTIONS_MODE":"queue","N8N_ENCRYPTION_KEY":"{{secret(32)}}","QUEUE_HEALTH_CHECK_ACTIVE":"true"},
"volumes":[{"source":"n8n-data","target":"/home/node/.n8n"}]}]}`,
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-gitea",
Name: "Gitea + PostgreSQL",
Description: "Self-hosted git service with managed PostgreSQL",
Category: "stack",
Logo: "https://cdn.simpleicons.org/gitea",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"gitea","POSTGRES_USER":"gitea"},"volumes":[{"source":"gitea-db","target":"/var/lib/postgresql/data"}]},
{"key":"gitea","type":"web","runtime":"gitea/gitea:latest","port":3000,"health_check":"/api/healthz","depends_on":["db"],
"environment":{"GITEA__database__DB_TYPE":"postgres","GITEA__database__HOST":"{{service.db.host}}:{{service.db.port}}","GITEA__database__NAME":"{{service.db.database}}","GITEA__database__USER":"{{service.db.user}}","GITEA__database__PASSWD":"{{service.db.password}}","GITEA__security__INSTALL_LOCK":"true","GITEA__security__SECRET_KEY":"{{secret(32)}}","GITEA__security__INTERNAL_TOKEN":"{{secret(64)}}","GITEA__server__DOMAIN":"{{DOMAIN:-localhost}}","GITEA__server__ROOT_URL":"{{ROOT_URL:-http://localhost:3000}}","GITEA__server__SSH_PORT":"2222","USER_UID":"1000","USER_GID":"1000"},
"volumes":[{"source":"gitea-data","target":"/data"}]}]}`,
Variables: `[{"key":"DOMAIN","label":"Public domain","default":"","required":false,"secret":false},{"key":"ROOT_URL","label":"Root URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-nextcloud",
Name: "Nextcloud + PostgreSQL + Redis",
Description: "Self-hosted file sync and collaboration with managed PostgreSQL and Redis",
Category: "stack",
Logo: "https://cdn.simpleicons.org/nextcloud",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"nextcloud","POSTGRES_USER":"nextcloud"},"volumes":[{"source":"nextcloud-db","target":"/var/lib/postgresql/data"}]},
{"key":"redis","type":"database","runtime":"redis"},
{"key":"nextcloud","type":"web","runtime":"nextcloud:latest","port":80,"depends_on":["db","redis"],
"environment":{"POSTGRES_HOST":"{{service.db.host}}","POSTGRES_DB":"{{service.db.database}}","POSTGRES_USER":"{{service.db.user}}","POSTGRES_PASSWORD":"{{service.db.password}}","REDIS_HOST":"{{service.redis.host}}","REDIS_HOST_PASSWORD":"{{service.redis.password}}","NEXTCLOUD_ADMIN_USER":"admin","NEXTCLOUD_ADMIN_PASSWORD":"{{secret(24)}}","NEXTCLOUD_TRUSTED_DOMAINS":"{{DOMAIN:-localhost}}","OVERWRITEHOST":"{{DOMAIN:-}}","OVERWRITEPROTOCOL":"https"},
"volumes":[{"source":"nextcloud-data","target":"/var/www/html"}]}]}`,
Variables: `[{"key":"DOMAIN","label":"Trusted domain","default":"localhost","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-vaultwarden",
Name: "Vaultwarden + PostgreSQL",
Description: "Bitwarden-compatible password manager with managed PostgreSQL",
Category: "stack",
Logo: "https://cdn.simpleicons.org/vaultwarden",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"vaultwarden","POSTGRES_USER":"vaultwarden"},"volumes":[{"source":"vaultwarden-db","target":"/var/lib/postgresql/data"}]},
{"key":"vaultwarden","type":"web","runtime":"vaultwarden/server:latest","port":80,"health_check":"/alive","depends_on":["db"],
"environment":{"DATABASE_URL":"{{service.db.url}}","ADMIN_TOKEN":"{{secret(48)}}","DOMAIN":"{{DOMAIN:-http://localhost:80}}","SIGNUPS_ALLOWED":"false","WEBSOCKET_ENABLED":"true"},
"volumes":[{"source":"vaultwarden-data","target":"/data"}]}]}`,
Variables: `[{"key":"DOMAIN","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-umami",
Name: "Umami + PostgreSQL",
Description: "Lightweight privacy-first web analytics with managed PostgreSQL",
Category: "stack",
Logo: "https://cdn.simpleicons.org/umami",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"umami","POSTGRES_USER":"umami"},"volumes":[{"source":"umami-db","target":"/var/lib/postgresql/data"}]},
{"key":"umami","type":"web","runtime":"ghcr.io/umami-software/umami:postgresql-latest","port":3000,"depends_on":["db"],
"environment":{"DATABASE_URL":"{{service.db.url}}","APP_SECRET":"{{secret(32)}}"}}]}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-immich",
Name: "Immich + PostgreSQL + Redis",
Description: "Self-hosted photo and video backup with managed PostgreSQL and Redis",
Category: "stack",
Logo: "https://cdn.simpleicons.org/immich",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"immich","POSTGRES_USER":"immich"},"volumes":[{"source":"immich-db","target":"/var/lib/postgresql/data"}]},
{"key":"redis","type":"database","runtime":"redis"},
{"key":"ml","name":"immich-machine-learning","type":"worker","runtime":"ghcr.io/immich-app/immich-machine-learning:release",
"environment":{"DB_DATABASE_NAME":"{{service.db.database}}","DB_HOSTNAME":"{{service.db.host}}","DB_USERNAME":"{{service.db.user}}","DB_PASSWORD":"{{service.db.password}}","REDIS_HOSTNAME":"{{service.redis.host}}","REDIS_PASSWORD":"{{service.redis.password}}"},
"volumes":[{"source":"immich-model-cache","target":"/cache"}]},
{"key":"immich","name":"immich-server","type":"web","runtime":"ghcr.io/immich-app/immich-server:release","port":2283,"health_check":"/api/server/ping","depends_on":["db","redis","ml"],
"environment":{"DB_DATABASE_NAME":"{{service.db.database}}","DB_HOSTNAME":"{{service.db.host}}","DB_USERNAME":"{{service.db.user}}","DB_PASSWORD":"{{service.db.password}}","REDIS_HOSTNAME":"{{service.redis.host}}","REDIS_PASSWORD":"{{service.redis.password}}"},
"volumes":[{"source":"immich-upload","target":"/usr/src/app/upload"}]}]}`,
Variables: `[]`,
IsOfficial: true,
},
}
return templates
}
+33 -1
View File
@@ -1,6 +1,9 @@
package api
import "testing"
import (
"encoding/json"
"testing"
)
func TestNormalizeTemplateServiceType(t *testing.T) {
valid := []string{"web", "worker", "database", "cron"}
@@ -71,3 +74,32 @@ func TestResolveTemplateRuntimeImage(t *testing.T) {
t.Fatalf("expected unknown runtime passthrough, got %q", got)
}
}
func TestSeedTemplatesResolveCleanly(t *testing.T) {
for _, tpl := range SeedTemplates() {
var config TemplateConfig
if err := json.Unmarshal([]byte(tpl.Config), &config); err != nil {
t.Fatalf("%s: invalid config JSON: %v", tpl.ID, err)
}
var variables []TemplateVariable
if err := json.Unmarshal([]byte(tpl.Variables), &variables); err != nil {
t.Fatalf("%s: invalid variables JSON: %v", tpl.ID, err)
}
if len(config.Services) == 0 {
continue // legacy single-service template
}
sorted, err := topoSortServices(config.Services)
if err != nil {
t.Fatalf("%s: topo sort: %v", tpl.ID, err)
}
if err := validateTemplateGraph(sorted); err != nil {
t.Fatalf("%s: graph validation: %v", tpl.ID, err)
}
// User vars left empty — every expression must still resolve via
// defaults, secrets, or service refs.
graph := resolveTemplateGraph(sorted, map[string]string{})
if len(graph.unresolved) > 0 {
t.Fatalf("%s: unresolved expressions: %v", tpl.ID, graph.unresolved)
}
}
}
+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
}
+543
View File
@@ -0,0 +1,543 @@
package api
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
// Outbound webhooks: every audited action (LogAudit) emits an event named
// "resource.action" (e.g. "service.deploy"). Webhooks subscribe with an
// event list supporting "service.*" and "*" wildcards. Deliveries are
// HMAC-signed and retried up to 3 times with backoff; each attempt lands
// in webhook_deliveries.
var (
webhookQueue = make(chan webhookJob, 512)
webhookWorkers sync.Once
)
type webhookJob struct {
Webhook sqlcdb.OutboundWebhook
Event string
Payload []byte
}
type webhookEventPayload struct {
Event string `json:"event"`
Resource string `json:"resource"`
ResourceID string `json:"resource_id"`
Action string `json:"action"`
UserID string `json:"user_id,omitempty"`
Details map[string]interface{} `json:"details,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
// emitWebhookEvent fans an audited action out to all enabled webhooks whose
// event list matches. Called from LogAudit — fire-and-forget.
func emitWebhookEvent(userID, resource, resourceID, action string, details map[string]interface{}) {
db := GetAuditDB()
if db == nil {
return
}
// Actions that already carry a resource prefix ("service.clone") pass
// through unchanged; bare actions ("create") get resource-qualified.
event := action
if !strings.Contains(action, ".") {
event = resource + "." + action
}
queries := sqlcdb.New(db.DB)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
hooks, err := queries.ListEnabledOutboundWebhooks(ctx)
if err != nil || len(hooks) == 0 {
return
}
payload, err := json.Marshal(webhookEventPayload{
Event: event, Resource: resource, ResourceID: resourceID,
Action: action, UserID: userID, Details: details, Timestamp: time.Now().UTC(),
})
if err != nil {
return
}
uid, uidErr := uuid.Parse(strings.TrimSpace(userID))
for _, hook := range hooks {
if !webhookEventMatches(hook.Events, event) {
continue
}
// Webhooks only receive events for their owner — unless subscribed to "*"
// is deliberate admin reach; owner scoping keeps per-user isolation.
if uidErr != nil || hook.UserID != uid {
continue
}
select {
case webhookQueue <- webhookJob{Webhook: hook, Event: event, Payload: payload}:
default:
// Queue full: drop rather than block the request path.
}
}
}
func webhookEventMatches(events json.RawMessage, event string) bool {
var subs []string
if err := json.Unmarshal(events, &subs); err != nil || len(subs) == 0 {
return false
}
for _, sub := range subs {
sub = strings.TrimSpace(sub)
if sub == "*" || sub == event {
return true
}
if strings.HasSuffix(sub, ".*") && strings.HasPrefix(event, strings.TrimSuffix(sub, "*")) {
return true
}
}
return false
}
// StartWebhookDispatcher launches delivery workers; call once at boot.
func StartWebhookDispatcher() {
webhookWorkers.Do(func() {
for i := 0; i < 4; i++ {
go webhookDeliveryWorker()
}
})
}
func webhookDeliveryWorker() {
for job := range webhookQueue {
deliverWebhook(job)
}
}
var webhookHTTPClient = &http.Client{Timeout: 10 * time.Second}
func deliverWebhook(job webhookJob) {
db := GetAuditDB()
if db == nil {
return
}
queries := sqlcdb.New(db.DB)
ctx := context.Background()
deliveryID := uuid.New()
_ = queries.CreateWebhookDelivery(ctx, sqlcdb.CreateWebhookDeliveryParams{
ID: deliveryID,
WebhookID: job.Webhook.ID,
Event: job.Event,
Payload: pqtype.NullRawMessage{RawMessage: job.Payload, Valid: true},
Status: "pending",
})
var lastStatus string = "failed"
var respStatus sql.NullInt32
var respBody sql.NullString
var durationMs sql.NullInt32
var deliveredAt sql.NullTime
for attempt := int32(1); attempt <= 3; attempt++ {
start := time.Now()
status, code, body := postWebhook(job, deliveryID, attempt)
durationMs = sql.NullInt32{Int32: int32(time.Since(start).Milliseconds()), Valid: true}
respStatus = code
respBody = body
if status == "success" {
lastStatus = "success"
deliveredAt = sql.NullTime{Time: time.Now().UTC(), Valid: true}
_ = queries.UpdateWebhookDeliveryResult(ctx, sqlcdb.UpdateWebhookDeliveryResultParams{
ID: deliveryID,
Status: lastStatus,
ResponseStatus: respStatus,
ResponseBody: respBody,
Attempts: attempt,
DurationMs: durationMs,
DeliveredAt: deliveredAt,
})
return
}
if attempt < 3 {
time.Sleep(time.Duration(attempt*attempt) * 2 * time.Second)
}
}
_ = queries.UpdateWebhookDeliveryResult(ctx, sqlcdb.UpdateWebhookDeliveryResultParams{
ID: deliveryID,
Status: lastStatus,
ResponseStatus: respStatus,
ResponseBody: respBody,
Attempts: 3,
DurationMs: durationMs,
DeliveredAt: deliveredAt,
})
}
func postWebhook(job webhookJob, deliveryID uuid.UUID, attempt int32) (string, sql.NullInt32, sql.NullString) {
req, err := http.NewRequest(http.MethodPost, job.Webhook.Url, bytes.NewReader(job.Payload))
if err != nil {
return "failed", sql.NullInt32{}, sql.NullString{String: err.Error(), Valid: true}
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Containr-Webhook/1.0")
req.Header.Set("X-Containr-Event", job.Event)
req.Header.Set("X-Containr-Delivery", deliveryID.String())
if job.Webhook.Secret != "" {
mac := hmac.New(sha256.New, []byte(job.Webhook.Secret))
mac.Write(job.Payload)
req.Header.Set("X-Containr-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
if job.Webhook.Headers.Valid {
var custom map[string]string
if err := json.Unmarshal(job.Webhook.Headers.RawMessage, &custom); err == nil {
for k, v := range custom {
req.Header.Set(k, v)
}
}
}
resp, err := webhookHTTPClient.Do(req)
if err != nil {
return "failed", sql.NullInt32{}, sql.NullString{String: err.Error(), Valid: true}
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
code := sql.NullInt32{Int32: int32(resp.StatusCode), Valid: true}
bodyStr := sql.NullString{String: string(body), Valid: len(body) > 0}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return "success", code, bodyStr
}
return "failed", code, bodyStr
}
// ---------- HTTP API ----------
type outboundWebhookResponse struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Secret string `json:"secret,omitempty"`
Events []string `json:"events"`
Headers map[string]string `json:"headers,omitempty"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func outboundWebhookJSON(row sqlcdb.OutboundWebhook, revealSecret bool) outboundWebhookResponse {
var events []string
_ = json.Unmarshal(row.Events, &events)
if events == nil {
events = []string{}
}
var headers map[string]string
if row.Headers.Valid {
_ = json.Unmarshal(row.Headers.RawMessage, &headers)
}
resp := outboundWebhookResponse{
ID: row.ID.String(),
Name: row.Name,
URL: row.Url,
Events: events,
Headers: headers,
Enabled: row.Enabled,
}
if revealSecret {
resp.Secret = row.Secret
}
if row.CreatedAt.Valid {
resp.CreatedAt = row.CreatedAt.Time
}
if row.UpdatedAt.Valid {
resp.UpdatedAt = row.UpdatedAt.Time
}
return resp
}
func validWebhookURL(raw string) error {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return errors.New("url must be a valid http(s) URL")
}
return nil
}
type webhookMutationRequest struct {
Name string `json:"name"`
URL string `json:"url"`
Secret *string `json:"secret"`
Events []string `json:"events"`
Headers map[string]string `json:"headers"`
Enabled *bool `json:"enabled"`
}
func handleListOutboundWebhooks(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
rows, err := sqlcdb.New(db.DB).ListOutboundWebhooksByUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list webhooks"})
return
}
out := make([]outboundWebhookResponse, 0, len(rows))
for _, row := range rows {
out = append(out, outboundWebhookJSON(row, false))
}
c.JSON(http.StatusOK, gin.H{"webhooks": out})
}
func handleCreateOutboundWebhook(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
var req webhookMutationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
respondError(c, http.StatusBadRequest, "VALIDATION", "name is required")
return
}
if err := validWebhookURL(req.URL); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
if len(req.Events) == 0 {
respondError(c, http.StatusBadRequest, "VALIDATION", "at least one event subscription is required (e.g. \"service.*\" or \"*\")")
return
}
secret := ""
if req.Secret != nil {
secret = strings.TrimSpace(*req.Secret)
}
if secret == "" {
buf := make([]byte, 24)
_, _ = rand.Read(buf)
secret = hex.EncodeToString(buf)
}
eventsJSON, _ := json.Marshal(req.Events)
var headersJSON pqtype.NullRawMessage
if len(req.Headers) > 0 {
if raw, err := json.Marshal(req.Headers); err == nil {
headersJSON = pqtype.NullRawMessage{RawMessage: raw, Valid: true}
}
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
db := c.MustGet("db").(*database.DB)
id := uuid.New()
err := sqlcdb.New(db.DB).CreateOutboundWebhook(c.Request.Context(), sqlcdb.CreateOutboundWebhookParams{
ID: id,
UserID: userID,
Name: req.Name,
Url: strings.TrimSpace(req.URL),
Secret: secret,
Events: eventsJSON,
Headers: headersJSON,
Enabled: enabled,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create webhook"})
return
}
LogAuditWithRequest(c, "webhook", id.String(), "create", map[string]interface{}{"name": req.Name})
c.JSON(http.StatusCreated, outboundWebhookJSON(sqlcdb.OutboundWebhook{
ID: id, Name: req.Name, Url: strings.TrimSpace(req.URL), Secret: secret,
Events: eventsJSON, Headers: headersJSON, Enabled: enabled,
CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
}, true))
}
func handleUpdateOutboundWebhook(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
webhookID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
row, err := queries.GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{
ID: webhookID, UserID: userID,
})
if errors.Is(err, sql.ErrNoRows) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found")
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load webhook"})
return
}
var req webhookMutationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
name := row.Name
if req.Name != "" {
name = strings.TrimSpace(req.Name)
}
targetURL := row.Url
if req.URL != "" {
if err := validWebhookURL(req.URL); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
targetURL = strings.TrimSpace(req.URL)
}
secret := row.Secret
if req.Secret != nil {
secret = strings.TrimSpace(*req.Secret)
}
eventsJSON := row.Events
if req.Events != nil {
if len(req.Events) == 0 {
respondError(c, http.StatusBadRequest, "VALIDATION", "at least one event subscription is required")
return
}
eventsJSON, _ = json.Marshal(req.Events)
}
headersJSON := row.Headers
if req.Headers != nil {
if raw, err := json.Marshal(req.Headers); err == nil {
headersJSON = pqtype.NullRawMessage{RawMessage: raw, Valid: true}
}
}
enabled := row.Enabled
if req.Enabled != nil {
enabled = *req.Enabled
}
err = queries.UpdateOutboundWebhook(c.Request.Context(), sqlcdb.UpdateOutboundWebhookParams{
ID: webhookID, UserID: userID,
Name: name, Url: targetURL, Secret: secret,
Events: eventsJSON, Headers: headersJSON, Enabled: enabled,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update webhook"})
return
}
LogAuditWithRequest(c, "webhook", webhookID.String(), "update", map[string]interface{}{"name": name})
row.Name, row.Url, row.Secret, row.Events, row.Headers, row.Enabled = name, targetURL, secret, eventsJSON, headersJSON, enabled
c.JSON(http.StatusOK, outboundWebhookJSON(row, true))
}
func handleDeleteOutboundWebhook(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
webhookID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
return
}
db := c.MustGet("db").(*database.DB)
err = sqlcdb.New(db.DB).DeleteOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.DeleteOutboundWebhookByIDAndUserParams{
ID: webhookID, UserID: userID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete webhook"})
return
}
LogAuditWithRequest(c, "webhook", webhookID.String(), "delete", map[string]interface{}{})
c.JSON(http.StatusOK, gin.H{"message": "Webhook deleted"})
}
func handleListOutboundWebhookDeliveries(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
webhookID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
if _, err := queries.GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{
ID: webhookID, UserID: userID,
}); errors.Is(err, sql.ErrNoRows) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found")
return
}
rows, err := queries.ListWebhookDeliveriesByWebhook(c.Request.Context(), webhookID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list deliveries"})
return
}
c.JSON(http.StatusOK, gin.H{"deliveries": rows})
}
func handleTestOutboundWebhook(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
webhookID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
return
}
db := c.MustGet("db").(*database.DB)
row, err := sqlcdb.New(db.DB).GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{
ID: webhookID, UserID: userID,
})
if errors.Is(err, sql.ErrNoRows) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found")
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load webhook"})
return
}
if !row.Enabled {
respondError(c, http.StatusBadRequest, "VALIDATION", "Webhook is disabled")
return
}
payload, _ := json.Marshal(webhookEventPayload{
Event: "ping", Resource: "webhook", ResourceID: webhookID.String(),
Action: "ping", UserID: userID.String(), Timestamp: time.Now().UTC(),
})
select {
case webhookQueue <- webhookJob{Webhook: row, Event: "ping", Payload: payload}:
c.JSON(http.StatusAccepted, gin.H{"message": "Test delivery queued"})
default:
respondError(c, http.StatusServiceUnavailable, "UNAVAILABLE", "Delivery queue is full")
}
}
+33
View File
@@ -0,0 +1,33 @@
package api
import (
"encoding/json"
"testing"
)
func TestWebhookEventMatches(t *testing.T) {
subs := func(list ...string) json.RawMessage {
raw, _ := json.Marshal(list)
return raw
}
cases := []struct {
events json.RawMessage
event string
want bool
}{
{subs("service.*"), "service.deploy", true},
{subs("service.*"), "database.create", false},
{subs("*"), "service.deploy", true},
{subs("deployment.fail", "service.*"), "deployment.fail", true},
{subs("deployment.fail"), "deployment.success", false},
{subs(" ping "), "ping", true},
{subs(), "service.deploy", false},
{json.RawMessage(`"not-a-list"`), "service.deploy", false},
{json.RawMessage(`invalid`), "service.deploy", false},
}
for i, tc := range cases {
if got := webhookEventMatches(tc.events, tc.event); got != tc.want {
t.Errorf("case %d: matches(%s, %q) = %v, want %v", i, tc.events, tc.event, got, tc.want)
}
}
}
+5 -1
View File
@@ -539,12 +539,16 @@ func createBuildContext(sourcePath, dockerfileContent string) (io.ReadCloser, er
return nil, err
}
// Add source files to tar
// Add source files to tar. The checkout's .git metadata is never build
// context — it bloats the tar and ships clone URLs to remote daemons.
err = filepath.Walk(sourcePath, func(file string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
if fi.IsDir() {
if fi.Name() == ".git" {
return filepath.SkipDir
}
return nil
}
+71
View File
@@ -3,6 +3,8 @@ package build
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"time"
@@ -92,6 +94,75 @@ func (bm *BuildManager) Build(ctx context.Context, req *types.BuildRequest) (*ty
}
}
// PackageContext produces the same build-context tar Build would hand the
// local daemon, without building it. Node agents download the tar and run
// docker build themselves — the Dockerfile lands at the tar root.
func (bm *BuildManager) PackageContext(ctx context.Context, req *types.BuildRequest) (io.ReadCloser, error) {
if req.BuildType == "" {
detectedType, err := bm.DetectBuildType(ctx, req.SourcePath)
if err != nil {
return nil, fmt.Errorf("failed to detect build type: %w", err)
}
req.BuildType = string(detectedType)
}
var dockerfileContent string
switch BuildType(req.BuildType) {
case BuildTypeDockerfile:
name, err := bm.dockerfileBuilder.DetectDockerfile(ctx, req.SourcePath)
if err != nil {
return nil, fmt.Errorf("failed to detect Dockerfile: %w", err)
}
path := filepath.Join(req.SourcePath, name)
if optimized, err := bm.dockerfileBuilder.OptimizeDockerfile(ctx, path); err == nil {
path = optimized
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read Dockerfile: %w", err)
}
dockerfileContent = string(data)
case BuildTypeRailpack:
if err := bm.railpackBuilder.DetectRailpack(ctx, req.SourcePath); err != nil {
return nil, fmt.Errorf("Railpack cannot build this project: %w", err)
}
content, err := bm.railpackBuilder.generateDockerfile(ctx, req.SourcePath, &RailpackConfig{
BuildCmd: req.BuildCommand,
StartCmd: req.StartCommand,
Env: req.Environment,
})
if err != nil {
return nil, fmt.Errorf("failed to generate Dockerfile with Railpack: %w", err)
}
dockerfileContent = content
case BuildTypeNixpacks:
plan, err := bm.nixpacksBuilder.GeneratePlan(ctx, req.SourcePath, &NixpacksConfig{
BuildCmd: req.BuildCommand,
StartCmd: req.StartCommand,
Env: req.Environment,
})
if err != nil {
return nil, fmt.Errorf("failed to generate build plan: %w", err)
}
path, err := bm.nixpacksBuilder.generateDockerfile(plan)
if err != nil {
return nil, fmt.Errorf("failed to generate Dockerfile: %w", err)
}
defer os.Remove(path)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read generated Dockerfile: %w", err)
}
dockerfileContent = string(data)
case BuildTypeStatic:
dockerfileContent = staticDockerfile
default:
return nil, fmt.Errorf("unsupported build type for remote packaging: %s", req.BuildType)
}
return createBuildContext(req.SourcePath, dockerfileContent)
}
// buildPrebuilt handles prebuilt image deployments
func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
if req.PrebuiltImage == "" {
+9 -4
View File
@@ -476,17 +476,22 @@ func (n *NixpacksBuilder) Build(ctx context.Context, req *types.BuildRequest) (*
}
// Create temporary Dockerfile
dockerfile, err := n.generateDockerfile(plan)
dockerfilePath, err := n.generateDockerfile(plan)
if err != nil {
return nil, fmt.Errorf("failed to generate Dockerfile: %w", err)
}
defer os.Remove(dockerfile)
defer os.Remove(dockerfilePath)
// Build Docker image
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
// Create build context tar
buildCtx, err := createBuildContext(req.SourcePath, dockerfile)
// Create build context tar — createBuildContext takes the Dockerfile
// contents, not its path.
dockerfileData, err := os.ReadFile(dockerfilePath)
if err != nil {
return nil, fmt.Errorf("failed to read generated Dockerfile: %w", err)
}
buildCtx, err := createBuildContext(req.SourcePath, string(dockerfileData))
if err != nil {
return nil, fmt.Errorf("failed to create build context: %w", err)
}
@@ -0,0 +1,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{
Use: "settings",
Short: "Show platform settings",
@@ -353,7 +431,13 @@ func init() {
securityScanCmd.Flags().String("type", "configuration", "dependency|configuration|comprehensive")
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)
HACmd.AddCommand(haStatusCmd, haAlertsCmd, haHealthCmd, haPoliciesCmd, haEnableCmd, haDisableCmd, haFailoverCmd)
ScalingCmd.AddCommand(scalingStatusCmd, scalingPoliciesCmd, scalingServicesCmd, scalingScaleCmd)
+16
View File
@@ -147,5 +147,21 @@ func runStatus(cmd *cobra.Command, args []string) error {
if p.IsAdmin {
fmt.Println("Role: platform admin")
}
var bannersResp struct {
Banners []struct {
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"`
} `json:"banners"`
}
if err := client.DoJSON("GET", "/banners/active", nil, &bannersResp); err == nil {
for _, b := range bannersResp.Banners {
fmt.Printf("[%s] %s\n", b.Level, b.Title)
if b.Body != "" {
fmt.Printf(" %s\n", b.Body)
}
}
}
return nil
}
@@ -0,0 +1,204 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// BackupTargetsCmd manages S3-compatible backup destinations.
var BackupTargetsCmd = &cobra.Command{
Use: "backup-targets",
Aliases: []string{"backup-target", "targets"},
Short: "Manage S3-compatible backup targets",
}
var btListCmd = &cobra.Command{
Use: "list",
Short: "List backup targets",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/backup-targets", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "backup_targets")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, t := range items {
creds := "no"
if str(t, "has_credentials") == "true" {
creds = "yes"
}
rows = append(rows, []string{str(t, "id"), str(t, "name"), str(t, "endpoint"), str(t, "bucket"), creds})
}
printRows(data, []string{"ID", "NAME", "ENDPOINT", "BUCKET", "CREDS"}, rows)
return nil
},
}
var btAddCmd = &cobra.Command{
Use: "add <name>",
Short: "Register an S3-compatible backup target (probed before storing)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{"name": args[0]}
for _, f := range []string{"endpoint", "bucket", "region", "prefix", "access-key", "secret-key"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := f
switch f {
case "access-key":
key = "access_key"
case "secret-key":
key = "secret_key"
}
body[key] = v
}
}
if cmd.Flags().Changed("no-tls") {
v, _ := cmd.Flags().GetBool("no-tls")
body["use_tls"] = !v
}
data, err := c.Do("POST", "/backup-targets", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var btUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a backup target (re-probed; empty keys keep stored credentials)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
// endpoint/bucket are required by the API — fall back to stored values.
for _, f := range []string{"name", "endpoint", "bucket", "region", "prefix", "access-key", "secret-key"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := f
switch f {
case "access-key":
key = "access_key"
case "secret-key":
key = "secret_key"
}
body[key] = v
}
}
if _, hasEndpoint := body["endpoint"]; !hasEndpoint {
data, err := c.Do("GET", "/backup-targets", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "backup_targets")
if err != nil {
return err
}
for _, t := range items {
if str(t, "id") == args[0] {
body["endpoint"] = str(t, "endpoint")
if _, ok := body["bucket"]; !ok {
body["bucket"] = str(t, "bucket")
}
if _, ok := body["name"]; !ok {
body["name"] = str(t, "name")
}
break
}
}
}
if cmd.Flags().Changed("no-tls") {
v, _ := cmd.Flags().GetBool("no-tls")
body["use_tls"] = !v
}
data, err := c.Do("PUT", "/backup-targets/"+args[0], body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var btRemoveCmd = &cobra.Command{
Use: "remove <id>",
Short: "Delete a backup target (fails while databases still point at it)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Delete backup target %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/backup-targets/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Deleted backup target %s\n", args[0])
return nil
},
}
var btTestCmd = &cobra.Command{
Use: "test <id>",
Short: "Re-probe a backup target's bucket access",
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", "/backup-targets/"+args[0]+"/test", nil)
if err != nil {
return err
}
m, _ := unwrapObject(data)
if str(m, "ok") == "false" {
return &APIError{Message: "probe failed: " + str(m, "error"), ExitCode: ExitError}
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Target ok (%sms)\n", str(m, "latency_ms"))
return nil
},
}
func init() {
btAddCmd.Flags().String("endpoint", "", "S3 endpoint host[:port] (e.g. s3.amazonaws.com, garage.local:3900)")
btAddCmd.Flags().String("bucket", "", "bucket name")
btAddCmd.Flags().String("region", "", "region (optional for most S3-compatible stores)")
btAddCmd.Flags().String("prefix", "", "key prefix for archives")
btAddCmd.Flags().String("access-key", "", "access key (stored encrypted)")
btAddCmd.Flags().String("secret-key", "", "secret key (stored encrypted)")
btAddCmd.Flags().Bool("no-tls", false, "use plain HTTP")
btUpdateCmd.Flags().String("name", "", "new name")
btUpdateCmd.Flags().String("endpoint", "", "S3 endpoint host[:port]")
btUpdateCmd.Flags().String("bucket", "", "bucket name")
btUpdateCmd.Flags().String("region", "", "region")
btUpdateCmd.Flags().String("prefix", "", "key prefix")
btUpdateCmd.Flags().String("access-key", "", "access key")
btUpdateCmd.Flags().String("secret-key", "", "secret key")
btUpdateCmd.Flags().Bool("no-tls", false, "use plain HTTP")
BackupTargetsCmd.AddCommand(btListCmd, btAddCmd, btUpdateCmd, btRemoveCmd, btTestCmd)
}
@@ -0,0 +1,133 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
var (
bannerTitle string
bannerLevel string
bannerBody string
bannerActive bool
)
// BannersCmd manages instance-wide announcement banners (admin).
var BannersCmd = &cobra.Command{
Use: "banners",
Short: "Manage announcement banners (admin)",
}
var bannerListCmd = &cobra.Command{
Use: "list",
Short: "List all banners",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/admin/banners", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "banners")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, b := range items {
rows = append(rows, []string{
str(b, "id"), str(b, "title"), str(b, "level"), str(b, "active"),
})
}
printRows(data, []string{"ID", "TITLE", "LEVEL", "ACTIVE"}, rows)
return nil
},
}
var bannerCreateCmd = &cobra.Command{
Use: "create <title>",
Short: "Create a banner (--body --level info|success|warning|error)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
req := map[string]interface{}{"title": args[0]}
if cmd.Flags().Changed("body") {
req["body"] = bannerBody
}
if cmd.Flags().Changed("level") {
req["level"] = bannerLevel
}
data, err := c.Do("POST", "/admin/banners", req)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var bannerUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a banner (--title --body --level --active/--inactive)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
req := map[string]interface{}{}
if cmd.Flags().Changed("title") {
req["title"] = bannerTitle
}
if cmd.Flags().Changed("body") {
req["body"] = bannerBody
}
if cmd.Flags().Changed("level") {
req["level"] = bannerLevel
}
if cmd.Flags().Changed("active") {
req["active"] = bannerActive
}
if len(req) == 0 {
return fmt.Errorf("nothing to update — pass --title/--body/--level/--active")
}
data, err := c.Do("PATCH", "/admin/banners/"+args[0], req)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var bannerDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a banner",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !Confirm("Delete banner " + 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/banners/"+args[0], nil)
return err
},
}
func init() {
bannerCreateCmd.Flags().StringVar(&bannerBody, "body", "", "banner body text")
bannerCreateCmd.Flags().StringVar(&bannerLevel, "level", "info", "info|success|warning|error")
bannerUpdateCmd.Flags().StringVar(&bannerTitle, "title", "", "new title")
bannerUpdateCmd.Flags().StringVar(&bannerBody, "body", "", "new body")
bannerUpdateCmd.Flags().StringVar(&bannerLevel, "level", "", "info|success|warning|error")
bannerUpdateCmd.Flags().BoolVar(&bannerActive, "active", true, "set banner active/inactive")
BannersCmd.AddCommand(bannerListCmd, bannerCreateCmd, bannerUpdateCmd, bannerDeleteCmd)
}
@@ -156,6 +156,48 @@ func (c *Client) Do(method, path string, body interface{}) ([]byte, error) {
return data, nil
}
// DoRaw sends a non-JSON request body (file uploads) and returns the response.
func (c *Client) DoRaw(method, path string, body io.Reader, contentType string) ([]byte, error) {
req, err := http.NewRequest(method, c.BaseURL+path, body)
if err != nil {
return nil, err
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
req.Header.Set("Content-Type", contentType)
req.Header.Set("Accept", "application/json")
hc := c.HTTPClient
if hc == nil {
hc = &http.Client{Timeout: 5 * time.Minute}
}
resp, err := hc.Do(req)
if err != nil {
return nil, &APIError{Status: 0, Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err), ExitCode: ExitUnavailable}
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode >= 400 {
msg := strings.TrimSpace(string(data))
var envelope struct {
Error string `json:"error"`
Code string `json:"code"`
}
if json.Unmarshal(data, &envelope) == nil && envelope.Error != "" {
msg = envelope.Error
if envelope.Code != "" {
msg = envelope.Code + ": " + msg
}
}
return nil, &APIError{Status: resp.StatusCode, Message: msg, ExitCode: exitCodeFor(resp.StatusCode)}
}
return data, nil
}
// DoJSON performs a request and unmarshals the response into out.
func (c *Client) DoJSON(method, path string, body interface{}, out interface{}) error {
data, err := c.Do(method, path, body)
+164 -2
View File
@@ -2,6 +2,7 @@ package commands
import (
"fmt"
"io"
"os"
"github.com/spf13/cobra"
@@ -79,6 +80,9 @@ var dbCreateCmd = &cobra.Command{
body[key] = v
}
}
if v, _ := cmd.Flags().GetBool("public"); v {
body["public_port"] = true
}
data, err := c.Do("POST", "/databases", body)
if err != nil {
return err
@@ -88,6 +92,43 @@ var dbCreateCmd = &cobra.Command{
},
}
var dbUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a database (--public/--public=false toggles the exposed host port)",
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("public") {
v, _ := cmd.Flags().GetBool("public")
body["public_port"] = v
}
if v, _ := cmd.Flags().GetString("name"); v != "" {
body["name"] = v
}
if cmd.Flags().Changed("backup-schedule") {
v, _ := cmd.Flags().GetString("backup-schedule")
body["backup_schedule"] = v
}
if cmd.Flags().Changed("backup-target") {
v, _ := cmd.Flags().GetString("backup-target")
body["backup_target_id"] = v
}
if len(body) == 0 {
return &APIError{Message: "nothing to update — pass --public/--private-style flags, --name, --backup-schedule, or --backup-target", ExitCode: ExitError}
}
data, err := c.Do("PUT", "/databases/"+args[0], body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a database and its data",
@@ -159,7 +200,36 @@ var dbRestoreCmd = &cobra.Command{
if !Confirm(fmt.Sprintf("Restore database %s from backup %s? Current data will be overwritten.", args[0], args[1])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
data, err := c.Do("POST", "/databases/"+args[0]+"/restore", map[string]string{"backup_id": args[1]})
data, err := c.Do("POST", "/databases/"+args[0]+"/restore", map[string]string{"backup_id": args[1], "database_id": args[0]})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var dbImportBackupCmd = &cobra.Command{
Use: "import-backup <id> <file.tar.gz|->",
Short: "Import a .tar.gz archive as a restore point (use '-' for stdin)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
var src io.ReadCloser
if args[1] == "-" {
src = io.NopCloser(os.Stdin)
} else {
f, err := os.Open(args[1])
if err != nil {
return err
}
src = f
}
defer src.Close()
data, err := c.DoRaw("POST", "/databases/"+args[0]+"/backups/import", src, "application/gzip")
if err != nil {
return err
}
@@ -196,9 +266,101 @@ var dbDownloadCmd = &cobra.Command{
},
}
var dbRegisterCmd = &cobra.Command{
Use: "register <name>",
Short: "Register an external database (probes the connection first)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{"name": args[0]}
for _, f := range []string{"type", "host", "database", "username", "password"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
body[f] = v
}
}
if v, _ := cmd.Flags().GetInt("port"); v > 0 {
body["port"] = v
}
if v, _ := cmd.Flags().GetBool("ssl"); v {
body["ssl"] = true
}
data, err := c.Do("POST", "/databases/register-external", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
m, _ := unwrapObject(data)
fmt.Printf("Registered external database %s (%s)\n", args[0], str(m, "id"))
return nil
},
}
var dbTestConnCmd = &cobra.Command{
Use: "test-connection [<id>]",
Short: "Probe a database connection — by id, or with --type/--host flags",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
var data []byte
if len(args) == 1 {
data, err = c.Do("POST", "/databases/"+args[0]+"/test-connection", map[string]interface{}{})
} else {
body := map[string]interface{}{}
for _, f := range []string{"type", "host", "database", "username", "password"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
body[f] = v
}
}
if v, _ := cmd.Flags().GetInt("port"); v > 0 {
body["port"] = v
}
if v, _ := cmd.Flags().GetBool("ssl"); v {
body["ssl"] = true
}
data, err = c.Do("POST", "/databases/test-connection", body)
}
if err != nil {
return err
}
m, _ := unwrapObject(data)
if str(m, "ok") == "false" {
return &APIError{Message: "connection failed: " + str(m, "error"), ExitCode: ExitError}
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Connection ok (%sms)\n", str(m, "latency_ms"))
return nil
},
}
func init() {
dbCreateCmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
dbCreateCmd.Flags().String("version", "", "engine version tag")
dbCreateCmd.Flags().String("storage-size", "", "volume size (e.g. 10Gi)")
DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbDownloadCmd)
dbCreateCmd.Flags().Bool("public", false, "bind the database port on all interfaces (default: localhost only)")
dbUpdateCmd.Flags().Bool("public", false, "bind the database port on all interfaces")
dbUpdateCmd.Flags().String("name", "", "rename the database")
dbUpdateCmd.Flags().String("backup-schedule", "", "cron expression for automatic backups (empty clears)")
dbUpdateCmd.Flags().String("backup-target", "", "backup target id for offsite archives (empty clears)")
for _, cmd := range []*cobra.Command{dbRegisterCmd, dbTestConnCmd} {
cmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
cmd.Flags().String("host", "", "database host")
cmd.Flags().Int("port", 0, "database port (default per type)")
cmd.Flags().String("database", "", "database name")
cmd.Flags().String("username", "", "database username")
cmd.Flags().String("password", "", "database password (stored encrypted on register)")
cmd.Flags().Bool("ssl", false, "require TLS")
}
DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbUpdateCmd, dbRegisterCmd, dbTestConnCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbImportBackupCmd, dbDownloadCmd)
}
+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,57 @@
package commands
import (
"fmt"
"io"
"os"
"github.com/spf13/cobra"
)
var importVars []string
var ImportCmd = &cobra.Command{
Use: "import",
Short: "Import external definitions into Containr",
}
var importComposeCmd = &cobra.Command{
Use: "compose <project-id> <compose.yml|->",
Short: "Deploy a docker-compose file straight into a project (no template)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
var raw []byte
var err error
if args[1] == "-" {
raw, err = io.ReadAll(os.Stdin)
} else {
raw, err = os.ReadFile(args[1])
}
if err != nil {
return fmt.Errorf("cannot read compose file: %w", err)
}
vars, err := parseKVFlags(importVars)
if err != nil {
return err
}
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{"compose_yaml": string(raw)}
if len(vars) > 0 {
body["variables"] = vars
}
data, err := c.Do("POST", "/projects/"+args[0]+"/import-compose", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() {
importComposeCmd.Flags().StringArrayVar(&importVars, "var", nil, "Variable override (KEY=VALUE, repeatable)")
ImportCmd.AddCommand(importComposeCmd)
}
@@ -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 (
"fmt"
"strings"
"github.com/spf13/cobra"
)
@@ -81,6 +82,164 @@ var nodesDeleteCmd = &cobra.Command{
},
}
var nodesUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a node agent (--name, --auto-prune, --tags, --domain)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
if cmd.Flags().Changed("name") {
body["name"], _ = cmd.Flags().GetString("name")
}
if cmd.Flags().Changed("auto-prune") {
body["auto_prune"], _ = cmd.Flags().GetBool("auto-prune")
}
if cmd.Flags().Changed("tags") {
raw, _ := cmd.Flags().GetString("tags")
tags := []string{}
for _, t := range strings.Split(raw, ",") {
if t = strings.TrimSpace(t); t != "" {
tags = append(tags, t)
}
}
body["tags"] = tags
}
if cmd.Flags().Changed("domain") {
body["default_domain"], _ = cmd.Flags().GetString("domain")
}
if len(body) == 0 {
return &APIError{Message: "nothing to update — pass --name, --auto-prune, --tags or --domain", ExitCode: ExitError}
}
data, err := c.Do("PUT", "/agents/"+args[0], body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodesPruneCmd = &cobra.Command{
Use: "prune <id>",
Short: "Enqueue a bounded docker system prune on a node",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
volumes, _ := cmd.Flags().GetBool("volumes")
if volumes && !Confirm("Prune INCLUDING volumes? This can delete database data.") {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
until, _ := cmd.Flags().GetString("until")
data, err := c.Do("POST", "/agents/"+args[0]+"/prune",
map[string]interface{}{"until": until, "volumes": volumes})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodesCordonCmd = &cobra.Command{
Use: "cordon <id>",
Short: "Stop new placements on a node (running replicas stay)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/agents/"+args[0]+"/cordon", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodesUncordonCmd = &cobra.Command{
Use: "uncordon <id>",
Short: "Re-open a node for placement",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/agents/"+args[0]+"/uncordon", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodesDrainCmd = &cobra.Command{
Use: "drain <id>",
Short: "Cordon a node, remove its service containers, unpin affected services",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Drain node %s? Its service containers are removed and services unpinned (they redeploy locally).", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
data, err := c.Do("POST", "/agents/"+args[0]+"/drain", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodesUpgradeCmd = &cobra.Command{
Use: "upgrade <id>",
Short: "Upgrade the node agent to the server's bundled binary",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/agents/"+args[0]+"/upgrade", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodesCommandsCmd = &cobra.Command{
Use: "commands <id>",
Short: "List recent commands sent to a node agent",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/agents/"+args[0]+"/commands", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// Agent tokens (cagt_) onboard new nodes.
var nodeTokensCmd = &cobra.Command{
Use: "tokens",
@@ -145,6 +304,12 @@ var nodeTokensRevokeCmd = &cobra.Command{
}
func init() {
nodesUpdateCmd.Flags().String("name", "", "node display name")
nodesUpdateCmd.Flags().Bool("auto-prune", false, "enqueue a daily bounded docker prune")
nodesUpdateCmd.Flags().String("tags", "", "comma-separated placement tags (services with placement_tags only schedule onto matching nodes)")
nodesUpdateCmd.Flags().String("domain", "", "base domain for this node — domainless services placed here get <name>.<domain> automatically (empty clears)")
nodesPruneCmd.Flags().String("until", "", "only prune objects older than this duration (e.g. 168h)")
nodesPruneCmd.Flags().Bool("volumes", false, "also prune volumes (destructive — asks twice)")
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesDeleteCmd, nodeTokensCmd)
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesUpdateCmd, nodesCordonCmd, nodesUncordonCmd, nodesDrainCmd, nodesUpgradeCmd, nodesPruneCmd, nodesCommandsCmd, nodesDeleteCmd, nodeTokensCmd)
}
@@ -0,0 +1,214 @@
package commands
// SSH node bootstrap — one SSH session installs the agent (binary +
// systemd + enroll token), then SSH is never needed again. SSH keys
// stay client-side; the server only sees the resulting agent.
import (
"errors"
"fmt"
"net"
"os"
"os/user"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/term"
)
var (
nodeSSHKey string
nodeSSHName string
nodeSSHPrint bool
)
var nodesAddCmd = &cobra.Command{
Use: "add <[user@]host[:port]>",
Short: "Bootstrap a node agent over SSH (one session, then closed)",
Long: `Installs the containr agent on a remote host:
1. mints a single-use enroll token via the API
2. SSHes in (agent socket, --key, or default key files)
3. runs the instance's install script remotely
4. closes the session — the agent heartbeats outbound from then on
Use --print to skip SSH and emit the remote one-liner for manual runs.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
// Mint the enroll token first — SSH failures then don't leave
// partial state on the remote.
body := map[string]interface{}{"label": "bootstrap"}
if nodeSSHName != "" {
body["label"] = "bootstrap:" + nodeSSHName
}
tokenData, err := c.Do("POST", "/agent-tokens", body)
if err != nil {
return err
}
tokenObj, err := unwrapObject(tokenData, "token")
if err != nil {
return err
}
token := str(tokenObj, "token")
if token == "" {
return &APIError{Message: "enroll token missing from response", ExitCode: ExitError}
}
// Agent bootstrap endpoints live at /api/agents (no /v1) — strip
// the API version suffix off the client base.
apiRoot := strings.TrimSuffix(c.BaseURL, "/api/v1")
installCmd := fmt.Sprintf(
`curl -fsSL %q | bash -s -- --url %q --token %q --name %q`,
apiRoot+"/api/agents/install.sh", apiRoot, token, firstNonEmptyStr(nodeSSHName, nodeSSHTarget(args[0]).host),
)
if nodeSSHPrint {
fmt.Println(installCmd)
return nil
}
target := nodeSSHTarget(args[0])
if err := sshBootstrap(target, installCmd); err != nil {
return err
}
fmt.Printf("agent installing on %s — it should appear in `containr nodes list` within ~15s\n", target.host)
return nil
},
}
type sshTarget struct {
user string
host string
port string
}
func nodeSSHTarget(raw string) sshTarget {
t := sshTarget{port: "22"}
rest := raw
if at := strings.LastIndex(rest, "@"); at >= 0 {
t.user = rest[:at]
rest = rest[at+1:]
}
if colon := strings.LastIndex(rest, ":"); colon >= 0 && !strings.Contains(rest, "]") {
t.host, t.port = rest[:colon], rest[colon+1:]
} else {
t.host = rest
}
if t.user == "" {
if u, err := user.Current(); err == nil {
t.user = u.Username
}
}
return t
}
func sshAuthMethods(keyPath string) []ssh.AuthMethod {
var methods []ssh.AuthMethod
// ssh-agent first — matches how people actually manage keys.
if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
if conn, err := net.Dial("unix", sock); err == nil {
defer conn.Close()
methods = append(methods, ssh.PublicKeysCallback(agent.NewClient(conn).Signers))
}
}
var keyFiles []string
if keyPath != "" {
keyFiles = []string{keyPath}
} else if home, err := os.UserHomeDir(); err == nil {
for _, name := range []string{"id_ed25519", "id_rsa", "id_ecdsa"} {
p := filepath.Join(home, ".ssh", name)
if _, err := os.Stat(p); err == nil {
keyFiles = append(keyFiles, p)
}
}
}
for _, p := range keyFiles {
if signer, err := sshPrivateKey(p); err == nil {
methods = append(methods, ssh.PublicKeys(signer))
}
}
return methods
}
func sshPrivateKey(path string) (ssh.Signer, error) {
pem, err := os.ReadFile(path)
if err != nil {
return nil, err
}
signer, err := ssh.ParsePrivateKey(pem)
if err != nil {
var missing *ssh.PassphraseMissingError
if errors.As(err, &missing) {
fmt.Fprintf(os.Stderr, "passphrase for %s: ", path)
pass, readErr := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if readErr != nil {
return nil, readErr
}
return ssh.ParsePrivateKeyWithPassphrase(pem, pass)
}
return nil, err
}
return signer, nil
}
func sshBootstrap(t sshTarget, remoteCmd string) error {
config := &ssh.ClientConfig{
User: t.user,
Auth: sshAuthMethods(nodeSSHKey),
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // jarvis: TOFU would be better; ssh_known_hosts parsing is the upgrade path
Timeout: 15 * time.Second,
}
if len(config.Auth) == 0 {
return &APIError{Message: "no SSH auth methods (ssh-agent empty, no usable keys)", ExitCode: ExitError}
}
conn, err := ssh.Dial("tcp", net.JoinHostPort(t.host, t.port), config)
if err != nil {
return &APIError{Message: fmt.Sprintf("ssh connect: %v", err), ExitCode: ExitError}
}
defer conn.Close()
session, err := conn.NewSession()
if err != nil {
return err
}
defer session.Close()
session.Stdout = os.Stdout
session.Stderr = os.Stderr
// Root shells run directly; otherwise escalate via sudo. NOPASSWD
// sudo is assumed — a password prompt here means the operator should
// use --print and run the command themselves.
if t.user == "root" {
return session.Run(remoteCmd)
}
return session.Run("sudo -n " + remoteCmd)
}
func firstNonEmptyStr(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
func init() {
nodesAddCmd.Flags().StringVar(&nodeSSHKey, "key", "", "SSH private key path (defaults: agent, ~/.ssh/id_*)")
nodesAddCmd.Flags().StringVar(&nodeSSHName, "name", "", "node display name (defaults to remote hostname)")
nodesAddCmd.Flags().BoolVar(&nodeSSHPrint, "print", false, "print the remote install command instead of running SSH")
NodesCmd.AddCommand(nodesAddCmd)
}
@@ -0,0 +1,24 @@
package commands
import (
"github.com/spf13/cobra"
)
// OperationsCmd shows the cross-cutting operations view.
var OperationsCmd = &cobra.Command{
Use: "operations",
Aliases: []string{"ops"},
Short: "Show active and recent platform work (deploys, cron, backups)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/operations", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
+128 -5
View File
@@ -60,7 +60,7 @@ var servicesGetCmd = &cobra.Command{
return nil
}
s, _ := unwrapObject(data, "service")
for _, k := range []string{"id", "name", "type", "status", "environment", "image", "git_repo", "git_branch", "domain", "port"} {
for _, k := range []string{"id", "name", "type", "status", "environment", "image", "git_repo", "git_branch", "domain", "port", "node_name"} {
fmt.Printf("%-14s %s\n", k+":", str(s, k))
}
return nil
@@ -79,7 +79,7 @@ var servicesCreateCmd = &cobra.Command{
body := map[string]interface{}{
"name": args[1],
}
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir", "node"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := f
// JSON uses snake_case keys matching the API schema.
@@ -102,6 +102,8 @@ var servicesCreateCmd = &cobra.Command{
key = "static_build_cmd"
case "static-dir":
key = "static_dir"
case "node":
key = "node_id"
}
body[key] = v
}
@@ -112,6 +114,31 @@ var servicesCreateCmd = &cobra.Command{
if v, _ := cmd.Flags().GetInt("replicas"); v > 0 {
body["replicas"] = v
}
if v, _ := cmd.Flags().GetBool("spread"); v {
body["spread"] = v
}
if cmd.Flags().Changed("sleep") {
v, _ := cmd.Flags().GetBool("sleep")
body["sleep_enabled"] = v
}
if cmd.Flags().Changed("sleep-idle") {
v, _ := cmd.Flags().GetInt("sleep-idle")
body["sleep_idle_minutes"] = v
}
if v, _ := cmd.Flags().GetString("placement-tags"); v != "" {
tags := []string{}
for _, t := range strings.Split(v, ",") {
if t = strings.TrimSpace(t); t != "" {
tags = append(tags, t)
}
}
body["placement_tags"] = tags
}
if labels, err := traefikLabelFlag(cmd); err != nil {
return err
} else if labels != nil {
body["traefik_labels"] = labels
}
data, err := c.Do("POST", "/projects/"+args[0]+"/services", body)
if err != nil {
return err
@@ -126,6 +153,28 @@ var servicesCreateCmd = &cobra.Command{
},
}
// traefikLabelFlag parses --traefik-label key=value pairs into the API's
// map shape. Returns nil when the flag was never passed; an empty map when
// only empty values were given (clears overrides on update).
func traefikLabelFlag(cmd *cobra.Command) (map[string]string, error) {
if !cmd.Flags().Changed("traefik-label") {
return nil, nil
}
pairs, _ := cmd.Flags().GetStringArray("traefik-label")
labels := map[string]string{}
for _, p := range pairs {
if strings.TrimSpace(p) == "" {
continue
}
k, v, ok := strings.Cut(p, "=")
if !ok || strings.TrimSpace(k) == "" {
return nil, &APIError{Message: fmt.Sprintf("invalid --traefik-label %q — want key=value", p), ExitCode: ExitError}
}
labels[strings.TrimSpace(k)] = v
}
return labels, nil
}
var servicesDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a service",
@@ -159,7 +208,7 @@ var servicesUpdateCmd = &cobra.Command{
return err
}
body := map[string]interface{}{}
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir", "node"} {
if cmd.Flags().Changed(f) {
key := f
switch f {
@@ -175,6 +224,8 @@ var servicesUpdateCmd = &cobra.Command{
key = "static_build_cmd"
case "static-dir":
key = "static_dir"
case "node":
key = "node_id"
}
v, _ := cmd.Flags().GetString(f)
body[key] = v
@@ -195,6 +246,33 @@ var servicesUpdateCmd = &cobra.Command{
}
body["volumes"] = volumes
}
if cmd.Flags().Changed("sleep") {
v, _ := cmd.Flags().GetBool("sleep")
body["sleep_enabled"] = v
}
if cmd.Flags().Changed("spread") {
v, _ := cmd.Flags().GetBool("spread")
body["spread"] = v
}
if cmd.Flags().Changed("placement-tags") {
v, _ := cmd.Flags().GetString("placement-tags")
tags := []string{}
for _, t := range strings.Split(v, ",") {
if t = strings.TrimSpace(t); t != "" {
tags = append(tags, t)
}
}
body["placement_tags"] = tags
}
if cmd.Flags().Changed("sleep-idle") {
v, _ := cmd.Flags().GetInt("sleep-idle")
body["sleep_idle_minutes"] = v
}
if labels, err := traefikLabelFlag(cmd); err != nil {
return err
} else if labels != nil {
body["traefik_labels"] = labels
}
if cmd.Flags().Changed("maintenance") {
v, _ := cmd.Flags().GetString("maintenance")
body["maintenance_mode"] = v == "on" || v == "true"
@@ -482,6 +560,12 @@ func init() {
f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)")
f.String("static-cmd", "", "build command for the static builder")
f.String("static-dir", "", "output dir for the static builder (default dist)")
f.String("node", "", "pin to a node agent id, or 'auto' for least-loaded")
f.Bool("spread", false, "distribute replicas across all online schedulable nodes")
f.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
f.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)")
f.String("placement-tags", "", "comma-separated node tags required for remote placement (auto/spread only)")
f.StringArray("traefik-label", nil, "Traefik middleware override, key=value (e.g. middlewares.rl.ratelimit.average=100); repeatable")
uf := servicesUpdateCmd.Flags()
uf.String("name", "", "service name")
@@ -504,8 +588,14 @@ func init() {
uf.String("memory-reserve", "", "soft memory reservation (empty clears)")
uf.String("static-cmd", "", "build command 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.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"} {
for _, a := range []string{"start", "stop", "restart", "redeploy", "sleep", "wake"} {
verb := a
ServicesCmd.AddCommand(&cobra.Command{
Use: verb + " <id>",
@@ -520,5 +610,38 @@ func init() {
domainsAddCmd.Flags().Bool("default", false, "set as the default domain")
servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd)
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd)
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd, servicesEnvCheckCmd)
}
var servicesEnvCheckCmd = &cobra.Command{
Use: "env-check <id>",
Short: "Diagnose service environment: empty vars, unresolved ${{refs}}",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/services/"+args[0]+"/env-check", nil)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
m, _ := unwrapObject(data)
if b, ok := m["ok"].(bool); ok && b {
fmt.Println("env check: ok")
return nil
}
for _, group := range []string{"unresolved", "empty", "unreadable"} {
if list, ok := m[group].([]interface{}); ok {
for _, item := range list {
fmt.Printf("%s: %v\n", group, item)
}
}
}
return &APIError{Message: "env check failed", ExitCode: ExitError}
},
}
+163 -2
View File
@@ -1,8 +1,11 @@
package commands
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
)
@@ -104,16 +107,31 @@ var templatesDeleteCmd = &cobra.Command{
},
}
var tplDeployVars []string
var tplDeployName string
var templatesDeployCmd = &cobra.Command{
Use: "deploy <template-id> <project-id>",
Short: "Deploy a template into a project",
Long: "Deploys a single-service template or a multi-service graph (v2) template. Pass --name for single-service templates; graph members take names from the template. Use --var KEY=VALUE to override template variables.",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", map[string]string{"project_id": args[1]})
body := map[string]interface{}{"project_id": args[1]}
if tplDeployName != "" {
body["name"] = tplDeployName
}
vars, err := parseKVFlags(tplDeployVars)
if err != nil {
return err
}
if len(vars) > 0 {
body["variables"] = vars
}
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", body)
if err != nil {
return err
}
@@ -122,6 +140,149 @@ var templatesDeployCmd = &cobra.Command{
},
}
var templatesPlanCmd = &cobra.Command{
Use: "plan <template-id>",
Short: "Dry-resolve a template — expanded expressions, service refs, missing variables",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
vars, err := parseKVFlags(tplDeployVars)
if err != nil {
return err
}
body := map[string]interface{}{}
if len(vars) > 0 {
body["variables"] = vars
}
data, err := c.Do("POST", "/templates/"+args[0]+"/plan", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesImportComposeCmd = &cobra.Command{
Use: "import-compose <compose.yml|->",
Short: "Convert a docker-compose file into a template graph config",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var raw []byte
var err error
if args[0] == "-" {
raw, err = io.ReadAll(os.Stdin)
} else {
raw, err = os.ReadFile(args[0])
}
if err != nil {
return fmt.Errorf("cannot read compose file: %w", err)
}
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/templates/import/compose", map[string]string{"compose_yaml": string(raw)})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesImportGitCmd = &cobra.Command{
Use: "import-git <repo> [path]",
Short: "Fetch a compose file from a git repo and convert it into a template graph config",
Long: "repo accepts a clone URL, ssh remote, or owner/repo shorthand resolved via connected git providers (private repos included). path defaults to compose.yaml/yml or docker-compose.yml/yaml. --ref selects a branch or commit.",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
body := map[string]string{"repo": args[0]}
if len(args) > 1 {
body["path"] = args[1]
}
if v, _ := cmd.Flags().GetString("ref"); v != "" {
body["ref"] = v
}
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/templates/import/git", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesDeployGraphCmd = &cobra.Command{
Use: "deploy-graph <project-id> <config.json|->",
Short: "Deploy an ad-hoc service graph (v2 config with services[]) without a stored template",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
var raw []byte
var err error
if args[1] == "-" {
raw, err = io.ReadAll(os.Stdin)
} else {
raw, err = os.ReadFile(args[1])
}
if err != nil {
return fmt.Errorf("cannot read config: %w", err)
}
var config json.RawMessage
if err := json.Unmarshal(raw, &config); err != nil {
return fmt.Errorf("invalid config JSON: %w", err)
}
c, err := client()
if err != nil {
return err
}
vars, err := parseKVFlags(tplDeployVars)
if err != nil {
return err
}
body := map[string]interface{}{"project_id": args[0]}
var cfg map[string]interface{}
if err := json.Unmarshal(config, &cfg); err != nil {
return fmt.Errorf("invalid config JSON: %w", err)
}
body["config"] = cfg
if len(vars) > 0 {
body["variables"] = vars
}
data, err := c.Do("POST", "/templates/deploy", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// parseKVFlags converts repeated --var KEY=VALUE flags into a map.
func parseKVFlags(flags []string) (map[string]string, error) {
out := map[string]string{}
for _, f := range flags {
idx := strings.Index(f, "=")
if idx <= 0 {
return nil, fmt.Errorf("invalid --var %q — expected KEY=VALUE", f)
}
out[f[:idx]] = f[idx+1:]
}
return out, nil
}
func init() {
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd)
templatesDeployCmd.Flags().StringVar(&tplDeployName, "name", "", "Name for the created service (single-service templates)")
templatesDeployCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
templatesPlanCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
templatesDeployGraphCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
templatesImportGitCmd.Flags().String("ref", "", "git branch or commit (default: remote HEAD)")
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd, templatesPlanCmd, templatesImportComposeCmd, templatesImportGitCmd, templatesDeployGraphCmd)
}
@@ -0,0 +1,216 @@
package commands
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
var webhookEvents []string
var webhookHeaders []string
var webhookSecret string
var webhookName string
// WebhooksCmd manages outbound webhooks — signed HTTP event delivery.
var WebhooksCmd = &cobra.Command{
Use: "webhooks",
Aliases: []string{"hooks"},
Short: "Manage outbound webhooks (signed event delivery)",
}
var webhookListCmd = &cobra.Command{
Use: "list",
Short: "List webhooks",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/webhooks", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "webhooks")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, w := range items {
events := ""
if ev, ok := w["events"].([]interface{}); ok {
names := make([]string, 0, len(ev))
for _, e := range ev {
if s, ok := e.(string); ok {
names = append(names, s)
}
}
events = strings.Join(names, ",")
}
rows = append(rows, []string{
str(w, "id"), str(w, "name"), str(w, "url"), events, str(w, "enabled"),
})
}
printRows(data, []string{"ID", "NAME", "URL", "EVENTS", "ENABLED"}, rows)
return nil
},
}
func webhookBody(req map[string]interface{}) (map[string]interface{}, error) {
if len(webhookEvents) > 0 {
req["events"] = webhookEvents
}
if webhookSecret != "" {
req["secret"] = webhookSecret
}
if webhookName != "" {
req["name"] = webhookName
}
if len(webhookHeaders) > 0 {
hdrs, err := parseKVFlags(webhookHeaders)
if err != nil {
return nil, err
}
req["headers"] = hdrs
}
return req, nil
}
var webhookCreateCmd = &cobra.Command{
Use: "create <name> <url>",
Short: "Create a webhook (events repeat with --event; use \"*\" for all)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
if len(webhookEvents) == 0 {
return fmt.Errorf("at least one --event is required (e.g. --event 'service.*' or --event '*')")
}
c, err := client()
if err != nil {
return err
}
body, err := webhookBody(map[string]interface{}{"name": args[0], "url": args[1]})
if err != nil {
return err
}
data, err := c.Do("POST", "/webhooks", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var webhookUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a webhook (--name --url --event --secret --header --enabled/--disabled)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body, err := webhookBody(map[string]interface{}{})
if err != nil {
return err
}
if url, _ := cmd.Flags().GetString("url"); url != "" {
body["url"] = url
}
if cmd.Flags().Changed("enabled") {
body["enabled"] = true
}
if cmd.Flags().Changed("disabled") {
body["enabled"] = false
}
data, err := c.Do("PATCH", "/webhooks/"+args[0], body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var webhookDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a webhook",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !Confirm("Delete webhook " + args[0] + "?") {
return nil
}
c, err := client()
if err != nil {
return err
}
data, err := c.Do("DELETE", "/webhooks/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var webhookDeliveriesCmd = &cobra.Command{
Use: "deliveries <id>",
Short: "Show recent delivery attempts for a webhook",
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", "/webhooks/"+args[0]+"/deliveries", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "deliveries")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, d := range items {
rows = append(rows, []string{
str(d, "id"), str(d, "event"), str(d, "status"),
str(d, "response_status"), str(d, "attempts"), relTime(d, "created_at"),
})
}
printRows(data, []string{"ID", "EVENT", "STATUS", "HTTP", "TRIES", "AGE"}, rows)
return nil
},
}
var webhookTestCmd = &cobra.Command{
Use: "test <id>",
Short: "Queue a test ping delivery",
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", "/webhooks/"+args[0]+"/test", map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() {
webhookCreateCmd.Flags().StringArrayVar(&webhookEvents, "event", nil, "Event subscription (e.g. 'service.*', 'deployment.fail', '*'; repeatable)")
webhookCreateCmd.Flags().StringVar(&webhookSecret, "secret", "", "Signing secret (auto-generated when omitted)")
webhookCreateCmd.Flags().StringArrayVar(&webhookHeaders, "header", nil, "Extra request header KEY=VALUE (repeatable)")
webhookUpdateCmd.Flags().StringVar(&webhookName, "name", "", "New name")
webhookUpdateCmd.Flags().String("url", "", "New URL")
webhookUpdateCmd.Flags().StringArrayVar(&webhookEvents, "event", nil, "Replace event subscriptions (repeatable)")
webhookUpdateCmd.Flags().StringVar(&webhookSecret, "secret", "", "New signing secret")
webhookUpdateCmd.Flags().StringArrayVar(&webhookHeaders, "header", nil, "Replace request headers KEY=VALUE (repeatable)")
webhookUpdateCmd.Flags().Bool("enabled", false, "Enable the webhook")
webhookUpdateCmd.Flags().Bool("disabled", false, "Disable the webhook")
WebhooksCmd.AddCommand(webhookListCmd, webhookCreateCmd, webhookUpdateCmd, webhookDeleteCmd, webhookDeliveriesCmd, webhookTestCmd)
}
+9
View File
@@ -75,9 +75,11 @@ func init() {
rootCmd.AddCommand(commands.VariablesCmd)
rootCmd.AddCommand(commands.VolumesCmd)
rootCmd.AddCommand(commands.RegistriesCmd)
rootCmd.AddCommand(commands.BackupTargetsCmd)
rootCmd.AddCommand(commands.DatabasesCmd)
rootCmd.AddCommand(commands.CronCmd)
rootCmd.AddCommand(commands.TemplatesCmd)
rootCmd.AddCommand(commands.ImportCmd)
rootCmd.AddCommand(commands.NodesCmd)
rootCmd.AddCommand(commands.ScalingCmd)
rootCmd.AddCommand(commands.HACmd)
@@ -85,7 +87,14 @@ func init() {
rootCmd.AddCommand(commands.GatewayCmd)
rootCmd.AddCommand(commands.AdminCmd)
rootCmd.AddCommand(commands.NotificationsCmd)
rootCmd.AddCommand(commands.WebhooksCmd)
rootCmd.AddCommand(commands.OperationsCmd)
rootCmd.AddCommand(commands.ActivityCmd)
rootCmd.AddCommand(commands.ShellCmd)
rootCmd.AddCommand(commands.BannersCmd)
rootCmd.AddCommand(commands.InvitesCmd)
rootCmd.AddCommand(commands.UpCmd)
rootCmd.AddCommand(commands.MigrateCmd)
}
func initConfig() {
@@ -14,6 +14,18 @@ import (
"github.com/sqlc-dev/pqtype"
)
const clearServiceNodePins = `-- name: ClearServiceNodePins :execrows
UPDATE services SET node_id = NULL, updated_at = NOW() WHERE node_id = $1
`
func (q *Queries) ClearServiceNodePins(ctx context.Context, nodeID sql.NullString) (int64, error) {
result, err := q.db.ExecContext(ctx, clearServiceNodePins, nodeID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const completeCommand = `-- name: CompleteCommand :one
UPDATE agent_commands SET
status = $3,
@@ -63,7 +75,7 @@ INSERT INTO node_agents (
id, name, hostname, ip_address, port, status, version,
capabilities, resources, last_heartbeat, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain
`
type CreateAgentParams struct {
@@ -109,6 +121,10 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeA
&i.CreatedAt,
&i.UpdatedAt,
&i.Metadata,
&i.AutoPrune,
&i.Schedulable,
&i.Tags,
&i.DefaultDomain,
)
return i, err
}
@@ -228,8 +244,22 @@ func (q *Queries) DeleteAgent(ctx context.Context, id string) error {
return err
}
const deleteServiceContainersOnAgent = `-- name: DeleteServiceContainersOnAgent :exec
DELETE FROM container_instances WHERE node_agent_id = $1 AND service_id = $2
`
type DeleteServiceContainersOnAgentParams struct {
NodeAgentID string `json:"node_agent_id"`
ServiceID string `json:"service_id"`
}
func (q *Queries) DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error {
_, err := q.db.ExecContext(ctx, deleteServiceContainersOnAgent, arg.NodeAgentID, arg.ServiceID)
return err
}
const getAgent = `-- name: GetAgent :one
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata FROM node_agents WHERE id = $1
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain FROM node_agents WHERE id = $1
`
func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
@@ -249,12 +279,16 @@ func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
&i.CreatedAt,
&i.UpdatedAt,
&i.Metadata,
&i.AutoPrune,
&i.Schedulable,
&i.Tags,
&i.DefaultDomain,
)
return i, err
}
const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata FROM node_agents
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain FROM node_agents
WHERE hostname = $1 AND ip_address = $2
LIMIT 1
`
@@ -281,6 +315,10 @@ func (q *Queries) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAnd
&i.CreatedAt,
&i.UpdatedAt,
&i.Metadata,
&i.AutoPrune,
&i.Schedulable,
&i.Tags,
&i.DefaultDomain,
)
return i, err
}
@@ -378,6 +416,36 @@ func (q *Queries) GetContainerForAgent(ctx context.Context, arg GetContainerForA
return i, err
}
const getLastAgentCommandByType = `-- name: GetLastAgentCommandByType :one
SELECT id, type, node_agent_id, container_id, payload, status, result, error, created_at, updated_at, completed_at FROM agent_commands
WHERE node_agent_id = $1 AND type = $2
ORDER BY created_at DESC LIMIT 1
`
type GetLastAgentCommandByTypeParams struct {
NodeAgentID string `json:"node_agent_id"`
Type string `json:"type"`
}
func (q *Queries) GetLastAgentCommandByType(ctx context.Context, arg GetLastAgentCommandByTypeParams) (AgentCommand, error) {
row := q.db.QueryRowContext(ctx, getLastAgentCommandByType, arg.NodeAgentID, arg.Type)
var i AgentCommand
err := row.Scan(
&i.ID,
&i.Type,
&i.NodeAgentID,
&i.ContainerID,
&i.Payload,
&i.Status,
&i.Result,
&i.Error,
&i.CreatedAt,
&i.UpdatedAt,
&i.CompletedAt,
)
return i, err
}
const insertAgentHeartbeat = `-- name: InsertAgentHeartbeat :exec
INSERT INTO agent_heartbeats (
id, node_agent_id, timestamp, status, resources,
@@ -458,7 +526,7 @@ func (q *Queries) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHea
}
const listAgents = `-- name: ListAgents :many
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata FROM node_agents ORDER BY created_at ASC
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain FROM node_agents ORDER BY created_at ASC
`
func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
@@ -484,6 +552,10 @@ func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
&i.CreatedAt,
&i.UpdatedAt,
&i.Metadata,
&i.AutoPrune,
&i.Schedulable,
&i.Tags,
&i.DefaultDomain,
); err != nil {
return nil, err
}
@@ -627,6 +699,296 @@ func (q *Queries) ListPendingCommands(ctx context.Context, nodeAgentID string) (
return items, nil
}
const listSchedulableAgentDomains = `-- name: ListSchedulableAgentDomains :many
SELECT DISTINCT default_domain FROM node_agents
WHERE status = 'online' AND schedulable
AND default_domain <> ''
AND tags @> $1::jsonb
ORDER BY default_domain
`
// Base domains of every online, schedulable agent carrying the required
// placement tags — auto-domain candidates for spread services.
func (q *Queries) ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listSchedulableAgentDomains, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var default_domain string
if err := rows.Scan(&default_domain); err != nil {
return nil, err
}
items = append(items, default_domain)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSchedulableAgents = `-- name: ListSchedulableAgents :many
SELECT id, name FROM node_agents
WHERE status = 'online' AND schedulable
ORDER BY id
`
type ListSchedulableAgentsRow struct {
ID string `json:"id"`
Name string `json:"name"`
}
func (q *Queries) ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error) {
rows, err := q.db.QueryContext(ctx, listSchedulableAgents)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListSchedulableAgentsRow{}
for rows.Next() {
var i ListSchedulableAgentsRow
if err := rows.Scan(&i.ID, &i.Name); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSchedulableAgentsMatching = `-- name: ListSchedulableAgentsMatching :many
SELECT id, name FROM node_agents
WHERE status = 'online' AND schedulable
AND tags @> $1::jsonb
ORDER BY id
`
type ListSchedulableAgentsMatchingRow struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Only agents carrying every required placement tag (jsonb array containment).
func (q *Queries) ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error) {
rows, err := q.db.QueryContext(ctx, listSchedulableAgentsMatching, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListSchedulableAgentsMatchingRow{}
for rows.Next() {
var i ListSchedulableAgentsMatchingRow
if err := rows.Scan(&i.ID, &i.Name); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listServiceAgents = `-- name: ListServiceAgents :many
SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1
`
// Distinct agents holding inventory rows for a service.
func (q *Queries) ListServiceAgents(ctx context.Context, serviceID string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listServiceAgents, serviceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var node_agent_id string
if err := rows.Scan(&node_agent_id); err != nil {
return nil, err
}
items = append(items, node_agent_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listServiceContainers = `-- name: ListServiceContainers :many
SELECT id, name, node_agent_id, status, ports, resources FROM container_instances
WHERE service_id = $1
`
type ListServiceContainersRow struct {
ID string `json:"id"`
Name string `json:"name"`
NodeAgentID string `json:"node_agent_id"`
Status pqtype.NullRawMessage `json:"status"`
Ports pqtype.NullRawMessage `json:"ports"`
Resources pqtype.NullRawMessage `json:"resources"`
}
func (q *Queries) ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error) {
rows, err := q.db.QueryContext(ctx, listServiceContainers, serviceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListServiceContainersRow{}
for rows.Next() {
var i ListServiceContainersRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.NodeAgentID,
&i.Status,
&i.Ports,
&i.Resources,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const pickLeastLoadedAgent = `-- name: PickLeastLoadedAgent :one
SELECT a.id FROM node_agents a
LEFT JOIN LATERAL (
SELECT h.container_count FROM agent_heartbeats h
WHERE h.node_agent_id = a.id
ORDER BY h.timestamp DESC LIMIT 1
) h ON true
WHERE a.status = 'online' AND a.schedulable
ORDER BY
COALESCE(
(a.resources->'memory'->>'used')::double precision
/ NULLIF((a.resources->'memory'->>'total')::double precision, 0),
1.0
) ASC,
COALESCE((a.resources->'cpu'->>'usage')::double precision, 100.0) ASC,
COALESCE(h.container_count, 0) ASC,
a.created_at ASC
LIMIT 1
`
// Resource-aware: lowest memory utilisation first, then cpu, then container
// count. Agents without telemetry sort last (NULL → worst score).
func (q *Queries) PickLeastLoadedAgent(ctx context.Context) (string, error) {
row := q.db.QueryRowContext(ctx, pickLeastLoadedAgent)
var id string
err := row.Scan(&id)
return id, err
}
const pickLeastLoadedAgentMatching = `-- name: PickLeastLoadedAgentMatching :one
SELECT a.id FROM node_agents a
LEFT JOIN LATERAL (
SELECT h.container_count FROM agent_heartbeats h
WHERE h.node_agent_id = a.id
ORDER BY h.timestamp DESC LIMIT 1
) h ON true
WHERE a.status = 'online' AND a.schedulable
AND a.tags @> $1::jsonb
ORDER BY
COALESCE(
(a.resources->'memory'->>'used')::double precision
/ NULLIF((a.resources->'memory'->>'total')::double precision, 0),
1.0
) ASC,
COALESCE((a.resources->'cpu'->>'usage')::double precision, 100.0) ASC,
COALESCE(h.container_count, 0) ASC,
a.created_at ASC
LIMIT 1
`
// Same resource-aware ordering as PickLeastLoadedAgent, restricted to agents
// carrying every required placement tag.
func (q *Queries) PickLeastLoadedAgentMatching(ctx context.Context, dollar_1 json.RawMessage) (string, error) {
row := q.db.QueryRowContext(ctx, pickLeastLoadedAgentMatching, dollar_1)
var id string
err := row.Scan(&id)
return id, err
}
const scrubCommandPayload = `-- name: ScrubCommandPayload :exec
UPDATE agent_commands SET payload = $2, updated_at = NOW() WHERE id = $1
`
type ScrubCommandPayloadParams struct {
ID string `json:"id"`
Payload pqtype.NullRawMessage `json:"payload"`
}
func (q *Queries) ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error {
_, err := q.db.ExecContext(ctx, scrubCommandPayload, arg.ID, arg.Payload)
return err
}
const setAgentDefaultDomain = `-- name: SetAgentDefaultDomain :exec
UPDATE node_agents SET default_domain = $2, updated_at = NOW() WHERE id = $1
`
type SetAgentDefaultDomainParams struct {
ID string `json:"id"`
DefaultDomain string `json:"default_domain"`
}
func (q *Queries) SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error {
_, err := q.db.ExecContext(ctx, setAgentDefaultDomain, arg.ID, arg.DefaultDomain)
return err
}
const setAgentSchedulable = `-- name: SetAgentSchedulable :exec
UPDATE node_agents SET schedulable = $2, updated_at = NOW() WHERE id = $1
`
type SetAgentSchedulableParams struct {
ID string `json:"id"`
Schedulable bool `json:"schedulable"`
}
func (q *Queries) SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error {
_, err := q.db.ExecContext(ctx, setAgentSchedulable, arg.ID, arg.Schedulable)
return err
}
const setAgentTags = `-- name: SetAgentTags :exec
UPDATE node_agents SET tags = $2::jsonb, updated_at = NOW() WHERE id = $1
`
type SetAgentTagsParams struct {
ID string `json:"id"`
Column2 json.RawMessage `json:"column_2"`
}
func (q *Queries) SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error {
_, err := q.db.ExecContext(ctx, setAgentTags, arg.ID, arg.Column2)
return err
}
const updateAgent = `-- name: UpdateAgent :one
UPDATE node_agents SET
name = $2,
@@ -639,9 +1001,11 @@ UPDATE node_agents SET
resources = $9,
last_heartbeat = $10,
metadata = $11,
auto_prune = $12,
schedulable = $13,
updated_at = NOW()
WHERE id = $1
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain
`
type UpdateAgentParams struct {
@@ -656,6 +1020,8 @@ type UpdateAgentParams struct {
Resources pqtype.NullRawMessage `json:"resources"`
LastHeartbeat sql.NullTime `json:"last_heartbeat"`
Metadata pqtype.NullRawMessage `json:"metadata"`
AutoPrune bool `json:"auto_prune"`
Schedulable bool `json:"schedulable"`
}
func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) {
@@ -671,6 +1037,8 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
arg.Resources,
arg.LastHeartbeat,
arg.Metadata,
arg.AutoPrune,
arg.Schedulable,
)
var i NodeAgent
err := row.Scan(
@@ -687,6 +1055,10 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
&i.CreatedAt,
&i.UpdatedAt,
&i.Metadata,
&i.AutoPrune,
&i.Schedulable,
&i.Tags,
&i.DefaultDomain,
)
return i, err
}
@@ -717,6 +1089,53 @@ func (q *Queries) UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeart
return err
}
const updateContainerPorts = `-- name: UpdateContainerPorts :exec
UPDATE container_instances SET status = $2, ports = $3, updated_at = NOW()
WHERE id = $1
`
type UpdateContainerPortsParams struct {
ID string `json:"id"`
Status pqtype.NullRawMessage `json:"status"`
Ports pqtype.NullRawMessage `json:"ports"`
}
// Agent reports the real host bindings after create — persist them with
// the status flip so ingress can route to the assigned port.
func (q *Queries) UpdateContainerPorts(ctx context.Context, arg UpdateContainerPortsParams) error {
_, err := q.db.ExecContext(ctx, updateContainerPorts, arg.ID, arg.Status, arg.Ports)
return err
}
const updateContainerStateByName = `-- name: UpdateContainerStateByName :exec
UPDATE container_instances SET
status = jsonb_set(status, '{state}', $3::jsonb),
resources = jsonb_set(COALESCE(resources, '{}'::jsonb), '{net_bytes}', $4::jsonb),
updated_at = NOW()
WHERE name = $1 AND node_agent_id = $2
AND status->>'state' IS DISTINCT FROM 'removed'
`
type UpdateContainerStateByNameParams struct {
Name string `json:"name"`
NodeAgentID string `json:"node_agent_id"`
Column3 json.RawMessage `json:"column_3"`
Column4 json.RawMessage `json:"column_4"`
}
// Agent heartbeat reports real docker state + net counters; reconcile
// inventory for rows this node owns. Tombstones win — never resurrect a
// removed replica. $4 is the cumulative rx+tx byte counter.
func (q *Queries) UpdateContainerStateByName(ctx context.Context, arg UpdateContainerStateByNameParams) error {
_, err := q.db.ExecContext(ctx, updateContainerStateByName,
arg.Name,
arg.NodeAgentID,
arg.Column3,
arg.Column4,
)
return err
}
const updateContainerStatus = `-- name: UpdateContainerStatus :exec
UPDATE container_instances SET status = $2, updated_at = NOW()
WHERE id = $1
@@ -731,3 +1150,52 @@ func (q *Queries) UpdateContainerStatus(ctx context.Context, arg UpdateContainer
_, err := q.db.ExecContext(ctx, updateContainerStatus, arg.ID, arg.Status)
return err
}
const upsertServiceContainer = `-- name: UpsertServiceContainer :exec
INSERT INTO container_instances (
id, name, image, project_id, service_id, node_agent_id,
status, resources, ports, environment, volumes, networks,
restart_policy, health_check
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
ON CONFLICT (id) DO UPDATE SET
image = EXCLUDED.image,
status = EXCLUDED.status,
updated_at = NOW()
`
type UpsertServiceContainerParams struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
ProjectID string `json:"project_id"`
ServiceID string `json:"service_id"`
NodeAgentID string `json:"node_agent_id"`
Status pqtype.NullRawMessage `json:"status"`
Resources pqtype.NullRawMessage `json:"resources"`
Ports pqtype.NullRawMessage `json:"ports"`
Environment pqtype.NullRawMessage `json:"environment"`
Volumes pqtype.NullRawMessage `json:"volumes"`
Networks pqtype.NullRawMessage `json:"networks"`
RestartPolicy pqtype.NullRawMessage `json:"restart_policy"`
HealthCheck pqtype.NullRawMessage `json:"health_check"`
}
func (q *Queries) UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) error {
_, err := q.db.ExecContext(ctx, upsertServiceContainer,
arg.ID,
arg.Name,
arg.Image,
arg.ProjectID,
arg.ServiceID,
arg.NodeAgentID,
arg.Status,
arg.Resources,
arg.Ports,
arg.Environment,
arg.Volumes,
arg.Networks,
arg.RestartPolicy,
arg.HealthCheck,
)
return err
}
@@ -0,0 +1,253 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: backup_targets.sql
package sqlcdb
import (
"context"
"database/sql"
)
const countDatabasesUsingBackupTarget = `-- name: CountDatabasesUsingBackupTarget :one
SELECT COUNT(*)
FROM database_services
WHERE backup_target_id = $1
`
func (q *Queries) CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error) {
row := q.db.QueryRowContext(ctx, countDatabasesUsingBackupTarget, targetID)
var count int64
err := row.Scan(&count)
return count, err
}
const createBackupTarget = `-- name: CreateBackupTarget :exec
INSERT INTO backup_targets (id, user_id, name, endpoint, bucket, region, prefix, access_key, secret_key, use_tls, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`
type CreateBackupTargetParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket"`
Region string `json:"region"`
Prefix string `json:"prefix"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
UseTls bool `json:"use_tls"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error {
_, err := q.db.ExecContext(ctx, createBackupTarget,
arg.ID,
arg.UserID,
arg.Name,
arg.Endpoint,
arg.Bucket,
arg.Region,
arg.Prefix,
arg.AccessKey,
arg.SecretKey,
arg.UseTls,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const deleteBackupTargetByIDAndUser = `-- name: DeleteBackupTargetByIDAndUser :exec
DELETE FROM backup_targets
WHERE id = $1 AND user_id = $2
`
type DeleteBackupTargetByIDAndUserParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
}
func (q *Queries) DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error {
_, err := q.db.ExecContext(ctx, deleteBackupTargetByIDAndUser, arg.ID, arg.UserID)
return err
}
const getBackupTargetByID = `-- name: GetBackupTargetByID :one
SELECT backup_targets.id, backup_targets.user_id, backup_targets.name, backup_targets.endpoint, backup_targets.bucket, backup_targets.region, backup_targets.prefix, backup_targets.access_key, backup_targets.secret_key, backup_targets.use_tls, backup_targets.created_at, backup_targets.updated_at
FROM backup_targets
WHERE id = $1
`
func (q *Queries) GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error) {
row := q.db.QueryRowContext(ctx, getBackupTargetByID, id)
var i BackupTarget
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Endpoint,
&i.Bucket,
&i.Region,
&i.Prefix,
&i.AccessKey,
&i.SecretKey,
&i.UseTls,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getBackupTargetByIDAndUser = `-- name: GetBackupTargetByIDAndUser :one
SELECT backup_targets.id, backup_targets.user_id, backup_targets.name, backup_targets.endpoint, backup_targets.bucket, backup_targets.region, backup_targets.prefix, backup_targets.access_key, backup_targets.secret_key, backup_targets.use_tls, backup_targets.created_at, backup_targets.updated_at
FROM backup_targets
WHERE id = $1 AND user_id = $2
`
type GetBackupTargetByIDAndUserParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
}
func (q *Queries) GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error) {
row := q.db.QueryRowContext(ctx, getBackupTargetByIDAndUser, arg.ID, arg.UserID)
var i BackupTarget
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Endpoint,
&i.Bucket,
&i.Region,
&i.Prefix,
&i.AccessKey,
&i.SecretKey,
&i.UseTls,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listBackupTargetsByUser = `-- name: ListBackupTargetsByUser :many
SELECT backup_targets.id, backup_targets.user_id, backup_targets.name, backup_targets.endpoint, backup_targets.bucket, backup_targets.region, backup_targets.prefix, backup_targets.access_key, backup_targets.secret_key, backup_targets.use_tls, backup_targets.created_at, backup_targets.updated_at
FROM backup_targets
WHERE user_id = $1
ORDER BY created_at DESC
`
func (q *Queries) ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error) {
rows, err := q.db.QueryContext(ctx, listBackupTargetsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []BackupTarget{}
for rows.Next() {
var i BackupTarget
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Endpoint,
&i.Bucket,
&i.Region,
&i.Prefix,
&i.AccessKey,
&i.SecretKey,
&i.UseTls,
&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 setDatabaseBackupRemoteKeyByID = `-- name: SetDatabaseBackupRemoteKeyByID :exec
UPDATE database_backups
SET remote_key = $1
WHERE id = $2
`
type SetDatabaseBackupRemoteKeyByIDParams struct {
RemoteKey sql.NullString `json:"remote_key"`
ID string `json:"id"`
}
func (q *Queries) SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error {
_, err := q.db.ExecContext(ctx, setDatabaseBackupRemoteKeyByID, arg.RemoteKey, arg.ID)
return err
}
const setDatabaseBackupTargetByIDAndUser = `-- name: SetDatabaseBackupTargetByIDAndUser :exec
UPDATE database_services
SET backup_target_id = $1, updated_at = $2
WHERE id = $3 AND user_id = $4
`
type SetDatabaseBackupTargetByIDAndUserParams struct {
BackupTargetID sql.NullString `json:"backup_target_id"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID string `json:"id"`
UserID string `json:"user_id"`
}
func (q *Queries) SetDatabaseBackupTargetByIDAndUser(ctx context.Context, arg SetDatabaseBackupTargetByIDAndUserParams) error {
_, err := q.db.ExecContext(ctx, setDatabaseBackupTargetByIDAndUser,
arg.BackupTargetID,
arg.UpdatedAt,
arg.ID,
arg.UserID,
)
return err
}
const updateBackupTargetByIDAndUser = `-- name: UpdateBackupTargetByIDAndUser :exec
UPDATE backup_targets
SET name = $1, endpoint = $2, bucket = $3, region = $4, prefix = $5, access_key = $6, secret_key = $7, use_tls = $8, updated_at = $9
WHERE id = $10 AND user_id = $11
`
type UpdateBackupTargetByIDAndUserParams struct {
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket"`
Region string `json:"region"`
Prefix string `json:"prefix"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
UseTls bool `json:"use_tls"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID string `json:"id"`
UserID string `json:"user_id"`
}
func (q *Queries) UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error {
_, err := q.db.ExecContext(ctx, updateBackupTargetByIDAndUser,
arg.Name,
arg.Endpoint,
arg.Bucket,
arg.Region,
arg.Prefix,
arg.AccessKey,
arg.SecretKey,
arg.UseTls,
arg.UpdatedAt,
arg.ID,
arg.UserID,
)
return err
}
@@ -0,0 +1,231 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: banners.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const createBanner = `-- name: CreateBanner :one
INSERT INTO banners (title, body, level, active, dismissible, created_by, starts_at, ends_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
`
type CreateBannerParams struct {
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"`
Active bool `json:"active"`
Dismissible bool `json:"dismissible"`
CreatedBy uuid.NullUUID `json:"created_by"`
StartsAt sql.NullTime `json:"starts_at"`
EndsAt sql.NullTime `json:"ends_at"`
}
func (q *Queries) CreateBanner(ctx context.Context, arg CreateBannerParams) (Banner, error) {
row := q.db.QueryRowContext(ctx, createBanner,
arg.Title,
arg.Body,
arg.Level,
arg.Active,
arg.Dismissible,
arg.CreatedBy,
arg.StartsAt,
arg.EndsAt,
)
var i Banner
err := row.Scan(
&i.ID,
&i.Title,
&i.Body,
&i.Level,
&i.Active,
&i.Dismissible,
&i.CreatedBy,
&i.StartsAt,
&i.EndsAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteBanner = `-- name: DeleteBanner :exec
DELETE FROM banners
WHERE id = $1
`
func (q *Queries) DeleteBanner(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteBanner, id)
return err
}
const getBannerByID = `-- name: GetBannerByID :one
SELECT banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
FROM banners
WHERE id = $1
`
func (q *Queries) GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error) {
row := q.db.QueryRowContext(ctx, getBannerByID, id)
var i Banner
err := row.Scan(
&i.ID,
&i.Title,
&i.Body,
&i.Level,
&i.Active,
&i.Dismissible,
&i.CreatedBy,
&i.StartsAt,
&i.EndsAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listActiveBanners = `-- name: ListActiveBanners :many
SELECT banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
FROM banners
WHERE active
AND (starts_at IS NULL OR starts_at <= now())
AND (ends_at IS NULL OR ends_at > now())
ORDER BY created_at DESC
`
func (q *Queries) ListActiveBanners(ctx context.Context) ([]Banner, error) {
rows, err := q.db.QueryContext(ctx, listActiveBanners)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Banner{}
for rows.Next() {
var i Banner
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Body,
&i.Level,
&i.Active,
&i.Dismissible,
&i.CreatedBy,
&i.StartsAt,
&i.EndsAt,
&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 listBanners = `-- name: ListBanners :many
SELECT banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
FROM banners
ORDER BY created_at DESC
`
func (q *Queries) ListBanners(ctx context.Context) ([]Banner, error) {
rows, err := q.db.QueryContext(ctx, listBanners)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Banner{}
for rows.Next() {
var i Banner
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Body,
&i.Level,
&i.Active,
&i.Dismissible,
&i.CreatedBy,
&i.StartsAt,
&i.EndsAt,
&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 updateBanner = `-- name: UpdateBanner :one
UPDATE banners
SET title = $2,
body = $3,
level = $4,
active = $5,
dismissible = $6,
starts_at = $7,
ends_at = $8,
updated_at = now()
WHERE id = $1
RETURNING banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
`
type UpdateBannerParams struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"`
Active bool `json:"active"`
Dismissible bool `json:"dismissible"`
StartsAt sql.NullTime `json:"starts_at"`
EndsAt sql.NullTime `json:"ends_at"`
}
func (q *Queries) UpdateBanner(ctx context.Context, arg UpdateBannerParams) (Banner, error) {
row := q.db.QueryRowContext(ctx, updateBanner,
arg.ID,
arg.Title,
arg.Body,
arg.Level,
arg.Active,
arg.Dismissible,
arg.StartsAt,
arg.EndsAt,
)
var i Banner
err := row.Scan(
&i.ID,
&i.Title,
&i.Body,
&i.Level,
&i.Active,
&i.Dismissible,
&i.CreatedBy,
&i.StartsAt,
&i.EndsAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -8,6 +8,8 @@ package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const countDatabaseServicesByUserAndName = `-- name: CountDatabaseServicesByUserAndName :one
@@ -55,21 +57,23 @@ func (q *Queries) CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBa
}
const createDatabaseService = `-- name: CreateDatabaseService :exec
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, project_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`
type CreateDatabaseServiceParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
PublicPort bool `json:"public_port"`
ProjectID uuid.NullUUID `json:"project_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error {
@@ -82,6 +86,54 @@ func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseS
arg.Version,
arg.Plan,
arg.Region,
arg.PublicPort,
arg.ProjectID,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const createExternalDatabaseService = `-- name: CreateExternalDatabaseService :exec
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region,
provider, external_host, external_port, external_name, external_username, external_password, external_ssl,
created_at, updated_at)
VALUES ($1, $2, $3, $4, 'running', $5, $6, $7, 'external', $8, $9, $10, $11, $12, $13, $14, $15)
`
type CreateExternalDatabaseServiceParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ExternalHost sql.NullString `json:"external_host"`
ExternalPort sql.NullInt32 `json:"external_port"`
ExternalName sql.NullString `json:"external_name"`
ExternalUsername sql.NullString `json:"external_username"`
ExternalPassword string `json:"external_password"`
ExternalSsl bool `json:"external_ssl"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error {
_, err := q.db.ExecContext(ctx, createExternalDatabaseService,
arg.ID,
arg.UserID,
arg.Name,
arg.Type,
arg.Version,
arg.Plan,
arg.Region,
arg.ExternalHost,
arg.ExternalPort,
arg.ExternalName,
arg.ExternalUsername,
arg.ExternalPassword,
arg.ExternalSsl,
arg.CreatedAt,
arg.UpdatedAt,
)
@@ -106,6 +158,16 @@ func (q *Queries) DatabaseServiceExistsByIDAndUser(ctx context.Context, arg Data
return exists, err
}
const deleteDatabaseServiceByID = `-- name: DeleteDatabaseServiceByID :exec
DELETE FROM database_services
WHERE id = $1
`
func (q *Queries) DeleteDatabaseServiceByID(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteDatabaseServiceByID, id)
return err
}
const deleteDatabaseServiceByIDAndUser = `-- name: DeleteDatabaseServiceByIDAndUser :exec
DELETE FROM database_services
WHERE id = $1 AND user_id = $2
@@ -122,7 +184,7 @@ func (q *Queries) DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg Dele
}
const getDatabaseBackupByIDAndDatabaseAndUser = `-- name: GetDatabaseBackupByIDAndDatabaseAndUser :one
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.remote_key, b.created_at, b.completed_at
FROM database_backups b
JOIN database_services s ON s.id = b.database_id
WHERE b.id = $1 AND b.database_id = $2 AND s.user_id = $3
@@ -143,6 +205,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
&i.Size,
&i.Status,
&i.BackupPath,
&i.RemoteKey,
&i.CreatedAt,
&i.CompletedAt,
)
@@ -150,7 +213,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
}
const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at
FROM database_services
WHERE id = $1
`
@@ -170,6 +233,16 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
&i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.Provider,
&i.ExternalHost,
&i.ExternalPort,
&i.ExternalName,
&i.ExternalUsername,
&i.ExternalPassword,
&i.ExternalSsl,
&i.PublicPort,
&i.BackupTargetID,
&i.ProjectID,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -177,7 +250,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
}
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at
FROM database_services
WHERE id = $1 AND user_id = $2
`
@@ -202,6 +275,16 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
&i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.Provider,
&i.ExternalHost,
&i.ExternalPort,
&i.ExternalName,
&i.ExternalUsername,
&i.ExternalPassword,
&i.ExternalSsl,
&i.PublicPort,
&i.BackupTargetID,
&i.ProjectID,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -222,7 +305,7 @@ func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (str
}
const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at
FROM database_services
ORDER BY created_at DESC
`
@@ -248,6 +331,16 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic
&i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.Provider,
&i.ExternalHost,
&i.ExternalPort,
&i.ExternalName,
&i.ExternalUsername,
&i.ExternalPassword,
&i.ExternalSsl,
&i.PublicPort,
&i.BackupTargetID,
&i.ProjectID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -265,7 +358,7 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic
}
const listDatabaseBackupsByDatabaseAndUser = `-- name: ListDatabaseBackupsByDatabaseAndUser :many
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.remote_key, b.created_at, b.completed_at
FROM database_backups b
JOIN database_services s ON s.id = b.database_id
WHERE b.database_id = $1 AND s.user_id = $2
@@ -294,6 +387,7 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
&i.Size,
&i.Status,
&i.BackupPath,
&i.RemoteKey,
&i.CreatedAt,
&i.CompletedAt,
); err != nil {
@@ -310,8 +404,62 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
return items, nil
}
const listDatabaseServicesByProject = `-- name: ListDatabaseServicesByProject :many
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at
FROM database_services
WHERE project_id = $1
ORDER BY created_at DESC
`
func (q *Queries) ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error) {
rows, err := q.db.QueryContext(ctx, listDatabaseServicesByProject, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []DatabaseService{}
for rows.Next() {
var i DatabaseService
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Type,
&i.Status,
&i.Version,
&i.Plan,
&i.Region,
&i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.Provider,
&i.ExternalHost,
&i.ExternalPort,
&i.ExternalName,
&i.ExternalUsername,
&i.ExternalPassword,
&i.ExternalSsl,
&i.PublicPort,
&i.BackupTargetID,
&i.ProjectID,
&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 listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.public_port, database_services.backup_target_id, database_services.project_id, database_services.created_at, database_services.updated_at
FROM database_services
WHERE user_id = $1
ORDER BY created_at DESC
@@ -338,6 +486,16 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
&i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.Provider,
&i.ExternalHost,
&i.ExternalPort,
&i.ExternalName,
&i.ExternalUsername,
&i.ExternalPassword,
&i.ExternalSsl,
&i.PublicPort,
&i.BackupTargetID,
&i.ProjectID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -453,6 +611,29 @@ func (q *Queries) SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNe
return err
}
const setDatabaseServicePublicPortByIDAndUser = `-- name: SetDatabaseServicePublicPortByIDAndUser :exec
UPDATE database_services
SET public_port = $1, updated_at = $2
WHERE id = $3 AND user_id = $4
`
type SetDatabaseServicePublicPortByIDAndUserParams struct {
PublicPort bool `json:"public_port"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID string `json:"id"`
UserID string `json:"user_id"`
}
func (q *Queries) SetDatabaseServicePublicPortByIDAndUser(ctx context.Context, arg SetDatabaseServicePublicPortByIDAndUserParams) error {
_, err := q.db.ExecContext(ctx, setDatabaseServicePublicPortByIDAndUser,
arg.PublicPort,
arg.UpdatedAt,
arg.ID,
arg.UserID,
)
return err
}
const setDatabaseServiceStatusAndConnectionByID = `-- name: SetDatabaseServiceStatusAndConnectionByID :exec
UPDATE database_services
SET status = $1, connection_url = $2, updated_at = $3
@@ -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
}
+115 -13
View File
@@ -49,6 +49,35 @@ type AgentHeartbeat struct {
CreatedAt time.Time `json:"created_at"`
}
type BackupTarget struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket"`
Region string `json:"region"`
Prefix string `json:"prefix"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
UseTls bool `json:"use_tls"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type Banner struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"`
Active bool `json:"active"`
Dismissible bool `json:"dismissible"`
CreatedBy uuid.NullUUID `json:"created_by"`
StartsAt sql.NullTime `json:"starts_at"`
EndsAt sql.NullTime `json:"ends_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type ContainerInstance struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -75,24 +104,35 @@ type DatabaseBackup struct {
Size string `json:"size"`
Status string `json:"status"`
BackupPath sql.NullString `json:"backup_path"`
RemoteKey sql.NullString `json:"remote_key"`
CreatedAt sql.NullTime `json:"created_at"`
CompletedAt sql.NullTime `json:"completed_at"`
}
type DatabaseService struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"`
BackupSchedule sql.NullString `json:"backup_schedule"`
NextBackupAt sql.NullTime `json:"next_backup_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"`
BackupSchedule sql.NullString `json:"backup_schedule"`
NextBackupAt sql.NullTime `json:"next_backup_at"`
Provider string `json:"provider"`
ExternalHost sql.NullString `json:"external_host"`
ExternalPort sql.NullInt32 `json:"external_port"`
ExternalName sql.NullString `json:"external_name"`
ExternalUsername sql.NullString `json:"external_username"`
ExternalPassword string `json:"external_password"`
ExternalSsl bool `json:"external_ssl"`
PublicPort bool `json:"public_port"`
BackupTargetID sql.NullString `json:"backup_target_id"`
ProjectID uuid.NullUUID `json:"project_id"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type Deployment struct {
@@ -135,6 +175,10 @@ type NodeAgent struct {
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
Metadata pqtype.NullRawMessage `json:"metadata"`
AutoPrune bool `json:"auto_prune"`
Schedulable bool `json:"schedulable"`
Tags json.RawMessage `json:"tags"`
DefaultDomain string `json:"default_domain"`
}
type Notification struct {
@@ -149,6 +193,19 @@ type Notification struct {
CreatedAt sql.NullTime `json:"created_at"`
}
type OutboundWebhook struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
Url string `json:"url"`
Secret string `json:"secret"`
Events json.RawMessage `json:"events"`
Headers pqtype.NullRawMessage `json:"headers"`
Enabled bool `json:"enabled"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type Project struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
@@ -167,6 +224,17 @@ type ProjectMember struct {
CreatedAt sql.NullTime `json:"created_at"`
}
type Registry struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Name string `json:"name"`
Host string `json:"host"`
Username string `json:"username"`
Password string `json:"password"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Service struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
@@ -204,6 +272,15 @@ type Service struct {
Volumes json.RawMessage `json:"volumes"`
MaintenanceMode bool `json:"maintenance_mode"`
BasicAuthUsers string `json:"basic_auth_users"`
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"`
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 {
@@ -232,6 +309,17 @@ type ServiceTemplate struct {
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 {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
@@ -244,3 +332,17 @@ type UserToken struct {
RevokedAt sql.NullTime `json:"revoked_at"`
CreatedAt sql.NullTime `json:"created_at"`
}
type WebhookDelivery struct {
ID uuid.UUID `json:"id"`
WebhookID uuid.UUID `json:"webhook_id"`
Event string `json:"event"`
Payload pqtype.NullRawMessage `json:"payload"`
Status string `json:"status"`
ResponseStatus sql.NullInt32 `json:"response_status"`
ResponseBody sql.NullString `json:"response_body"`
Attempts int32 `json:"attempts"`
DurationMs sql.NullInt32 `json:"duration_ms"`
CreatedAt sql.NullTime `json:"created_at"`
DeliveredAt sql.NullTime `json:"delivered_at"`
}
@@ -6,35 +6,54 @@ package sqlcdb
import (
"context"
"database/sql"
"encoding/json"
"github.com/google/uuid"
)
type Querier interface {
ClearServiceNodePins(ctx context.Context, nodeID sql.NullString) (int64, error)
CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error)
CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error)
CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error)
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error
CreateBanner(ctx context.Context, arg CreateBannerParams) (Banner, error)
CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error)
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error
CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
CreateUserInvite(ctx context.Context, arg CreateUserInviteParams) (UserInvite, error)
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
DeleteBanner(ctx context.Context, id uuid.UUID) error
DeleteDatabaseServiceByID(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error
DeleteUserInvite(ctx context.Context, id uuid.UUID) error
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
GetAgent(ctx context.Context, id string) (NodeAgent, error)
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error)
GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error)
GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error)
GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error)
GetContainer(ctx context.Context, id string) (ContainerInstance, error)
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
@@ -42,40 +61,75 @@ type Querier interface {
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
GetLastAgentCommandByType(ctx context.Context, arg GetLastAgentCommandByTypeParams) (AgentCommand, error)
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error)
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
ListActiveBanners(ctx context.Context) ([]Banner, error)
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
ListAgents(ctx context.Context) ([]NodeAgent, error)
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
ListBanners(ctx context.Context) ([]Banner, error)
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error)
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error)
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error)
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListPendingProjects(ctx context.Context) ([]Project, error)
// Visibility model: a project is readable when it is approved for public
// display, when the caller owns or is a member of it, or when the caller is a
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
// Base domains of every online, schedulable agent carrying the required
// placement tags — auto-domain candidates for spread services.
ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error)
ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error)
// Only agents carrying every required placement tag (jsonb array containment).
ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error)
// Distinct agents holding inventory rows for a service.
ListServiceAgents(ctx context.Context, serviceID string) ([]string, error)
ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error)
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
ListUserInvites(ctx context.Context) ([]UserInvite, error)
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error)
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error
// Resource-aware: lowest memory utilisation first, then cpu, then container
// count. Agents without telemetry sort last (NULL → worst score).
PickLeastLoadedAgent(ctx context.Context) (string, error)
// Same resource-aware ordering as PickLeastLoadedAgent, restricted to agents
// carrying every required placement tag.
PickLeastLoadedAgentMatching(ctx context.Context, dollar_1 json.RawMessage) (string, error)
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error
SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error
SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error
SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
SetDatabaseBackupTargetByIDAndUser(ctx context.Context, arg SetDatabaseBackupTargetByIDAndUserParams) error
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
SetDatabaseServicePublicPortByIDAndUser(ctx context.Context, arg SetDatabaseServicePublicPortByIDAndUserParams) error
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
@@ -84,13 +138,25 @@ type Querier interface {
TouchUserToken(ctx context.Context, id uuid.UUID) error
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
UpdateBanner(ctx context.Context, arg UpdateBannerParams) (Banner, error)
// Agent reports the real host bindings after create — persist them with
// the status flip so ingress can route to the assigned port.
UpdateContainerPorts(ctx context.Context, arg UpdateContainerPortsParams) error
// Agent heartbeat reports real docker state + net counters; reconcile
// inventory for rows this node owns. Tombstones win — never resurrect a
// removed replica. $4 is the cumulative rx+tx byte counter.
UpdateContainerStateByName(ctx context.Context, arg UpdateContainerStateByNameParams) error
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
UpsertServiceContainer(ctx context.Context, arg UpsertServiceContainerParams) error
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
}
@@ -0,0 +1,329 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: webhooks.sql
package sqlcdb
import (
"context"
"database/sql"
"encoding/json"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
const createOutboundWebhook = `-- name: CreateOutboundWebhook :exec
INSERT INTO outbound_webhooks (id, user_id, name, url, secret, events, headers, enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
type CreateOutboundWebhookParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
Url string `json:"url"`
Secret string `json:"secret"`
Events json.RawMessage `json:"events"`
Headers pqtype.NullRawMessage `json:"headers"`
Enabled bool `json:"enabled"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error {
_, err := q.db.ExecContext(ctx, createOutboundWebhook,
arg.ID,
arg.UserID,
arg.Name,
arg.Url,
arg.Secret,
arg.Events,
arg.Headers,
arg.Enabled,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const createWebhookDelivery = `-- name: CreateWebhookDelivery :exec
INSERT INTO webhook_deliveries (id, webhook_id, event, payload, status, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
`
type CreateWebhookDeliveryParams struct {
ID uuid.UUID `json:"id"`
WebhookID uuid.UUID `json:"webhook_id"`
Event string `json:"event"`
Payload pqtype.NullRawMessage `json:"payload"`
Status string `json:"status"`
CreatedAt sql.NullTime `json:"created_at"`
}
func (q *Queries) CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error {
_, err := q.db.ExecContext(ctx, createWebhookDelivery,
arg.ID,
arg.WebhookID,
arg.Event,
arg.Payload,
arg.Status,
arg.CreatedAt,
)
return err
}
const deleteOutboundWebhookByIDAndUser = `-- name: DeleteOutboundWebhookByIDAndUser :exec
DELETE FROM outbound_webhooks
WHERE id = $1 AND user_id = $2
`
type DeleteOutboundWebhookByIDAndUserParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error {
_, err := q.db.ExecContext(ctx, deleteOutboundWebhookByIDAndUser, arg.ID, arg.UserID)
return err
}
const getOutboundWebhookByID = `-- name: GetOutboundWebhookByID :one
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
FROM outbound_webhooks
WHERE id = $1
`
func (q *Queries) GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error) {
row := q.db.QueryRowContext(ctx, getOutboundWebhookByID, id)
var i OutboundWebhook
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Url,
&i.Secret,
&i.Events,
&i.Headers,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getOutboundWebhookByIDAndUser = `-- name: GetOutboundWebhookByIDAndUser :one
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
FROM outbound_webhooks
WHERE id = $1 AND user_id = $2
`
type GetOutboundWebhookByIDAndUserParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error) {
row := q.db.QueryRowContext(ctx, getOutboundWebhookByIDAndUser, arg.ID, arg.UserID)
var i OutboundWebhook
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Url,
&i.Secret,
&i.Events,
&i.Headers,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listEnabledOutboundWebhooks = `-- name: ListEnabledOutboundWebhooks :many
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
FROM outbound_webhooks
WHERE enabled
`
func (q *Queries) ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error) {
rows, err := q.db.QueryContext(ctx, listEnabledOutboundWebhooks)
if err != nil {
return nil, err
}
defer rows.Close()
items := []OutboundWebhook{}
for rows.Next() {
var i OutboundWebhook
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Url,
&i.Secret,
&i.Events,
&i.Headers,
&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 listOutboundWebhooksByUser = `-- name: ListOutboundWebhooksByUser :many
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
FROM outbound_webhooks
WHERE user_id = $1
ORDER BY created_at DESC
`
func (q *Queries) ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error) {
rows, err := q.db.QueryContext(ctx, listOutboundWebhooksByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []OutboundWebhook{}
for rows.Next() {
var i OutboundWebhook
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Url,
&i.Secret,
&i.Events,
&i.Headers,
&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 listWebhookDeliveriesByWebhook = `-- name: ListWebhookDeliveriesByWebhook :many
SELECT webhook_deliveries.id, webhook_deliveries.webhook_id, webhook_deliveries.event, webhook_deliveries.payload, webhook_deliveries.status, webhook_deliveries.response_status, webhook_deliveries.response_body, webhook_deliveries.attempts, webhook_deliveries.duration_ms, webhook_deliveries.created_at, webhook_deliveries.delivered_at
FROM webhook_deliveries
WHERE webhook_id = $1
ORDER BY created_at DESC
LIMIT 100
`
func (q *Queries) ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error) {
rows, err := q.db.QueryContext(ctx, listWebhookDeliveriesByWebhook, webhookID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []WebhookDelivery{}
for rows.Next() {
var i WebhookDelivery
if err := rows.Scan(
&i.ID,
&i.WebhookID,
&i.Event,
&i.Payload,
&i.Status,
&i.ResponseStatus,
&i.ResponseBody,
&i.Attempts,
&i.DurationMs,
&i.CreatedAt,
&i.DeliveredAt,
); 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 updateOutboundWebhook = `-- name: UpdateOutboundWebhook :exec
UPDATE outbound_webhooks
SET name = $1, url = $2, secret = $3, events = $4, headers = $5, enabled = $6, updated_at = $7
WHERE id = $8 AND user_id = $9
`
type UpdateOutboundWebhookParams struct {
Name string `json:"name"`
Url string `json:"url"`
Secret string `json:"secret"`
Events json.RawMessage `json:"events"`
Headers pqtype.NullRawMessage `json:"headers"`
Enabled bool `json:"enabled"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error {
_, err := q.db.ExecContext(ctx, updateOutboundWebhook,
arg.Name,
arg.Url,
arg.Secret,
arg.Events,
arg.Headers,
arg.Enabled,
arg.UpdatedAt,
arg.ID,
arg.UserID,
)
return err
}
const updateWebhookDeliveryResult = `-- name: UpdateWebhookDeliveryResult :exec
UPDATE webhook_deliveries
SET status = $1, response_status = $2, response_body = $3, attempts = $4, duration_ms = $5, delivered_at = $6
WHERE id = $7
`
type UpdateWebhookDeliveryResultParams struct {
Status string `json:"status"`
ResponseStatus sql.NullInt32 `json:"response_status"`
ResponseBody sql.NullString `json:"response_body"`
Attempts int32 `json:"attempts"`
DurationMs sql.NullInt32 `json:"duration_ms"`
DeliveredAt sql.NullTime `json:"delivered_at"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error {
_, err := q.db.ExecContext(ctx, updateWebhookDeliveryResult,
arg.Status,
arg.ResponseStatus,
arg.ResponseBody,
arg.Attempts,
arg.DurationMs,
arg.DeliveredAt,
arg.ID,
)
return err
}
@@ -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"
)
// NodeRunner deploys a service's containers onto a remote node agent.
// Wired at boot; nil means every service runs on the local Docker host.
type NodeRunner interface {
// ReconcileOnNode brings the node's containers in line with the spec and
// returns the resulting runtime state.
ReconcileOnNode(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error)
// ReconcileSpread distributes the service's replicas across every online,
// schedulable agent — replica i lands on agents[i % len].
ReconcileSpread(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error)
// ReconcileAuto picks the least-loaded online agent carrying every tag in
// spec.PlacementTags and reconciles all replicas onto it. Re-resolved on
// every reconcile — the service follows the tag, not a stored node.
ReconcileAuto(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error)
// ControlService fans a lifecycle action ("start"|"stop"|"restart"|"remove")
// out to every replica of the service on the agent.
ControlService(ctx context.Context, serviceID, agentID, action string) error
// RemoveService tears down every replica of the service on the agent.
RemoveService(ctx context.Context, serviceID, agentID string) error
// RemoteRuntimeState reads the service's remote replicas from inventory
// across all agents. nil/empty means nothing runs remotely.
RemoteRuntimeState(ctx context.Context, serviceID string) (*RuntimeState, error)
// RetireService removes every remote replica of the service across all
// agents — used when a service returns to local placement.
RetireService(ctx context.Context, serviceID string) error
}
type DeploymentEngine struct {
buildManager *build.BuildManager
dockerClient *docker.Client
scheduler *Scheduler
nodeRunner NodeRunner
deployments map[string]*Deployment
deploymentLog chan *DeploymentEvent
}
@@ -67,6 +94,30 @@ type ServiceConfig struct {
// PublishedPort is the host port bound by the previous deployment; replica 0
// reuses it so public URLs survive restarts. 0 = pick ephemeral.
PublishedPort int32 `json:"published_port,omitempty"`
// NodeID pins the service to a registered node agent; "" = local Docker
// host.
NodeID string `json:"node_id,omitempty"`
// Spread distributes replicas across every online, schedulable agent.
Spread bool `json:"spread,omitempty"`
// RemoteBuild, when set, makes each target node docker-build the image
// itself from a packaged context artifact instead of pulling a registry
// tag. The image name comes from the deployment, not the spec.
RemoteBuild *RemoteBuildSpec `json:"remote_build,omitempty"`
// RemoteLoad ships a docker-save tarball to each target node — used for
// rollbacks of locally-built tags that no registry can serve.
RemoteLoad *RemoteLoadSpec `json:"remote_load,omitempty"`
}
// RemoteBuildSpec is the agent-side docker build request.
type RemoteBuildSpec struct {
ArtifactID string `json:"artifact_id"`
BuildArgs map[string]string `json:"build_args,omitempty"`
NoCache bool `json:"no_cache,omitempty"`
}
// RemoteLoadSpec asks the agent to docker-load an image tarball artifact.
type RemoteLoadSpec struct {
ArtifactID string `json:"artifact_id"`
}
type PortMapping struct {
@@ -177,6 +228,17 @@ func (de *DeploymentEngine) DockerClient() *docker.Client {
return de.dockerClient
}
// BuildManager exposes the engine's builders for context packaging — remote
// nodes build the tar this produces themselves.
func (de *DeploymentEngine) BuildManager() *build.BuildManager {
return de.buildManager
}
// SetNodeRunner wires remote-node dispatch for services pinned to an agent.
func (de *DeploymentEngine) SetNodeRunner(r NodeRunner) {
de.nodeRunner = r
}
func NewDeploymentEngine(buildManager *build.BuildManager, dockerClient *docker.Client) *DeploymentEngine {
return &DeploymentEngine{
buildManager: buildManager,
@@ -287,11 +349,19 @@ func (de *DeploymentEngine) executeDeployment(ctx context.Context, deployment *D
})
}
// buildImage builds the container image
// buildImage builds the container image. "remote" build types produce no
// local image — the target node builds or loads it from an artifact; the
// deployment still needs a deterministic tag so every node names it alike.
func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployment, buildConfig *BuildConfig) (string, error) {
if buildConfig == nil {
return "", fmt.Errorf("build config is required")
}
if buildConfig.BuildType == "remote" {
if buildConfig.PrebuiltImage != "" {
return buildConfig.PrebuiltImage, nil
}
return fmt.Sprintf("containr-%s-%s:%s", deployment.ServiceID, deployment.Environment, deployment.ID), nil
}
buildReq := &types.BuildRequest{
BuildType: buildConfig.BuildType,
@@ -352,6 +422,10 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
Maintenance: cfg.Maintenance,
MaintenanceURL: cfg.MaintenanceURL,
BasicAuthUsers: cfg.BasicAuthUsers,
NodeID: cfg.NodeID,
Spread: cfg.Spread,
RemoteBuild: cfg.RemoteBuild,
RemoteLoad: cfg.RemoteLoad,
}
for _, pm := range cfg.PortMappings {
if spec.Port == 0 {
+151 -38
View File
@@ -55,6 +55,24 @@ type RuntimeSpec struct {
Maintenance bool // redirect traffic to MaintenanceURL
MaintenanceURL string // absolute URL; empty = serve empty response
BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
// TraefikLabels defines extra middlewares for this service's router —
// keys "middlewares.<name>.<type>[.<field>]" (API-validated against an
// allowlist). Defined middlewares attach to the router after builtins.
TraefikLabels map[string]string
// NodeID pins the service to a node agent; "" runs on the local host.
NodeID string
// Spread distributes replicas across all online, schedulable agents
// instead of a single node. Mutually exclusive with NodeID.
Spread bool
// PlacementTags restricts auto/spread candidates to agents carrying
// every listed tag. An explicit NodeID pin bypasses tag filtering.
PlacementTags []string
// RemoteBuild makes target nodes docker-build Image from a packaged
// context artifact before replicas start (git-sourced remote deploys).
RemoteBuild *RemoteBuildSpec
// RemoteLoad makes target nodes docker-load a saved image tarball —
// remote rollbacks of locally-built tags.
RemoteLoad *RemoteLoadSpec
}
// RuntimeContainer describes one live replica.
@@ -151,8 +169,35 @@ func (de *DeploymentEngine) ListServiceContainers(ctx context.Context, serviceID
// ReconcileService brings live containers in line with the spec. When the
// spec hash changed (image/env/ports/etc.) all replicas are recreated;
// otherwise only the replica delta is applied.
// otherwise only the replica delta is applied. A pinned NodeID routes the
// whole reconcile to that agent's command queue instead of local Docker.
func (de *DeploymentEngine) ReconcileService(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error) {
if spec.Spread && spec.NodeID == "" {
if de.nodeRunner == nil {
return nil, fmt.Errorf("service has spread placement but remote dispatch is unavailable")
}
return de.nodeRunner.ReconcileSpread(ctx, spec)
}
if spec.NodeID != "" {
if de.nodeRunner == nil {
return nil, fmt.Errorf("service is pinned to node %s but remote dispatch is unavailable", spec.NodeID)
}
return de.nodeRunner.ReconcileOnNode(ctx, spec)
}
// Placement tags without a pin/spread resolve to the least-loaded
// matching agent on every reconcile — soft affinity, re-evaluated.
if len(spec.PlacementTags) > 0 {
if de.nodeRunner == nil {
return nil, fmt.Errorf("service has placement tags but remote dispatch is unavailable")
}
return de.nodeRunner.ReconcileAuto(ctx, spec)
}
// Local placement: retire any remote leftovers from a previous pin,
// spread, or tag affinity before reconciling local replicas.
if de.nodeRunner != nil {
_ = de.nodeRunner.RetireService(ctx, spec.ServiceID)
}
if spec.Replicas < 1 {
spec.Replicas = 1
}
@@ -233,44 +278,9 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
projectNet: {Aliases: []string{spec.Name}},
}
if edgeNet != "" {
router := "svc-" + spec.ServiceID[:8]
domains := spec.Domains
if len(domains) == 0 && spec.Domain != "" {
domains = []string{spec.Domain}
}
sort.Strings(domains)
rules := make([]string, 0, len(domains))
for _, d := range domains {
rules = append(rules, "Host(`"+d+"`)")
}
endpoints[edgeNet] = &network.EndpointSettings{}
labels["traefik.enable"] = "true"
labels["traefik.docker.network"] = edgeNet
labels["traefik.http.routers."+router+".rule"] = strings.Join(rules, " || ")
labels["traefik.http.routers."+router+".entrypoints"] = "web"
labels["traefik.http.services."+router+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
var middlewares []string
if spec.Maintenance {
if spec.MaintenanceURL != "" {
mw := router + "-maint"
labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
middlewares = append(middlewares, mw)
} else {
// No redirect target configured — take the site offline with
// empty responses instead of leaking traffic to the app.
labels["traefik.http.routers."+router+".service"] = "noop@internal"
}
}
if spec.BasicAuthUsers != "" {
mw := router + "-auth"
labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
middlewares = append(middlewares, mw)
}
if len(middlewares) > 0 {
labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
for k, v := range edgeLabels(spec, edgeNet) {
labels[k] = v
}
}
@@ -358,6 +368,81 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
// vpnkit drops a conflicting binding).
var errPortNotBound = errors.New("requested host port not bound")
// edgeLabels builds the Traefik router/service/middleware labels for a
// replica on the edge network: host rules, load balancer port, builtin
// maintenance/basic-auth middlewares, then operator-defined overrides.
func edgeLabels(spec RuntimeSpec, edgeNet string) map[string]string {
router := "svc-" + spec.ServiceID[:8]
domains := spec.Domains
if len(domains) == 0 && spec.Domain != "" {
domains = []string{spec.Domain}
}
sort.Strings(domains)
rules := make([]string, 0, len(domains))
for _, d := range domains {
rules = append(rules, "Host(`"+d+"`)")
}
labels := map[string]string{
"traefik.enable": "true",
"traefik.docker.network": edgeNet,
"traefik.http.routers." + router + ".rule": strings.Join(rules, " || "),
"traefik.http.routers." + router + ".entrypoints": "web",
"traefik.http.services." + router + ".loadbalancer.server.port": fmt.Sprintf("%d", spec.Port),
}
var middlewares []string
if spec.Maintenance {
if spec.MaintenanceURL != "" {
mw := router + "-maint"
labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
middlewares = append(middlewares, mw)
} else {
// No redirect target configured — take the site offline with
// empty responses instead of leaking traffic to the app.
labels["traefik.http.routers."+router+".service"] = "noop@internal"
}
}
if spec.BasicAuthUsers != "" {
mw := router + "-auth"
labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
middlewares = append(middlewares, mw)
}
// Operator-defined middlewares: "middlewares.<name>.<rest>" becomes
// "<router>-<name>.<rest>", and every defined name joins the router's
// chain after builtins. Names sort for a deterministic chain order.
customMWs := map[string][]string{}
for k, v := range spec.TraefikLabels {
rest := strings.TrimPrefix(k, "middlewares.")
dot := strings.Index(rest, ".")
if dot <= 0 {
continue
}
customMWs[rest[:dot]] = append(customMWs[rest[:dot]], rest[dot+1:]+"="+v)
}
mwNames := make([]string, 0, len(customMWs))
for name := range customMWs {
mwNames = append(mwNames, name)
}
sort.Strings(mwNames)
for _, name := range mwNames {
mw := router + "-" + name
sort.Strings(customMWs[name])
for _, kv := range customMWs[name] {
if i := strings.Index(kv, "="); i > 0 {
labels["traefik.http.middlewares."+mw+"."+kv[:i]] = kv[i+1:]
}
}
middlewares = append(middlewares, mw)
}
if len(middlewares) > 0 {
labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
}
return labels
}
// isPortConflict reports whether a container create failed because the
// requested host port was already bound.
func isPortConflict(err error) bool {
@@ -498,6 +583,34 @@ func (de *DeploymentEngine) RemoveServiceContainers(ctx context.Context, service
return nil
}
// ControlServiceOnNode fans a lifecycle action out to the service's replicas
// on a remote node agent. Callers resolve the pin; nil runner means the
// platform was booted without node support.
func (de *DeploymentEngine) ControlServiceOnNode(ctx context.Context, serviceID, nodeID, action string) error {
if de.nodeRunner == nil {
return fmt.Errorf("remote node control is not configured")
}
return de.nodeRunner.ControlService(ctx, serviceID, nodeID, action)
}
// RemoveServiceContainersOnNode tears down the service's replicas on a remote
// node agent.
func (de *DeploymentEngine) RemoveServiceContainersOnNode(ctx context.Context, serviceID, nodeID string) error {
if de.nodeRunner == nil {
return fmt.Errorf("remote node control is not configured")
}
return de.nodeRunner.RemoveService(ctx, serviceID, nodeID)
}
// RemoteRuntimeState exposes inventory-backed remote container state; nil
// runner means no node support was wired at boot.
func (de *DeploymentEngine) RemoteRuntimeState(ctx context.Context, serviceID string) (*RuntimeState, error) {
if de.nodeRunner == nil {
return nil, nil
}
return de.nodeRunner.RemoteRuntimeState(ctx, serviceID)
}
// RemoveProjectContainers deletes every managed container in a project.
func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, projectID string) error {
containers, err := de.dockerClient.ListContainersFiltered(ctx, filters.NewArgs(
+21
View File
@@ -129,6 +129,27 @@ func (q *Queue) Cancel(serviceID, deploymentID uuid.UUID) CancelState {
return NotFound
}
// ServiceQueueState reports queue depth for one service.
type ServiceQueueState struct {
ServiceID uuid.UUID `json:"service_id"`
Running bool `json:"running"`
Queued int `json:"queued"`
}
// Snapshot returns per-service queue depth for observability.
func (q *Queue) Snapshot() []ServiceQueueState {
q.mu.Lock()
defer q.mu.Unlock()
out := make([]ServiceQueueState, 0, len(q.states))
for id, st := range q.states {
if !st.running && len(st.pending) == 0 {
continue
}
out = append(out, ServiceQueueState{ServiceID: id, Running: st.running, Queued: len(st.pending)})
}
return out
}
// Do runs fn through the queue and waits for completion — for synchronous
// endpoints (redeploy) that still must not overlap an in-flight deployment.
// ctx bounds the wait, not the job; on timeout the job still runs to
@@ -125,6 +125,40 @@ func TestCancelActiveCancelsContext(t *testing.T) {
}
}
func TestSnapshot(t *testing.T) {
q := New()
if got := q.Snapshot(); len(got) != 0 {
t.Fatalf("empty queue snapshot = %v, want empty", got)
}
svc := uuid.New()
release := make(chan struct{})
q.Enqueue(svc, Job{Run: func(ctx context.Context) { <-release }})
q.Enqueue(svc, Job{Run: func(ctx context.Context) {}})
q.Enqueue(svc, Job{Run: func(ctx context.Context) {}})
deadline := time.After(2 * time.Second)
for {
snap := q.Snapshot()
if len(snap) == 1 && snap[0].Running {
if snap[0].ServiceID != svc {
t.Fatalf("snapshot service = %v, want %v", snap[0].ServiceID, svc)
}
if snap[0].Queued != 2 {
t.Fatalf("queued = %d, want 2", snap[0].Queued)
}
close(release)
return
}
select {
case <-deadline:
t.Fatalf("snapshot never showed running job: %v", snap)
default:
time.Sleep(time.Millisecond)
}
}
}
func TestEnqueuePosition(t *testing.T) {
q := New()
svc := uuid.New()
+42
View File
@@ -238,6 +238,11 @@ func (c *Client) CopyFromContainer(ctx context.Context, containerID, srcPath str
return reader, err
}
// CopyToContainer copies a tar archive stream into a container path.
func (c *Client) CopyToContainer(ctx context.Context, containerID, dstPath string, content io.Reader) error {
return c.cli.CopyToContainer(ctx, containerID, dstPath, content, container.CopyToContainerOptions{})
}
// GetContainerStats returns real-time resource usage statistics for a container
func (c *Client) GetContainerStats(ctx context.Context, containerID string, stream bool) (*container.StatsResponseReader, error) {
resp, err := c.cli.ContainerStats(ctx, containerID, stream)
@@ -331,6 +336,13 @@ func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options
return resp, nil
}
// SaveImage streams refs as a docker-save tarball. Remote nodes receive
// these through the artifact endpoint and docker load them — that is how
// locally-built rollback tags reach nodes without a registry.
func (c *Client) SaveImage(ctx context.Context, refs ...string) (io.ReadCloser, error) {
return c.cli.ImageSave(ctx, refs)
}
// RemoveImage removes an image
func (c *Client) RemoveImage(ctx context.Context, imageID string, force bool) ([]image.DeleteResponse, error) {
return c.cli.ImageRemove(ctx, imageID, image.RemoveOptions{
@@ -366,6 +378,21 @@ func (c *Client) RemoveNetwork(ctx context.Context, networkID string) error {
return c.cli.NetworkRemove(ctx, networkID)
}
// NetworkGateway returns the IPAM gateway of a network — the address
// containers on it can use to reach host-published ports.
func (c *Client) NetworkGateway(ctx context.Context, networkID string) string {
resp, err := c.cli.NetworkInspect(ctx, networkID, network.InspectOptions{})
if err != nil {
return ""
}
for _, cfg := range resp.IPAM.Config {
if cfg.Gateway != "" {
return cfg.Gateway
}
}
return ""
}
// ConnectNetwork connects a container to a network
func (c *Client) ConnectNetwork(ctx context.Context, networkID, containerID string, config network.EndpointSettings) error {
return c.cli.NetworkConnect(ctx, networkID, containerID, &config)
@@ -496,6 +523,21 @@ func (c *Client) ExecRun(ctx context.Context, containerID string, cmd []string)
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
func (c *Client) GetImageInfo(ctx context.Context, imageName string) (*ImageInfo, error) {
images, err := c.cli.ImageList(ctx, image.ListOptions{})
+57 -3
View File
@@ -53,17 +53,20 @@ func init() {
// Services
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir.", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir, sleep_enabled (bool — scale to zero on idle), sleep_idle_minutes (1-1440, default 15), node_id (agent id | 'auto' = least-loaded online node; git services build on the node itself), spread (bool — distribute replicas across every online schedulable node; mutually exclusive with a node pin), placement_tags (string array — remote placement only onto nodes carrying every tag; re-resolved per reconcile, pin overrides), traefik_labels (object — extra Traefik middlewares for this service's router; keys 'middlewares.<name>.<type>[.<field>]', allowlist-validated; defined middlewares attach to the router automatically).", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
register(spec{"containr_services_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false})
register(spec{"containr_services_sleep", "Put a service to sleep immediately (scale to zero)", "POST", "/services/{id}/sleep", []string{"id"}, nil, false})
register(spec{"containr_services_wake", "Wake a sleeping service", "POST", "/services/{id}/wake", []string{"id"}, nil, false})
register(spec{"containr_services_env_check", "Diagnose service env: returns ok plus unresolved ${{refs}}, empty vars, and undecryptable secrets", "GET", "/services/{id}/env-check", []string{"id"}, nil, false})
register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false})
register(spec{"containr_services_clone", "Clone a service (config, volumes, domains, variables) into the same or another project. Args: id, name, project_id, environment.", "POST", "/services/{id}/clone", []string{"id"}, nil, 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_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).", "PUT", "/services/{id}", []string{"id"}, nil, false})
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes. node_id pins placement to a node agent ('auto' = least-loaded, 'local' = run on the Containr host). spread (bool) distributes replicas across every online schedulable node; mutually exclusive with a pin — clear node_id to 'local' first if pinned. placement_tags (string array; [] clears) restricts auto/spread placement to nodes carrying every tag; mutually exclusive with an explicit pin — clear the pin first. traefik_labels (object; {} clears) replaces the service's Traefik middleware overrides — keys 'middlewares.<name>.<type>[.<field>]', e.g. {'middlewares.rl.ratelimit.average': '100'}.", "PUT", "/services/{id}", []string{"id"}, nil, false})
// Domains
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
@@ -100,12 +103,23 @@ func init() {
// Databases
register(spec{"containr_databases_list", "List managed databases", "GET", "/databases", nil, nil, false})
register(spec{"containr_databases_get", "Get a database (connection URL included)", "GET", "/databases/{id}", []string{"id"}, nil, false})
register(spec{"containr_databases_create", "Provision a managed database. Args: name, type (postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse).", "POST", "/databases", nil, nil, false})
register(spec{"containr_databases_create", "Provision a managed database. Args: name, type (postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse), public_port (bind on all interfaces, default localhost-only).", "POST", "/databases", nil, nil, false})
register(spec{"containr_databases_update", "Update a database. Args: id, name, backup_schedule (empty clears), backup_target_id (empty clears), public_port — toggling recreates the container so the new bind scope applies.", "PUT", "/databases/{id}", []string{"id"}, nil, false})
register(spec{"containr_databases_register_external", "Register a database hosted elsewhere — probed first, password stored encrypted. Args: name (required), type (required), host (required), port, database, username, password, ssl.", "POST", "/databases/register-external", nil, []string{"name", "type", "host"}, false})
register(spec{"containr_databases_test_connection", "Probe a DB connection ad-hoc. Args: type, host (required), port, database, username, password, ssl.", "POST", "/databases/test-connection", nil, []string{"type", "host"}, false})
register(spec{"containr_databases_test_connection_by_id", "Re-probe a registered external database with its stored credentials. Args: id.", "POST", "/databases/{id}/test-connection", []string{"id"}, nil, false})
register(spec{"containr_databases_action", "Start/stop/restart a database. Args: id, action.", "POST", "/databases/{id}/action", []string{"id"}, []string{"action"}, false})
register(spec{"containr_databases_backup", "Take a manual backup snapshot", "POST", "/databases/{id}/backup", []string{"id"}, nil, false})
register(spec{"containr_databases_restore", "Restore a database from a backup — overwrites current data. Requires confirm:true.", "POST", "/databases/{id}/restore", []string{"id"}, []string{"backup_id"}, true})
register(spec{"containr_databases_delete", "Delete a database and its data. Requires confirm:true.", "DELETE", "/databases/{id}", []string{"id"}, nil, true})
// S3-compatible backup targets
register(spec{"containr_backup_targets_list", "List S3-compatible backup targets (credentials never returned)", "GET", "/backup-targets", nil, nil, false})
register(spec{"containr_backup_targets_create", "Register a backup target — probed first, keys stored encrypted. Args: name, endpoint (required), bucket (required), region, prefix, access_key, secret_key, use_tls.", "POST", "/backup-targets", nil, []string{"endpoint", "bucket"}, false})
register(spec{"containr_backup_targets_update", "Update a backup target; empty credentials keep stored keys. Args: id, name, endpoint, bucket, region, prefix, access_key, secret_key, use_tls.", "PUT", "/backup-targets/{id}", []string{"id"}, nil, false})
register(spec{"containr_backup_targets_delete", "Delete a backup target — fails while databases still point at it. Requires confirm:true.", "DELETE", "/backup-targets/{id}", []string{"id"}, nil, true})
register(spec{"containr_backup_targets_test", "Re-probe a backup target's bucket access. Args: id.", "POST", "/backup-targets/{id}/test", []string{"id"}, nil, false})
// Cron
register(spec{"containr_cron_list", "List cron jobs", "GET", "/cron-jobs", nil, nil, false})
register(spec{"containr_cron_create", "Create a cron job. Args: service_id, schedule (5-field), command, name, timezone.", "POST", "/cron-jobs", nil, nil, false})
@@ -117,16 +131,56 @@ func init() {
register(spec{"containr_templates_list", "List templates", "GET", "/templates", nil, nil, false})
register(spec{"containr_templates_get", "Get a template definition", "GET", "/templates/{id}", []string{"id"}, nil, false})
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
register(spec{"containr_templates_plan", "Dry-resolve a template: expanded expressions, service refs, missing variables. Args: id, variables (object).", "POST", "/templates/{id}/plan", []string{"id"}, nil, false})
register(spec{"containr_templates_import_compose", "Convert docker-compose YAML into a v2 template graph config. Args: compose_yaml.", "POST", "/templates/import/compose", nil, []string{"compose_yaml"}, false})
register(spec{"containr_templates_import_git", "Fetch a compose file from a git repo (clone URL, ssh remote, or owner/repo via connected providers) and convert it into a v2 template graph config. Args: repo (required), path (default: compose.yaml/yml or docker-compose.yml/yaml), ref (branch or commit).", "POST", "/templates/import/git", nil, []string{"repo", "path", "ref"}, false})
register(spec{"containr_templates_deploy_graph", "Deploy an ad-hoc service graph without a stored template. Args: project_id, config ({services:[...]}), variables, plan, region.", "POST", "/templates/deploy", nil, []string{"project_id", "config", "variables", "plan", "region"}, false})
register(spec{"containr_projects_import_compose", "Deploy a docker-compose file straight into a project. Args: id (project), compose_yaml, variables, plan, region.", "POST", "/projects/{id}/import-compose", []string{"id"}, []string{"compose_yaml", "variables", "plan", "region"}, false})
// Notifications
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
register(spec{"containr_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_update", "Update an outbound webhook. Args: id, plus name/url/secret/events/headers/enabled to change.", "PATCH", "/webhooks/{id}", []string{"id"}, []string{"name", "url", "secret", "events", "headers", "enabled"}, false})
register(spec{"containr_webhooks_delete", "Delete an outbound webhook. Args: id. Requires confirm=true.", "DELETE", "/webhooks/{id}", []string{"id"}, nil, true})
register(spec{"containr_webhooks_deliveries", "List recent delivery attempts for a webhook. Args: id.", "GET", "/webhooks/{id}/deliveries", []string{"id"}, nil, false})
register(spec{"containr_webhooks_test", "Queue a test ping delivery for a webhook. Args: id.", "POST", "/webhooks/{id}/test", []string{"id"}, nil, false})
register(spec{"containr_operations_get", "Cross-cutting operations view: active deployments, deploy queue depth, recent failures, cron runs, backups.", "GET", "/operations", nil, nil, false})
register(spec{"containr_banners_active", "List active announcement banners shown to users.", "GET", "/banners/active", nil, nil, false})
register(spec{"containr_banners_list", "List all announcement banners (admin).", "GET", "/admin/banners", nil, nil, false})
register(spec{"containr_banners_create", "Create an announcement banner (admin). Args: title, body, level (info|success|warning|error), active, dismissible, starts_at, ends_at.", "POST", "/admin/banners", nil, []string{"title", "body", "level", "active", "dismissible", "starts_at", "ends_at"}, false})
register(spec{"containr_banners_update", "Update an announcement banner (admin). Args: id plus fields to change.", "PATCH", "/admin/banners/{id}", []string{"id"}, []string{"title", "body", "level", "active", "dismissible", "starts_at", "ends_at"}, false})
register(spec{"containr_banners_delete", "Delete an announcement banner (admin). Args: id. Requires confirm=true.", "DELETE", "/admin/banners/{id}", []string{"id"}, nil, true})
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_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)
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false})
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
register(spec{"containr_nodes_update", "Update a node agent (admin). Args: id; optional auto_prune, name, tags (string array — placement labels services can require via placement_tags; [] clears), default_domain (base domain — services placed on this node with no explicit domains get <name>.<default_domain>; empty clears).", "PUT", "/agents/{id}", []string{"id"}, []string{"auto_prune", "name", "tags", "default_domain"}, false})
register(spec{"containr_nodes_cordon", "Cordon a node: blocks new pins and 'auto' placements; running replicas stay (admin). Args: id.", "POST", "/agents/{id}/cordon", []string{"id"}, nil, false})
register(spec{"containr_nodes_uncordon", "Re-open a node for placement (admin). Args: id.", "POST", "/agents/{id}/uncordon", []string{"id"}, nil, false})
register(spec{"containr_nodes_drain", "Drain a node: cordon + remove all service containers via the agent + unpin affected services so they redeploy locally. Requires the node online (admin). Args: id.", "POST", "/agents/{id}/drain", []string{"id"}, nil, true})
register(spec{"containr_nodes_prune", "Enqueue a bounded docker system prune on a node (admin). Args: id; optional until (e.g. 168h), volumes (danger, off by default).", "POST", "/agents/{id}/prune", []string{"id"}, []string{"until", "volumes"}, true})
register(spec{"containr_nodes_upgrade", "Upgrade a node's agent to the server's bundled binary: downloads over the API, verifies sha256, swaps atomically, re-execs. Node must be online (admin). Args: id.", "POST", "/agents/{id}/upgrade", []string{"id"}, nil, true})
register(spec{"containr_nodes_commands", "List recent commands sent to a node agent (admin). Args: id.", "GET", "/agents/{id}/commands", []string{"id"}, nil, false})
// Scaling / HA / security
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
+11 -1
View File
@@ -54,6 +54,9 @@ type ScanSummary struct {
// Scanner handles security scanning operations
type Scanner struct {
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
@@ -115,8 +118,11 @@ func (s *Scanner) performScan(scan *SecurityScan) {
// Update scan with results
completedAt := time.Now()
scan.CompletedAt = &completedAt
scan.Summary = summary
scan.Status = "completed"
_, err := s.db.Exec(`
UPDATE security_scans
UPDATE security_scans
SET status = $1, completed_at = $2, summary = $3
WHERE id = $4
`, "completed", completedAt, summaryToJSON(summary), scan.ID)
@@ -137,6 +143,10 @@ func (s *Scanner) performScan(scan *SecurityScan) {
log.Printf("Failed to store vulnerability %s: %v", vuln.ID, err)
}
}
if s.OnScanComplete != nil {
s.OnScanComplete(scan)
}
}
// 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,33 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE database_services
ADD COLUMN IF NOT EXISTS provider VARCHAR(20) NOT NULL DEFAULT 'managed',
ADD COLUMN IF NOT EXISTS external_host VARCHAR(255),
ADD COLUMN IF NOT EXISTS external_port INTEGER,
ADD COLUMN IF NOT EXISTS external_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS external_username VARCHAR(255),
ADD COLUMN IF NOT EXISTS external_password TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS external_ssl BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE database_services
DROP CONSTRAINT IF EXISTS database_services_plan_check,
ADD CONSTRAINT database_services_plan_check
CHECK (plan IN ('hobby', 'starter', 'standard', 'business', 'external'));
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE database_services
DROP CONSTRAINT IF EXISTS database_services_plan_check,
ADD CONSTRAINT database_services_plan_check
CHECK (plan IN ('hobby', 'starter', 'standard', 'business'));
ALTER TABLE database_services
DROP COLUMN IF EXISTS provider,
DROP COLUMN IF EXISTS external_host,
DROP COLUMN IF EXISTS external_port,
DROP COLUMN IF EXISTS external_name,
DROP COLUMN IF EXISTS external_username,
DROP COLUMN IF EXISTS external_password,
DROP COLUMN IF EXISTS external_ssl;
-- +goose StatementEnd
@@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE database_services
ADD COLUMN IF NOT EXISTS public_port BOOLEAN NOT NULL DEFAULT false;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE database_services
DROP COLUMN IF EXISTS public_port;
-- +goose StatementEnd
@@ -0,0 +1,34 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS backup_targets (
id VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
endpoint VARCHAR(512) NOT NULL,
bucket VARCHAR(255) NOT NULL,
region VARCHAR(100) NOT NULL DEFAULT '',
prefix VARCHAR(255) NOT NULL DEFAULT '',
access_key TEXT NOT NULL DEFAULT '',
secret_key TEXT NOT NULL DEFAULT '',
use_tls BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
ALTER TABLE database_services
ADD COLUMN IF NOT EXISTS backup_target_id VARCHAR(255);
ALTER TABLE database_backups
ADD COLUMN IF NOT EXISTS remote_key VARCHAR(512);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE database_backups
DROP COLUMN IF EXISTS remote_key;
ALTER TABLE database_services
DROP COLUMN IF EXISTS backup_target_id;
DROP TABLE IF EXISTS backup_targets;
-- +goose StatementEnd
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff. Show more