Compare commits

..
184 Commits
Author SHA1 Message Date
Tomáš DvořákandDevin 9243d020cc feat(notifications): ntfy/Gotify push channels with live delivery (#69)
Per-user notification_channels (ntfy + Gotify): every
insertUserNotification now fans out best-effort to enabled channels —
deployment/backup/security/agent alerts reach the user's phone via the
ntfy app with zero extra infrastructure.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

* fix(filters): erasableSyntaxOnly compat + BuildStatus cast

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

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

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

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

Four new emitters behind insertUserNotification:

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

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

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

---------

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

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

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

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

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

* fix(filters): erasableSyntaxOnly compat + BuildStatus cast

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 10:35:12 +02:00
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
Tomáš Dvořák 4734be0d9d Merge pull request #33 from Dvorinka/feat/release-mcp
ci: MCP release binaries + docs-only path gate
2026-10-09 05:11:14 +02:00
Tomáš Dvořák 4e8d364911 Merge pull request #32 from Dvorinka/feat/shared-vars
feat(vars): shared project variables — ${{shared.KEY}} refs
2026-10-09 05:11:08 +02:00
Tomáš Dvořák f53695be4f Merge pull request #23 from Dvorinka/feat/clone-move-registries
feat: service clone/move + private registry credentials (C4)
2026-10-09 05:10:27 +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 DvorakandDevin 57f8b4f56e ci: release MCP binaries + path-gate heavyweight jobs
- build-cli.sh also builds containr-mcp-<os>-<arch> per platform; the
  existing containr-* asset glob in the release job picks them up
- paths-filter 'changes' job gates backend/frontend/smoke/docker on PRs —
  docs-only PRs skip ~10min of compute; skipped checks satisfy protection
- docker job requires success() explicitly (skipped parents would pass
  '!= failure'); release requires docker success to never release stale images

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 01:07:18 +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 58424ed0ab Merge remote-tracking branch 'origin/main' into feat/clone-move-registries 2026-10-09 01:04:41 +02:00
Tomas DvorakandDevin 25fa8c47e6 feat(vars): project shared variables — ${{shared.KEY}} refs, CRUD, UI
- project_variables table (secret entries encrypted at rest, masked in API)
- resolveServiceEnv resolves shared.* before service lookup
- GET|PUT /projects/{id}/variables; CLI 'variables shared list|set|unset';
  MCP containr_shared_variables_{list,set}
- Settings tab → Shared Variables editor; env help text mentions shared refs

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 01:04:19 +02:00
Tomáš Dvořák b3da014d26 Merge pull request #22 from Dvorinka/feat/builder-resources
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (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
C3: builder selection, resource reservations, static builder
2026-10-09 01:03:19 +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 2049087ccd Merge remote-tracking branch 'origin/main' into feat/clone-move-registries
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:06 +02:00
Tomas Dvorak 01edb677e5 Merge remote-tracking branch 'origin/main' into feat/builder-resources
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:49:31 +02:00
Tomáš Dvořák b22ac401c8 Merge pull request #21 from Dvorinka/feat/service-domains
C2: multi-domain, DNS preflight, maintenance + basic-auth gates
2026-10-09 00:48:49 +02:00
Tomáš Dvořák ea96929435 Merge pull request #20 from Dvorinka/feat/service-volumes
C1: service volume mounts + admin volume inventory
2026-10-09 00:48:43 +02:00
Tomáš Dvořák 971773f80c Merge pull request #19 from Dvorinka/feat/deploy-queue
feat(deploy): pipeline maturity — queue, cancel, no-cache, secrets at rest
2026-10-09 00:48:37 +02:00
Tomáš Dvořák 7e99e41f8d Merge pull request #18 from Dvorinka/feat/agent-docs
docs: AGENTS.md + llms.txt
2026-10-09 00:48:32 +02:00
Tomáš Dvořák 6c5ff7be41 Merge pull request #17 from Dvorinka/feat/api-ergonomics
feat(api): error codes, idempotency keys, cursor pagination
2026-10-09 00:48:27 +02:00
Tomáš Dvořák 7b93506e2d Merge pull request #16 from Dvorinka/feat/mcp-server
feat(mcp): stdio MCP server exposing the full API as tools
2026-10-09 00:48:22 +02:00
Tomáš Dvořák f34fba165c Merge pull request #15 from Dvorinka/feat/cli-v2
feat(cli): v2 — full API coverage, profiles, --json/--yes, containr up
2026-10-09 00:48:16 +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
Tomáš Dvořák 6949c01464 Merge pull request #14 from Dvorinka/feat/personal-access-tokens
feat: personal access tokens with scoped bearer auth
2026-10-09 00:41:09 +02:00
Tomas Dvorak f22ae22375 Merge branch 'feat/service-volumes' into feat/service-domains 2026-10-09 00:40:13 +02:00
Tomas DvorakandDevin a4b6ea4bd2 fix(ui): volumes save — fill type/read_only defaults for generated type
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 00:40:12 +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
Tomas DvorakandDevin 3664eaed1a feat(services): clone/move ops + owner-scoped registry credentials (C4)
Service lifecycle: POST /services/:id/clone duplicates config, volumes,
domains, access gates, builder settings, and variables (secret ciphertext
copied verbatim); POST /services/:id/move re-parents to another owned
project. Both enforce ownership, reject name conflicts, and audit.

Registries: CRUD at /registries — host-matched pull credentials for
prebuilt-image deploys, encrypted at rest via secrets pkg, never returned
(has_password flag). Empty password on update keeps the stored credential;
host normalized (scheme/case/trailing slash). Credentials thread through
BuildConfig → BuildRequest → pull auth config, used only for the pull.

Surfaces: CLI services clone|move + registries group, 7 MCP tools,
OpenAPI paths/schemas, frontend Lifecycle card (clone/move/delete) and
Settings registries section, 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 17:21:27 +02:00
Tomas DvorakandDevin 912f6f5142 feat(services): builder selection, resource reservations, static builder (C3)
services.builder picks the build strategy per service: auto detects from
repo contents (Dockerfile → railpack → nixpacks), explicit overrides force
a builder, and static builds assets in a node stage then serves them from
an nginx image (static_build_cmd/static_dir, port 80 default).

- cpu_reserve/memory_reserve → Docker CPU shares + MemoryReservation;
  cpu/memory stay hard caps (NanoCPUs/Memory). Empty string clears.
- app_settings.default_cpu/default_memory override service-create
  fallbacks; capacity_policy=block fails deploys whose requested memory
  exceeds node capacity (default warn logs via slog).
- Resource specs are validated — "512mb" fails instead of silently
  dropping the limit.
- Fixes: deployments.go hardcoded nixpacks (auto-detect was dead code);
  DockerfileBuilder passed the Dockerfile *path* as build-context content;
  BuildImage never drained the daemon stream so builds could abort and
  errors went unseen — stream is now consumed and errorDetail surfaced.
- CLI: --builder/--cpu-reserve/--memory-reserve/--static-cmd/--static-dir
  on create+update; MCP args on services create/update; UI Build &
  Resources card; OpenAPI + llms.txt + CLI.md.

Verified live: static build of a real repo → nginx image → deployed;
reserves reach HostConfig (shares=256, MemoryReservation=128Mi);
validation rejects bad specs; builder persists + clears.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 17:08:35 +02:00
Tomas DvorakandDevin e995c98d05 feat(services): multi-domain, DNS check, maintenance + basic-auth gates (C2)
service_domains table holds the full hostname set; services.domain stays
the derived default and is synced on every domain write. Runtime spec
carries all domains into a multi-Host Traefik rule plus access gates.

- GET|POST /services/:id/domains, DELETE + POST /:domain_id/default
- GET /services/:id/domains/check — DNS preflight vs public_ip/base_url
  (ok | wrong-target | pending), stamps last_checked_at
- maintenance_mode → Traefik redirectregex to /api/v1/maintenance
  (base_url/BASE_URL setting; falls back to noop@internal offline)
- basic_auth [{username,password}] → bcrypt htpasswd on the label;
  responses return usernames only
- First domain auto-defaults; domain="" via PUT clears all domains
- CLI: services domains list|add|remove|default|check, update
  --maintenance/--basic-auth; MCP: 5 domain tools + update args
- Settings UI: Domains card (CRUD + DNS check badges) + Access card
- Fixes latent C1 bug: PUT without volumes 500'd — nil []byte sent as
  bytea, not NULL; COALESCE needs untyped nil + ::jsonb cast

Verified live: domain CRUD + default sync, pending/wrong-target checks,
bcrypt htpasswd and both middlewares on the deployed container's labels.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 16:51:03 +02:00
Tomas DvorakandDevin 2f8f595d6e feat(services): volume mounts + admin volume inventory (C1)
services.volumes JSONB [{type: volume|bind, source, target, read_only}]
mapped into every replica's container config at deploy and reconcile
time, hashed into the runtime spec so mount changes trigger
reconciliation. Mounted via existing docker.HostConfig.Mounts.

- RuntimeSpec.Volumes + specHash + mount.Mount mapping in createReplica
- Create/update service accepts volumes; PUT replaces the whole list
- Replica-count reconcile now goes through the deploy queue — the last
  raw `go` reconcile left that could race a build
- GET /admin/volumes inventories node volumes with in_use flags;
  DELETE /admin/volumes/:name removes unused ones (409 on in-use)
- CLI: services update --volume/--bind/--clear-volumes, volumes
  list|delete; MCP: services_update + volumes_list/delete
- Settings UI: Volumes section with mount editor
- Migration normalizes the nullable baseline column to NOT NULL [].

Verified live: deploy applies mounts to the container, docker
auto-creates named volumes, in-use deletion is refused.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 16:38:46 +02:00
Tomas DvorakandDevin 747b714a02 feat(deploy): webhook queue+nocache, restart sweep, secrets at rest
- Webhook-triggered deploys now go through the per-service deploy queue
  and default to no_cache builds (dflow convention — prevents stale
  layer surprises on push).
- Boot sweep marks deployments interrupted by restart as failed instead
  of leaving phantom queued/building rows.
- is_secret environment variables are AES-GCM-encrypted at rest
  (enc:v1: prefix); key from SECRETS_KEY or JWT_SECRET fallback.
  Existing plaintext secrets encrypt on boot; deploy/exec paths decrypt
  transparently. Masked-echo writes preserve stored ciphertext.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 16:27:10 +02:00
Tomas DvorakandDevin 7839dc48a3 feat(deploy): per-service deploy queue, cancellable deployments, no-cache builds
- internal/deployqueue: serialized FIFO per service — concurrent deploys
  of one service no longer race (the container-name-conflict failure
  mode); different services still run in parallel.
- Deploys and rollbacks enqueue through the queue; a busy service
  returns status 'queued' and promotes when the active job finishes.
- POST /deployments/:id/cancel — queued jobs drop immediately
  (200 cancelled); active jobs get ctx cancelled so build/reconcile
  abort (202 cancelling, settles 'cancelled'); finished deployments
  return 409 NOT_CANCELLABLE.
- Redeploy and variables save now route through the same queue so they
  can't overlap a build; PUT /services/:id/variables accepts
  redeploy:true to apply env changes to running containers.
- no_cache on deployment create plumbs BuildRequest -> BuildOptions for
  nixpacks, dockerfile and railpack builders.
- CLI: deployments cancel, deploy --no-cache, variables set/unset
  --redeploy. MCP: containr_deployments_cancel; deploy/variables_set
  document no_cache/redeploy. UI: Cancel button + queued/cancelled
  badges on the deployments table. OpenAPI + types regenerated.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 16:23:28 +02:00
Tomas DvorakandDevin 84d58cf808 docs: AGENTS.md + llms.txt — agent-facing surface documentation
Repo-level AGENTS.md for coding agents (layout, conventions, verify
commands) and llms.txt describing the operator surface: PAT scopes,
CLI/MCP setup, error codes, idempotency, cursor pagination.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 15:40:04 +02:00
Tomas DvorakandDevin 18f78f00a3 feat(api): error codes, idempotency keys, cursor pagination
- Stable machine-readable error codes alongside the error message
  (UNAUTHENTICATED, INVALID_TOKEN, ADMIN_REQUIRED, SCOPE_INSUFFICIENT,
  IDEMPOTENCY_IN_PROGRESS); CLI renders "CODE: message".
- Idempotency middleware on authed/admin groups: POST requests with an
  Idempotency-Key header replay the first response for 24h (Redis-backed,
  caller-scoped); concurrent duplicates get 409. X-Idempotent-Replay
  header marks replays.
- Shared pagination helpers: ?cursor= (opaque, preferred) alongside
  legacy ?page= on projects and deployments lists; pagination meta
  includes next_cursor; invalid cursors fail soft to page 1.
- CLI --idempotency-key flag for safe scripted retries.
- OpenAPI updated: error envelope, cursor params, idempotency contract.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 15:38:36 +02:00
Tomas Dvorak 485e41a817 feat(mcp): stdio MCP server exposing the full API as tools
Add containr-mcp so MCP-compatible agents (Claude, Cursor, Devin)
operate the platform directly — ROADMAP2 phase A3. Reuses the CLI's
thin client and PAT auth; zero duplicated resource logic.

- ~60 containr_* tools across projects, services, deployments, logs,
  exec, variables, databases, cron, templates, nodes, scaling, ha,
  security, gateway, admin, notifications, tokens
- declarative tool specs → {param} path substitution, GET extras become
  query params, remaining args form the request body
- destructive tools require confirm:true and return isError without it
- results are pretty JSON; API error envelopes surface as isError
- configured via CONTAINR_API_URL + CONTAINR_TOKEN env; official
  modelcontextprotocol/go-sdk over StdioTransport

Verified over stdio: initialize + tools/list, auth_status returns the
token owner's identity, projects list, destructive refusal, token
creation through MCP.

Authored By: TDvorak <[email protected]>
2026-10-08 15:25:34 +02:00
Tomas Dvorak e51b20b2d4 feat(cli): v2 — full API coverage, profiles, --json/--yes, containr up
Rewrite the CLI as a thin client over the authenticated API so agents
and scripts reach every resource the UI does (ROADMAP2 phase A2).

- shared Client with bearer auth, error-envelope decoding, and stable
  exit codes: 0 ok / 1 error / 2 auth / 3 not-found / 4 validation /
  5 unreachable
- named profiles in ~/.containr.yaml (current_profile + profiles.*);
  resolution order --flag > env > profile > localhost default;
  CONTAINR_TOKEN / CONTAINR_API_URL / CONTAINR_PROFILE env support
- --json prints raw API responses; --yes skips confirmations;
  destructive commands refuse non-interactively without it
- auth login verifies the token against /user/profile before saving,
  interactive prompt via term.ReadPassword
- commands for every resource: tokens, projects, services, deploy,
  deployments (logs/rollback), logs --follow (SSE), exec, variables
  (set/unset merge with secret masking preserved), databases
  (backup/restore/download), cron, templates, nodes + agent tokens,
  scaling, ha, security, gateway, admin, notifications
- containr up: git remote + branch → create-or-reuse service → deploy

Verified live: login/status/tokens/projects/services, up creating a
service + deployment, non-TTY confirmation refusal, scope-capped admin
rejection (exit 2), revoke.

Authored By: TDvorak <[email protected]>
2026-10-08 15:19:31 +02:00
Tomas Dvorak 17e429b86b docs: ROADMAP2 — dflow successor plan and phased build
Deep analysis of dflow (~43 queue processors, ~30 collections, ~180
server actions) mapped against Containr, sectioned into phases A–G.
Phase A (agent spine) is in progress — this commit adds PATs.

Authored By: TDvorak <[email protected]>
2026-10-08 14:31:39 +02:00
Tomas Dvorak e881747388 feat: personal access tokens with scoped bearer auth
Add cnp_ personal access tokens so the CLI, MCP server, and external
agents authenticate without a browser session — the agent-first spine
from ROADMAP2 phase A. Modeled on agent_auth_tokens: raw token shown
once, only SHA-256 hash stored.

- user_tokens table (goose migration + sqlc), name/prefix/scope/expiry,
  owner-scoped revoke, last_used_at stamping
- middleware: cnp_ bearer resolution, read scope gates mutations with
  403 SCOPE_INSUFFICIENT, non-admin scopes cap is_admin even for admin
  owners — a token never outranks its scope or its owner
- GET/POST/DELETE /api/v1/user/tokens + OpenAPI + generated types
- Settings UI: create with scope/expiry pickers, one-time token display
  with copy, list with last-used/expiry, confirm-revoke
- docs: OPERATIONS.md section, README feature line

Verified end-to-end against live Postgres: create/list/revoke, read
scope blocks PUT (403), write PAT on admin owner blocked from admin
routes (403), admin scope works, revoked token returns 401.

Authored By: TDvorak <[email protected]>
2026-10-08 14:31:29 +02:00
Tomáš Dvořák f9ae5b962c chore: add FUNDING.yml — GitHub Sponsors button
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (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
2026-10-04 19:50:34 +02:00
Tomáš DvořákandDevin 15b5b0e6e7 fix: HA checks resolve service targets over project network; persist checks (#13)
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (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
- Health checks for local services dialed 127.0.0.1 from inside the backend
  container — the host's published port is unreachable there. Service-bound
  checks now resolve the service's DNS name on its project network (backend
  joins on demand), falling back to the service's stored port. A resolver
  failure surfaces as TARGET_UNRESOLVED instead of a fake loopback probe.
- Checks lived only in memory: a backend restart silently deleted every
  configured health check. ha_health_checks table + write-through on
  add/update/tick and hydrate-on-boot, matching the scaling-policy pattern.
- sqlc schema.sql gained the runtime columns added by later migrations —
  the stale snapshot already caused one silently-dropped-column bug.
- OpenAPI: runtime response now documents ports and health fields; types
  regenerated.
- Buttons no longer wrap ("+ new project" rendered on two lines).

Verified live: service check probes whoami-web:80 via DNS → healthy;
check survives a backend restart and rehydrates.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-04 09:37:47 +02:00
Tomáš DvořákandDevin 32b52d121f fix: e2e QA hardening — honest runtime state, stable public ports, read-only anon UI (#12)
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (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
* fix: e2e QA hardening — honest runtime URLs, health, and read-only anon UI

Backend:
- managed DB provisioning: attach DB containers to the platform network and
  probe the container IP, fixing the 127.0.0.1 reachability failure inside
  the backend container; log provisioning failures
- stop fabricating connection URLs when provisioning produced none
- template deploys now carry port + health_check into service runtime fields
- service health probed over the project network (backend joins it) instead
  of an in-container wget that fails on shell-less images
- runtime/public/connection URLs rewrite loopback to the request Host so
  links are usable for LAN/remote clients

Frontend:
- service Networking panel shows real runtime URLs/ports/health instead of
  hardcoded containr.dev + 8080->443
- anonymous views hide all mutation controls (new project, add service,
  group, context menu)
- project cards: remove decorative svcN labels; badge says DOWN only when
  zero services run, IDLE when empty, DEGRADED for partial
- mobile projects header no longer clips (fluid search width)
- unknown routes render a not-found page instead of silently redirecting

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

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

* fix: telemetry scope label, chunk split, remove stale vite artifacts

- /system/host now reports scope=host|docker-desktop-vm and the daemon OS;
  Projects dashboard labels cards VM.* when the daemon is a Desktop VM so
  memory/core numbers aren't mistaken for physical host values
- manualChunks: split @xyflow/react and recharts out of the main bundle;
  removes the >1MB chunk warning
- drop tracked vite.config.js/.d.ts build artifacts that shadowed the real
  vite.config.ts

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

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

* fix: persist service host port; demo-data scope for telemetry

Public URLs were ephemeral: every restart/redeploy rebound a random host
port, so endpoint links rotted. Persist published_port on the service and
prefer it for replica 0 on create. If the port is taken, fall back to
ephemeral and repersist the new assignment. Post-start the binding is
verified — Docker Desktop accepts a conflicting publish silently, which
previously left services running with no endpoint at all.

demo-data.ts gains the required scope field for HostMonitoring (CI fix).

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-04 09:01:28 +02:00
Tomáš Dvořák d968eb2315 Merge pull request #11 from Dvorinka/feat/app-demo-polish
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (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(app): unified demo mode + public-demo build for Vercel
2026-09-23 19:07:14 +02:00
Tomas Dvorak 38e0bc1802 merge: resolve main into feat/app-demo-polish 2026-09-23 19:06:42 +02:00
Tomáš Dvořák fe08f8d299 Merge pull request #10 from Dvorinka/feat/fullscreen-canvas
feat(canvas): fullscreen Railway-style workspace canvas
2026-09-23 19:02:55 +02:00
Tomáš Dvořák ad797a49c9 Merge pull request #9 from Dvorinka/feat/vercel-landing
feat: landing page + public template catalog
2026-09-23 19:02:47 +02:00
Tomas DvorakandDevin 6661357963 feat(canvas): shared 26px grid + collision-free node placement
Nodes snapped to the dot-grid lattice on drop; occupied spots resolve
to the nearest free lattice cell instead of stacking. Same protection
inside groups (sibling-aware), on inspector assign, and on ungroup.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:59:14 +02:00
Tomas DvorakandDevin 7fc6ed1215 feat(canvas): full service cockpit on left-click + real grouping
The inspector is now a proper service panel: address with copy, group
assignment (Ungrouped / any group / "+ New group" wrapping the node),
masked env vars with a deep link into the service's Variables tab, a
config grid deep-linking to Config, inferred connections, and the
lifecycle actions. ServiceDetailPage honours ?section= for the deep
links.

Grouping is first-class: assign via the inspector or drag nodes onto a
frame; members slot beneath existing ones and the frame grows only as
needed instead of clamping or ballooning. Fixed a drag bug where moving
a member inside its own group ejected it.

Context menus everywhere: right-click a node (existing actions), a
group frame (Rename via inline edit, Ungroup keeps children in place),
or empty pane (Add service, Add group here, Tidy layout, Fit view).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:50:38 +02:00
Tomas DvorakandDevin d9bfd8a2fd feat(canvas): fullscreen workspace outside the app shell
Move /projects/:id and its service detail route out of PlatformShell —
the project view now owns the entire viewport like Railway's canvas:
no sidebar, no global nav, just the slim project bar over the graph.
Service detail inherits the same chrome-free context with a breadcrumb
back to the canvas, and back/delete navigation preserves ?demo=1.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:20:05 +02:00
Tomas DvorakandDevin a23fa2a0cc fix(site): blinking title cursor in hero mockup, matching the app
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:12:59 +02:00
Tomas DvorakandDevin 1425618c92 feat(canvas): brand tech icons on services, matching site preview
Resolve a service's tech (postgres, redis, go, node, nginx, kafka,
~50 slugs) from its image/name and render the Simple Icons logo in
the tinted icon tile — canvas nodes, inspector, service detail
header, and database rows — with graceful fallback to type icons.
Demo services get resolvable images, ports, and domains; a Redis
Cache service joins Core Platform so the demo canvas mirrors the
landing preview (5 services, web/api/worker → api → postgres/redis).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 18:12:15 +02:00
Tomas DvorakandDevin 894192bf9b feat(site): drop live demo, add Railway-style canvas preview
Remove all links to the deleted containr-app deployment and replace
the static hero screenshot with a CSS/HTML product mockup: a project
canvas with five icon-branded service nodes, connected edges (one
animated deploying edge), env/zoom/minimap pills, status dots,
domains, and image tags. Mobile gets a two-column layout with its
own edge paths.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 17:56:43 +02:00
Tomas DvorakandDevin e825eb0d7c feat(site): wire landing to deployed app demo
- All CTAs (nav, hero, install note, gallery, catalog, footer) link to
  https://containr-app.vercel.app - the app frontend deployed as a
  public demo (VITE_PUBLIC_DEMO build).
- templates.html: per-template "Open in demo app" deep links via
  ?template=<id>; dead default API removed so the official snapshot
  renders instantly until an instance is configured with ?api=.
- Hero canvas screenshot updated to the fullscreen canvas build.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 17:41:43 +02:00
Tomas DvorakandDevin 5b0a8e0759 feat(app): unified demo mode + public-demo build for Vercel
- Central demo-mode helper (lib/demo-mode.ts): ?demo=1 query flag plus
  VITE_PUBLIC_DEMO build flag that makes every route demo for the
  public Vercel showcase.
- Single demo fixture source (lib/demo-data.ts): projects, services,
  builds, databases, cron, HA, security, host monitoring, templates.
  Fixes cross-page ID drift that broke project navigation.
- Demo support on Projects, HA, Security, Usage, Templates, Landing,
  Service detail; DemoRestricted gate on People/Settings/Audit/Admin.
- Demo-aware PlatformShell: nav filtering, env:demo, demo account menu,
  ?demo=1 preserved through internal links.
- TemplatesPage: selection moved into ?template= URL param for deep
  links; template fixtures shared via demo-data.
- requestJson returns {} for unparseable 200 bodies (SPA fallback) so
  envelope unwraps degrade instead of dereferencing null.
- Recharts ResponsiveContainer initialDimension kills -1 warnings.
- vercel.json: vite framework + SPA rewrites for direct deep links.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 17:38:02 +02:00
Tomas DvorakandDevin e57f0470eb feat(canvas): fullscreen Railway-style workspace canvas
Rebuild the project canvas as a full-viewport graph instead of a boxed
66vh panel. Slim workspace top bar now holds the view tabs, project meta
and actions; the canvas fills all remaining space.

- Floating glass controls: env filter + group pill (top-left), zoom out /
  live zoom % / zoom in / fit / tidy pill (bottom-left), dark MiniMap
  (bottom-right), hint chip, demo badge.
- Inspector panel: selecting a node opens a right-side glass panel with
  status, image, address, inferred connections incl. variable reasons,
  and deploy/restart/stop/delete actions. Esc or pane click closes.
- Auto-layout: dependency-depth layered layout (consumers left,
  datastores right) for first-visit placement and the Tidy action.
  Unpositioned services park right of existing bounds, not at (0,0).
- Nodes: fixed 240px width (was 210 vs 220-280 CSS mismatch), per-type
  icons/colors incl. cron, spinner icon while deploying, hover lift.
- Edges: lime bezier strokes, animated dashes while either endpoint is
  deploying, hover glow updated from stale purple to accent.
- Groups: off-brand crimson replaced with accent tokens, double-click
  title to rename inline.
- Keyboard: F fit, 0 reset zoom, +/- zoom, Esc deselect. Marquee select
  on Shift-drag. Double-click zoom disabled, dead connection drags off.
- Mobile: icon-only tabs, minimap/hint hidden under md.
- Retake docs/screenshots/canvas.png + site copy (1600x900 demo).

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 17:00:50 +02:00
Tomas DvorakandDevin 117b469fae feat: public template catalog + Vercel git integration
Backend: service_templates.is_public lets owners publish user templates to
the anonymous catalog (GET /api/v1/templates), mirroring Railway's
marketplace publish flow. Migration, sqlc queries, visibility rules, and
openapi.yaml updated; types regenerated.

App: template editor gains a "List in the public catalog" checkbox;
Public badge on catalog cards and detail view.

Site: new templates.html catalog page - search, category filters, Official
vs Community badges, detail modal with variables and copyable import JSON.
Fetches the live catalog from a reachable instance (?api= or localStorage
override) and falls back to templates.json, generated from SeedTemplates
via cmd/dumptemplates. Shared styles extracted to style.css.

Vercel: project connected to GitHub repo with rootDirectory=site;
.vercelignore limits CLI uploads to site/ so `vercel deploy --prod` from
repo root matches git builds.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 16:40:58 +02:00
Tomas DvorakandDevin 2b38406a63 feat(site): standalone landing page for Vercel
Static single-file page in site/ matching the app theme (dark #0c0d0f,
lime #b4e34a, self-hosted Inter + JetBrains Mono). Real product
screenshots, install one-liner with copy button, template marquee,
tabbed gallery. No build step; deployed via vercel deploy --prod.

Live: https://containr-lyart.vercel.app

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 15:33:23 +02:00
Tomáš Dvořák 84b102a2c6 Merge pull request #8 from Dvorinka/ci/auto-release
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (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(ci): release pipeline on merge to main
2026-09-23 12:08:34 +02:00
Tomas DvorakandDevin 9a2aa7267b fix(ci): recreate release instead of --clobber upload
gh release upload --clobber races GitHub's asset tombstones and 422s
intermittently on reruns. Delete-then-create is deterministic; the
git tag is kept.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 11:57:37 +02:00
Tomas DvorakandDevin a99f2e461e feat(ci): release pipeline on merge to main
After tests + image publish, a release job bumps the tag via
conventional commits (github-tag-action, default_bump=false so
docs/chore merges skip releasing), retags :main images in GHCR as
semver+latest (tag pushes via GITHUB_TOKEN do not retrigger CI),
cross-compiles the CLI for 5 platforms, and cuts a GitHub Release
with generated notes and checksums. Manual v* tag pushes get the
same release treatment. CLI --version now reports the tag via the
previously dead -X ldflags.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 11:44:37 +02:00
Tomas DvorakandDevin 7c19af307e fix(build): reuse committed docs snapshot when docs/ is outside context
Script resolved paths from repo root; inside the frontend Docker build
(context = app/frontend) it scanned /docs and died on ENOENT, breaking
the compose smoke job. Resolve from the frontend dir and fall back to
the committed snapshot when the docs tree is absent.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 11:44:37 +02:00
Tomas DvorakandDevin d48d9c293a fix(frontend): queryFn signatures + Link import
useQuery received api fns typed (params?) => Promise<T>; React Query
passes a QueryFunctionContext, so data degraded to {} and broke tsc.
Wrap calls in () => and import Link from react-router-dom.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 11:44:36 +02:00
Tomas DvorakandDevin 0844fab6bf docs: refresh README for public platform and admin console
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 20:02:11 +02:00
Tomas DvorakandDevin 8b6214a0f5 feat: public platform, admin console, templates, landing docs
Projects become publicly browsable after admin approval; management and
secret-adjacent reads (logs, variables, exec, connection URLs) require
owner/member/admin auth, and platform controls sit behind RequireAdmin.

- Backend: OptionalAuth middleware, read/authed/admin route groups,
  projects.is_approved + service_templates.owner_id migration,
  visibility-aware sqlc queries, admin overview/users/approval endpoints,
  EnsureAdminAccount bootstrap (ADMIN_* env), 30 seeded templates with
  owner CRUD, managed-database connection URLs gated owner+admin, fix
  DB.Stats() infinite recursion.
- Frontend: expandable persisted sidebar + clickable user menu, public
  landing page with searchable docs browser (GitHub sync, bundled
  snapshot + localStorage fallback), DocsBrowser shared with /docs,
  template create/edit/JSON-upload for owned templates, database create
  dialog, admin console (stats, approvals, project edit/delete, user
  admin toggles), HA/Security/Usage polish with dead CSS tokens mapped
  to real theme vars, anonymous-safe gating on every mutation control.
- Tests: safe window.localStorage access fixes happy-dom crashes in
  PlatformShell and canvas storage.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 19:58:59 +02:00
Tomáš Dvořák 8f5789bf93 Merge pull request #7 from Dvorinka/feat/dragonfly-selfhosted-auth
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
Dragonfly cache, remote-install fix, self-hosted email-only auth
2026-09-21 21:17:43 +02:00
Tomas DvorakandDevin 3bf44dbcd5 Add global recent-deployments feed for the projects dashboard
The RECENT DEPLOYMENTS section queried the builds table, so
image-sourced services — which deploy without building — never
appeared and the dashboard reported "no deployments yet" while
containers were running.

- GET /api/v1/deployments lists latest deployments across the
  caller's services, joined with service + project names, ordered
  newest-first with a bounded limit
- ProjectsPage consumes it via listRecentDeployments; the DEPLOYS
  stat card now counts real deployments
- is_admin is now projected in register/upsert user responses, and
  the Better Auth mirror grants admin to the first mirrored account
  (previously only the legacy register path did)

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 21:07:01 +02:00
Tomas DvorakandDevin b7e2e93261 fix: polish service runtime UX and harden reconcile edge cases
Backend:
- tolerate concurrent project-network creation (re-resolve on conflict)
- always publish an ephemeral host port when a port is configured, even
  with a domain set — Traefik routes on top when the edge network exists
- public_url now prefers the configured domain, falls back to the first
  published port
- service logs resolve replicas by containr.service label and merge all
  replica streams — previously queried a nonexistent containr-<id> name

Canvas/detail:
- degraded status renders amber end-to-end (node card, detail badge,
  status dot) instead of falling through to "Stopped"
- edges pick handle sides by relative node position — no more backward
  loops when the source sits right of the target
- context menu gains Start for stopped services, Visit uses the
  published URL when no domain is set, menu clamps to canvas bounds
- node cards show the image tag instead of a redundant hint
- scaling tab falls back to live runtime replica counts when no
  autoscaler state exists; manual scale no longer requires it

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 20:29:31 +02:00
Tomas Dvorak 04c3b99531 fix: render service icons via function call, not dynamic component 2026-09-21 19:38:41 +02:00
Tomas Dvorak dbdf6733d1 fix: extract service icon into component — react-hooks lint 2026-09-21 19:35:48 +02:00
Tomas DvorakandDevin ff356230fe feat: real service runtime — private networks, replicas, lifecycle, rollback
Services now reconcile into actual Docker containers instead of
bookkeeping rows:

- Per-project bridge network with service-name DNS aliases and
  ${{service.KEY}} env expansion (HOST/PORT builtins)
- Replicas, published ports, Traefik domain routing, health checks,
  restart policies persisted on the service spec; spec-hash drift
  recreates containers on redeploy
- Lifecycle API: start/stop/restart/redeploy, live runtime state,
  real image rollback (replaces the fake sleep)
- Manual scale and PUT replicas reconcile live containers; service
  and project delete remove their containers and the project network
- Canvas: real URLs, replica badges, env filter, right-click
  deploy/restart/stop/delete menu, empty state
- Service detail: real lifecycle buttons, live replica/URL strip,
  Networking editor, ${{ }} variable helper
- Fix prebuilt-image pulls racing the tag (drain the pull stream)

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 19:31:19 +02:00
Tomas DvorakandDevin c16b32f56a feat: owner accounts and in-app platform settings
- First registered account becomes admin (users.is_admin, backfilled
  to earliest account); better-auth mirror path does the same
- Public registration closes after bootstrap; admin can reopen it via
  GET/PUT /api/v1/settings (admin-gated) or SIGNUP_ENABLED env
- In-app settings override env vars; secrets stored in app_settings,
  token values never returned by the API
- Saving a Cloudflare Tunnel token pulls and runs a managed
  cloudflared sidecar on the backend's networks; clearing removes it;
  startup restores it from saved settings
- group_add DOCKER_GID grants the non-root backend user Docker socket
  access; install.sh probes the in-container socket gid
- Settings page gains an owner-only Platform section (registration
  toggle, tunnel token, link to Cloudflare dashboard); sign-up page
  stays reachable while registration is open
- OpenAPI spec + generated types updated; manual user creation now
  requires admin

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 18:05:22 +02:00
Tomas DvorakandDevin c3959d13e2 fix: merge vite dev proxy into existing server block
Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 17:28:04 +02:00
Tomas DvorakandDevin 060038cb6f feat: swap Redis for Dragonfly, fix remote installs, self-hosted email-only auth
- Replace redis:7-alpine with Dragonfly (Redis-protocol compatible; go-redis
  client unchanged) in root, infra, and backend-dev compose files. Bounded
  defaults --proactor_threads/--maxmemory so it boots on small VPSs.
- Frontend now defaults to same-origin API calls; nginx proxies /api/* to the
  backend (WebSocket-aware, docker DNS resolver for upstream re-resolution).
  Removes the baked localhost:8082 that broke installs reached by IP/domain.
- /auth/bootstrap returns configured OAuth providers; the UI hides
  GitHub/Google buttons unless real credentials exist. Self-hosted stays
  email/password only - OAuth reserved for a future managed offering.
- Sidecar ignores PLACEHOLDER_* OAuth credentials and trusts same-host
  origins dynamically (Origin host == request Host) so remote installs pass
  origin checks without config; cross-origin requests still rejected.
- install.sh writes ENVIRONMENT=production; COOKIE_SECURE=false in
  production is now a startup warning instead of fatal (plain-HTTP installs).
- Bootstrap failure surfaces an error notice instead of silently rendering
  the sign-in page.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 17:24:43 +02:00
Tomáš Dvořák 9ba2bd06c5 Merge pull request #6 from Dvorinka/docs/readme-tighten
CI / Infra (Compose + Scripts) (push) Failing after 5s
CI / Backend (Go) (push) Successful in 1m6s
CI / Frontend (React) (push) Failing after 5m18s
CI / Compose boot smoke (push) Failing after 5s
CI / Build & publish images (push) Skipped
docs: tighten README
2026-09-21 16:56:53 +02:00
Tomas DvorakandDevin 254d896802 docs: tighten README
- fold "What is" into a single intro paragraph, drop the heading
- compress 15 feature bullets into 8 grouped lines
- drop the port table (already in docs/guides + onboarding)
- collapse quick-start subsections into prose; dev/self-hosted details
  point to docs/guides and infra/
- fold the Configuration section into two lines; footer links inline

170 -> 100 lines, no information lost that docs/ doesn't already carry.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 16:52:54 +02:00
Tomáš Dvořák 3f6e506445 Merge pull request #5 from Dvorinka/feat/standalone-install
feat: standalone curl-pipe installer, no clone required
2026-09-21 16:51:11 +02:00
Tomas DvorakandDevin a0141ae552 feat: standalone curl-pipe installer, no clone required
- install.sh works outside a checkout: installs into CONTAINR_DIR
  (default ./containr), downloads docker-compose.yml + .env.example from
  the repo, generates secrets, pulls published images
- CONTAINR_REF selects the git ref for downloads; CONTAINR_BUILD=1 or a
  failed pull fetches the source tarball and builds locally
- env-var overrides (HTTP_PORT/API_PORT/...) are persisted into .env;
  health-wait ports resolve env -> .env -> defaults
- drop fixed `name: containr` from root compose so installs in different
  directories get distinct project names
- README: curl one-liner is the primary install path

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 16:43:42 +02:00
Tomáš Dvořák ec1555cbe4 Merge pull request #4 from Dvorinka/docs/readme-screenshots
docs: add app screenshots, default compose to published :latest images
2026-09-21 16:26:06 +02:00
Tomas DvorakandDevin 430370dbc7 docs: add app screenshots, default compose to published :latest images
- docker-compose.yml now references ghcr.io/dvorinka/containr-*:latest with
  pull_policy: missing, so `docker compose up` pulls published images by
  default; CONTAINR_VERSION pins a release tag, build config retained for
  source builds
- install.sh pulls instead of always building (CONTAINR_BUILD=1 or a failed
  pull falls back to a source build)
- README gains a Screenshots section (dashboard, canvas, service detail,
  databases) captured from a live local stack, plus updated install docs

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 16:20:16 +02:00
Tomáš Dvořák c0733dc30c Merge pull request #3 from Dvorinka/fix/better-auth-cve
fix: security sweep - better-auth 1.6.33, node 22, go 1.26
2026-09-21 14:42:32 +02:00
Tomas DvorakandDevin 479eeb021b fix(deps): patch fixable Go vulns, build on go 1.26
- golang-jwt/v5 5.0.0->5.2.2 (CVE-2025-30204), x/crypto, x/net,
  x/text, otel -> latest; go.mod now requires go 1.26
- builder golang:1.24-alpine -> 1.26-alpine, clearing the stdlib
  HIGH batch (crypto/tls, net/http, x509, net/mail DoS)
- npm-side findings (tar, brace-expansion, pacote, sigstore, ...)
  are npm-CLI internals inside the node base image, inert at
  runtime - no overrides; fixed by base image updates

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 14:36:28 +02:00
Tomas DvorakandDevin a083c30d76 fix(auth): bump better-auth to 1.6.33, node to 22
- CVE-2026-53514 (HIGH): better-auth <1.6.11 vulnerable to
  unverified-email invitation acceptance; pinned 1.6.33 (1.7.x breaks
  the migration introspection + schema check on this setup)
- qs override to >=6.16.0 clears moderate DoS via express
- kysely audit fix pulled 0.29.x requiring node>=22; node:20 is EOL
  anyway - both Dockerfiles now on node:22-alpine, matching CI
- npm audit: 0 vulnerabilities; e2e verified (migrations run,
  sign-up returns session)

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 14:15:21 +02:00
336 changed files with 68140 additions and 3695 deletions

No files matched your search

+55 -6
View File
@@ -1,4 +1,5 @@
# Local development env file. Use .env.prod for production.
# Local development env file. install.sh writes ENVIRONMENT=production for
# self-hosted installs - keep development here only for the checkout workflow.
# Domain Configuration
DOMAIN=localhost
@@ -11,6 +12,10 @@ API_PORT=8082
POSTGRES_PORT=5432
REDIS_PORT=6379
# Image tag to pull from GHCR (e.g. latest, 0.1.1). Use CONTAINR_BUILD=1
# with install.sh to build from source instead.
CONTAINR_VERSION=latest
# Database Configuration
POSTGRES_DB=containr
POSTGRES_USER=containr_user
@@ -24,20 +29,40 @@ AUTO_MIGRATE=true
MIGRATION_LOCK_TIMEOUT=2m
SEED_DATA_ON_START=false
# Redis Configuration
# Dragonfly Configuration (Redis-compatible cache - REDIS_* names kept for compatibility)
REDIS_PASSWORD=your_secure_redis_password
REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0
# DRAGONFLY_VERSION=latest
# DRAGONFLY_THREADS=1
# DRAGONFLY_MAXMEMORY=256mb
# 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
VITE_API_URL=http://localhost:8082
VITE_AUTH_URL=http://localhost:8082/api/auth
# Optional build-time API URL overrides. Leave empty for the default
# same-origin setup - the frontend nginx proxies /api to the backend, which
# works from any host or IP without rebuilding the image.
# VITE_API_URL=https://api.example.com
# VITE_AUTH_URL=https://api.example.com/api/auth
AUTH_PORT=3001
# In production this must be true.
COOKIE_SECURE=false
@@ -50,7 +75,15 @@ BETTER_AUTH_AUTO_MIGRATE=true
BETTER_AUTH_INTERNAL_TOKEN=PLACEHOLDER_INTERNAL_AUTH_TOKEN
BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session
BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001
# Extra trusted origins (comma-separated). Same-origin traffic is trusted
# automatically, so this only needs entries for cross-origin callers.
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082
# Platform owner bootstrap — creates/promotes the admin account on startup.
# Keep the real values in a local untracked file, never in committed env files.
ADMIN_EMAIL=[email protected]
ADMIN_PASSWORD=
ADMIN_NAME=Platform Admin
# Optional explicit auth DB settings (recommended when password contains URL special chars)
DB_HOST=postgres
DB_PORT=5432
@@ -60,7 +93,9 @@ DB_PASSWORD=your_secure_postgres_password
FRONTEND_URL=http://localhost:3000
BACKEND_URL=http://localhost:8082
# OAuth (user auth)
# OAuth (user auth) - GitHub/Google sign-in is reserved for the managed
# offering. Self-hosted installs use email/password only; PLACEHOLDER_*
# values are treated as unset and the buttons stay hidden.
GITHUB_CLIENT_ID=PLACEHOLDER_GITHUB_OAUTH_CLIENT_ID
GITHUB_CLIENT_SECRET=PLACEHOLDER_GITHUB_OAUTH_CLIENT_SECRET
GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID
@@ -105,10 +140,21 @@ TRAEFIK_AUTH=admin:$$apr1$$b8mh8c8v$$KkR8hQZQZQZQZQZQZQZQZ/
# Development convenience: true in local dev, false in production
TRAEFIK_API_INSECURE=true
# Optional: Cloudflare Tunnel (alternative to domain)
# Optional: Cloudflare Tunnel (alternative to domain). Can also be set in-app
# under Settings > Platform (in-app value overrides this).
# Get token from: https://dash.cloudflare.com/argotunnel
CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here
# Optional: reopen public registration after the owner account exists.
# Manageable in-app under Settings > Platform (in-app value overrides this).
# SIGNUP_ENABLED=false
# Docker socket gid AS SEEN INSIDE containers - lets the backend manage
# containers (deployments, cloudflared sidecar). install.sh detects it with a
# throwaway container. Modern Docker maps the socket to root: use 0.
# Older daemons expose the host docker gid: stat -c %g /var/run/docker.sock
DOCKER_GID=0
# Optional: Custom Docker Registry
# DOCKER_REGISTRY=your-registry.com
# DOCKER_USERNAME=your_username
@@ -123,3 +169,6 @@ CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here
# 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
+1
View File
@@ -0,0 +1 @@
github: Dvorinka
+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
+165 -1
View File
@@ -17,6 +17,30 @@ permissions:
contents: read
jobs:
# Cheap gate so docs-only/roadmap PRs skip the heavyweight jobs. Skipped
# required checks still satisfy branch protection ("skipped" = success).
changes:
name: Changed paths
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
id: filter
with:
filters: |
code:
- 'app/**'
- 'infra/**'
- 'docker-compose.yml'
- 'install.sh'
- '.github/workflows/**'
- 'docs/api/**'
mobile:
- 'app/mobile/**'
infra:
name: Infra (Compose + Scripts)
runs-on: ubuntu-latest
@@ -48,6 +72,8 @@ jobs:
backend:
name: Backend (Go)
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
defaults:
run:
working-directory: app/backend
@@ -70,6 +96,8 @@ jobs:
frontend:
name: Frontend (React)
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
defaults:
run:
working-directory: app/frontend
@@ -96,20 +124,52 @@ 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
timeout-minutes: 20
needs: changes
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
steps:
- name: Checkout
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 redis 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: |
@@ -137,6 +197,7 @@ jobs:
name: Build & publish images
runs-on: ubuntu-latest
needs: [backend, frontend, smoke]
if: needs.backend.result == 'success' && needs.frontend.result == 'success' && needs.smoke.result == 'success'
permissions:
contents: read
packages: write
@@ -145,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'
@@ -189,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
@@ -222,3 +293,96 @@ jobs:
ignore-unfixed: true
vuln-type: os,library
severity: CRITICAL,HIGH
release:
name: Release
runs-on: ubuntu-latest
needs: [docker]
if: github.event_name != 'pull_request' && needs.docker.result == 'success'
permissions:
contents: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# Conventional-commit bump. Tags pushed with GITHUB_TOKEN do not
# retrigger this workflow, so images are retagged below instead.
- name: Bump version and push tag
if: github.ref == 'refs/heads/main'
id: tag_version
uses: mathieudutour/[email protected]
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
default_bump: false
- name: Resolve release tag
id: release
run: |
if [ "${{ github.ref_type }}" = "tag" ]; then
tag="${GITHUB_REF_NAME}"
else
tag="${{ steps.tag_version.outputs.new_tag }}"
fi
if [ -z "$tag" ]; then
echo "No releasable commits since last tag - skipping release."
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup Go
if: steps.release.outputs.tag != ''
uses: actions/setup-go@v5
with:
go-version-file: app/backend/go.mod
cache: true
- name: Build CLI binaries
if: steps.release.outputs.tag != ''
working-directory: app/backend
env:
VERSION: ${{ steps.release.outputs.tag }}
run: bash ../../scripts/build-cli.sh
- name: Checksums
if: steps.release.outputs.tag != ''
working-directory: app/backend/bin
run: sha256sum containr-* > checksums.txt
- name: Login to GHCR
if: steps.release.outputs.tag != '' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Tag pushes from github-tag-action do not trigger workflows, so the
# release job retags the just-published :main images in-registry.
- name: Tag release images in GHCR
if: steps.release.outputs.tag != '' && github.ref == 'refs/heads/main'
env:
TAG: ${{ steps.release.outputs.tag }}
run: |
repo="${GITHUB_REPOSITORY,,}"
for img in backend frontend; do
docker buildx imagetools create \
--tag "ghcr.io/${repo}-${img}:${TAG}" \
--tag "ghcr.io/${repo}-${img}:latest" \
"ghcr.io/${repo}-${img}:main"
done
- name: Create GitHub Release
if: steps.release.outputs.tag != ''
working-directory: app/backend/bin
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.release.outputs.tag }}
run: |
# Delete-then-create keeps reruns idempotent: --clobber uploads
# race GitHub's asset tombstones and intermittently 422. The git
# tag itself is kept (no --cleanup-tag).
gh release delete "$TAG" --yes 2>/dev/null || true
gh release create "$TAG" --title "$TAG" --generate-notes \
containr-* checksums.txt
+3
View File
@@ -121,3 +121,6 @@ bin
# Local reference checkouts (not project code)
ref/
.playwright-mcp/
canvas-*.png
.vercel
+2
View File
@@ -0,0 +1,2 @@
/*
!/site
+37
View File
@@ -0,0 +1,37 @@
# AGENTS.md — Containr
Containr is a self-hosted Docker PaaS (Railway-style). Backend: Go + Gin + PostgreSQL (sqlc) + Redis-compatible cache (Dragonfly) + Docker API. Frontend: React + Vite + TypeScript + React Query. API contract: `docs/api/openapi.yaml` (generated types: `app/frontend/src/generated/api-types.ts`).
This file is for **coding agents working on the repo**. For operating Containr as a deployed product, see `llms.txt`.
## Layout
- `app/backend` — Go monolith. Entrypoints: `cmd/server` (API), `cmd/cli` (`containr` CLI), `cmd/mcp` (`containr-mcp` MCP server).
- `app/backend/internal/api` — Gin handlers + route groups (`routes.go`: public / `read` OptionalAuth / `authed` / `admin`).
- `app/backend/internal/middleware` — `Auth` (JWT + Better Auth session + PAT), `RequireAdmin`, `Idempotency`.
- `app/backend/internal/cli/commands` — all CLI commands; shared `Client`, `Output`, `confirm` helpers live here.
- `app/backend/internal/mcpserver` — declarative MCP tool registry mirroring the API.
- `app/backend/sqlc` — `schema.sql` + `queries/*.sql`; generate via `sqlc generate` into `internal/database/sqlcdb`.
- `app/backend/migrations_goose` — goose migrations (`YYYYMMDDHHMMSS_name.sql`).
- `app/frontend` — React app. Pages under `src/features/`, API calls via `src/lib/api-client.ts`.
## Build / test / verify
```sh
cd app/backend && go build ./... && go vet ./... && go test ./...
cd app/frontend && npx tsc --noEmit && npm run lint && npm run build
cd app/frontend && npm run generate:api # after any openapi.yaml change
```
Dev stack: Postgres on `:15432` (`containr-postgres-1`), Dragonfly on `:16379` (`containr-dragonfly-1`). Server needs `DATABASE_URL`, `REDIS_URL`, `JWT_SECRET`, `PORT=8080`.
## Conventions that matter
- New endpoints go in the right route group; mutations on `authed`/`admin` get idempotency for free.
- Deploy work goes through `deploy_queue` (`internal/deployqueue`) — per-service FIFO; enqueue jobs instead of `go`-ing deployment/reconcile goroutines so they never race a build.
- New list endpoints use `pageWindow`/`paginationMeta` (`internal/api/pagination.go`) — cursor + page, `next_cursor` in the envelope.
- New errors use stable codes via `respondError(c, status, code, msg)` in `internal/api/errors.go`.
- New CLI command → also register an MCP tool in `internal/mcpserver/server.go` and document in OpenAPI. All three surfaces ship together.
- Destructive CLI commands must gate on `--yes`/`confirm` via `commands.confirm`; destructive MCP tools require `confirm: true`.
- sqlc: edit `schema.sql` + `queries/*.sql`, run `sqlc generate`, add the goose migration.
- Don't commit secrets. PATs (`cnp_…`) and agent tokens (`cagt_…`) are sha256-hashed at rest; raw values are shown once.
+96 -99
View File
@@ -11,8 +11,8 @@
<p align="center">
<a href="#quick-start">Quick Start</a> •
<a href="#screenshots">Screenshots</a> •
<a href="#features">Features</a> •
<a href="#architecture">Architecture</a> •
<a href="docs/guides/">Guides</a> •
<a href="CONTRIBUTING.md">Contributing</a>
</p>
@@ -22,124 +22,121 @@
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
</p>
## What is Containr?
Containr is an open-source control plane for Docker. Create a project, drop
services onto a visual canvas, wire them together with environment variables,
and deploy — no Kubernetes, no cloud bill. Services appear as nodes with
auto-inferred connections; logs, metrics, builds, and rollbacks are one
click away.
Containr is an open-source control plane for Docker. It gives you a
project-first deployment workflow — create a project, drop services onto a
visual canvas, wire them together with environment variables, and deploy —
without Kubernetes, without a cloud bill, and without giving up control of
your infrastructure.
The platform doubles as a public showcase: visitors browse approved projects
and the template catalog without an account, while owners manage everything
behind sign-in and a platform admin approves, curates, and moderates from a
dedicated console.
The UI is inspired by Railway's project canvas: services appear as nodes,
connections are inferred automatically, and everything — logs, metrics,
deployments, builds — is one click away.
## Screenshots
| Projects dashboard | Project canvas |
| --- | --- |
| ![Projects dashboard with host telemetry](docs/screenshots/dashboard.png) | ![Visual service canvas with two services](docs/screenshots/canvas.png) |
| Service detail | Managed databases |
| --- | --- |
| ![Service view with metrics, logs and config tabs](docs/screenshots/service-detail.png) | ![Managed database list showing a running PostgreSQL](docs/screenshots/databases.png) |
## Features
- **Project canvas** — visual service topology with groups, drag/drop layout,
and auto-inferred connections
- **Deployments** — Docker-based deploys with history, logs, and rollback
- **Git integration** — deploy from GitHub, GitLab, Bitbucket, or Gitea;
repository and branch pickers, webhooks, GitHub App support
- **Builds** — build pipeline with live status over WebSocket
- **Service metrics** — real CPU/memory/network telemetry from Docker stats
- **Templates** — one-click service catalog (databases, apps, tooling)
- **Databases** — managed database services with backup/restore actions
- **Cron jobs** — scheduled jobs with execution history
- **Preview environments** — per-project preview deploys
- **Variables & environments** — per-service env vars, production/preview/dev
- **Node monitoring** — host CPU, memory, disk, and network telemetry
- **Security** — audit logs, vulnerability scans, compliance reports
- **Auth** — Better Auth sessions (email + OAuth), per-user resource scoping
- **Self-hosted networking** — Traefik reverse proxy, optional Cloudflare
Tunnel for exposure without port forwarding
- **Project canvas** — visual service topology, groups, drag/drop, auto-inferred connections, right-click lifecycle actions
- **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; 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
- **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
- **HA & scaling** — failover policies, node agents, autoscaling rules
- **Auth & security** — Better Auth sessions (email/password self-hosted), personal access tokens with `read`/`write`/`admin` scopes for CLI and agents, audit logs, vulnerability scans
- **CLI** — `containr` covers the full API: projects, services, deployments, logs, exec, variables, databases, cron, templates, nodes, scaling, HA, security, gateway, admin — with `--json`, `--yes`, named profiles, and `containr up` for current-directory deploys
- **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
## Access model
| Audience | Can do |
| --- | --- |
| **Anonymous** | Browse approved projects, services, templates, databases, HA status, docs |
| **Signed-in user** | Create/manage own projects, services, databases, templates; view own logs, variables, builds, deployments |
| **Admin** | Everything above plus project approval/moderation, user admin flags, HA manager controls, agent tokens, platform settings |
Logs, variables, exec consoles, and database connection URLs are never
public — they require owner, member, or admin access regardless of a
project's public visibility.
## Quick Start
Requires Docker with the Compose plugin, `openssl`, and `curl`:
```bash
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh | bash
```
Installs into `./containr`, generates secrets, pulls the published `:latest`
images, and starts the stack — UI at `http://localhost:3000`, API at
`http://localhost:8082`. Non-interactive; re-runs never overwrite `.env`.
The platform admin is provisioned at startup from environment variables:
```bash
ADMIN_EMAIL=[email protected]
ADMIN_PASSWORD=<generated> # keep in a local untracked file
ADMIN_NAME=Your Name # optional
```
Both the local account and the Better Auth session account are created, so
either sign-in path works. Without `ADMIN_*`, the first registered account
becomes owner; registration then closes automatically. The owner can reopen
it and configure a Cloudflare Tunnel token under **Settings → Platform** —
in-app values override env vars.
Override install defaults with env vars:
```bash
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh | \
HTTP_PORT=8080 CONTAINR_DIR=/opt/containr bash
```
`CONTAINR_VERSION` pins a release tag, `CONTAINR_REF` selects the git ref for
downloaded files, `CONTAINR_BUILD=1` builds from source. From a git checkout,
`./install.sh` installs in place; plain `docker compose up -d` pulls the same
images (`--build` to compile locally).
Local development and self-hosted deployment (Traefik + Cloudflare Tunnel):
see [docs/guides/](docs/guides/) and `infra/docker-compose.yml`.
## Architecture
```
app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow
app/backend/ Go API (Gin) · PostgreSQL · Redis · Docker SDK · Better Auth sidecar
app/backend/ Go API (Gin) · PostgreSQL · Dragonfly · Docker SDK · Better Auth sidecar
infra/ Self-hosted compose: Traefik + Cloudflare Tunnel
docs/ Guides, OpenAPI spec, design documents
```
| Component | Default port |
| ------------ | ------------ |
| Frontend | 3000 (prod) / 5173 (dev) |
| API | 8082 |
| Auth sidecar | 3001 |
| PostgreSQL | 5432 |
| Redis | 6379 |
## Quick Start
### Easy install (recommended)
Requires Docker with the Compose plugin, `openssl`, and `curl`.
```bash
git clone https://github.com/Dvorinka/Containr.git
cd Containr
./install.sh
```
The script creates `.env` from `.env.example`, fills in all required secrets
with random values, builds the images, and starts the stack. It keeps an
existing `.env` untouched, so it is safe to re-run.
Frontend at `http://localhost:3000`, API at `http://localhost:8082`.
### Docker Compose (manual)
```bash
cp .env.example .env # fill in secrets
docker compose up -d --build
```
Host ports can be remapped in `.env` (`HTTP_PORT`, `API_PORT`,
`POSTGRES_PORT`, `REDIS_PORT`) if the defaults collide with other services.
### Local development
```bash
docker compose up -d postgres redis # data services only
cd app/backend && go run ./cmd/server # API + embedded auth sidecar
cd app/frontend && npm install && npm run dev
```
Frontend dev server at `http://localhost:5173`.
### Self-hosted with Traefik + Cloudflare Tunnel
See `infra/docker-compose.yml` and
[docs/guides/CLOUDFLARE_SETUP.md](docs/guides/CLOUDFLARE_SETUP.md).
## Configuration
All configuration is via environment variables — see `.env.example`.
Required for production: `JWT_SECRET`, `BETTER_AUTH_SECRET`,
`BETTER_AUTH_INTERNAL_TOKEN`, database and Redis credentials.
**Never commit `.env` or `.env.prod`.** Both are gitignored; if a secret was
ever committed, rotate it.
All configuration is via environment variables — see `.env.example`. Never
commit `.env` or `.env.prod`; keep generated admin credentials in a local
untracked file (e.g. `admin-credentials.local`). If a secret was ever
committed, rotate it.
## Documentation
- In-app: the landing page and `/docs` render searchable docs — synced from
this repository, with a bundled snapshot so they are never blank offline
- [docs/guides/](docs/guides/) — setup, deployment, Cloudflare, autoscaling
- [docs/api/openapi.yaml](docs/api/openapi.yaml) — API contract
- [docs/developers/onboarding.md](docs/developers/onboarding.md) — contributor onboarding
- [docs/design/](docs/design/) — UI design references
- `make help` — common tasks
## Contributing
---
See [CONTRIBUTING.md](CONTRIBUTING.md). Issues and PRs welcome.
## Security
See [SECURITY.md](SECURITY.md). Report vulnerabilities privately — do not
open public issues.
## License
[MIT](LICENSE) © 2026 Tomas Dvorak
[Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) · [MIT License](LICENSE) © 2026 Tomas Dvorak
+584
View File
@@ -0,0 +1,584 @@
# Containr — Roadmap 2: The dflow Convergence
Last verified: **2026-10-08** — deep pass over the full dflow codebase
(`~/Desktop/PROG+HTML/dflow`, community fork of dflow-sh/dflow, frozen for
preservation) against Containr `main` (`15b5b0e`).
Goal: make Containr the superior successor — absorb everything worth having
from dflow, then go past it and past Railway. Same power for humans and
agents: the UI is the friendly face, the CLI and MCP are the machine face,
all three sit on the same API. **Branding stays Containr/Vertice** —
white-labeling is an admin option, not an identity change.
---
## 1. What dflow actually is (verified)
Next.js 15 + PayloadCMS 3 + MongoDB + Redis/BullMQ. Runtime model: every
host is a remote server driven **over SSH** with Dokku auto-installed as the
per-app container manager. Async work = 43 BullMQ queue processors
(`src/queues/**`), live logs via Redis pub/sub → SSE endpoint, persisted to
deployment records. ~30 collections, ~180 server actions, 11 dashboard
routes per service (general, deployments, logs, environment, domains,
volumes, proxy, scaling, backups, settings).
### Full capability matrix — dflow → Containr
Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable (dokku/SSH-specific)
#### Deployment pipeline
| dflow capability | Containr status | Action |
|---|---|---|
| Queued deployments, serialized per server, cancellable while queued | ❌ goroutine fire-and-forget; `CancelDeployment` exists in engine but is **never routed** | Add deploy queue + cancel route |
| Deployment states `queued → building → success/failed`, live SSE logs + persisted record | 🟡 states exist (`building→running`); WS streams builds; no queued state | Extend status + stream |
| Redeploy cache modes (`ps:rebuild` vs fresh pull); webhook pushes force `no-cache` | ❌ always rebuilds | Add `cache` flag on deploy/redeploy |
| Cancel queued deployment from UI | ❌ (engine fn unrouted) | Route it |
| Per-service job ordering | ❌ concurrent deploys of one service can race | Serialize per service |
#### Service configuration
| dflow capability | Containr status | Action |
|---|---|---|
| Multi-domain per service + default flag + cert type | ❌ single `services.domain` | `service_domains` table |
| Cert types: letsencrypt / none / Cloudflare Origin / **CF-for-SaaS custom hostnames** | 🟡 Traefik ACME only | LE default; CF custom hostnames deferred (only needed if hosting tenant domains) |
| DNS preflight check (`checkDNSConfigAction`) | ❌ | `GET /services/:id/domains/check` |
| Volumes per service + dangling-volume browser/attach/delete | ❌ engine supports `VolumeMounts`; no API/UI | `services.volumes` jsonb + Volumes tab |
| Resource **limits and reservations**, per-server defaults | 🟡 `cpu_limit`/`memory_limit` int columns (unclear units) | explicit string units + reserves + instance defaults |
| Pre-deploy capacity check (dokku binary interceptor) | ❌ | Node capacity check pre-deploy/scale |
| Maintenance mode toggle | ❌ | Traefik middleware label |
| Basic-auth gate per service (`toggleHttpAuth`) | ❌ | Traefik basic-auth middleware |
| Raw proxy config override (`setServiceNginxConfig`) | ❌ | Allowlisted `traefik_labels` jsonb |
| Service clone (full config copy) | ❌ | `POST /services/:id/clone` |
| Move service between projects (`SwitchServiceProjectDialog`) | ❌ | `PUT /services/:id {project_id}` |
| Env var save → hot-apply + optional restart (`noRestart`) | ❌ rows rewritten; containers keep stale env until manual redeploy | Apply→redeploy flow |
| Static-site builder; Heroku buildpacks | 🟡 railpack/nixpacks/dockerfile auto-detect, no user override | `services.builder` enum + static builder |
| Docker registry accounts (private registries) | ❌ | `registry_credentials` per service/instance |
#### Databases
| dflow capability | Containr status | Action |
|---|---|---|
| Managed DBs (pg/mysql/mariadb/mongo/redis/clickhouse via dokku plugins) | ✅ plus dragonfly | — |
| **External DB registration** (Neon/Atlas/RDS conn details + test) | ❌ | `provider=external` + test endpoint |
| Internal backups + restore (same-type enforced) | ✅ | — |
| Scheduled backups | ✅ cron field | — |
| **External S3 backups** (auth/schedule/unschedule ops) | ❌ | `backup_targets` + shipper |
| **Cross-server DB migration** (export → sftp hop → import) | ❌ | `POST /databases/:id/migrate` |
| Public DB port expose/unexpose | ❌ loopback only | opt-in TCP expose |
| DB → service env linking (`dokku link`) | ✅ Bind action injects `DATABASE_URL` | — |
#### Servers / infrastructure
| dflow capability | Containr status | Action |
|---|---|---|
| Attach server = paste IP + SSH key; Dokku auto-installs | 🟡 agents exist, manual install | **SSH bootstrap**: one SSH session installs agent, never needed again |
| Tailscale/Headscale, NetBird, ZeroTier enroll + mesh connect | ❌ | Agent mesh-IP detection first; auth-key enroll optional |
| AWS EC2 + Hetzner provisioning w/ cloud-init | ❌ | IaC (ROADMAP §2.2): Terraform Proxmox/AWS + Hetzner module |
| Security groups (AWS SG rules, sync) | ❌ | Under IaC provider modules |
| Server-level global/wildcard domain (`updateServerDomain`) | ❌ | `nodes.default_domain` |
| Scheduled server cleanup (dokku cleanup → docker prune) | ❌ | Agent `prune` command + schedule |
| Queue flush/inspect per server (`flushServerQueues`, `getServerQueues`) | ➖ no queue yet | Ops page shows jobs once queue exists |
| Arbitrary SSH command on server (`executeCommand`) | ➖ exec is container-scoped | Node terminal via agent (PTY) |
| Server onboarding wizard, reset, cleanup-on-delete | 🟡 token issue/revoke only | Onboarding flow + `nodes add` UX |
| Ansible playbooks (stored, runnable per server, exec history) | ➖ | **Defer** — security surface; SSH bootstrap covers provisioning |
| Per-node resource dashboards | 🟡 heartbeat telemetry exists | Node detail page |
#### Platform / org
| dflow capability | Containr status | Action |
|---|---|---|
| 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) | ✅ `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) | ✅ `/activity` + project view | — |
| Admin metrics dashboard (users/servers/queued/failed counts) | 🟡 `/admin` exists | Extend |
| **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`) | ✅ `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 |
|---|---|---|
| Payload REST/GraphQL auto-API + per-user API keys | ❌ | PATs + OpenAPI (better: typed, documented) |
| 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) | ✅ 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) |
---
## 2. The spine: agent-first platform
*Agent and user on the same level.* Concretely:
- Every operation reachable by a **personal access token**, not just
sessions.
- Every operation in the **CLI** with `--json`, stable exit codes, `--yes`.
- Every operation in an **MCP server** (stdio first) so Devin/Claude Code/
Cursor/Codex drive Containr natively.
- `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) ✅ SHIPPED (PRs #14–#18)
- [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.
- [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
webhooks domains volumes backups admin`.
- `containr up` / `deploy` — cwd→service create-or-update→watch
(the `railway up` equivalent; single most important command).
- `containr link` (bind cwd→service), `containr open`, `containr run`,
`containr shell` (PTY once exec upgrade lands), `completion`.
- Universal `--json`, `-o yaml`, `--watch`, `--yes`; exit codes
0/1/2/3/4 (ok/err/not-found/unauthorized/conflict).
- GoReleaser → GH releases + `install.sh`; `version` command.
- Conventions from docsync/envdiff/cix/repolint.
- [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.
- [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.
- [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
git repo, streams logs, sets vars, rolls back — zero UI. Same via CLI
for a human.
---
## 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.
- [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 ✅ SHIPPED (PRs #20–#23)
- [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`.
- [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
no proxy code needed.
- [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.
- [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
- [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. 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)
- [x] **Template = ordered service graph** — `services[]` entries with
`{name, type, source, build, ports, volumes, variables, depends_on}`;
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).
- [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
- [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.
- [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
- [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
- [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.
- [x] **Push notifications** — per-user `notification_channels`
(ntfy + Gotify): every `insertUserNotification` fans out best-effort
to enabled channels; CRUD + live test-ping via web settings, CLI
`notifications channels`, and MCP. FCM/APNs remain optional.
- [ ] **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)
- [x] **Gateway traffic path** — `/g/:slug/*` mounted → api_key auth →
rpm + monthly quota → `gateway/proxy.go` → usage_counters /
metrics_timeseries / incident_events.
- [x] **Preview environments runtime** — previews are real service clones:
branch override, queued deployment, node-domain hostnames, TTL sweeper,
promote = redeploy of the source at the preview branch.
- [x] **Environments** — CRUD (`GET/POST /projects/:id/environments`,
`DELETE /environments/:id`), switcher chips with service counts in the
workspace, CLI `environments` + MCP parity. Per-env vars/domains ride the
service's own scope (one env per service); promote ships via preview
promote.
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`.
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
webhook table tests, Playwright e2e golden path.
- [ ] **First-run wizard** — admin → GitHub App (manifest flow from
Phase G makes this self-serve) → Tunnel → done.
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release.
## 11. Deliberately not imported from dflow
- Dokku-over-SSH runtime — our Docker API + agent model is strictly
better.
- Per-server-named BullMQ queues — our queue stays a single ordered
table.
- Payload/Mongo/Next.js stack, multi-tenant plugin machinery.
- Ansible playbook runner — arbitrary code-on-node is a security
surface; SSH bootstrap + agent commands cover the real needs.
Revisit only with a concrete demand.
- `isAdmin`-only collection access — our owner/admin/public model is
richer already.
- Netdata install flow — our agent telemetry + optional Beszel covers
monitoring; don't ship a third dashboard stack.
## 12. Recommended order
```
A (agent surface) ──► B (deploy pipeline) ──► C/D (service+data depth)
│ │
▼ ▼
CLI/MCP dogfood every feature E (templates v2)
│
F (nodes/infra) ──► H (migrators)
│
G (polish) + debt
```
Why this order: PATs+CLI+MCP first = every later feature ships
user-and-agent-visible at once. The queue (B) is prerequisite for
the ops surface and correct concurrency. Templates v2 needs volumes,
multi-domain, clone. Migrators last — they're launch marketing, not
foundation.
## 13. Design rules
- **API is the contract.** UI, CLI, MCP are thin clients over
`openapi.yaml`. A feature isn't done until all three reach it.
- **Everything scriptable.** JSON in/out, `--json`/`--yes`, uniform
pagination, name-or-id addressing.
- **Same powers, same position.** Nothing user-facing that isn't
API-facing; agent-visible state (tokens, jobs) visible to users too.
- **dflow is a donor, not a dependency.** Port semantics, not code.
- **Containr branding is the default**; white-label is instance config.
- **Self-hosted first** — no required external service, no phone-home.
+14 -5
View File
@@ -1,5 +1,7 @@
# Build Go API binary
FROM golang:1.24-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:20-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:20-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
+1 -1
View File
@@ -5,7 +5,7 @@
### Prerequisites
- Go 1.21+
- PostgreSQL 12+
- Redis (optional)
- Dragonfly (Redis-compatible cache)
### Environment Variables
+153 -127
View File
@@ -8,7 +8,7 @@
"name": "containr-auth",
"version": "0.1.0",
"dependencies": {
"better-auth": "^1.3.4",
"better-auth": "^1.6.33",
"dotenv": "^16.6.1",
"express": "^4.21.2",
"kysely": "^0.28.15",
@@ -16,38 +16,39 @@
}
},
"node_modules/@better-auth/core": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.5.6.tgz",
"integrity": "sha512-Ez9DZdIMFyxHremmoLz1emFPGNQomDC1jqqBPnZ6Ci+6TiGN3R9w/Y03cJn6I8r1ycKgOzeVMZtJ/erOZ27Gsw==",
"version": "1.6.33",
"resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.33.tgz",
"integrity": "sha512-DTadFTdcEad+IAmcJAEVGjD+6wtjt8BkLqmfBdRK+AeYOHaKy/gwtSlmxDuHKfsRaOmn1P8/gtEi/L5wVeEojQ==",
"license": "MIT",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.39.0",
"@standard-schema/spec": "^1.1.0",
"zod": "^4.3.6"
},
"peerDependencies": {
"@better-auth/utils": "0.3.1",
"@better-fetch/fetch": "1.1.21",
"@cloudflare/workers-types": ">=4",
"@better-auth/utils": "0.4.2",
"@better-fetch/fetch": "1.3.1",
"@opentelemetry/api": "^1.9.0",
"better-call": "1.3.2",
"better-call": "1.4.0",
"jose": "^6.1.0",
"kysely": "^0.28.5",
"kysely": "^0.28.5 || ^0.29.0",
"nanostores": "^1.0.1"
},
"peerDependenciesMeta": {
"@cloudflare/workers-types": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@better-auth/drizzle-adapter": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.5.6.tgz",
"integrity": "sha512-VfFFmaoFw3ug12SiSuIwzrMoHyIVmkMGWm9gZ4sXdYYVX4HboCL4m3fjzOhppcmK5OGatRuU+N1UX6wxCITcXw==",
"version": "1.6.33",
"resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.33.tgz",
"integrity": "sha512-/BXRx0FoqoWKV3LZLWjnPJHHGvXEZro7goVzzV7K7ZPZG+PbBa+HYN+eJuXSMFb3JCwJEhW+JNLHWekzHQnTLA==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "1.5.6",
"@better-auth/utils": "^0.3.0",
"drizzle-orm": ">=0.41.0"
"@better-auth/core": "^1.6.33",
"@better-auth/utils": "0.4.2",
"drizzle-orm": "^0.45.2"
},
"peerDependenciesMeta": {
"drizzle-orm": {
@@ -56,13 +57,14 @@
}
},
"node_modules/@better-auth/kysely-adapter": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.5.6.tgz",
"integrity": "sha512-Fnf+h8WVKtw6lEOmVmiVVzDf3shJtM60AYf9XTnbdCeUd6MxN/KnaJZpkgtYnRs7a+nwtkVB+fg4lGETebGFXQ==",
"version": "1.6.33",
"resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.33.tgz",
"integrity": "sha512-CUYXGxJhx29iilmCr2jdzWBGg/Iq1XjghGlRO15N5SsR+hz8KOGob57UFMVtHEQAhN3tcdgJNllx/ZLmNCyO/A==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "1.5.6",
"@better-auth/utils": "^0.3.0",
"kysely": "^0.27.0 || ^0.28.0"
"@better-auth/core": "^1.6.33",
"@better-auth/utils": "0.4.2",
"kysely": "^0.28.17 || ^0.29.0"
},
"peerDependenciesMeta": {
"kysely": {
@@ -71,21 +73,23 @@
}
},
"node_modules/@better-auth/memory-adapter": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.5.6.tgz",
"integrity": "sha512-rS7ZsrIl5uvloUgNN0u9LOZJMMXnsZXVdUZ3MrTBKWM2KpoJjzPr9yN3Szyma5+0V7SltnzSGHPkYj2bEzzmlA==",
"version": "1.6.33",
"resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.33.tgz",
"integrity": "sha512-vw8uuW+zWUd4TLNnmtlwIiUb4jcj/mAopKWohv7ojDR6Esha7ZkM6EsW8W1PVdRhAfLiHeGqh0RvNtFm5mNDjQ==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "1.5.6",
"@better-auth/utils": "^0.3.0"
"@better-auth/core": "^1.6.33",
"@better-auth/utils": "0.4.2"
}
},
"node_modules/@better-auth/mongo-adapter": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.5.6.tgz",
"integrity": "sha512-6+M3MS2mor8fTUV3EI1FBLP0cs6QfbN+Ovx9+XxR/GdfKIBoNFzmPEPRbdGt+ft6PvrITsUm+T70+kkHgVSP6w==",
"version": "1.6.33",
"resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.33.tgz",
"integrity": "sha512-JxAENypnmQbYcNboePWafLaP2egLgKQYoKPnZ8dNvaV50/h/dSlBDn1KaUrEMkJWjRXQ2rwgJzUl2yvGxQUZow==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "1.5.6",
"@better-auth/utils": "^0.3.0",
"@better-auth/core": "^1.6.33",
"@better-auth/utils": "0.4.2",
"mongodb": "^6.0.0 || ^7.0.0"
},
"peerDependenciesMeta": {
@@ -95,12 +99,13 @@
}
},
"node_modules/@better-auth/prisma-adapter": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.5.6.tgz",
"integrity": "sha512-UxY9vQJs1Tt+O+T2YQnseDMlWmUSQvFZSBb5YiFRg7zcm+TEzujh4iX2/csA0YiZptLheovIuVWTP9nriewEBA==",
"version": "1.6.33",
"resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.33.tgz",
"integrity": "sha512-wloYc35VpI7tjuWgOckB4GVX8y0Ow12ER5CfSqc55K88BefgRtR3brruZ9GhHSll+ZIxjf/0p5IGa+z8rKUR2Q==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "1.5.6",
"@better-auth/utils": "^0.3.0",
"@better-auth/core": "^1.6.33",
"@better-auth/utils": "0.4.2",
"@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0",
"prisma": "^5.0.0 || ^6.0.0 || ^7.0.0"
},
@@ -114,31 +119,36 @@
}
},
"node_modules/@better-auth/telemetry": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.5.6.tgz",
"integrity": "sha512-yXC7NSxnIFlxDkGdpD7KA+J9nqIQAPCJKe77GoaC5bWoe/DALo1MYorZfTgOafS7wrslNtsPT4feV/LJi1ubqQ==",
"dependencies": {
"@better-auth/utils": "0.3.1",
"@better-fetch/fetch": "1.1.21"
},
"version": "1.6.33",
"resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.33.tgz",
"integrity": "sha512-k8BmE90sWIXlst84zyvCjWoXuftdujCmevlDDI86zXg81Pl+F2AZFPZFXzWkqaNmecnUjbm9bohNGCY6JonWQQ==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "1.5.6"
"@better-auth/core": "^1.6.33",
"@better-auth/utils": "0.4.2",
"@better-fetch/fetch": "1.3.1"
}
},
"node_modules/@better-auth/utils": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.1.tgz",
"integrity": "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz",
"integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^2.0.1"
}
},
"node_modules/@better-fetch/fetch": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz",
"integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.1.tgz",
"integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==",
"license": "MIT"
},
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
@@ -147,9 +157,10 @@
}
},
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
@@ -157,19 +168,11 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz",
"integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==",
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz",
"integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
@@ -177,7 +180,8 @@
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/accepts": {
"version": "1.3.8",
@@ -197,25 +201,26 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"node_modules/better-auth": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.5.6.tgz",
"integrity": "sha512-QSpJTqaT1XVfWRQe/fm3PgeuwOIlz1nWX/Dx7nsHStJ382bLzmDbQk2u7IT0IJ6wS5SRxfqEE1Ev9TXontgyAQ==",
"version": "1.6.33",
"resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.33.tgz",
"integrity": "sha512-QkVKOmJw/LQ3HjIib2wx2Td0xB9EuPgw0ARYPKeFu3niN0VSBW32j4QwfewFZOQi5Hb/jOB6U+wdvtBhF7+URw==",
"license": "MIT",
"dependencies": {
"@better-auth/core": "1.5.6",
"@better-auth/drizzle-adapter": "1.5.6",
"@better-auth/kysely-adapter": "1.5.6",
"@better-auth/memory-adapter": "1.5.6",
"@better-auth/mongo-adapter": "1.5.6",
"@better-auth/prisma-adapter": "1.5.6",
"@better-auth/telemetry": "1.5.6",
"@better-auth/utils": "0.3.1",
"@better-fetch/fetch": "1.1.21",
"@better-auth/core": "1.6.33",
"@better-auth/drizzle-adapter": "1.6.33",
"@better-auth/kysely-adapter": "1.6.33",
"@better-auth/memory-adapter": "1.6.33",
"@better-auth/mongo-adapter": "1.6.33",
"@better-auth/prisma-adapter": "1.6.33",
"@better-auth/telemetry": "1.6.33",
"@better-auth/utils": "0.4.2",
"@better-fetch/fetch": "1.3.1",
"@noble/ciphers": "^2.1.1",
"@noble/hashes": "^2.0.1",
"better-call": "1.3.2",
"better-call": "1.4.0",
"defu": "^6.1.4",
"jose": "^6.1.3",
"kysely": "^0.28.12",
"kysely": "^0.28.17 || ^0.29.0",
"nanostores": "^1.1.1",
"zod": "^4.3.6"
},
@@ -227,7 +232,7 @@
"@tanstack/solid-start": "^1.0.0",
"better-sqlite3": "^12.0.0",
"drizzle-kit": ">=0.31.4",
"drizzle-orm": ">=0.41.0",
"drizzle-orm": "^0.45.2",
"mongodb": "^6.0.0 || ^7.0.0",
"mysql2": "^3.0.0",
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
@@ -301,14 +306,15 @@
}
},
"node_modules/better-call": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.2.tgz",
"integrity": "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.0.tgz",
"integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==",
"license": "MIT",
"dependencies": {
"@better-auth/utils": "^0.3.1",
"@better-fetch/fetch": "^1.1.21",
"rou3": "^0.7.12",
"set-cookie-parser": "^3.0.1"
"@better-auth/utils": "^0.5.0",
"@better-fetch/fetch": "^1.3.1",
"rou3": "^0.9.1",
"set-cookie-parser": "^3.1.2"
},
"peerDependencies": {
"zod": "^4.0.0"
@@ -319,10 +325,20 @@
}
}
},
"node_modules/better-call/node_modules/@better-auth/utils": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.0.tgz",
"integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^2.0.1"
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
"integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
@@ -332,7 +348,7 @@
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"qs": "~6.16.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
@@ -714,17 +730,19 @@
}
},
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"version": "6.2.12",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/kysely": {
"version": "0.28.15",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.15.tgz",
"integrity": "sha512-r2clcf7HLWvDXaVUEvQymXJY4i3bSOIV3xsL/Upy3ZfSv5HeKsk9tsqbBptLvth5qHEIhxeHTA2jNLyQABkLBA==",
"version": "0.28.17",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz",
"integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
@@ -797,15 +815,16 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/nanostores": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.2.0.tgz",
"integrity": "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==",
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.3.tgz",
"integrity": "sha512-rQLB6eV4f2AW/n3L0JmwCROpaisYy9EDEADvEFSd1C/qG8hB6O5TPlh9A791JRbJr4CnMQBzptDcvD9OR1+6WA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"engines": {
"node": "^20.0.0 || >=22.0.0"
}
@@ -982,11 +1001,13 @@
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@@ -1018,9 +1039,10 @@
}
},
"node_modules/rou3": {
"version": "0.7.12",
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz",
"integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.9.2.tgz",
"integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
@@ -1089,9 +1111,10 @@
}
},
"node_modules/set-cookie-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
"integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
@@ -1099,13 +1122,14 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -1117,12 +1141,13 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -1235,9 +1260,10 @@
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"version": "4.6.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+4 -1
View File
@@ -8,10 +8,13 @@
"start": "node src/server.js"
},
"dependencies": {
"better-auth": "^1.3.4",
"better-auth": "1.6.33",
"dotenv": "^16.6.1",
"express": "^4.21.2",
"kysely": "^0.28.15",
"pg": "^8.16.3"
},
"overrides": {
"qs": "^6.16.0"
}
}
+32 -6
View File
@@ -46,10 +46,15 @@ const database = new PostgresDialect({
pool,
});
const githubClientId = env.GITHUB_CLIENT_ID?.trim() || '';
const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || '';
const googleClientId = env.GOOGLE_CLIENT_ID?.trim() || '';
const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim() || '';
const realCredential = (value) => {
const trimmed = (value || '').trim();
return trimmed && !trimmed.startsWith('PLACEHOLDER_') ? trimmed : '';
};
const githubClientId = realCredential(env.GITHUB_CLIENT_ID);
const githubClientSecret = realCredential(env.GITHUB_CLIENT_SECRET);
const googleClientId = realCredential(env.GOOGLE_CLIENT_ID);
const googleClientSecret = realCredential(env.GOOGLE_CLIENT_SECRET);
const socialProviders = {};
if (githubClientId && githubClientSecret) {
@@ -67,7 +72,7 @@ if (googleClientId && googleClientSecret) {
};
}
const trustedOrigins = [
const staticTrustedOrigins = [
...splitCsv(env.BETTER_AUTH_TRUSTED_ORIGINS),
env.FRONTEND_URL,
env.BACKEND_URL,
@@ -86,12 +91,33 @@ const trustedOrigins = [
.map((origin) => (origin || '').trim())
.filter(Boolean);
// Self-hosted installs are reached over arbitrary hosts/IPs, so the static
// list above cannot cover them. Trust an Origin only when it matches the
// request's own Host header - that is genuine same-origin traffic (browsers
// cannot forge either header cross-origin). Do NOT consult x-forwarded-host
// here: clients can set it freely.
const resolveTrustedOrigins = (request) => {
const origins = new Set(staticTrustedOrigins);
const host = request?.headers?.get('host');
const origin = request?.headers?.get('origin');
if (host && origin) {
try {
if (new URL(origin).host === host) {
origins.add(origin);
}
} catch {
// malformed Origin header - leave untrusted
}
}
return [...origins];
};
export const auth = betterAuth({
appName: env.BETTER_AUTH_APP_NAME || 'Containr',
baseURL: env.BETTER_AUTH_URL || defaultBackendURL,
basePath: '/api/auth',
secret: env.BETTER_AUTH_SECRET || 'PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN',
trustedOrigins,
trustedOrigins: resolveTrustedOrigins,
database,
user: {
modelName: 'auth_users',
+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
}
}
+9 -4
View File
@@ -6,9 +6,14 @@ import (
"os"
)
// Injected at build time via -ldflags (see scripts/build-cli.sh).
var (
Version = "dev"
BuildTime = "unknown"
GitCommit = "unknown"
)
func main() {
if err := cli.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime))
os.Exit(cli.Execute())
}
+24
View File
@@ -0,0 +1,24 @@
// dumptemplates prints the seeded official template catalog as JSON in the same
// shape as GET /api/v1/templates. The landing site (site/templates.json) uses it
// as the offline fallback catalog: `go run ./cmd/dumptemplates > ../../site/templates.json`.
package main
import (
"containr/internal/api"
"encoding/json"
"fmt"
"os"
)
func main() {
out := struct {
Templates []api.ServiceTemplate `json:"templates"`
}{Templates: api.SeedTemplates()}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(out); err != nil {
fmt.Fprintln(os.Stderr, "encode:", err)
os.Exit(1)
}
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"containr/internal/mcpserver"
)
// Injected at build time via -ldflags.
var Version = "dev"
// containr-mcp exposes the whole Containr API as MCP tools over stdio.
// Configure it in an MCP client with:
//
// command: containr-mcp
// env: CONTAINR_API_URL, CONTAINR_TOKEN (a cnp_ personal access token)
func main() {
apiURL := os.Getenv("CONTAINR_API_URL")
if apiURL == "" {
apiURL = "http://localhost:8080/api/v1"
}
apiURL = strings.TrimSuffix(apiURL, "/")
if !strings.HasSuffix(apiURL, "/api/v1") {
apiURL += "/api/v1"
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := mcpserver.Run(ctx, apiURL, os.Getenv("CONTAINR_TOKEN"), Version); err != nil {
fmt.Fprintf(os.Stderr, "containr-mcp: %v\n", err)
os.Exit(1)
}
}
+6
View File
@@ -86,9 +86,15 @@ func main() {
}()
}
// Provision the platform owner account (idempotent, non-fatal).
api.EnsureAdminAccount(context.Background(), db, cfg)
// Setup Gin router
if cfg.IsProduction() {
gin.SetMode(gin.ReleaseMode)
if !cfg.CookieSecure {
log.Println("Warning: COOKIE_SECURE=false in production - session cookies are sent over plain HTTP. Enable TLS and set COOKIE_SECURE=true.")
}
}
router := gin.New()
+10 -6
View File
@@ -14,15 +14,15 @@ services:
retries: 10
restart: unless-stopped
redis:
image: redis:7-alpine
dragonfly:
image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
env_file:
- ../../.env.prod
command: ["sh", "-c", "redis-server --requirepass \"$$REDIS_PASSWORD\""]
command: ["sh", "-c", "exec dragonfly --requirepass \"$${REDIS_PASSWORD}\" --proactor_threads=1 --maxmemory=256mb --dir=/data"]
ports:
- "6379:6379"
volumes:
- backend_redis_data:/data
- backend_dragonfly_data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
interval: 10s
@@ -38,10 +38,14 @@ services:
- ../../.env.prod
ports:
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
group_add:
- "${DOCKER_GID:-0}"
depends_on:
postgres:
condition: service_healthy
redis:
dragonfly:
condition: service_healthy
restart: unless-stopped
healthcheck:
@@ -52,4 +56,4 @@ services:
volumes:
backend_postgres_data:
backend_redis_data:
backend_dragonfly_data:
+33 -17
View File
@@ -1,6 +1,6 @@
module containr
go 1.24.0
go 1.26.0
require (
github.com/docker/docker v28.5.2+incompatible
@@ -8,18 +8,23 @@ require (
github.com/gin-gonic/gin v1.9.1
github.com/go-playground/validator/v10 v10.30.1
github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
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
github.com/rs/cors v1.10.1
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/sqlc-dev/pqtype v0.3.0
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.47.0
golang.org/x/crypto v0.57.0
golang.org/x/term v0.46.0
gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -34,22 +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
@@ -59,38 +70,43 @@ 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/robfig/cron/v3 v3.0.1 // 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
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
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
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.40.0 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
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
)
+59 -29
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,13 +65,15 @@ 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/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
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=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -80,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=
@@ -97,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=
@@ -105,6 +118,8 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -126,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=
@@ -140,9 +157,15 @@ 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=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
@@ -174,28 +197,32 @@ 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=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -205,26 +232,29 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
+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)
}
}
}
+346
View File
@@ -0,0 +1,346 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"strings"
"time"
"containr/internal/config"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// ---------------------------------------------------------------------------
// Admin account bootstrap
// ---------------------------------------------------------------------------
// EnsureAdminAccount provisions the platform owner account from
// ADMIN_EMAIL/ADMIN_PASSWORD (optional ADMIN_NAME). It is idempotent: existing
// users are simply promoted to admin. Both the local users row (legacy JWT
// login) and the Better Auth account (cookie sessions) are created so either
// sign-in path works. Failures are logged, never fatal — the platform must
// boot even when auth is misconfigured.
func EnsureAdminAccount(ctx context.Context, db *database.DB, cfg *config.Config) {
email := strings.ToLower(strings.TrimSpace(cfg.AdminEmail))
password := cfg.AdminPassword
if email == "" || password == "" {
return
}
if db == nil || db.DB == nil {
log.Println("Admin bootstrap skipped: database unavailable")
return
}
name := strings.TrimSpace(cfg.AdminName)
if name == "" {
name = "Platform Admin"
}
// Local users row (owns resources, is_admin flag, legacy login hash).
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Printf("Admin bootstrap failed: %v", err)
return
}
var userID string
err = db.QueryRowContext(ctx, `
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, true)
ON CONFLICT (email) DO UPDATE
SET is_admin = true, updated_at = NOW()
RETURNING id
`, email, string(hashed), name).Scan(&userID)
if err != nil {
log.Printf("Admin bootstrap failed to upsert user %s: %v", email, err)
return
}
// Better Auth account (cookie-session sign-in). Duplicate sign-up returns
// an error which we treat as "already provisioned".
if err := ensureBetterAuthUser(ctx, name, email, password); err != nil {
if errors.Is(err, errBetterAuthUserExists) {
log.Printf("Admin account %s already exists in auth service; ensured admin flag", email)
} else {
log.Printf("Warning: admin bootstrap could not create auth user for %s: %v", email, err)
}
return
}
log.Printf("Admin account provisioned: %s", email)
}
// ensureBetterAuthUser calls the Better Auth sign-up endpoint directly. Unlike
// createBetterAuthUser it runs outside a request context.
func ensureBetterAuthUser(ctx context.Context, name, email, password string) error {
endpoint, err := betterAuthSignUpURL()
if err != nil {
return err
}
body, err := json.Marshal(gin.H{"name": name, "email": email, "password": password})
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
if token := strings.TrimSpace(os.Getenv("BETTER_AUTH_INTERNAL_TOKEN")); token != "" {
request.Header.Set("X-Containr-Auth-Internal", token)
}
response, err := (&http.Client{Timeout: 10 * time.Second}).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices {
return nil
}
var payload map[string]interface{}
_ = json.NewDecoder(response.Body).Decode(&payload)
message := ""
if raw, ok := payload["message"].(string); ok {
message = strings.ToLower(raw)
} else if raw, ok := payload["error"].(string); ok {
message = strings.ToLower(raw)
}
if strings.Contains(message, "exist") || strings.Contains(message, "already") {
return errBetterAuthUserExists
}
if message == "" {
message = http.StatusText(response.StatusCode)
}
return errors.New(message)
}
// ---------------------------------------------------------------------------
// Admin API
// ---------------------------------------------------------------------------
type adminUserRow struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
CreatedAt string `json:"created_at"`
}
func handleAdminOverview(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ctx := c.Request.Context()
count := func(query string) int {
var n int
if err := db.QueryRowContext(ctx, query).Scan(&n); err != nil {
return 0
}
return n
}
pendingRows, err := queries.ListPendingProjects(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list pending projects"})
return
}
pending := make([]Project, 0, len(pendingRows))
for _, row := range pendingRows {
pending = append(pending, mapSQLCProject(row))
}
c.JSON(http.StatusOK, gin.H{
"stats": gin.H{
"users": count(`SELECT COUNT(*) FROM users`),
"projects": count(`SELECT COUNT(*) FROM projects`),
"pending_projects": count(`SELECT COUNT(*) FROM projects WHERE is_approved = false`),
"services": count(`SELECT COUNT(*) FROM services`),
"running_services": count(`SELECT COUNT(*) FROM services WHERE status = 'running'`),
"databases": count(`SELECT COUNT(*) FROM database_services`),
"agents": count(`SELECT COUNT(*) FROM node_agents`),
"templates": count(`SELECT COUNT(*) FROM service_templates`),
"user_templates": count(`SELECT COUNT(*) FROM service_templates WHERE is_official = false`),
"cron_jobs": count(`SELECT COUNT(*) FROM cron_jobs`),
"deployments_total": count(`SELECT COUNT(*) FROM deployments`),
},
"pending_projects": pending,
})
}
func handleAdminListUsers(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
rows, err := db.QueryContext(c.Request.Context(), `
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')
FROM users
ORDER BY created_at ASC
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"})
return
}
defer rows.Close()
users := make([]adminUserRow, 0)
for rows.Next() {
var u adminUserRow
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.IsAdmin, &u.CreatedAt); err != nil {
continue
}
users = append(users, u)
}
c.JSON(http.StatusOK, gin.H{"users": users})
}
func handleAdminSetUserAdmin(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
targetID := strings.TrimSpace(c.Param("id"))
if _, err := uuid.Parse(targetID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}
var req struct {
IsAdmin bool `json:"is_admin"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
callerID := optionalUserID(c)
if callerID == targetID && !req.IsAdmin {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot revoke your own admin access"})
return
}
result, err := db.ExecContext(c.Request.Context(),
`UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2`,
req.IsAdmin, targetID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
return
}
if n, _ := result.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
LogAudit(callerID, "user", targetID, "set_admin", map[string]interface{}{"is_admin": req.IsAdmin})
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)
projectID, ok := parseProjectIDParam(c)
if !ok {
return
}
var req struct {
IsApproved bool `json:"is_approved"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rows, err := queries.SetProjectApproved(c.Request.Context(), sqlcdb.SetProjectApprovedParams{
IsApproved: req.IsApproved,
ProjectID: projectID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project"})
return
}
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
action := "unapprove"
if req.IsApproved {
action = "approve"
}
LogAudit(optionalUserID(c), "project", projectID.String(), action, nil)
row, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
ProjectID: projectID,
UserID: optionalUserUUID(c),
IsAdmin: true,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "Project updated"})
return
}
c.JSON(http.StatusOK, gin.H{"project": mapSQLCProject(row)})
}
+426 -31
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,11 +1480,53 @@ 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)
}
}
// SetupRoutes registers authenticated dashboard/control agent routes.
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
//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.
func (h *NodeAgentHandler) SetupAdminRoutes(router *gin.RouterGroup) {
tokens := router.Group("/agent-tokens")
{
tokens.POST("", h.CreateAgentToken)
@@ -1241,8 +1549,95 @@ func (h *NodeAgentHandler) SetupRoutes(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
}
+71 -27
View File
@@ -46,6 +46,7 @@ type User struct {
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url,omitempty"`
IsAdmin bool `json:"is_admin"`
CreatedAt string `json:"created_at"`
}
@@ -63,10 +64,10 @@ func handleLogin(c *gin.Context) {
var user User
var hashedPassword string
err := db.QueryRow(`
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
FROM users
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), is_admin, created_at
FROM users
WHERE email = $1
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt)
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err == sql.ErrNoRows {
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
@@ -120,13 +121,32 @@ func handleAuthBootstrap(c *gin.Context) {
mode = "register"
}
providers := []string{}
for _, provider := range []string{"GITHUB", "GOOGLE"} {
if oauthProviderConfigured(provider) {
providers = append(providers, strings.ToLower(provider))
}
}
c.JSON(http.StatusOK, gin.H{
"has_users": count > 0,
"user_count": count,
"mode": mode,
"has_users": count > 0,
"user_count": count,
"mode": mode,
"providers": providers,
"signup_enabled": signupEnabled(db),
})
}
// oauthProviderConfigured reports whether an OAuth provider has real
// credentials. Placeholder values from .env.example do not count, so
// self-hosted installs default to email/password only.
func oauthProviderConfigured(provider string) bool {
id := strings.TrimSpace(os.Getenv(provider + "_CLIENT_ID"))
secret := strings.TrimSpace(os.Getenv(provider + "_CLIENT_SECRET"))
return id != "" && secret != "" &&
!strings.HasPrefix(id, "PLACEHOLDER_") && !strings.HasPrefix(secret, "PLACEHOLDER_")
}
type betterAuthLoginUser struct {
ID string `json:"id"`
Email string `json:"email"`
@@ -236,17 +256,23 @@ func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser
return User{}, err
}
// A mirrored first account owns the platform, same as handleRegister.
var total int
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&total); err != nil {
return User{}, err
}
var user User
err = db.QueryRow(`
INSERT INTO users (email, password_hash, name, avatar_url)
VALUES ($1, $2, $3, NULLIF($4, ''))
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
VALUES ($1, $2, $3, NULLIF($4, ''), $5)
ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name,
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
updated_at = NOW()
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image)).
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image), total == 0).
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err != nil {
return User{}, err
}
@@ -264,24 +290,26 @@ func handleRegister(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
jwtSecret := c.MustGet("jwt_secret").(string)
count, err := countLocalUsers(db)
total, err := countLocalUsers(db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
if count > 0 {
// Closed once the first account exists unless the owner reopens registration.
if total > 0 && !signupEnabled(db) {
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
return
}
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&count)
var existing int
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&existing)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
if count > 0 {
if existing > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
return
}
@@ -293,13 +321,13 @@ func handleRegister(c *gin.Context) {
return
}
// Create user
// First account owns the platform.
var user User
err = db.QueryRow(`
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, $4)
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
@@ -320,13 +348,16 @@ func handleRegister(c *gin.Context) {
}
func handleCreateUser(c *gin.Context) {
db, ok := requireAdmin(c)
if !ok {
return
}
var req ManualUserCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
db := c.MustGet("db").(*database.DB)
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
req.Name = strings.TrimSpace(req.Name)
if req.Email == "" || req.Name == "" {
@@ -363,8 +394,8 @@ func handleCreateUser(c *gin.Context) {
err = db.QueryRow(`
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
return
@@ -459,10 +490,10 @@ func handleGetProfile(c *gin.Context) {
var user User
err := db.QueryRow(`
SELECT id, email, name, COALESCE(avatar_url, ''), created_at
FROM users
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
FROM users
WHERE id = $1
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
@@ -515,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) {
+3 -1
View File
@@ -67,7 +67,9 @@ func shouldBlockPublicBetterAuthSignup(c *gin.Context, cfg *config.Config, db *d
return true
}
return count > 0
// Registration is closed once the first account exists unless the owner
// reopens it in Settings (signup_enabled).
return count > 0 && !signupEnabled(db)
}
func parseAuthProxyTarget(rawTarget string) (*url.URL, 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)
}
+107
View File
@@ -3,7 +3,10 @@ package api
import (
"net/http"
"containr/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func requireAuthenticatedUserID(c *gin.Context) (string, bool) {
@@ -21,3 +24,107 @@ func requireAuthenticatedUserID(c *gin.Context) (string, bool) {
return userID, true
}
// optionalUserID returns the resolved user id or "" for anonymous callers on
// OptionalAuth routes. Never writes a response.
func optionalUserID(c *gin.Context) string {
userIDValue, exists := c.Get("user_id")
if !exists {
return ""
}
userID, _ := userIDValue.(string)
return userID
}
// optionalUserUUID is the uuid variant of optionalUserID; anonymous callers
// get uuid.Nil, which matches no owner_id and therefore only sees approved
// public resources.
func optionalUserUUID(c *gin.Context) uuid.UUID {
userID := optionalUserID(c)
parsed, err := uuid.Parse(userID)
if err != nil {
return uuid.Nil
}
return parsed
}
// contextIsAdmin reports whether the resolved caller is a platform admin.
func contextIsAdmin(c *gin.Context) bool {
return c.GetBool("is_admin")
}
// projectReadAccess reports (exists, allowedToRead). Approved projects are
// public; unapproved ones are visible to the owner, project members, and
// platform admins only.
func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
var isApproved bool
var ownerID string
err := db.QueryRow(
`SELECT is_approved, owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&isApproved, &ownerID)
if err != nil {
return false, false
}
if isApproved || contextIsAdmin(c) {
return true, true
}
userID := optionalUserID(c)
if userID == "" {
return true, false
}
if ownerID == userID {
return true, true
}
var member bool
if err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
projectID, userID,
).Scan(&member); err == nil && member {
return true, true
}
return true, false
}
// projectManageAccess reports (exists, allowedToOperate). Unlike
// projectReadAccess, approval does NOT grant access — only the owner,
// project members, and platform admins may reach secret-adjacent data
// such as variables and runtime logs.
func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
var ownerID string
err := db.QueryRow(
`SELECT owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&ownerID)
if err != nil {
return false, false
}
if contextIsAdmin(c) {
return true, true
}
userID := optionalUserID(c)
if userID == "" {
return true, false
}
if ownerID == userID {
return true, true
}
var member bool
if err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
projectID, userID,
).Scan(&member); err == nil && member {
return true, true
}
return true, false
}
// serviceProjectID resolves the project that owns a service.
func serviceProjectID(db *database.DB, serviceID uuid.UUID) (uuid.UUID, bool) {
var projectID uuid.UUID
err := db.QueryRow(`SELECT project_id FROM services WHERE id = $1`, serviceID).Scan(&projectID)
if err != nil {
return uuid.Nil, false
}
return projectID, true
}
+42 -29
View File
@@ -65,19 +65,34 @@ type UpdateCronJobRequest struct {
Retention int `json:"retention"`
}
// cronJobProjectID resolves the project a cron job belongs to.
func cronJobProjectID(db *database.DB, jobID string) (uuid.UUID, bool) {
var projectID uuid.UUID
err := db.QueryRow(
`SELECT cj.project_id FROM cron_jobs cj WHERE cj.id = $1`,
jobID,
).Scan(&projectID)
if err != nil {
return uuid.Nil, false
}
return projectID, true
}
func handleGetCronJobs(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
userID := optionalUserUUID(c)
isAdmin := contextIsAdmin(c)
projectID := c.Query("project_id")
serviceID := c.Query("service_id")
query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE p.owner_id = $1`
args := []interface{}{userID}
WHERE (p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))`
args := []interface{}{userID, isAdmin}
if projectID != "" {
args = append(args, projectID)
@@ -130,7 +145,7 @@ func handleCreateCronJob(c *gin.Context) {
req.ServiceID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -184,30 +199,32 @@ func handleCreateCronJob(c *gin.Context) {
func handleGetCronJob(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
jobID := c.Param("id")
var job CronJob
var ownerCheck string
err := db.QueryRow(
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at, p.owner_id
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
&job.Retention, &job.CreatedAt, &job.UpdatedAt, &ownerCheck)
&job.Retention, &job.CreatedAt, &job.UpdatedAt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
if ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
projectID, found := cronJobProjectID(db, jobID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
@@ -233,7 +250,7 @@ func handleUpdateCronJob(c *gin.Context) {
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -303,7 +320,7 @@ func handleDeleteCronJob(c *gin.Context) {
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -321,19 +338,15 @@ func handleDeleteCronJob(c *gin.Context) {
func handleGetCronExecutions(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
jobID := c.Param("id")
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
projectID, found := cronJobProjectID(db, jobID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
@@ -380,7 +393,7 @@ func handleTriggerCronJob(c *gin.Context) {
jobID,
).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck)
if err != nil || ownerCheck != userID {
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
+368 -144
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,8 +24,11 @@ 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"
"github.com/docker/docker/api/types/registry"
"github.com/docker/go-connections/nat"
"github.com/gin-gonic/gin"
@@ -41,6 +47,7 @@ const (
// DatabaseService represents a managed database service
type DatabaseService struct {
ID string `json:"id" db:"id"`
OwnerID string `json:"-" db:"user_id"`
Name string `json:"name" db:"name"`
Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly
Status string `json:"status" db:"status"` // running, stopped, building, error
@@ -49,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
@@ -84,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
@@ -96,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
@@ -107,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
@@ -168,13 +199,14 @@ func NewDatabaseHandler(db *sql.DB, dockerClient *docker.Client) *DatabaseHandle
}
}
// GetDatabases returns all database services for a user
// GetDatabases returns database services. The platform is public-read: every
// caller sees the fleet, but connection URLs (which may embed credentials)
// are only exposed to admins.
func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
rows, err := h.queries.ListDatabaseServicesByUser(c.Request.Context(), userID)
userID := optionalUserID(c)
isAdmin := contextIsAdmin(c)
rows, err := h.queries.ListAllDatabaseServices(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch databases"})
return
@@ -182,17 +214,23 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
databases := make([]DatabaseService, 0, len(rows))
for _, row := range rows {
db := mapDatabaseServiceListRow(row)
db := mapDatabaseServiceRow(row)
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil {
db.Backups = backupConfig
privileged := isAdmin || (userID != "" && db.OwnerID == userID)
if privileged {
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil {
db.Backups = backupConfig
} else {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
}
db.Settings = h.generateMockSettings()
db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c))
} else {
db.Backups = h.generateMockBackupConfig()
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
db.ConnectionURL = ""
}
db.Settings = h.generateMockSettings()
db.ConnectionURL = h.resolveConnectionURL(db)
databases = append(databases, db)
}
@@ -202,15 +240,11 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
// GetDatabase returns a specific database service
func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
userID := optionalUserID(c)
isAdmin := contextIsAdmin(c)
databaseID := c.Param("id")
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID,
UserID: userID,
})
row, err := h.queries.GetDatabaseServiceByID(c.Request.Context(), databaseID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
@@ -219,21 +253,40 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"})
return
}
db := mapDatabaseServiceGetRow(row)
db := mapDatabaseServiceRow(row)
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil {
db.Backups = backupConfig
privileged := isAdmin || (userID != "" && db.OwnerID == userID)
if privileged {
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil {
db.Backups = backupConfig
} else {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
}
db.Settings = h.generateMockSettings()
db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c))
} else {
db.Backups = h.generateMockBackupConfig()
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
db.ConnectionURL = ""
}
db.Settings = h.generateMockSettings()
db.ConnectionURL = h.resolveConnectionURL(db)
c.JSON(http.StatusOK, db)
}
// effectiveUserID returns the user scope for row-level queries. Admins act
// on behalf of the row's owner so management works across accounts.
func (h *DatabaseHandler) effectiveUserID(c *gin.Context, userID, databaseID string) string {
if !contextIsAdmin(c) || databaseID == "" {
return userID
}
owner, err := h.queries.GetDatabaseServiceOwnerID(c.Request.Context(), databaseID)
if err == nil && owner != "" {
return owner
}
return userID
}
// CreateDatabase creates a new database service
func (h *DatabaseHandler) CreateDatabase(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
@@ -248,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 {
@@ -279,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) {
@@ -321,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)
@@ -346,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)
@@ -357,6 +433,7 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
var req DatabaseUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -367,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
@@ -440,22 +570,22 @@ func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) {
return
}
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
@@ -481,6 +611,7 @@ func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
var req DatabaseActionRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -488,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
}
@@ -574,6 +712,7 @@ func (h *DatabaseHandler) CreateBackup(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID,
@@ -591,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))
@@ -623,6 +769,7 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
var req DatabaseRestoreRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -630,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
}
@@ -684,6 +837,7 @@ func (h *DatabaseHandler) DownloadBackup(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
backupID := c.Param("bid")
backup, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(c.Request.Context(), sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{
@@ -757,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()
}
@@ -888,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
@@ -904,35 +1059,6 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string
}, nil
}
func (h *DatabaseHandler) generateMockBackupConfig() DatabaseBackupConfig {
return DatabaseBackupConfig{
Enabled: true,
LastBackup: &time.Time{},
Retention: 30,
NextBackup: &time.Time{},
Backups: []DatabaseBackup{
{
ID: "backup_1",
CreatedAt: time.Now().Add(-6 * time.Hour),
Size: "245 MB",
Status: "completed",
},
{
ID: "backup_2",
CreatedAt: time.Now().Add(-24 * time.Hour),
Size: "238 MB",
Status: "completed",
},
{
ID: "backup_3",
CreatedAt: time.Now().Add(-48 * time.Hour),
Size: "241 MB",
Status: "completed",
},
},
}
}
func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
return DatabaseSettings{
MaxConnections: 100,
@@ -942,37 +1068,55 @@ 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 strings.TrimSpace(db.ConnectionURL) != "" {
return db.ConnectionURL
if db.Provider == "external" && db.External != nil {
return externalConnectionURL(db.Type, db.External, secrets.Decrypt(db.externalPasswordEnc))
}
return h.generateConnectionURL(db)
return strings.TrimSpace(db.ConnectionURL)
}
func (h *DatabaseHandler) generateConnectionURL(db DatabaseService) string {
safeName := sanitizeDBIdentifier(db.Name, "app")
switch db.Type {
case "postgresql":
return fmt.Sprintf("postgresql://user:password@%s.containr.local:5432/%s", safeName, safeName)
case "redis":
return fmt.Sprintf("redis://%s.containr.local:6379", safeName)
case "dragonfly":
return fmt.Sprintf("redis://%s.containr.local:6379", safeName)
case "mysql":
return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", safeName, safeName)
case "mariadb":
return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", safeName, safeName)
case "mongodb":
return fmt.Sprintf("mongodb://user:password@%s.containr.local:27017/%s", safeName, safeName)
case "clickhouse":
return fmt.Sprintf("http://%s.containr.local:8123", safeName)
default:
// requestHostname returns the host the client used to reach the API, without
// the port. Empty when it cannot be determined.
func requestHostname(c *gin.Context) string {
host := strings.TrimSpace(c.Request.Host)
if host == "" {
return ""
}
if h, _, err := net.SplitHostPort(host); err == nil {
return strings.Trim(h, "[]")
}
return strings.Trim(host, "[]")
}
// rewriteLoopbackURL swaps loopback/unspecified hosts in rawURL for the host
// the caller actually used, so URLs handed to remote browsers are usable.
func rewriteLoopbackURL(rawURL, host string) string {
if rawURL == "" || host == "" {
return rawURL
}
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
switch strings.ToLower(u.Hostname()) {
case "localhost", "127.0.0.1", "0.0.0.0", "::1", "":
default:
return rawURL
}
port := u.Port()
if port == "" {
u.Host = host
} else {
u.Host = net.JoinHostPort(host, port)
}
return u.String()
}
func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService {
if h.dockerClient == nil {
if h.dockerClient == nil || db.Provider == "external" {
return db
}
@@ -1038,9 +1182,10 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
}
}
func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService {
return DatabaseService{
func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
svc := DatabaseService{
ID: row.ID,
OwnerID: row.UserID,
Name: row.Name,
Type: row.Type,
Status: row.Status,
@@ -1049,27 +1194,25 @@ func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) Databas
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),
}
}
func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService {
return DatabaseService{
ID: row.ID,
Name: row.Name,
Type: row.Type,
Status: row.Status,
Version: row.Version,
Plan: row.Plan,
Region: row.Region,
BackupSchedule: databaseNullString(row.BackupSchedule),
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
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 {
@@ -1231,21 +1374,32 @@ 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()
if h.dockerClient == nil {
fallbackURL := h.generateConnectionURL(DatabaseService{Name: databaseName, Type: dbType})
_ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{String: fallbackURL, Valid: fallbackURL != ""})
_ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{})
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{})
return
}
@@ -1265,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
@@ -1290,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{}{}}
@@ -1349,11 +1507,22 @@ 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
}
if err := waitForRuntimePortReadiness(ctx, hostPort); err != nil {
if err := h.waitForDatabaseReadiness(ctx, containerID, hostPort, plan.Port.Port()); err != nil {
return "", err
}
@@ -1387,12 +1556,24 @@ func (h *DatabaseHandler) resolvePublishedHostPort(ctx context.Context, containe
return "", fmt.Errorf("published host port not available")
}
func waitForRuntimePortReadiness(ctx context.Context, hostPort string) error {
addr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort))
deadline := time.Now().Add(30 * time.Second)
// waitForDatabaseReadiness probes the database's TCP port until it accepts a
// connection. The published port lives on host loopback, which a containerized
// backend cannot reach; in that case the database is additionally attached to
// the backend's own network and probed by container IP.
func (h *DatabaseHandler) waitForDatabaseReadiness(ctx context.Context, containerID, hostPort, containerPort string) error {
probeAddr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort))
if backendNet := h.dockerClient.OwnNetworkName(ctx); backendNet != "" {
if err := h.dockerClient.ConnectNetwork(ctx, backendNet, containerID, network.EndpointSettings{}); err != nil {
log.Printf("containr: attach database %s to %s failed: %v", containerID, backendNet, err)
}
if ip := h.containerIPOnNetwork(ctx, containerID, backendNet); ip != "" {
probeAddr = net.JoinHostPort(ip, containerPort)
}
}
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 1500*time.Millisecond)
conn, err := net.DialTimeout("tcp", probeAddr, 1500*time.Millisecond)
if err == nil {
_ = conn.Close()
return nil
@@ -1405,7 +1586,18 @@ func waitForRuntimePortReadiness(ctx context.Context, hostPort string) error {
}
}
return fmt.Errorf("database runtime did not become reachable on %s", addr)
return fmt.Errorf("database runtime did not become reachable on %s", probeAddr)
}
func (h *DatabaseHandler) containerIPOnNetwork(ctx context.Context, containerID, networkName string) string {
inspect, err := h.dockerClient.GetContainer(ctx, containerID)
if err != nil || inspect.NetworkSettings == nil {
return ""
}
if ep, ok := inspect.NetworkSettings.Networks[networkName]; ok && ep != nil {
return strings.TrimSpace(ep.IPAddress)
}
return ""
}
func (h *DatabaseHandler) ensureImage(ctx context.Context, imageRef string) error {
@@ -1677,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()
@@ -1699,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)
+39 -7
View File
@@ -51,15 +51,16 @@ func TestDatabaseConnectionURLAndDefaultVersion(t *testing.T) {
}
for _, tc := range cases {
url := handler.generateConnectionURL(DatabaseService{
Type: tc.dbType,
Name: "example",
})
if url == "" {
t.Fatalf("generateConnectionURL returned empty for type %q", tc.dbType)
plan, err := buildDatabaseRuntimePlan(tc.dbType, "example", nil)
if err != nil {
t.Fatalf("buildDatabaseRuntimePlan(%q) failed: %v", tc.dbType, err)
}
url := plan.ConnectionURL("15432")
if len(tc.urlContains) > 0 && !strings.HasPrefix(url, tc.urlContains) {
t.Fatalf("generateConnectionURL(%q) = %q, expected prefix %q", tc.dbType, url, tc.urlContains)
t.Fatalf("ConnectionURL(%q) = %q, expected prefix %q", tc.dbType, url, tc.urlContains)
}
if !strings.Contains(url, ":15432") {
t.Fatalf("ConnectionURL(%q) = %q, expected published port", tc.dbType, url)
}
gotVersion := handler.getDefaultVersion(tc.dbType)
@@ -69,6 +70,17 @@ func TestDatabaseConnectionURLAndDefaultVersion(t *testing.T) {
}
}
func TestResolveConnectionURLNeverFabricates(t *testing.T) {
handler := &DatabaseHandler{}
if got := handler.resolveConnectionURL(DatabaseService{Type: "postgresql", Name: "example"}); got != "" {
t.Fatalf("resolveConnectionURL with no stored URL = %q, want empty", got)
}
if got := handler.resolveConnectionURL(DatabaseService{ConnectionURL: "postgresql://u:p@localhost:15432/db"}); got != "postgresql://u:p@localhost:15432/db" {
t.Fatalf("resolveConnectionURL = %q, want stored URL", got)
}
}
func TestBuildDatabaseRuntimePlanSupportsAllTypes(t *testing.T) {
cases := []struct {
dbType string
@@ -199,3 +211,23 @@ func TestHumanReadableBytes(t *testing.T) {
}
}
}
func TestRewriteLoopbackURL(t *testing.T) {
tests := []struct {
name, raw, host, want string
}{
{"service url", "http://localhost:35981", "10.0.0.182", "http://10.0.0.182:35981"},
{"db url keeps creds", "postgresql://u:p@localhost:35432/db?sslmode=disable", "10.0.0.182", "postgresql://u:[email protected]:35432/db?sslmode=disable"},
{"ipv4 loopback", "redis://:[email protected]:36379/0", "host.lan", "redis://:[email protected]:36379/0"},
{"unspecified", "http://0.0.0.0:9000/x", "10.0.0.182", "http://10.0.0.182:9000/x"},
{"external host untouched", "https://example.com:443/x", "10.0.0.182", "https://example.com:443/x"},
{"empty raw", "", "10.0.0.182", ""},
{"empty host", "http://localhost:35981", "", "http://localhost:35981"},
{"no port", "http://localhost/path", "10.0.0.182", "http://10.0.0.182/path"},
}
for _, tt := range tests {
if got := rewriteLoopbackURL(tt.raw, tt.host); got != tt.want {
t.Errorf("%s: rewriteLoopbackURL(%q,%q) = %q, want %q", tt.name, tt.raw, tt.host, got, tt.want)
}
}
}
+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)
}
}
+547 -71
View File
@@ -3,9 +3,17 @@ package api
import (
"containr/internal/database"
"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"
@@ -34,6 +42,7 @@ type CreateDeploymentRequest struct {
Branch string `json:"branch"`
Trigger string `json:"trigger"`
EnvVars map[string]string `json:"env_vars"`
NoCache bool `json:"no_cache"`
}
type DeploymentResponse struct {
@@ -63,38 +72,31 @@ func handleGetDeployments(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
offset, limit := pageWindow(c, 50, 100)
var total int
_ = db.(*database.DB).QueryRow(
`SELECT COUNT(*) FROM deployments WHERE service_id = $1`, serviceID,
).Scan(&total)
rows, err := db.(*database.DB).Query(
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
FROM deployments
WHERE service_id = $1
ORDER BY created_at DESC
LIMIT 50`,
serviceID,
FROM deployments
WHERE service_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`,
serviceID, limit, offset,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
@@ -117,7 +119,85 @@ func handleGetDeployments(c *gin.Context) {
deployments = append(deployments, d)
}
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
c.JSON(http.StatusOK, gin.H{
"deployments": deployments,
"pagination": paginationMeta(offset, limit, total),
})
}
// RecentDeployment is a deployment row joined with its service and project names.
type RecentDeployment struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectName string `json:"project_name"`
Status string `json:"status"`
ImageName string `json:"image_name"`
StartedAt *time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at"`
CreatedAt time.Time `json:"created_at"`
}
// handleGetRecentDeployments lists the latest deployments across all of the
// caller's services — feeds the dashboard deploy feed.
func handleGetRecentDeployments(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID := optionalUserUUID(c)
isAdmin := contextIsAdmin(c)
offset, limit := pageWindow(c, 10, 50)
var total int
if err := db.(*database.DB).QueryRow(
`SELECT COUNT(*)
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`,
userID, isAdmin,
).Scan(&total); err != nil {
total = 0
}
rows, err := db.(*database.DB).Query(
`SELECT d.id, d.service_id, s.name, p.name, d.status,
COALESCE(d.image_name, ''), d.started_at, d.completed_at, d.created_at
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
ORDER BY d.created_at DESC
LIMIT $3 OFFSET $4`,
userID, isAdmin, limit, offset,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
return
}
defer rows.Close()
deployments := []RecentDeployment{}
for rows.Next() {
var d RecentDeployment
if err := rows.Scan(&d.ID, &d.ServiceID, &d.ServiceName, &d.ProjectName,
&d.Status, &d.ImageName, &d.StartedAt, &d.CompletedAt, &d.CreatedAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"})
return
}
deployments = append(deployments, d)
}
c.JSON(http.StatusOK, gin.H{
"deployments": deployments,
"pagination": paginationMeta(offset, limit, total),
})
}
func handleCreateDeployment(c *gin.Context) {
@@ -153,8 +233,14 @@ func handleCreateDeployment(c *gin.Context) {
var service Service
var projectOwner string
err = db.(*database.DB).QueryRow(
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at, p.owner_id
FROM services s
JOIN projects p ON s.project_id = p.id
@@ -164,6 +250,9 @@ func handleCreateDeployment(c *gin.Context) {
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
)
@@ -234,7 +323,23 @@ func handleCreateDeployment(c *gin.Context) {
}
engineInstance := engine.(*deployment.DeploymentEngine)
go runDeploymentAndSync(context.Background(), db.(*database.DB), engineInstance, &d, service, req, userID.(string))
pos := getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
DeploymentID: d.ID,
Run: func(jctx context.Context) {
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, service, req, userID.(string))
},
})
if pos > 0 {
d.Status = "queued"
_, _ = db.(*database.DB).Exec(
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), d.ID,
)
_, _ = db.(*database.DB).Exec(
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
)
}
}
c.JSON(http.StatusCreated, DeploymentResponse{
@@ -256,30 +361,142 @@ func runDeploymentAndSync(
service Service,
req CreateDeploymentRequest,
userID string,
) {
runDeploymentAndSyncWithImage(parentCtx, db, engine, dbDeployment, service, req, userID, "")
}
// runDeploymentAndSyncWithImage deploys imageOverride as a prebuilt image
// (rollback / redeploy of an existing tag) when set; otherwise builds from
// source or pulls the service image.
func runDeploymentAndSyncWithImage(
parentCtx context.Context,
db *database.DB,
engine *deployment.DeploymentEngine,
dbDeployment *DeploymentModel,
service Service,
req CreateDeploymentRequest,
userID string,
imageOverride string,
) {
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 = "."
}
// Resolve the runtime env: stored service variables (with ${{svc.KEY}}
// references expanded) plus any per-deployment overrides.
env, envErr := resolveServiceEnv(db, service)
if envErr != nil {
env = map[string]string{}
}
for k, v := range req.EnvVars {
env[k] = v
}
var command []string
if cmd := strings.TrimSpace(service.Command); cmd != "" {
command = strings.Fields(cmd)
}
replicas := service.Replicas
if replicas < 1 {
replicas = 1
}
var publishedPort int32
// Best effort: reuse the host port from the last live deployment so public
// URLs survive redeploys. Column exists post-migration; older DBs get
// ephemeral ports.
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
publicPort := int32(service.Port)
if service.Builder == "static" && publicPort == 0 {
// The static builder's image is nginx — route to its default port
// unless the user picked another.
publicPort = 80
}
maintenanceMode, basicAuthUsers := serviceAccess(db, service.ID)
maintURL := ""
if maintenanceMode {
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(),
Environment: service.Environment,
Config: deployment.ServiceConfig{
Name: service.Name,
Image: service.Image,
Environment: req.EnvVars,
Replicas: 1,
},
BuildConfig: &deployment.BuildConfig{
BuildType: "nixpacks",
SourcePath: sourcePath,
Branch: req.Branch,
Commit: req.CommitHash,
Name: service.Name,
Image: service.Image,
Command: command,
Environment: env,
Replicas: replicas,
PublicPort: publicPort,
PublishedPort: publishedPort,
Domain: service.Domain,
HealthPath: service.HealthCheckPath,
RestartPolicy: service.RestartPolicy,
VolumeMounts: loadServiceVolumes(db, service.ID),
Domains: serviceDomainNames(db, service.ID, service.Domain),
Resources: deployment.ResourceLimits{
MemoryBytes: parseMemoryLimit(service.Memory),
MemoryReservation: parseMemoryLimit(service.MemoryReserve),
CPUQuota: parseCPULimit(service.CPU),
CPUShares: parseCPUReserveShares(service.CPUReserve),
},
Maintenance: maintenanceMode,
MaintenanceURL: maintURL,
BasicAuthUsers: basicAuthUsers,
NodeID: nodeID,
Spread: spread,
},
Trigger: deployment.TriggerConfig{
Type: req.Trigger,
@@ -289,6 +506,112 @@ func runDeploymentAndSync(
},
}
if imageOverride != "" || service.GitRepo == "" {
// Prebuilt image deploy (rollback, redeploy, or image-sourced
// service): resolve private-registry credentials by image host.
image := imageOverride
if image == "" {
image = service.Image
}
if remote && strings.HasPrefix(image, "containr-") {
// Locally-built tag — no registry serves it. Ship the image
// itself as a docker-save artifact; the node loads it.
rc, err := engine.DockerClient().SaveImage(ctx, image)
if err != nil {
failDeploy("export rollback image: " + err.Error())
return
}
artifactID, err := putArtifact(rc)
_ = rc.Close()
if err != nil {
failDeploy("store rollback image artifact: " + err.Error())
return
}
artifactIDs = append(artifactIDs, artifactID)
deployReq.Config.RemoteLoad = &deployment.RemoteLoadSpec{ArtifactID: artifactID}
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "remote",
PrebuiltImage: image,
}
} else {
var ownerID string
_ = 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
// builder additionally feeds its generated Dockerfile the service's
// build command and output dir.
buildType := ""
if service.Builder != "" && service.Builder != "auto" {
buildType = service.Builder
}
buildArgs := map[string]string{}
buildCommand := ""
if buildType == "static" {
buildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build")
buildArgs["STATIC_DIR"] = firstNonEmpty(service.StaticDir, "dist")
}
if remote {
// Package the context — the node docker-builds it itself.
packReq := &types.BuildRequest{
BuildType: buildType,
SourcePath: sourcePath,
BuildCommand: buildCommand,
BuildArgs: buildArgs,
Environment: env,
}
rc, err := engine.BuildManager().PackageContext(ctx, packReq)
if err != nil {
failDeploy("package build context: " + err.Error())
return
}
artifactID, err := putArtifact(rc)
_ = rc.Close()
if err != nil {
failDeploy("store build context artifact: " + err.Error())
return
}
artifactIDs = append(artifactIDs, artifactID)
deployReq.Config.RemoteBuild = &deployment.RemoteBuildSpec{
ArtifactID: artifactID,
BuildArgs: buildArgs,
NoCache: req.NoCache,
}
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "remote",
Branch: req.Branch,
Commit: req.CommitHash,
NoCache: req.NoCache,
}
} else {
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: buildType,
SourcePath: sourcePath,
BuildCommand: buildCommand,
BuildArgs: buildArgs,
Branch: req.Branch,
Commit: req.CommitHash,
NoCache: req.NoCache,
}
}
}
if msg := capacityCheck(parentCtx, db, engine.DockerClient(), service, replicas); msg != "" {
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,
)
return
}
engineDeployment, err := engine.Deploy(ctx, deployReq)
if err != nil {
failedAt := time.Now()
@@ -313,16 +636,19 @@ func runDeploymentAndSync(
select {
case <-ctx.Done():
failedAt := time.Now()
timeoutErr := "Deployment timed out before completion"
finalStatus, finalErr := "failed", "Deployment timed out before completion"
if errors.Is(ctx.Err(), context.Canceled) {
finalStatus, finalErr = "cancelled", "Deployment cancelled"
}
_, _ = db.Exec(
`UPDATE deployments
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
WHERE id = $3`,
timeoutErr, failedAt, dbDeployment.ID,
SET status = $1, error = $2, completed_at = $3, updated_at = $3
WHERE id = $4`,
finalStatus, finalErr, failedAt, dbDeployment.ID,
)
_, _ = db.Exec(
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
failedAt, service.ID,
`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`,
finalStatus, failedAt, service.ID,
)
return
case <-syncTicker.C:
@@ -389,13 +715,86 @@ func runDeploymentAndSync(
}
func mapEngineStatusToDBStatus(status string) string {
switch status {
case "running":
if status == "running" {
return "deployed"
case "cancelled":
return "failed"
}
return status
}
// getDeployQueue returns the per-service deploy queue. SetupRoutes always
// sets it; the fallback keeps handler tests that skip SetupRoutes working.
func getDeployQueue(c *gin.Context) *deployqueue.Queue {
if v, exists := c.Get("deploy_queue"); exists {
if q, ok := v.(*deployqueue.Queue); ok && q != nil {
return q
}
}
return deployqueue.New()
}
// handleCancelDeployment cancels a queued or in-flight deployment. Queued
// jobs are dropped and marked cancelled immediately; active jobs get their
// context cancelled so the build/reconcile aborts and the sync loop writes
// the terminal 'cancelled' status.
func handleCancelDeployment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available", "code": "DEPENDENCY_UNAVAILABLE"})
return
}
deploymentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID", "code": "VALIDATION"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated", "code": "UNAUTHENTICATED"})
return
}
var status, owner string
var serviceID uuid.UUID
err = db.(*database.DB).QueryRow(
`SELECT d.status, d.service_id, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(&status, &serviceID, &owner)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found", "code": "NOT_FOUND"})
return
}
if owner != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied", "code": "FORBIDDEN"})
return
}
switch status {
case "deployed", "failed", "cancelled":
c.JSON(http.StatusConflict, gin.H{"error": "Deployment already finished", "code": "NOT_CANCELLABLE"})
return
}
switch getDeployQueue(c).Cancel(serviceID, deploymentID) {
case deployqueue.WasQueued:
now := time.Now()
_, _ = db.(*database.DB).Exec(
`UPDATE deployments SET status = 'cancelled', error = 'Deployment cancelled', completed_at = $1, updated_at = $1 WHERE id = $2`,
now, deploymentID,
)
_, _ = db.(*database.DB).Exec(
`UPDATE services SET status = 'cancelled', updated_at = $1 WHERE id = $2`,
now, serviceID,
)
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
case deployqueue.WasActive:
c.JSON(http.StatusAccepted, gin.H{"status": "cancelling"})
default:
return status
c.JSON(http.StatusConflict, gin.H{"error": "Deployment is not running on this node", "code": "NOT_CANCELLABLE"})
}
}
@@ -427,18 +826,12 @@ func handleGetDeployment(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var d DeploymentModel
var ownerCheck string
var projectID uuid.UUID
err = db.(*database.DB).QueryRow(
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.owner_id
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
@@ -447,7 +840,7 @@ func handleGetDeployment(c *gin.Context) {
).Scan(
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
&d.CreatedAt, &d.UpdatedAt, &ownerCheck,
&d.CreatedAt, &d.UpdatedAt, &projectID,
)
if err != nil {
@@ -455,8 +848,8 @@ func handleGetDeployment(c *gin.Context) {
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
@@ -509,7 +902,7 @@ func handleRollbackDeployment(c *gin.Context) {
return
}
if ownerCheck != userID.(string) {
if ownerCheck != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -550,17 +943,68 @@ func handleRollbackDeployment(c *gin.Context) {
time.Now(), serviceID,
)
go func() {
time.Sleep(2 * time.Second)
db.(*database.DB).Exec(
`UPDATE deployments SET status = 'deployed', completed_at = $1, updated_at = $1 WHERE id = $2`,
time.Now(), rollbackID,
// Real rollback: redeploy the target deployment's image.
engineValue, _ := c.Get("deployment_engine")
engine, _ := engineValue.(*deployment.DeploymentEngine)
targetImage := targetDeployment.ImageName
if targetDeployment.ImageTag != "" {
targetImage += ":" + targetDeployment.ImageTag
}
if engine == nil || targetImage == "" {
completedAt := time.Now()
reason := "Rollback image unavailable"
if engine == nil {
reason = "Deployment engine unavailable. Docker may not be configured on this server."
}
_, _ = db.(*database.DB).Exec(
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
reason, completedAt, rollbackID,
)
db.(*database.DB).Exec(
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
_, _ = db.(*database.DB).Exec(
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
completedAt, serviceID,
)
}()
} else {
var service Service
_ = db.(*database.DB).QueryRow(
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at
FROM services s WHERE s.id = $1`, serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt,
)
rollbackReq := CreateDeploymentRequest{
CommitHash: func() string {
if targetDeployment.CommitHash != nil {
return *targetDeployment.CommitHash
}
return ""
}(),
Trigger: "rollback",
}
// Reuse the stored image directly — no rebuild.
getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
DeploymentID: rollback.ID,
Run: func(jctx context.Context) {
runDeploymentAndSyncWithImage(jctx, db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
},
})
}
c.JSON(http.StatusCreated, gin.H{
"deployment": DeploymentResponse{
@@ -575,3 +1019,35 @@ func handleRollbackDeployment(c *gin.Context) {
"message": "Rollback initiated",
})
}
// capacityCheck compares the requested per-replica memory (limit, falling
// back to reservation) against node capacity. app_settings.capacity_policy
// = "block" fails the deployment; the default "warn" only logs. CPU is not
// gated — shares are a weight, not a reservation.
func capacityCheck(ctx context.Context, db *database.DB, client *docker.Client, service Service, replicas int) string {
if client == nil {
return ""
}
requested := parseMemoryLimit(service.Memory)
if requested == 0 {
requested = parseMemoryLimit(service.MemoryReserve)
}
if requested == 0 {
return ""
}
info, err := client.GetSystemInfo(ctx)
if err != nil || info.MemTotal <= 0 {
return ""
}
need := requested * int64(replicas)
if need <= info.MemTotal {
return ""
}
msg := fmt.Sprintf("requested %d bytes x %d replicas exceeds node memory (%d bytes)",
requested, replicas, info.MemTotal)
if strings.ToLower(settingValue(db, "capacity_policy", "", "warn")) == "block" {
return msg
}
slog.Warn("capacity check exceeded", "service_id", service.ID, "detail", msg)
return ""
}
+443
View File
@@ -0,0 +1,443 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"net"
"net/http"
"regexp"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// ServiceDomain is one hostname routed to a service. services.domain stays
// the derived default; this table is the source of truth for the full set.
type ServiceDomain struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
Domain string `json:"domain"`
IsDefault bool `json:"is_default"`
CertType string `json:"cert_type"`
CertStatus string `json:"cert_status"`
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// BasicAuthCred is a plaintext credential the server encodes to htpasswd.
type BasicAuthCred struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
var hostnameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`)
func validHostname(d string) bool {
return len(d) <= 253 && hostnameRE.MatchString(d)
}
func loadServiceDomains(db *database.DB, serviceID uuid.UUID) []ServiceDomain {
rows, err := db.Query(
`SELECT id, service_id, domain, is_default, cert_type, cert_status, last_checked_at, created_at
FROM service_domains WHERE service_id = $1 ORDER BY is_default DESC, created_at ASC`, serviceID)
if err != nil {
return nil
}
defer rows.Close()
out := []ServiceDomain{}
for rows.Next() {
var d ServiceDomain
if err := rows.Scan(&d.ID, &d.ServiceID, &d.Domain, &d.IsDefault, &d.CertType, &d.CertStatus, &d.LastCheckedAt, &d.CreatedAt); err == nil {
out = append(out, d)
}
}
return out
}
func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) []string {
domains := loadServiceDomains(db, serviceID)
out := make([]string, 0, len(domains))
for _, d := range domains {
out = append(out, d.Domain)
}
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
}
// syncDefaultDomain writes the is_default row back to services.domain so
// legacy reads (CLI tables, public_url) keep working.
func syncDefaultDomain(db *database.DB, serviceID uuid.UUID) {
var domain string
err := db.QueryRow(
`SELECT domain FROM service_domains WHERE service_id = $1 AND is_default`, serviceID).Scan(&domain)
if err == sql.ErrNoRows {
err = db.QueryRow(
`SELECT domain FROM service_domains WHERE service_id = $1 ORDER BY created_at ASC LIMIT 1`, serviceID).Scan(&domain)
}
if err == sql.ErrNoRows {
domain = ""
}
_, _ = db.Exec(`UPDATE services SET domain = $1 WHERE id = $2`, domain, serviceID)
}
// serviceAccess loads the maintenance/basic-auth columns for the runtime spec.
func serviceAccess(db *database.DB, serviceID uuid.UUID) (maintenance bool, basicAuth string) {
_ = db.QueryRow(`SELECT maintenance_mode, basic_auth_users FROM services WHERE id = $1`, serviceID).
Scan(&maintenance, &basicAuth)
return maintenance, basicAuth
}
// maintenanceURL is the redirect target for maintenance mode — the public
// base URL of this API plus the maintenance page path.
func maintenanceURL(db *database.DB) string {
base := strings.TrimRight(settingValue(db, "base_url", "BASE_URL", ""), "/")
if base == "" {
return ""
}
return base + "/api/v1/maintenance"
}
// encodeBasicAuth turns plaintext creds into Traefik's htpasswd format
// (user:bcrypt hash, comma-separated). Bcrypt is what Traefik expects.
func encodeBasicAuth(creds []BasicAuthCred) (string, error) {
pairs := make([]string, 0, len(creds))
for _, cred := range creds {
if cred.Username == "" || cred.Password == "" {
continue
}
hash, err := bcrypt.GenerateFromPassword([]byte(cred.Password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
pairs = append(pairs, cred.Username+":"+string(hash))
}
return strings.Join(pairs, ","), nil
}
// basicAuthUsernames returns just the usernames for API responses — never
// the hashes.
func basicAuthUsernames(encoded string) []string {
var out []string
for _, pair := range strings.Split(encoded, ",") {
if u, _, ok := strings.Cut(pair, ":"); ok && u != "" {
out = append(out, u)
}
}
return out
}
// serviceWriteAccess mirrors the service update handler's access check.
func serviceWriteAccess(c *gin.Context, db *database.DB, serviceID uuid.UUID) bool {
userID, _ := c.Get("user_id")
var ok bool
err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool))`,
serviceID, userID, contextIsAdmin(c)).Scan(&ok)
return err == nil && ok
}
func handleListServiceDomains(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
c.JSON(http.StatusOK, gin.H{"domains": loadServiceDomains(db.(*database.DB), serviceID)})
}
func handleAddServiceDomain(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
return
}
var req struct {
Domain string `json:"domain" binding:"required"`
IsDefault bool `json:"is_default"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
domain := strings.ToLower(strings.TrimSpace(req.Domain))
if !validHostname(domain) {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid hostname")
return
}
// First domain on a service becomes the default automatically.
var count int
_ = db.(*database.DB).QueryRow(`SELECT COUNT(*) FROM service_domains WHERE service_id = $1`, serviceID).Scan(&count)
makeDefault := req.IsDefault || count == 0
tx, err := db.(*database.DB).Begin()
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
defer tx.Rollback()
if makeDefault {
if _, err := tx.Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
}
var inserted ServiceDomain
err = tx.QueryRow(
`INSERT INTO service_domains (service_id, domain, is_default)
VALUES ($1, $2, $3)
RETURNING id, service_id, domain, is_default, cert_type, cert_status, created_at`,
serviceID, domain, makeDefault).
Scan(&inserted.ID, &inserted.ServiceID, &inserted.Domain, &inserted.IsDefault, &inserted.CertType, &inserted.CertStatus, &inserted.CreatedAt)
if err != nil {
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
respondError(c, http.StatusConflict, "CONFLICT", "Domain already attached to this service")
return
}
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
if err := tx.Commit(); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain")
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusCreated, gin.H{"domain": inserted})
}
func handleDeleteServiceDomain(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
return
}
domainID, err := uuid.Parse(c.Param("domain_id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
return
}
res, err := db.(*database.DB).Exec(
`DELETE FROM service_domains WHERE id = $1 AND service_id = $2`, domainID, serviceID)
affected, _ := res.RowsAffected()
if err != nil || affected == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusOK, gin.H{"deleted": domainID})
}
func handleSetDefaultDomain(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
if !serviceWriteAccess(c, db.(*database.DB), serviceID) {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found")
return
}
domainID, err := uuid.Parse(c.Param("domain_id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID")
return
}
res, err := db.(*database.DB).Exec(
`UPDATE service_domains SET is_default = (id = $1) WHERE service_id = $2`,
domainID, serviceID)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to set default domain")
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found")
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
c.JSON(http.StatusOK, gin.H{"domains": loadServiceDomains(db.(*database.DB), serviceID)})
}
// handleCheckServiceDomains resolves each domain's DNS and compares it to
// the configured expected target (app_settings.public_ip / PUBLIC_IP, or the
// base_url host). Statuses: ok | wrong-target | pending.
func handleCheckServiceDomains(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available")
return
}
dbh := db.(*database.DB)
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID")
return
}
expectedIP := settingValue(dbh, "public_ip", "PUBLIC_IP", "")
expectedHost := ""
if base := settingValue(dbh, "base_url", "BASE_URL", ""); base != "" {
if u := strings.TrimPrefix(strings.TrimPrefix(base, "https://"), "http://"); u != "" {
expectedHost, _, _ = strings.Cut(u, "/")
expectedHost, _, _ = strings.Cut(expectedHost, ":")
}
}
type result struct {
Domain string `json:"domain"`
Status string `json:"status"` // ok | wrong-target | pending
Resolved []string `json:"resolved"`
}
results := []result{}
resolver := net.DefaultResolver
for _, d := range loadServiceDomains(dbh, serviceID) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 4*time.Second)
addrs, _ := resolver.LookupHost(ctx, d.Domain)
cname, _ := resolver.LookupCNAME(ctx, d.Domain)
cancel()
r := result{Domain: d.Domain, Resolved: addrs}
cname = strings.TrimSuffix(cname, ".")
switch {
case len(addrs) == 0:
r.Status = "pending"
case expectedIP != "":
r.Status = "wrong-target"
for _, a := range addrs {
if a == expectedIP {
r.Status = "ok"
}
}
case expectedHost != "":
r.Status = "ok" // resolves; CNAME match is advisory only
if cname != "" && cname != d.Domain && cname != expectedHost {
r.Status = "wrong-target"
}
default:
r.Status = "ok"
}
results = append(results, r)
_, _ = dbh.Exec(`UPDATE service_domains SET last_checked_at = NOW() WHERE id = $1`, d.ID)
}
c.JSON(http.StatusOK, gin.H{"domains": results})
}
// handleMaintenancePage serves the maintenance-mode landing page. Public:
// browsers hit it via the Traefik redirect before any auth.
func handleMaintenancePage(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Maintenance</title><style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0b0e14;color:#e6e8ee;font-family:system-ui,sans-serif}
.card{text-align:center;padding:2rem}.title{font-size:1.4rem;font-weight:600;margin-bottom:.5rem}
.sub{color:#9aa3b2;font-size:.95rem}.brand{margin-top:2rem;color:#5b6472;font-size:.75rem;letter-spacing:.12em;text-transform:uppercase}
</style></head><body><div class="card">
<div class="title">This service is under maintenance</div>
<div class="sub">Please check back shortly.</div>
<div class="brand">Containr</div>
</div></body></html>`))
}
+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))
}
}
+140
View File
@@ -0,0 +1,140 @@
package api
// Project environments — the named lanes services deploy into
// (production/development/preview are seeded; custom names allowed).
import (
"context"
"net/http"
"regexp"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
var envNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$`)
type environmentDTO struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
ProjectID uuid.UUID `json:"project_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ServiceCount int64 `json:"service_count"`
}
func toEnvironmentDTO(id uuid.UUID, name string, projectID uuid.UUID, created, updated time.Time, count int64) environmentDTO {
return environmentDTO{ID: id, Name: name, ProjectID: projectID, CreatedAt: created, UpdatedAt: updated, ServiceCount: count}
}
// handleListEnvironments returns the project's environments with live
// service counts — the data behind the env switcher.
func handleListEnvironments(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid project ID")
return
}
if exists, allowed := projectReadAccess(c, db, projectID); !exists || !allowed {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Project not found")
return
}
rows, err := sqlcdb.New(db.DB).ListProjectEnvironments(context.Background(), projectID)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list environments")
return
}
envs := make([]environmentDTO, 0, len(rows))
for _, r := range rows {
envs = append(envs, toEnvironmentDTO(r.ID, r.Name, r.ProjectID, r.CreatedAt.Time, r.UpdatedAt.Time, r.ServiceCount))
}
c.JSON(http.StatusOK, gin.H{"environments": envs})
}
// handleCreateEnvironment adds a named environment lane to the project.
func handleCreateEnvironment(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid project ID")
return
}
userID, exists := c.Get("user_id")
if !exists || !ownsProject(db, projectID, userID.(string), contextIsAdmin(c)) {
respondError(c, http.StatusForbidden, "FORBIDDEN", "Access denied")
return
}
var req struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "name is required")
return
}
name := strings.ToLower(strings.TrimSpace(req.Name))
if !envNamePattern.MatchString(name) {
respondError(c, http.StatusBadRequest, "VALIDATION", "Name must be lowercase alphanumeric with dashes, 1-50 chars")
return
}
q := sqlcdb.New(db.DB)
if _, err := q.GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
ProjectID: projectID, Name: name,
}); err == nil {
respondError(c, http.StatusConflict, "CONFLICT", "Environment already exists")
return
}
if err := q.InsertProjectEnvironment(context.Background(), sqlcdb.InsertProjectEnvironmentParams{
Name: name, ProjectID: projectID,
}); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create environment")
return
}
env, _ := q.GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
ProjectID: projectID, Name: name,
})
LogAuditWithRequest(c, "project", projectID.String(), "environment.create", map[string]interface{}{"name": name})
c.JSON(http.StatusCreated, gin.H{"environment": toEnvironmentDTO(env.ID, env.Name, env.ProjectID, env.CreatedAt.Time, env.UpdatedAt.Time, 0)})
}
// handleDeleteEnvironment removes an environment that holds no services.
func handleDeleteEnvironment(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
envID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid environment ID")
return
}
userID, exists := c.Get("user_id")
if !exists {
respondError(c, http.StatusUnauthorized, "UNAUTHORIZED", "User not authenticated")
return
}
var projectID uuid.UUID
if err := db.QueryRow(`SELECT project_id FROM environments WHERE id = $1`, envID).Scan(&projectID); err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Environment not found")
return
}
if !ownsProject(db, projectID, userID.(string), contextIsAdmin(c)) {
respondError(c, http.StatusForbidden, "FORBIDDEN", "Access denied")
return
}
q := sqlcdb.New(db.DB)
if n, err := q.CountEnvironmentServices(context.Background(), envID); err == nil && n > 0 {
respondError(c, http.StatusConflict, "CONFLICT", "Environment still contains services — move them first")
return
}
if err := q.DeleteProjectEnvironment(context.Background(), sqlcdb.DeleteProjectEnvironmentParams{
ID: envID, ProjectID: projectID,
}); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete environment")
return
}
LogAuditWithRequest(c, "project", projectID.String(), "environment.delete", map[string]interface{}{"environment_id": envID.String()})
c.JSON(http.StatusOK, gin.H{"message": "Environment deleted"})
}
+29
View File
@@ -0,0 +1,29 @@
package api
import (
"github.com/gin-gonic/gin"
)
// Stable machine-readable error codes for agent clients. The "error" field
// stays human-readable; "code" is the contract the CLI/MCP rely on.
const (
CodeUnauthorized = "UNAUTHENTICATED"
CodeInvalidToken = "INVALID_TOKEN"
CodeForbidden = "FORBIDDEN"
CodeAdminRequired = "ADMIN_REQUIRED"
CodeScopeInsufficient = "SCOPE_INSUFFICIENT"
CodeNotFound = "NOT_FOUND"
CodeValidation = "VALIDATION"
CodeConflict = "CONFLICT"
CodeInternal = "INTERNAL"
CodeUnavailable = "DEPENDENCY_UNAVAILABLE"
CodeIdempotencyRunning = "IDEMPOTENCY_IN_PROGRESS"
)
// respondError emits the standard error envelope. Existing handlers return
// bare {"error": msg} — this adds the stable code for new and
// agent-facing paths.
func respondError(c *gin.Context, status int, code, message string) {
c.JSON(status, gin.H{"error": message, "code": code})
c.Abort()
}
+12 -9
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"containr/internal/database"
"containr/internal/docker"
@@ -17,7 +18,6 @@ import (
// commands run as the container's default user with a 30s ceiling.
func handleExecInService(c *gin.Context) {
serviceID := c.Param("id")
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req struct {
@@ -33,17 +33,20 @@ func handleExecInService(c *gin.Context) {
return
}
var ownerID string
err := db.QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON p.id = s.project_id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerID)
if err != nil || ownerID != userID {
serviceUUID, err := uuid.Parse(serviceID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
projectID, found := serviceProjectID(db, serviceUUID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if _, allowed := projectManageAccess(c, db, projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
dockerClient, _ := c.Get("docker_client")
dockerCli, _ := dockerClient.(*docker.Client)
+311
View File
@@ -0,0 +1,311 @@
package api
// Data plane for the public API gateway: /g/:slug/* proxies to the
// upstream_url of the matching api_services row. Callers authenticate with
// api_keys (X-API-Key or Authorization: Bearer), pass the rpm + monthly
// quota gates, and every request lands in usage_counters /
// metrics_timeseries / incident_events for the /admin/gateway analytics
// surfaces.
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"containr/internal/database"
"containr/internal/gateway"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
// jarvis: ceiling — a single shared transport; per-request timeouts come
// from api_services.request_timeout_ms on the request context.
var gatewayHTTPClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
// gatewayRPM is a process-local per-minute counter map. Containr runs a
// single API process, so an in-memory bucket is sufficient — a multi-node
// control plane would need a Redis-backed limiter instead.
var gatewayRPM = struct {
sync.Mutex
buckets map[string]*rpmBucket
}{buckets: map[string]*rpmBucket{}}
type rpmBucket struct {
window time.Time
count int
}
// gatewayRateAllow returns false when the bucket for the current minute has
// already reached limit. A limit <= 0 disables the check. `now` is injected
// so tests can freeze the window.
func gatewayRateAllow(bucketKey string, limit int, now time.Time) bool {
if limit <= 0 {
return true
}
window := now.Truncate(time.Minute)
gatewayRPM.Lock()
defer gatewayRPM.Unlock()
b := gatewayRPM.buckets[bucketKey]
if b == nil || !b.window.Equal(window) {
b = &rpmBucket{window: window}
gatewayRPM.buckets[bucketKey] = b
// Best-effort sweep so the map cannot grow without bound.
for k, old := range gatewayRPM.buckets {
if old.window.Before(window.Add(-time.Minute)) {
delete(gatewayRPM.buckets, k)
}
}
}
b.count++
return b.count <= limit
}
type gatewayService struct {
ID string
UpstreamURL string
RoutePrefix string
UpstreamAuthHeader sql.NullString
UpstreamAuthValue sql.NullString
Enabled bool
RPMLimit sql.NullInt64
MonthlyQuota sql.NullInt64
RequestTimeoutMS int
}
type gatewayKey struct {
ID string
Hash string
Enabled bool
RPMLimit sql.NullInt64
MonthlyQuota sql.NullInt64
AllowedServiceIDs string
}
// extractGatewayKey reads the caller's API key from X-API-Key first, then
// Authorization: Bearer.
func extractGatewayKey(c *gin.Context) string {
if k := strings.TrimSpace(c.GetHeader("X-API-Key")); k != "" {
return k
}
auth := strings.TrimSpace(c.GetHeader("Authorization"))
if len(auth) > 7 && strings.EqualFold(auth[:7], "Bearer ") {
return strings.TrimSpace(auth[7:])
}
return ""
}
// lookupGatewayService resolves the slug to an enabled service row.
func lookupGatewayService(ctx context.Context, db *database.DB, slug string) (*gatewayService, error) {
var s gatewayService
var enabled int
err := db.QueryRowContext(ctx, `
SELECT id, upstream_url, route_prefix, upstream_auth_header,
upstream_auth_value, enabled, rpm_limit, monthly_quota,
request_timeout_ms
FROM api_services WHERE slug = $1
`, slug).Scan(&s.ID, &s.UpstreamURL, &s.RoutePrefix,
&s.UpstreamAuthHeader, &s.UpstreamAuthValue, &enabled,
&s.RPMLimit, &s.MonthlyQuota, &s.RequestTimeoutMS)
if err != nil {
return nil, err
}
s.Enabled = enabled != 0
return &s, nil
}
// authenticateGatewayKey resolves a presented key by its indexed prefix and
// bcrypt-verifies candidates. Returns nil when nothing matches.
func authenticateGatewayKey(ctx context.Context, db *database.DB, presented string) (*gatewayKey, error) {
prefix := presented
if len(prefix) > 8 {
prefix = prefix[:8]
}
rows, err := db.QueryContext(ctx, `
SELECT id, key_hash, enabled, rpm_limit, monthly_quota, allowed_service_ids
FROM api_keys WHERE key_prefix = $1
`, prefix)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var k gatewayKey
var enabled int
if err := rows.Scan(&k.ID, &k.Hash, &enabled, &k.RPMLimit, &k.MonthlyQuota, &k.AllowedServiceIDs); err != nil {
return nil, err
}
k.Enabled = enabled != 0
if bcrypt.CompareHashAndPassword([]byte(k.Hash), []byte(presented)) == nil {
return &k, nil
}
}
return nil, rows.Err()
}
// keyAllowsService applies the allowed_service_ids JSON allowlist. An empty
// list means the key may call every service.
func keyAllowsService(k *gatewayKey, serviceID string) bool {
var allowed []string
if err := json.Unmarshal([]byte(k.AllowedServiceIDs), &allowed); err != nil || len(allowed) == 0 {
return true
}
for _, id := range allowed {
if id == serviceID {
return true
}
}
return false
}
// monthlyUsage sums a counter dimension for the current calendar month.
func monthlyUsage(ctx context.Context, db *database.DB, column, id, period string) (int, error) {
var total int
err := db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(request_count), 0) FROM usage_counters
WHERE `+column+` = $1 AND period_month = $2
`, id, period).Scan(&total)
return total, err
}
// recordGatewayRequest persists usage, metrics, and failure incidents for a
// proxied call. Runs in the request path but is three small writes.
func recordGatewayRequest(ctx context.Context, db *database.DB, svc *gatewayService, k *gatewayKey, status int, period string) {
_, _ = db.ExecContext(ctx, `
INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at)
VALUES ($1, $2, $3, 1, NOW())
ON CONFLICT (api_key_id, service_id, period_month)
DO UPDATE SET request_count = usage_counters.request_count + 1, updated_at = NOW()
`, k.ID, svc.ID, period)
_, _ = db.ExecContext(ctx, `
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1
`, k.ID)
labels, _ := json.Marshal(map[string]interface{}{
"service_id": svc.ID,
"key_id": k.ID,
"status": status,
})
_, _ = db.ExecContext(ctx, `
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
VALUES ('gateway_request', 1, $1, NOW())
`, string(labels))
}
func recordGatewayIncident(ctx context.Context, db *database.DB, svcID string, keyID *string, code, msg, severity string, status int) {
var kid interface{}
if keyID != nil {
kid = *keyID
}
_, _ = db.ExecContext(ctx, `
INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`, svcID, kid, code, msg, severity, status)
}
// handleGatewayProxy is mounted at /g/:slug and /g/:slug/*path on the root
// router — outside /api/v1 so route_prefix stays clean.
func handleGatewayProxy(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
ctx := c.Request.Context()
slug := c.Param("slug")
svc, err := lookupGatewayService(ctx, db, slug)
if err == sql.ErrNoRows {
respondError(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Gateway service not found")
return
}
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to resolve gateway service")
return
}
if !svc.Enabled {
respondError(c, http.StatusServiceUnavailable, "SERVICE_DISABLED", "Gateway service is disabled")
return
}
presented := extractGatewayKey(c)
if presented == "" {
recordGatewayIncident(ctx, db, svc.ID, nil, "MISSING_API_KEY", "Request without API key", "low", http.StatusUnauthorized)
respondError(c, http.StatusUnauthorized, "API_KEY_REQUIRED", "An API key is required for this service")
return
}
key, err := authenticateGatewayKey(ctx, db, presented)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to authenticate API key")
return
}
// Disabled and unknown keys collapse to the same 401 — a disabled key
// must not leak its existence.
if key == nil || !key.Enabled {
recordGatewayIncident(ctx, db, svc.ID, nil, "INVALID_API_KEY", "Request with invalid or disabled API key", "low", http.StatusUnauthorized)
respondError(c, http.StatusUnauthorized, "INVALID_API_KEY", "Invalid API key")
return
}
if !keyAllowsService(key, svc.ID) {
recordGatewayIncident(ctx, db, svc.ID, &key.ID, "KEY_SCOPE_DENIED", "API key not allowed on this service", "medium", http.StatusForbidden)
respondError(c, http.StatusForbidden, "KEY_SCOPE_DENIED", "API key is not permitted for this service")
return
}
now := time.Now()
if !gatewayRateAllow("key:"+key.ID, int(key.RPMLimit.Int64), now) ||
!gatewayRateAllow("svc:"+svc.ID, int(svc.RPMLimit.Int64), now) {
recordGatewayIncident(ctx, db, svc.ID, &key.ID, "RATE_LIMITED", "Per-minute rate limit exceeded", "medium", http.StatusTooManyRequests)
respondError(c, http.StatusTooManyRequests, "RATE_LIMITED", "Rate limit exceeded")
return
}
period := now.Format("2006-01")
if key.MonthlyQuota.Valid && key.MonthlyQuota.Int64 > 0 {
if used, err := monthlyUsage(ctx, db, "api_key_id", key.ID, period); err == nil && int64(used) >= key.MonthlyQuota.Int64 {
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Monthly request quota exceeded")
return
}
}
if svc.MonthlyQuota.Valid && svc.MonthlyQuota.Int64 > 0 {
if used, err := monthlyUsage(ctx, db, "service_id", svc.ID, period); err == nil && int64(used) >= svc.MonthlyQuota.Int64 {
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Service monthly quota exceeded")
return
}
}
target, err := gateway.BuildTargetURL(svc.UpstreamURL, svc.RoutePrefix, c.Request.URL.Path, c.Request.URL.RawQuery)
if err != nil {
respondError(c, http.StatusBadGateway, "UPSTREAM_URL_INVALID", "Gateway upstream is misconfigured")
return
}
headers := map[string]string{}
if svc.UpstreamAuthHeader.Valid && svc.UpstreamAuthHeader.String != "" {
headers[svc.UpstreamAuthHeader.String] = svc.UpstreamAuthValue.String
}
timeout := time.Duration(svc.RequestTimeoutMS) * time.Millisecond
if timeout <= 0 {
timeout = 8 * time.Second
}
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
status, _, err := gateway.ProxyRequest(gatewayHTTPClient, c.Writer, c.Request.WithContext(reqCtx), target, headers)
if err != nil {
recordGatewayIncident(reqCtx, db, svc.ID, &key.ID, "UPSTREAM_ERROR", err.Error(), "high", http.StatusBadGateway)
recordGatewayRequest(ctx, db, svc, key, http.StatusBadGateway, period)
respondError(c, http.StatusBadGateway, "UPSTREAM_ERROR", "Upstream request failed")
return
}
recordGatewayRequest(ctx, db, svc, key, status, period)
if status >= 500 {
recordGatewayIncident(ctx, db, svc.ID, &key.ID, "UPSTREAM_5XX", "Upstream returned "+http.StatusText(status), "high", status)
}
}
@@ -0,0 +1,81 @@
package api
import (
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestGatewayRateAllow(t *testing.T) {
now := time.Date(2026, 10, 15, 12, 0, 30, 0, time.UTC)
for i := 0; i < 3; i++ {
if !gatewayRateAllow("test:a", 3, now) {
t.Fatalf("call %d within limit rejected", i+1)
}
}
if gatewayRateAllow("test:a", 3, now) {
t.Fatal("call beyond limit allowed")
}
if !gatewayRateAllow("test:a", 3, now.Add(time.Minute)) {
t.Fatal("new minute window still rejected")
}
if !gatewayRateAllow("test:unlimited", 0, now) {
t.Fatal("non-positive limit should disable the check")
}
}
func TestExtractGatewayKey(t *testing.T) {
mkctx := func(headers map[string]string) *gin.Context {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/g/x", nil)
for k, v := range headers {
c.Request.Header.Set(k, v)
}
return c
}
if got := extractGatewayKey(mkctx(map[string]string{"X-API-Key": "ap_abc"})); got != "ap_abc" {
t.Fatalf("X-API-Key not extracted: %q", got)
}
if got := extractGatewayKey(mkctx(map[string]string{"Authorization": "Bearer ap_def"})); got != "ap_def" {
t.Fatalf("Bearer not extracted: %q", got)
}
// X-API-Key wins when both are present.
if got := extractGatewayKey(mkctx(map[string]string{
"X-API-Key": "ap_first",
"Authorization": "Bearer ap_second",
})); got != "ap_first" {
t.Fatalf("X-API-Key should take precedence: %q", got)
}
if got := extractGatewayKey(mkctx(map[string]string{})); got != "" {
t.Fatalf("expected empty key, got %q", got)
}
}
func TestKeyAllowsService(t *testing.T) {
svcID := "11111111-2222-3333-4444-555555555555"
other := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
cases := []struct {
name string
allowed string
want bool
}{
{"empty list permits all", "[]", true},
{"blank permits all", "", true},
{"invalid json permits all", "not-json", true},
{"listed service allowed", `["` + svcID + `"]`, true},
{"unlisted service denied", `["` + other + `"]`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
k := &gatewayKey{AllowedServiceIDs: tc.allowed}
if got := keyAllowsService(k, svcID); got != tc.want {
t.Fatalf("keyAllowsService(%q) = %v, want %v", tc.allowed, got, tc.want)
}
})
}
}
+5 -3
View File
@@ -133,7 +133,9 @@ func handleGetGitProviders(c *gin.Context) {
}
func handleGetGitHubAppInstallURL(c *gin.Context) {
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")
}
+71 -24
View File
@@ -7,6 +7,7 @@ import (
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
@@ -14,6 +15,7 @@ import (
"containr/internal/database"
"containr/internal/deployment"
"containr/internal/deployqueue"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -89,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
@@ -112,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()
@@ -133,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()
@@ -159,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
}
@@ -178,19 +213,31 @@ func handleGitWebhookPush(c *gin.Context) {
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
go runDeploymentAndSync(
context.Background(), db, engine, &d, service,
CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook"},
repoUserID,
)
// Webhook pushes default to a clean build — stale layers are the
// classic silent-rollback failure (dflow convention).
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) {
runDeploymentAndSync(jctx, db, engine, &d, service, webhookReq, repoUserID)
},
}); pos > 0 {
_, _ = db.Exec(
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), d.ID,
)
_, _ = db.Exec(
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
}
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,
})
}
+27 -12
View File
@@ -25,43 +25,58 @@ func NewHAManager(haManager *ha.HighAvailabilityManager) *HAManager {
}
}
// RegisterRoutes registers HA routes
func (h *HAManager) RegisterRoutes(router *gin.RouterGroup) {
// RegisterReadRoutes registers public HA read endpoints.
func (h *HAManager) RegisterReadRoutes(router *gin.RouterGroup) {
ha := router.Group("/ha")
{
ha.GET("/status", h.GetHAStatus)
// Failover policies
ha.GET("/failover/policies", h.GetFailoverPolicies)
ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy)
// Health checks
ha.GET("/health/checks", h.GetHealthChecks)
ha.GET("/health/checks/:checkId", h.GetHealthCheck)
ha.GET("/health/results", h.GetHealthResults)
// Alerts
ha.GET("/alerts/rules", h.GetAlertRules)
ha.GET("/alerts/rules/:ruleId", h.GetAlertRule)
ha.GET("/alerts/active", h.GetActiveAlerts)
// Notifiers
ha.GET("/notifiers", h.GetNotifiers)
ha.GET("/notifiers/:notifierId", h.GetNotifier)
}
}
// RegisterAdminRoutes registers HA mutation endpoints.
func (h *HAManager) RegisterAdminRoutes(router *gin.RouterGroup) {
ha := router.Group("/ha")
{
ha.POST("/enable", h.EnableHA)
ha.POST("/disable", h.DisableHA)
ha.POST("/failover", h.TriggerFailover)
// Failover policies
ha.GET("/failover/policies", h.GetFailoverPolicies)
ha.POST("/failover/policies", h.SetFailoverPolicy)
ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy)
ha.PUT("/failover/policies/:serviceId", h.UpdateFailoverPolicy)
ha.DELETE("/failover/policies/:serviceId", h.DeleteFailoverPolicy)
// Health checks
ha.GET("/health/checks", h.GetHealthChecks)
ha.POST("/health/checks", h.AddHealthCheck)
ha.GET("/health/checks/:checkId", h.GetHealthCheck)
ha.PUT("/health/checks/:checkId", h.UpdateHealthCheck)
ha.DELETE("/health/checks/:checkId", h.DeleteHealthCheck)
ha.GET("/health/results", h.GetHealthResults)
// Alerts
ha.GET("/alerts/rules", h.GetAlertRules)
ha.POST("/alerts/rules", h.AddAlertRule)
ha.GET("/alerts/rules/:ruleId", h.GetAlertRule)
ha.PUT("/alerts/rules/:ruleId", h.UpdateAlertRule)
ha.DELETE("/alerts/rules/:ruleId", h.DeleteAlertRule)
ha.GET("/alerts/active", h.GetActiveAlerts)
ha.POST("/alerts/:alertId/resolve", h.ResolveAlert)
// Notifiers
ha.GET("/notifiers", h.GetNotifiers)
ha.POST("/notifiers", h.AddNotifier)
ha.GET("/notifiers/:notifierId", h.GetNotifier)
ha.DELETE("/notifiers/:notifierId", h.DeleteNotifier)
}
}
+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})
}
+42 -38
View File
@@ -8,9 +8,11 @@ import (
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"github.com/docker/docker/api/types/filters"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
@@ -35,27 +37,13 @@ func handleGetLogs(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
@@ -73,7 +61,21 @@ func handleGetLogs(c *gin.Context) {
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
return
}
containerName := fmt.Sprintf("containr-%s", serviceID)
// Replicas are named containr-<serviceID>-<n>; resolve them by label and
// merge logs across all of them.
ctx := context.Background()
containers, err := client.ListContainersFiltered(ctx, filters.NewArgs(
filters.Arg("label", "containr.service="+serviceID.String()),
), false)
if err != nil {
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.")
return
}
if len(containers) == 0 {
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
return
}
sort.Slice(containers, func(i, j int) bool { return containers[i].Names[0] < containers[j].Names[0] })
logOpts := docker.LogOptions{
Stdout: true,
@@ -83,17 +85,25 @@ func handleGetLogs(c *gin.Context) {
Timestamps: true,
}
ctx := context.Background()
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts)
if err != nil {
if isDockerNotFoundError(err) {
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
return
var readers []io.ReadCloser
var streams []io.Reader
for _, ctr := range containers {
r, rerr := client.GetContainerLogs(ctx, ctr.ID, logOpts)
if rerr == nil {
readers = append(readers, r)
streams = append(streams, r)
}
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.")
}
if len(readers) == 0 {
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
return
}
defer logsReader.Close()
logsReader := io.MultiReader(streams...)
defer func() {
for _, r := range readers {
_ = r.Close()
}
}()
if follow {
c.Header("Content-Type", "text/event-stream")
@@ -185,30 +195,24 @@ func handleGetDeploymentLogs(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var buildLog, runtimeLog string
var ownerCheck string
var projectID uuid.UUID
err = db.(*database.DB).QueryRow(
`SELECT d.build_log, d.runtime_log, p.owner_id
`SELECT d.build_log, d.runtime_log, p.id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(&buildLog, &runtimeLog, &ownerCheck)
).Scan(&buildLog, &runtimeLog, &projectID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
+6 -13
View File
@@ -60,26 +60,19 @@ func handleGetServiceMetrics(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerID string
var projectID uuid.UUID
var status string
err = db.QueryRow(
`SELECT p.owner_id, COALESCE(s.status, '')
FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
`SELECT s.project_id, COALESCE(s.status, '')
FROM services s WHERE s.id = $1`,
serviceID,
).Scan(&ownerID, &status)
).Scan(&projectID, &status)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
+496
View File
@@ -0,0 +1,496 @@
package api
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
)
// agentNodeRunner implements deployment.NodeRunner by dispatching container
// lifecycle commands to a node agent's command queue and waiting for the
// reported result. Domains route through the file provider —
// syncRemoteIngress renders per-service configs from live inventory.
type agentNodeRunner struct {
q *sqlcdb.Queries
db *database.DB
}
func newAgentNodeRunner(db *database.DB) *agentNodeRunner {
return &agentNodeRunner{q: sqlcdb.New(db.DB), db: db}
}
// remoteContainerID is deterministic per (agent, service, replica) so redeploys
// update the same inventory row instead of accumulating stale ones.
func remoteContainerID(agentID, serviceID string, replica int) string {
sum := sha256.Sum256([]byte(agentID + "|" + serviceID + "|" + fmt.Sprint(replica)))
return fmt.Sprintf("%x-%x-%x-%x-%x", sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16])
}
func remoteContainerName(serviceID string, replica int) string {
return fmt.Sprintf("containr-%s-%d", serviceID, replica)
}
func (r *agentNodeRunner) ReconcileOnNode(ctx context.Context, spec deployment.RuntimeSpec) (*deployment.RuntimeState, error) {
agent, err := r.q.GetAgent(ctx, spec.NodeID)
if err != nil {
return nil, fmt.Errorf("node %s not found", spec.NodeID)
}
if !agent.Status.Valid || agent.Status.String != "online" {
return nil, fmt.Errorf("node %s is %s — heartbeats resume scheduling when it reconnects", agent.Name, agent.Status.String)
}
return r.reconcileOnAgents(ctx, spec, func(int) string { return spec.NodeID })
}
// ReconcileSpread distributes replicas deterministically across online,
// schedulable agents: replica i lands on agents[i % len(agents)], agents
// sorted by id. Replicas wrap when they outnumber nodes. The local host is
// not a spread target — pin "local" or leave unset for local execution.
func (r *agentNodeRunner) ReconcileSpread(ctx context.Context, spec deployment.RuntimeSpec) (*deployment.RuntimeState, error) {
tagsRaw, _ := json.Marshal(spec.PlacementTags)
if len(spec.PlacementTags) == 0 {
tagsRaw = []byte("[]")
}
agents, err := r.q.ListSchedulableAgentsMatching(ctx, json.RawMessage(tagsRaw))
if err != nil {
return nil, fmt.Errorf("list schedulable nodes: %w", err)
}
if len(agents) == 0 {
if len(spec.PlacementTags) > 0 {
return nil, fmt.Errorf("no online schedulable nodes carry placement tags %v — tag a node or relax the requirement", spec.PlacementTags)
}
return nil, fmt.Errorf("no online schedulable nodes — connect an agent or unset spread")
}
return r.reconcileOnAgents(ctx, spec, func(i int) string {
return agents[i%len(agents)].ID
})
}
// ReconcileAuto resolves the least-loaded online agent matching the
// service's placement tags and reconciles every replica onto it. Stale
// replicas on other agents retire through reconcileOnAgents — a service
// follows its tags when the matching set changes.
func (r *agentNodeRunner) ReconcileAuto(ctx context.Context, spec deployment.RuntimeSpec) (*deployment.RuntimeState, error) {
tagsRaw, _ := json.Marshal(spec.PlacementTags)
if len(spec.PlacementTags) == 0 {
tagsRaw = []byte("[]")
}
agentID, err := r.q.PickLeastLoadedAgentMatching(ctx, json.RawMessage(tagsRaw))
if err != nil {
return nil, fmt.Errorf("no online nodes carry placement tags %v", spec.PlacementTags)
}
return r.reconcileOnAgents(ctx, spec, func(int) string { return agentID })
}
// RemoteRuntimeState reads the service's remote replicas from inventory.
// Rows only carry the last dispatched state — the agent doesn't stream
// container events — so "running" means "last create succeeded".
func (r *agentNodeRunner) RemoteRuntimeState(ctx context.Context, serviceID string) (*deployment.RuntimeState, error) {
rows, err := r.q.ListServiceContainers(ctx, serviceID)
if err != nil {
return nil, err
}
state := &deployment.RuntimeState{Status: "stopped"}
running := 0
for _, row := range rows {
var status struct {
State string `json:"state"`
}
unmarshalRaw(row.Status, &status)
if status.State == "" || status.State == "removed" {
continue
}
replica := -1
if idx, err := strconv.Atoi(strings.TrimPrefix(row.Name, fmt.Sprintf("containr-%s-", serviceID))); err == nil {
replica = idx
}
state.Containers = append(state.Containers, deployment.RuntimeContainer{
ID: row.ID, Name: row.Name, State: status.State, Replica: replica,
})
if status.State == "running" {
running++
}
}
if running > 0 {
state.Status = "running"
}
return state, nil
}
// reconcileOnAgents creates the service's replicas on the agents chosen by
// targetFor and retires stale replicas on any agent — including leftovers
// from a previous pin or a different spread assignment.
func (r *agentNodeRunner) reconcileOnAgents(ctx context.Context, spec deployment.RuntimeSpec, targetFor func(replica int) string) (*deployment.RuntimeState, error) {
replicas := spec.Replicas
if replicas < 1 {
replicas = 1
}
prefix := fmt.Sprintf("containr-%s-", spec.ServiceID)
existing, _ := r.q.ListServiceContainers(ctx, spec.ServiceID)
for _, row := range existing {
if !strings.HasPrefix(row.Name, prefix) {
continue
}
replica, err := strconv.Atoi(strings.TrimPrefix(row.Name, prefix))
keep := err == nil && replica < replicas && row.NodeAgentID == targetFor(replica)
if keep {
continue
}
if _, cmdErr := r.enqueueAndWait(ctx, row.NodeAgentID, "", "remove_container",
map[string]interface{}{"container_name": row.Name}); cmdErr != nil {
return nil, fmt.Errorf("remove stale replica %s on node %s: %w", row.Name, row.NodeAgentID, cmdErr)
}
_ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
ID: row.ID,
Status: rawJSON(map[string]interface{}{"state": "removed"}),
})
}
// Ship the image to every distinct target agent before replicas start.
// RemoteBuild nodes docker-build from the packaged context artifact;
// RemoteLoad nodes docker-load a saved tarball (local-tag rollbacks).
// Registry-pullable images skip this — docker run pulls on demand.
if spec.RemoteBuild != nil || spec.RemoteLoad != nil {
seen := map[string]bool{}
for i := 0; i < replicas; i++ {
agentID := targetFor(i)
if seen[agentID] {
continue
}
seen[agentID] = true
if spec.RemoteBuild != nil {
buildArgs := map[string]interface{}{}
for k, v := range spec.RemoteBuild.BuildArgs {
buildArgs[k] = v
}
_, err := r.enqueueAndWaitTimeout(ctx, agentID, "", "build_image",
map[string]interface{}{
"artifact_id": spec.RemoteBuild.ArtifactID,
"image": spec.Image,
"build_args": buildArgs,
"no_cache": spec.RemoteBuild.NoCache,
}, 25*time.Minute)
if err != nil {
return nil, fmt.Errorf("build image on node %s: %w", agentID, err)
}
} else if spec.RemoteLoad != nil {
_, err := r.enqueueAndWaitTimeout(ctx, agentID, "", "load_image",
map[string]interface{}{
"artifact_id": spec.RemoteLoad.ArtifactID,
}, 10*time.Minute)
if err != nil {
return nil, fmt.Errorf("load image on node %s: %w", agentID, err)
}
}
}
}
state := &deployment.RuntimeState{Desired: replicas, Status: "running"}
for i := 0; i < replicas; i++ {
agentID := targetFor(i)
name := remoteContainerName(spec.ServiceID, i)
containerID := remoteContainerID(agentID, spec.ServiceID, i)
container, env, ports, volumes, restart := r.replicaPayload(spec, agentID, i, name)
_ = r.q.UpsertServiceContainer(ctx, sqlcdb.UpsertServiceContainerParams{
ID: containerID,
Name: name,
Image: spec.Image,
ProjectID: spec.ProjectID,
ServiceID: spec.ServiceID,
NodeAgentID: agentID,
Status: rawJSON(map[string]interface{}{"state": "created", "health": "none"}),
Resources: rawJSON(map[string]interface{}{"memory": spec.MemoryBytes, "cpus": float64(spec.NanoCPUs) / 1e9}),
Ports: rawJSON(ports),
Environment: rawJSON(env),
Volumes: rawJSON(volumes),
Networks: rawJSON([]string{}),
RestartPolicy: rawJSON(map[string]interface{}{"name": restart}),
HealthCheck: rawJSON(map[string]interface{}{"path": spec.HealthPath}),
})
result, err := r.enqueueAndWait(ctx, agentID, containerID, "create_container",
map[string]interface{}{"container": container})
if err != nil {
return nil, fmt.Errorf("replica %d on node %s: %w", i, agentID, err)
}
if assigned := reportedHostPorts(result); len(assigned) > 0 {
for _, p := range ports {
pm, ok := p.(map[string]interface{})
if !ok {
continue
}
cport := jsonInt(pm["container_port"])
proto, _ := pm["protocol"].(string)
if hp, ok := assigned[fmt.Sprintf("%d/%s", cport, proto)]; ok {
if n, err := strconv.Atoi(hp); err == nil {
pm["host_port"] = n
}
}
}
_ = r.q.UpdateContainerPorts(ctx, sqlcdb.UpdateContainerPortsParams{
ID: containerID,
Status: rawJSON(map[string]interface{}{"state": "running", "health": "none"}),
Ports: rawJSON(ports),
})
} else {
_ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
ID: containerID,
Status: rawJSON(map[string]interface{}{"state": "running", "health": "none"}),
})
}
state.Containers = append(state.Containers, deployment.RuntimeContainer{
ID: containerID, Name: name, State: "running", Replica: i,
})
}
syncRemoteIngress(ctx, r.db, spec)
return state, nil
}
// replicaPayload builds the agent create_container payload for one replica.
func (r *agentNodeRunner) replicaPayload(spec deployment.RuntimeSpec, agentID string, i int, name string) (map[string]interface{}, map[string]interface{}, []interface{}, []interface{}, string) {
ports := []interface{}{}
if spec.Port > 0 {
hostPort := spec.PublishedPort // 0 → ephemeral on the node
if i > 0 {
hostPort = 0 // only replica 0 claims the preferred host port
}
ports = append(ports, map[string]interface{}{
"published": true,
"host_port": hostPort,
"container_port": spec.Port,
"protocol": "tcp",
})
}
volumes := make([]interface{}, 0, len(spec.Volumes))
for _, v := range spec.Volumes {
volumes = append(volumes, map[string]interface{}{
"source": v.Source, "target": v.Destination, "read_only": v.ReadOnly,
})
}
env := map[string]interface{}{}
for k, v := range spec.Env {
env[k] = v
}
restart := spec.RestartPolicy
if restart == "" {
restart = "unless-stopped"
}
cmd := make([]interface{}, 0, len(spec.Command))
for _, arg := range spec.Command {
cmd = append(cmd, arg)
}
return map[string]interface{}{
"name": name,
"image": spec.Image,
"command": cmd,
"environment": env,
"registry": r.pullCredentials(spec.ProjectID, spec.Image),
"ports": ports,
"volumes": volumes,
"restart_policy": restart,
"memory": spec.MemoryBytes,
"cpus": float64(spec.NanoCPUs) / 1e9,
"labels": map[string]interface{}{
"containr.managed": "true",
"containr.project": spec.ProjectID,
"containr.service": spec.ServiceID,
"containr.replica": fmt.Sprint(i),
"containr.node": agentID,
},
}, env, ports, volumes, restart
}
// pullCredentials resolves the project owner's registry auth for the image
// host so private images pull on remote nodes. Empty map → anonymous pull.
func (r *agentNodeRunner) pullCredentials(projectID, imageRef string) map[string]interface{} {
var ownerID string
if err := r.db.QueryRow(
`SELECT owner_id FROM projects WHERE id = $1`, projectID,
).Scan(&ownerID); err != nil {
return map[string]interface{}{}
}
auth := registryAuthFor(r.db, ownerID, imageRef)
if auth.Username == "" {
return map[string]interface{}{}
}
return map[string]interface{}{
"server": auth.ServerAddress,
"username": auth.Username,
"password": auth.Password,
}
}
// ControlService fans a lifecycle action out to every replica row the
// service has on the agent. Actions map onto the agent's container commands;
// "remove" also tombstones the inventory row.
func (r *agentNodeRunner) ControlService(ctx context.Context, serviceID, agentID, action string) error {
cmdType := map[string]string{
"start": "start_container",
"stop": "stop_container",
"restart": "restart_container",
"remove": "remove_container",
}[action]
if cmdType == "" {
return fmt.Errorf("unsupported action %q", action)
}
rows, err := r.q.ListContainersForAgent(ctx, agentID)
if err != nil {
return err
}
removed := map[string]interface{}{"state": "removed"}
for _, row := range rows {
if row.ServiceID != serviceID {
continue
}
// Tombstoned replicas are already gone remotely — skip the dispatch
// or a fresh docker rm on a missing name reports a false failure.
if action == "remove" {
var st struct {
State string `json:"state"`
}
if err := json.Unmarshal(row.Status.RawMessage, &st); err == nil && st.State == "removed" {
continue
}
}
if _, err := r.enqueueAndWait(ctx, agentID, row.ID, cmdType,
map[string]interface{}{"container_name": row.Name}); err != nil {
return fmt.Errorf("%s %s on node: %w", action, row.Name, err)
}
if action == "remove" {
_ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
ID: row.ID, Status: rawJSON(removed)})
}
}
return nil
}
// RemoveService tears down every replica of the service on the agent and
// clears the inventory rows.
func (r *agentNodeRunner) RemoveService(ctx context.Context, serviceID, agentID string) error {
if err := r.ControlService(ctx, serviceID, agentID, "remove"); err != nil {
return err
}
if err := r.q.DeleteServiceContainersOnAgent(ctx, sqlcdb.DeleteServiceContainersOnAgentParams{
NodeAgentID: agentID,
ServiceID: serviceID,
}); err != nil {
return err
}
removeServiceIngress(serviceID)
return nil
}
// RetireService removes the service's remote replicas wherever they run —
// called when the service converges to local placement so a cleared pin or
// removed tag requirement doesn't leave containers behind.
func (r *agentNodeRunner) RetireService(ctx context.Context, serviceID string) error {
agentIDs, err := r.q.ListServiceAgents(ctx, serviceID)
if err != nil {
return err
}
for _, agentID := range agentIDs {
if err := r.RemoveService(ctx, serviceID, agentID); err != nil {
return fmt.Errorf("retire on node %s: %w", agentID, err)
}
}
removeServiceIngress(serviceID)
return nil
}
// enqueueAndWait pushes a command to the agent's queue and polls for the
// reported result, returning the agent's result string on success. Agents
// poll on their own interval (≥5s), so this blocks — it runs inside the
// deploy queue worker, never on a request path.
func (r *agentNodeRunner) enqueueAndWait(ctx context.Context, agentID, containerID, cmdType string, payload map[string]interface{}) (string, error) {
return r.enqueueAndWaitTimeout(ctx, agentID, containerID, cmdType, payload, 8*time.Minute)
}
func (r *agentNodeRunner) enqueueAndWaitTimeout(ctx context.Context, agentID, containerID, cmdType string, payload map[string]interface{}, timeout time.Duration) (string, error) {
var cid sql.NullString
if containerID != "" {
cid = sql.NullString{String: containerID, Valid: true}
}
cmd, err := r.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
ID: uuid.New().String(),
Type: cmdType,
NodeAgentID: agentID,
ContainerID: cid,
Payload: rawJSON(payload),
})
if err != nil {
return "", err
}
deadline := time.Now().Add(timeout) // image pulls/builds on a cold node
for {
row, err := r.q.GetCommandForAgent(ctx, sqlcdb.GetCommandForAgentParams{
ID: cmd.ID,
NodeAgentID: agentID,
})
if err == nil {
switch row.Status.String {
case "completed":
return row.Result.String, nil
case "failed":
if row.Error.Valid && row.Error.String != "" {
return "", fmt.Errorf("agent: %s", row.Error.String)
}
return "", fmt.Errorf("agent reported failure")
}
}
if time.Now().After(deadline) {
return "", fmt.Errorf("command %s timed out waiting for the agent", cmdType)
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(2 * time.Second):
}
}
}
// resolveNodePin validates or resolves a node_id request value. "auto" picks
// the least-loaded online agent that carries every required placement tag;
// "" / "local" clears the pin. An explicit pin bypasses tag filtering —
// the operator is overriding placement on purpose.
func resolveNodePin(ctx context.Context, db *database.DB, requested string, requiredTags []string) (string, error) {
requested = strings.TrimSpace(requested)
if requested == "" || requested == "local" {
return "", nil
}
q := sqlcdb.New(db.DB)
if requested == "auto" {
tagsRaw, _ := json.Marshal(requiredTags)
if len(requiredTags) == 0 {
tagsRaw = []byte("[]")
}
id, err := q.PickLeastLoadedAgentMatching(ctx, json.RawMessage(tagsRaw))
if err != nil {
if len(requiredTags) > 0 {
return "", fmt.Errorf("no online nodes carry placement tags %v", requiredTags)
}
return "", fmt.Errorf("no online nodes available")
}
return id, nil
}
agent, err := q.GetAgent(ctx, requested)
if err != nil {
return "", fmt.Errorf("node not found")
}
if !agent.Schedulable {
return "", fmt.Errorf("node %s is cordoned — uncordon it before pinning", agent.Name)
}
return requested, nil
}
@@ -0,0 +1,225 @@
package api
// Push notification channels — self-hosted ntfy and Gotify endpoints users
// attach to their account. insertUserNotification fans out best-effort.
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// jarvis: ceiling — one shared client; no retry/backoff, delivery is
// best-effort and failures are silent by design (notifications must never
// break the producing flow).
var pushHTTPClient = &http.Client{Timeout: 5 * time.Second}
type notificationChannelDTO struct {
ID string `json:"id"`
Kind string `json:"kind"`
Endpoint string `json:"endpoint"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
}
func mapChannel(row sqlcdb.NotificationChannel) notificationChannelDTO {
return notificationChannelDTO{
ID: row.ID.String(),
Kind: row.Kind,
Endpoint: row.Endpoint,
Enabled: row.Enabled,
CreatedAt: row.CreatedAt.Time.Format(time.RFC3339),
}
}
func handleListNotificationChannels(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
uid, _ := uuid.Parse(userID)
rows, err := sqlcdb.New(db.DB).ListAllNotificationChannelsByUser(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list channels")
return
}
out := make([]notificationChannelDTO, 0, len(rows))
for _, r := range rows {
out = append(out, mapChannel(r))
}
c.JSON(http.StatusOK, gin.H{"channels": out})
}
func handleCreateNotificationChannel(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
var req struct {
Kind string `json:"kind" binding:"required,oneof=ntfy gotify"`
Endpoint string `json:"endpoint" binding:"required"`
Token string `json:"token"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "kind (ntfy|gotify) and endpoint are required")
return
}
endpoint := strings.TrimRight(strings.TrimSpace(req.Endpoint), "/")
u, err := url.Parse(endpoint)
if err != nil || u.Scheme == "" || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
respondError(c, http.StatusBadRequest, "VALIDATION", "endpoint must be a valid http(s) URL")
return
}
db := c.MustGet("db").(*database.DB)
uid, _ := uuid.Parse(userID)
row, err := sqlcdb.New(db.DB).InsertNotificationChannel(c.Request.Context(), sqlcdb.InsertNotificationChannelParams{
UserID: uid,
Kind: req.Kind,
Endpoint: endpoint,
Token: sql.NullString{String: req.Token, Valid: req.Token != ""},
})
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create channel")
return
}
c.JSON(http.StatusCreated, gin.H{"channel": mapChannel(row)})
}
func handleDeleteNotificationChannel(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid channel id")
return
}
db := c.MustGet("db").(*database.DB)
uid, _ := uuid.Parse(userID)
n, err := sqlcdb.New(db.DB).DeleteNotificationChannel(c.Request.Context(), sqlcdb.DeleteNotificationChannelParams{
ID: id, UserID: uid,
})
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete channel")
return
}
if n == 0 {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Channel not found")
return
}
c.JSON(http.StatusOK, gin.H{"message": "Channel deleted"})
}
// handleTestNotificationChannel pushes a probe notification through the
// channel — surfaces misconfiguration before real alerts depend on it.
func handleTestNotificationChannel(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid channel id")
return
}
db := c.MustGet("db").(*database.DB)
uid, _ := uuid.Parse(userID)
rows, err := sqlcdb.New(db.DB).ListAllNotificationChannelsByUser(c.Request.Context(), uid)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load channel")
return
}
var target *sqlcdb.NotificationChannel
for i := range rows {
if rows[i].ID == id {
target = &rows[i]
break
}
}
if target == nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "Channel not found")
return
}
if err := deliverPush(c.Request.Context(), *target, "test", "Containr test notification", "Your push channel is configured correctly."); err != nil {
respondError(c, http.StatusBadGateway, "DELIVERY_FAILED", fmt.Sprintf("Push failed: %v", err))
return
}
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
}
// deliverPush sends one notification through a channel. ntfy expects the
// body at POST {endpoint} with Title/Priority headers; Gotify takes JSON at
// POST {endpoint}/message?token=...
func deliverPush(ctx context.Context, ch sqlcdb.NotificationChannel, kind, title, body string) error {
var req *http.Request
var err error
switch ch.Kind {
case "ntfy":
req, err = http.NewRequestWithContext(ctx, http.MethodPost, ch.Endpoint, strings.NewReader(body))
if err == nil {
req.Header.Set("Title", title)
req.Header.Set("Tags", "containr,"+kind)
if ch.Token.Valid && ch.Token.String != "" {
req.Header.Set("Authorization", "Bearer "+ch.Token.String)
}
}
case "gotify":
payload, _ := json.Marshal(map[string]interface{}{
"title": title,
"message": body,
"priority": 5,
})
endpoint := ch.Endpoint + "/message"
if ch.Token.Valid && ch.Token.String != "" {
endpoint += "?token=" + url.QueryEscape(ch.Token.String)
}
req, err = http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err == nil {
req.Header.Set("Content-Type", "application/json")
}
default:
return fmt.Errorf("unsupported channel kind %q", ch.Kind)
}
if err != nil {
return err
}
res, err := pushHTTPClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return fmt.Errorf("push endpoint returned %d", res.StatusCode)
}
return nil
}
// fanoutUserPush delivers a notification to every enabled channel the user
// configured. Errors are swallowed — the in-app row already landed.
func fanoutUserPush(db *database.DB, userID, kind, title, body string) {
uid, err := uuid.Parse(userID)
if err != nil {
return
}
rows, err := sqlcdb.New(db.DB).ListNotificationChannelsByUser(context.Background(), uid)
if err != nil || len(rows) == 0 {
return
}
for _, ch := range rows {
_ = deliverPush(context.Background(), ch, kind, title, body)
}
}
@@ -0,0 +1,80 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"containr/internal/database/sqlcdb"
)
func TestDeliverPushNtfy(t *testing.T) {
var gotAuth, gotTitle, gotTags, gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotTitle = r.Header.Get("Title")
gotTags = r.Header.Get("Tags")
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ch := sqlcdb.NotificationChannel{
Kind: "ntfy",
Endpoint: srv.URL + "/alerts",
Token: sql.NullString{String: "tk_abc", Valid: true},
}
if err := deliverPush(context.Background(), ch, "deployment", "Deploy done", "svc is live"); err != nil {
t.Fatalf("deliverPush: %v", err)
}
if gotAuth != "Bearer tk_abc" {
t.Fatalf("auth header = %q", gotAuth)
}
if gotTitle != "Deploy done" || gotTags != "containr,deployment" || gotBody != "svc is live" {
t.Fatalf("title=%q tags=%q body=%q", gotTitle, gotTags, gotBody)
}
}
func TestDeliverPushGotify(t *testing.T) {
var gotPath, gotToken string
var payload map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotToken = r.URL.Query().Get("token")
_ = json.NewDecoder(r.Body).Decode(&payload)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ch := sqlcdb.NotificationChannel{
Kind: "gotify",
Endpoint: srv.URL,
Token: sql.NullString{String: "apptoken", Valid: true},
}
if err := deliverPush(context.Background(), ch, "backup", "Backup done", "db backup ok"); err != nil {
t.Fatalf("deliverPush: %v", err)
}
if gotPath != "/message" || gotToken != "apptoken" {
t.Fatalf("path=%q token=%q", gotPath, gotToken)
}
if payload["title"] != "Backup done" || payload["message"] != "db backup ok" {
t.Fatalf("payload = %v", payload)
}
}
func TestDeliverPushFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
ch := sqlcdb.NotificationChannel{Kind: "ntfy", Endpoint: srv.URL}
if err := deliverPush(context.Background(), ch, "k", "t", "b"); err == nil {
t.Fatal("expected error on 500")
}
}
@@ -0,0 +1,238 @@
package api
// Notification producers — the emitters behind `insertUserNotification`.
// Deployment success/failure lives in deployments.go; this file covers
// the remaining producers from the roadmap: admin fan-out helper, the
// agent offline/online sweep, and the upgrade-available check.
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
// Version is stamped at image build time via -ldflags "-X
// containr/internal/api.Version=…". "dev" disables the upgrade check.
var Version = "dev"
// notifyAdmins inserts a notification for every admin user. Best-effort.
func notifyAdmins(db *database.DB, kind, title, body, resourceType, resourceID string) {
if db == nil {
return
}
rows, err := db.Query(`SELECT id FROM users WHERE is_admin`)
if err != nil {
log.Printf("containr: notify admins lookup failed: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err == nil {
insertUserNotification(db, id.String(), kind, title, body, resourceType, resourceID)
}
}
}
// StartAgentHealthSweep marks agents offline when their heartbeat goes
// stale (and back online when it resumes), notifying admins on each
// transition. Heartbeats keep the row fresh via UpdateAgentHeartbeat.
func StartAgentHealthSweep(ctx context.Context, db *database.DB) {
const staleAfter = 3 * time.Minute
go func() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
reconcileAgentHealth(db, staleAfter)
}
}
}()
}
type agentHealthRow struct {
id string
name string
status string
stale bool
autoPrune bool
}
func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
cutoff := time.Now().Add(-staleAfter)
var agents []agentHealthRow
rows, err := db.Query(
`SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''),
COALESCE(last_heartbeat < $1, TRUE), auto_prune
FROM node_agents`, cutoff)
if err != nil {
log.Printf("containr: agent health sweep: %v", err)
return
}
for rows.Next() {
var a agentHealthRow
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale, &a.autoPrune); err == nil {
agents = append(agents, a)
}
}
rows.Close()
for _, a := range agents {
switch {
case a.stale && a.status != "offline":
if _, err := db.Exec(
`UPDATE node_agents SET status = 'offline', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
continue
}
notifyAdmins(db, "agent", "Node agent offline",
fmt.Sprintf("Agent %s missed its heartbeat for over %s.", a.name, staleAfter), "agent", a.id)
case !a.stale && a.status == "offline":
if _, err := db.Exec(
`UPDATE node_agents SET status = 'online', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
continue
}
notifyAdmins(db, "agent", "Node agent back online",
fmt.Sprintf("Agent %s resumed heartbeating.", a.name), "agent", a.id)
}
}
scheduleAutoPrune(db, agents)
}
// scheduleAutoPrune enqueues a `prune` command once per day for agents
// with auto_prune enabled. Disk pressure is the most common small-VPS
// failure — this is the dflow setServerAutoCleanup analog.
func scheduleAutoPrune(db *database.DB, agents []agentHealthRow) {
q := sqlcdb.New(db.DB)
for _, a := range agents {
if !a.autoPrune || a.stale {
continue
}
last, err := q.GetLastAgentCommandByType(context.Background(),
sqlcdb.GetLastAgentCommandByTypeParams{NodeAgentID: a.id, Type: "prune"})
if err == nil && last.CreatedAt.Valid && time.Since(last.CreatedAt.Time) < 24*time.Hour {
continue
}
if _, err := q.CreateCommand(context.Background(), sqlcdb.CreateCommandParams{
ID: uuid.New().String(),
Type: "prune",
NodeAgentID: a.id,
}); err != nil {
log.Printf("containr: auto-prune enqueue failed for agent %s: %v", a.id, err)
}
}
}
// StartUpgradeCheck polls GitHub for the latest release once a day and
// notifies admins when it is newer than the running version. The last
// notified tag is persisted in app_settings so restarts don't re-fire.
func StartUpgradeCheck(ctx context.Context, db *database.DB, currentVersion string) {
if currentVersion == "" || currentVersion == "dev" {
return
}
go func() {
checkForUpgrade(db, currentVersion)
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
checkForUpgrade(db, currentVersion)
}
}
}()
}
func checkForUpgrade(db *database.DB, currentVersion string) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.github.com/repos/Dvorinka/Containr/releases/latest", nil)
if err != nil {
return
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "containr-upgrade-check")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return
}
var release struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil || release.TagName == "" {
return
}
if !semverNewer(release.TagName, currentVersion) {
return
}
var lastNotified string
_ = db.QueryRow(`SELECT value FROM app_settings WHERE key = 'upgrade_notified_tag'`).Scan(&lastNotified)
if lastNotified == release.TagName {
return
}
notifyAdmins(db, "upgrade", "Containr update available",
fmt.Sprintf("Release %s is available (running %s). %s", release.TagName, currentVersion, release.HTMLURL),
"release", release.TagName)
_, _ = db.Exec(
`INSERT INTO app_settings (key, value, updated_at) VALUES ('upgrade_notified_tag', $1, NOW())
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`, release.TagName)
}
// semverNewer reports whether candidate is a higher semver than current.
// Non-semver input (e.g. "dev", commit hashes) compares false.
func semverNewer(candidate, current string) bool {
parse := func(s string) ([]int, bool) {
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
// Prerelease/build metadata sorts after the same base version
// upstream — for "is a newer release available" purposes the base
// comparison is what matters.
if j := strings.IndexAny(s, "-+"); j >= 0 {
s = s[:j]
}
parts := strings.Split(s, ".")
if len(parts) == 0 || len(parts) > 3 {
return nil, false
}
out := make([]int, 3)
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
return nil, false
}
out[i] = n
}
return out, true
}
c, ok1 := parse(candidate)
cur, ok2 := parse(current)
if !ok1 || !ok2 {
return false
}
for i := 0; i < 3; i++ {
if c[i] != cur[i] {
return c[i] > cur[i]
}
}
return false
}
@@ -0,0 +1,26 @@
package api
import "testing"
func TestSemverNewer(t *testing.T) {
cases := []struct {
candidate, current string
want bool
}{
{"v1.2.3", "v1.2.2", true},
{"1.3.0", "v1.2.9", true},
{"v2.0.0", "v1.9.9", true},
{"v1.2.3", "v1.2.3", false},
{"v1.2.0", "v1.2.3", false},
{"v1.10.0", "v1.9.9", true}, // numeric, not lexicographic
{"dev", "v1.0.0", false},
{"v1.0.0", "main", false},
{"v1.2.3-rc.1", "v1.2.2", true}, // prerelease suffix tolerated
{"", "v1.0.0", false},
}
for _, tc := range cases {
if got := semverNewer(tc.candidate, tc.current); got != tc.want {
t.Errorf("semverNewer(%q, %q) = %v, want %v", tc.candidate, tc.current, got, tc.want)
}
}
}
@@ -41,6 +41,7 @@ func insertUserNotification(db *database.DB, userID, kind, title, body, resource
ResourceID: sql.NullString{String: resourceID, Valid: resourceID != ""},
CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
})
go fanoutUserPush(db, userID, kind, title, body)
}
func mapNotification(row sqlcdb.Notification) NotificationItem {
+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,
})
}
+52
View File
@@ -0,0 +1,52 @@
package api
import (
"encoding/base64"
"fmt"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
// pageWindow parses pagination from either ?page=&limit= (legacy) or
// ?cursor=&limit= (agent-friendly). The cursor is an opaque base64 offset —
// cheap to implement over the existing offset queries while the public
// contract stays cursor-shaped for clients.
func pageWindow(c *gin.Context, defaultLimit, maxLimit int) (offset, limit int) {
limit = defaultLimit
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= maxLimit {
limit = n
}
}
if cur := c.Query("cursor"); cur != "" {
if raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(cur, "o_")); err == nil {
if n, err := strconv.Atoi(string(raw)); err == nil && n >= 0 {
return n, limit
}
}
return 0, limit // invalid cursor → first page, fail soft
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
return (page - 1) * limit, limit
}
// paginationMeta renders the standard envelope: legacy page fields plus
// next_cursor when another page exists.
func paginationMeta(offset, limit, total int) gin.H {
page := offset/limit + 1
meta := gin.H{
"page": page,
"limit": limit,
"total": total,
"pages": (total + limit - 1) / limit,
}
if offset+limit < total {
meta["next_cursor"] = "o_" + base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprint(offset+limit)))
}
return meta
}
@@ -0,0 +1,55 @@
package api
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func ctxWithQuery(q string) *gin.Context {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/x?"+q, nil)
return c
}
func TestPageWindowDefaults(t *testing.T) {
offset, limit := pageWindow(ctxWithQuery(""), 10, 100)
if offset != 0 || limit != 10 {
t.Fatalf("expected 0/10, got %d/%d", offset, limit)
}
}
func TestPageWindowLegacyPage(t *testing.T) {
offset, limit := pageWindow(ctxWithQuery("page=3&limit=20"), 10, 100)
if offset != 40 || limit != 20 {
t.Fatalf("expected 40/20, got %d/%d", offset, limit)
}
}
func TestPageWindowCursor(t *testing.T) {
// cursor for offset 50 produced by paginationMeta
meta := paginationMeta(0, 50, 200)
cursor := meta["next_cursor"].(string)
offset, limit := pageWindow(ctxWithQuery("cursor="+cursor+"&limit=50"), 10, 100)
if offset != 50 || limit != 50 {
t.Fatalf("expected 50/50, got %d/%d", offset, limit)
}
}
func TestPageWindowInvalidCursorFailsSoft(t *testing.T) {
offset, _ := pageWindow(ctxWithQuery("cursor=garbage"), 10, 100)
if offset != 0 {
t.Fatalf("expected offset 0 on invalid cursor, got %d", offset)
}
}
func TestPaginationMetaNextCursorOnlyWhenMore(t *testing.T) {
if _, ok := paginationMeta(0, 10, 10)["next_cursor"]; ok {
t.Fatal("no next page should omit next_cursor")
}
if _, ok := paginationMeta(0, 10, 25)["next_cursor"]; !ok {
t.Fatal("expected next_cursor when more pages exist")
}
}
+317 -135
View File
@@ -2,7 +2,12 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/deployqueue"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strings"
@@ -26,6 +31,10 @@ type PreviewEnvironment struct {
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// PreviewServiceID is the cloned service this preview actually deploys;
// its status is the source of truth while the preview is active.
PreviewServiceID *uuid.UUID `json:"preview_service_id,omitempty" db:"preview_service_id"`
// Related data
Service *Service `json:"service,omitempty"`
DeploymentID *uuid.UUID `json:"deployment_id,omitempty"`
@@ -69,39 +78,21 @@ func handleGetPreviewEnvironments(c *gin.Context) {
return
}
// Check if project exists and user has access
var project Project
err = db.(*database.DB).QueryRow(
"SELECT id, name, owner_id FROM projects WHERE id = $1",
projectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
if err != nil {
// Approved projects are public; unapproved ones need owner/member/admin.
projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID)
if !projectExists || !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
// Get user ID from JWT token (set by auth middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Get preview environments for the project with service info
rows, err := db.(*database.DB).Query(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
`SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
WHERE pe.project_id = $1
WHERE pe.project_id = $1
ORDER BY pe.created_at DESC`,
projectID,
)
@@ -119,7 +110,7 @@ func handleGetPreviewEnvironments(c *gin.Context) {
var serviceType sql.NullString
err := rows.Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
&env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
&serviceID, &serviceName, &serviceType,
)
@@ -193,7 +184,7 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
if project.OwnerID != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -233,87 +224,111 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
ttlHours = 24 // Default 24 hours
}
// Create preview environment
// A preview is a real service: clone the source, pin it to the
// requested branch, swap its domains for a preview hostname, and run a
// normal deployment through the queue. Expiry sweeps the clone away.
envName := generatePreviewEnvironmentName(req.BranchName)
cloneName := previewServiceName(service.Name, req.BranchName)
cloneID, err := cloneServiceRow(db.(*database.DB), req.ServiceID, req.ProjectID, cloneName, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service for preview"})
return
}
// The clone's branch override is what makes it a preview — everything
// else (env vars, volumes, builder, resources) stays identical.
if _, err := db.(*database.DB).Exec(
`UPDATE services SET git_branch = $1, domain = '', updated_at = NOW() WHERE id = $2`,
req.BranchName, cloneID,
); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure preview service"})
return
}
// Cloned service_domains would collide with the parent's Traefik
// router — previews get their own hostname or none.
if _, err := db.(*database.DB).Exec(
`DELETE FROM service_domains WHERE service_id = $1`, cloneID,
); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure preview domains"})
return
}
previewHost := resolvePreviewHost(db.(*database.DB), cloneID, envName)
if previewHost != "" {
_, _ = db.(*database.DB).Exec(
`INSERT INTO service_domains (service_id, domain, is_default, cert_type) VALUES ($1, $2, true, 'tls')`,
cloneID, previewHost,
)
}
expiresAt := time.Now().Add(time.Duration(ttlHours) * time.Hour)
env := PreviewEnvironment{
ID: uuid.New(),
ProjectID: req.ProjectID,
ServiceID: req.ServiceID,
BranchName: req.BranchName,
PRNumber: req.PRNumber,
Environment: generatePreviewEnvironmentName(req.BranchName),
Environment: envName,
Status: "building",
ExpiresAt: &[]time.Time{time.Now().Add(time.Duration(ttlHours) * time.Hour)}[0],
ExpiresAt: &expiresAt,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if previewHost != "" {
env.URL = "https://" + previewHost
}
// Insert preview environment into database
_, err = db.(*database.DB).Exec(
`INSERT INTO preview_environments
(id, project_id, service_id, branch_name, pr_number, environment,
`INSERT INTO preview_environments
(id, project_id, service_id, preview_service_id, branch_name, pr_number, environment,
status, url, expires_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
env.ID, env.ProjectID, env.ServiceID, env.BranchName, env.PRNumber,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
env.ID, env.ProjectID, env.ServiceID, cloneID, env.BranchName, env.PRNumber,
env.Environment, env.Status, env.URL, env.ExpiresAt, env.CreatedAt, env.UpdatedAt,
)
if err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create preview environment"})
return
}
deploymentID := uuid.New()
sanitizedBranch := strings.ReplaceAll(req.BranchName, "/", "-")
sanitizedBranch = strings.ReplaceAll(sanitizedBranch, "_", "-")
version := fmt.Sprintf("preview-%s-%d", sanitizedBranch, time.Now().Unix())
startedAt := time.Now().UTC()
completedAt := startedAt
_, deployErr := db.(*database.DB).Exec(
_, err = db.(*database.DB).Exec(
`INSERT INTO deployments
(id, service_id, version, commit_hash, status, started_at, completed_at, deployment_log, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())`,
deploymentID,
req.ServiceID,
version,
req.BranchName,
"running",
startedAt,
completedAt,
fmt.Sprintf("Preview environment %s activated for branch %s", env.Environment, req.BranchName),
(id, service_id, version, commit_hash, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW())`,
deploymentID, cloneID, "preview-"+env.Environment, req.BranchName,
)
if deployErr != nil {
_, _ = db.(*database.DB).Exec(
`UPDATE preview_environments
SET status = 'failed', updated_at = NOW()
WHERE id = $1`,
env.ID,
)
if err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM preview_environments WHERE id = $1`, env.ID)
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to provision preview deployment"})
return
}
if strings.TrimSpace(env.URL) == "" {
env.URL = fmt.Sprintf("https://%s.preview.containr.local", env.Environment)
}
env.Status = "running"
env.UpdatedAt = time.Now().UTC()
env.DeploymentID = &deploymentID
_, err = db.(*database.DB).Exec(
`UPDATE preview_environments
SET status = $1, url = $2, updated_at = $3
WHERE id = $4`,
env.Status,
env.URL,
env.UpdatedAt,
env.ID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to finalize preview environment"})
return
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
cloneService, loadErr := loadServiceForDeploy(db.(*database.DB), cloneID)
if loadErr == nil {
engineInstance := engine
d := DeploymentModel{ID: deploymentID, ServiceID: cloneID, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now()}
getDeployQueue(c).Enqueue(cloneID, deployqueue.Job{
DeploymentID: deploymentID,
Run: func(jctx context.Context) {
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, cloneService, CreateDeploymentRequest{Branch: req.BranchName, Trigger: "preview"}, userID.(string))
},
})
}
}
}
LogAuditWithRequest(c, "service", req.ServiceID.String(), "preview.create", map[string]interface{}{
"preview_environment_id": env.ID.String(),
"preview_service_id": cloneID.String(),
"branch": req.BranchName,
})
c.JSON(http.StatusCreated, gin.H{"preview_environment": env})
}
@@ -332,29 +347,24 @@ func handleGetPreviewEnvironment(c *gin.Context) {
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Get preview environment with project ownership check
// Public when the parent project is approved; otherwise owner/member/admin.
userID := optionalUserUUID(c)
var env PreviewEnvironment
var serviceID sql.NullString
var serviceName sql.NullString
var serviceType sql.NullString
err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
`SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`,
envID, userID,
WHERE pe.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
envID, userID, contextIsAdmin(c),
).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
&env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
&serviceID, &serviceName, &serviceType,
)
@@ -414,8 +424,8 @@ func handleUpdatePreviewEnvironment(c *gin.Context) {
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`,
envID, userID,
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
envID, userID, contextIsAdmin(c),
).Scan(
&existingEnv.ID, &existingEnv.ProjectID, &existingEnv.ServiceID, &existingEnv.BranchName,
&existingEnv.PRNumber, &existingEnv.Environment, &existingEnv.Status, &existingEnv.URL,
@@ -498,27 +508,24 @@ func handleDeletePreviewEnvironment(c *gin.Context) {
}
// Check if user owns the project
if projectOwnerID != userID.(string) {
if projectOwnerID != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
cleanupID := uuid.New()
cleanupStartedAt := time.Now().UTC()
cleanupCompletedAt := cleanupStartedAt
_, _ = db.(*database.DB).Exec(
`INSERT INTO deployments
(id, service_id, version, status, started_at, completed_at, deployment_log, created_at, updated_at)
VALUES ($1, (SELECT service_id FROM preview_environments WHERE id = $2), $3, $4, $5, $6, $7, NOW(), NOW())`,
cleanupID,
envID,
"preview-cleanup",
"rolled_back",
cleanupStartedAt,
cleanupCompletedAt,
"Preview environment resources cleaned up",
)
// Tear down the clone's runtime and row — the preview is the clone.
var cloneID *uuid.UUID
_ = db.(*database.DB).QueryRow(
`SELECT preview_service_id FROM preview_environments WHERE id = $1`, envID,
).Scan(&cloneID)
if cloneID != nil {
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, *cloneID)
}
}
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *cloneID)
}
// Delete preview environment
_, err = db.(*database.DB).Exec(
@@ -565,13 +572,13 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
// Get preview environment details
var env PreviewEnvironment
err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status
`SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.environment, pe.status
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`,
envID, userID,
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
envID, userID, contextIsAdmin(c),
).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status,
&env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.Environment, &env.Status,
)
if err != nil {
@@ -585,38 +592,53 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
return
}
// Promote is a real redeploy: the source service builds the preview's
// branch onto its own deployment track.
deploymentID := uuid.New()
promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix())
_, err = db.(*database.DB).Exec(
`INSERT INTO deployments (id, service_id, version, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())`,
deploymentID, env.ServiceID, promotionVersion, "pending",
`INSERT INTO deployments (id, service_id, version, commit_hash, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW())`,
deploymentID, env.ServiceID, promotionVersion, env.BranchName,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"})
return
}
if _, err := db.(*database.DB).Exec(
`UPDATE services
SET environment = $1, updated_at = NOW()
WHERE id = $2`,
req.TargetEnvironment, env.ServiceID,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service target environment"})
sourceService, loadErr := loadServiceForDeploy(db.(*database.DB), env.ServiceID)
if loadErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load promotion target service"})
return
}
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
engineInstance := engine
d := DeploymentModel{ID: deploymentID, ServiceID: env.ServiceID, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now()}
getDeployQueue(c).Enqueue(env.ServiceID, deployqueue.Job{
DeploymentID: deploymentID,
Run: func(jctx context.Context) {
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, sourceService, CreateDeploymentRequest{Branch: env.BranchName, Trigger: "promote"}, userID.(string))
},
})
}
}
previewStatus := "stopped"
if req.CreateBackup {
previewStatus = "expired"
// Promotion retires the preview — the clone's containers and row go.
if env.PreviewServiceID != nil {
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, *env.PreviewServiceID)
}
}
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *env.PreviewServiceID)
}
if _, err := db.(*database.DB).Exec(
`UPDATE preview_environments
SET status = $1, updated_at = NOW()
WHERE id = $2`,
previewStatus, env.ID,
SET status = 'stopped', updated_at = NOW()
WHERE id = $1`,
env.ID,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment status"})
return
@@ -631,7 +653,7 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
"create_backup": req.CreateBackup,
"deployment_id": deploymentID,
"status": "queued",
"preview_status": previewStatus,
"preview_status": "stopped",
},
})
}
@@ -662,11 +684,11 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
// Find expired preview environments for user's projects
rows, err := db.(*database.DB).Query(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment
`SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.environment
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE p.owner_id = $1 AND pe.expires_at < NOW() AND pe.status != 'expired'`,
userID,
WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`,
userID, contextIsAdmin(c),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find expired preview environments"})
@@ -678,7 +700,7 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
for rows.Next() {
var env PreviewEnvironment
err := rows.Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment,
&env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.Environment,
)
if err != nil {
continue
@@ -686,10 +708,9 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
expiredEnvs = append(expiredEnvs, env)
}
// Mark expired environments as expired and trigger cleanup
// Mark expired environments as expired and tear down their clones
cleanupCount := 0
for _, env := range expiredEnvs {
// Update status to expired
_, err := db.(*database.DB).Exec(
"UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1",
env.ID,
@@ -697,7 +718,14 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
if err != nil {
continue
}
if env.PreviewServiceID != nil {
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, *env.PreviewServiceID)
}
}
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *env.PreviewServiceID)
}
cleanupCount++
}
@@ -707,3 +735,157 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
"expired_environments": expiredEnvs,
})
}
// previewServiceName builds the clone's service name — deterministic enough
// to spot in lists, short enough for dnsLabel-derived hostnames.
func previewServiceName(serviceName, branch string) string {
label := dnsLabel(serviceName + "-" + branch)
if label == "" {
return "preview-" + uuid.NewString()[:8]
}
return label + "-preview"
}
// resolvePreviewHost picks a preview hostname under the clone's node default
// domain — same source as serviceAutoDomains. Returns "" when no node
// carries a default domain; the preview still runs, reachable internally.
func resolvePreviewHost(db *database.DB, cloneID uuid.UUID, envName string) string {
label := dnsLabel(envName)
if label == "" {
return ""
}
var nodeID sql.NullString
var spread bool
if err := db.QueryRow(
`SELECT node_id, COALESCE(spread, false) FROM services WHERE id = $1`, cloneID,
).Scan(&nodeID, &spread); err != nil {
return ""
}
q := sqlcdb.New(db.DB)
ctx := context.Background()
var base string
switch {
case nodeID.Valid && nodeID.String != "":
if a, err := q.GetAgent(ctx, nodeID.String); err == nil {
base = a.DefaultDomain
}
case spread:
if bases, err := q.ListSchedulableAgentDomains(ctx, json.RawMessage(tagsJSON(servicePlacementTags(db, cloneID)))); err == nil && len(bases) > 0 {
base = bases[0]
}
default:
// Local preview: borrow the first node default domain if any —
// still better than no hostname for a Traefik-fronted local host.
if bases, err := q.ListSchedulableAgentDomains(ctx, json.RawMessage("[]")); err == nil && len(bases) > 0 {
base = bases[0]
}
}
if base == "" {
return ""
}
return label + "." + base
}
// loadServiceForDeploy hydrates the Service fields runDeploymentAndSync
// needs — mirrors the loader in handleDeployService.
func loadServiceForDeploy(db *database.DB, serviceID uuid.UUID) (Service, error) {
var service Service
err := db.QueryRow(
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at
FROM services s
WHERE s.id = $1`,
serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt,
)
return service, err
}
// StartPreviewSweeper expires preview environments past their TTL — tears
// down each clone's runtime and row, marks the preview expired.
func StartPreviewSweeper(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine) {
if db == nil || engine == nil {
return
}
go func() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
sweepExpiredPreviews(ctx, db, engine)
syncPreviewStatuses(ctx, db)
}
}
}()
}
// sweepExpiredPreviews retires previews whose TTL elapsed.
func sweepExpiredPreviews(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine) {
rows, err := db.QueryContext(ctx,
`SELECT id, preview_service_id FROM preview_environments
WHERE expires_at < NOW() AND status NOT IN ('expired', 'stopped')`)
if err != nil {
return
}
var ids []uuid.UUID
var clones []uuid.UUID
for rows.Next() {
var id uuid.UUID
var cloneID *uuid.UUID
if err := rows.Scan(&id, &cloneID); err == nil {
ids = append(ids, id)
if cloneID != nil {
clones = append(clones, *cloneID)
}
}
}
rows.Close()
for _, cloneID := range clones {
removeServiceRuntime(ctx, db, engine, cloneID)
_, _ = db.ExecContext(ctx, `DELETE FROM services WHERE id = $1`, cloneID)
}
for _, id := range ids {
_, _ = db.ExecContext(ctx,
`UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1`, id)
}
}
// syncPreviewStatuses folds the clone's live service status into the
// preview row so list/detail endpoints reflect the real deploy state.
func syncPreviewStatuses(ctx context.Context, db *database.DB) {
_, _ = db.ExecContext(ctx, `
UPDATE preview_environments pe
SET status = derived.st, updated_at = NOW()
FROM (
SELECT pe2.id,
CASE
WHEN s.status IN ('running', 'deployed') THEN 'running'
WHEN s.status IN ('failed', 'error') THEN 'failed'
WHEN s.status IN ('building', 'deploying', 'queued', 'pending', 'cloning') THEN 'building'
ELSE pe2.status
END AS st
FROM preview_environments pe2
JOIN services s ON pe2.preview_service_id = s.id
) derived
WHERE pe.id = derived.id
AND pe.status IN ('building', 'running', 'failed')
AND pe.status <> derived.st
`)
}
@@ -0,0 +1,30 @@
package api
import (
"strings"
"testing"
)
func TestPreviewServiceName(t *testing.T) {
cases := []struct {
svc, branch string
want string
}{
{"whoami-web", "feature-x", "whoami-web-feature-x-preview"},
{"My App", "feat/JARV-123_foo", "my-app-feat-jarv-123-foo-preview"},
{"svc", "", "svc-preview"},
}
for _, tc := range cases {
got := previewServiceName(tc.svc, tc.branch)
if got != tc.want {
t.Fatalf("previewServiceName(%q,%q) = %q, want %q", tc.svc, tc.branch, got, tc.want)
}
if len(got) > 63 {
t.Fatalf("name exceeds DNS label limit: %q", got)
}
}
// A name that sanitizes to nothing must still be usable.
if got := previewServiceName("!!!", "%%%"); !strings.HasPrefix(got, "preview-") {
t.Fatalf("expected fallback name, got %q", got)
}
}
@@ -86,3 +86,19 @@ func TestRequireAuthenticatedUserIDRejectsMissingUserContext(t *testing.T) {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
}
}
func TestRecentDeploymentsAllowAnonymousRead(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/v1/deployments", handleGetRecentDeployments)
req := httptest.NewRequest(http.MethodGet, "/api/v1/deployments", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
// Deployments are publicly readable; with no db in context the handler
// fails at the dependency boundary, never at an auth check.
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, rec.Code)
}
}
+56 -33
View File
@@ -3,10 +3,12 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"context"
"database/sql"
"errors"
"log"
"net/http"
"strconv"
"strings"
"time"
@@ -19,6 +21,7 @@ type Project struct {
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
IsApproved bool `json:"is_approved"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
@@ -46,26 +49,15 @@ type UpdateProjectRequest struct {
}
func handleGetProjects(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
userID := optionalUserUUID(c)
isAdmin := contextIsAdmin(c)
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
offset, limit := pageWindow(c, 10, 100)
search := strings.TrimSpace(c.DefaultQuery("search", ""))
if page < 1 {
page = 1
}
if limit > 100 || limit < 1 {
limit = 10
}
offset := (page - 1) * limit
searchParam := sql.NullString{}
if search != "" {
searchParam = sql.NullString{String: search, Valid: true}
@@ -73,6 +65,7 @@ func handleGetProjects(c *gin.Context) {
rows, err := queries.ListProjectsWithStatsByUser(c.Request.Context(), sqlcdb.ListProjectsWithStatsByUserParams{
UserID: userID,
IsAdmin: isAdmin,
Search: searchParam,
LimitCount: int32(limit),
OffsetCount: int32(offset),
@@ -88,23 +81,18 @@ func handleGetProjects(c *gin.Context) {
}
total, err := queries.CountProjectsByUser(c.Request.Context(), sqlcdb.CountProjectsByUserParams{
UserID: userID,
Search: searchParam,
UserID: userID,
IsAdmin: isAdmin,
Search: searchParam,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database count error"})
return
}
totalCount := int(total)
c.JSON(http.StatusOK, gin.H{
"projects": projects,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": totalCount,
"pages": (totalCount + limit - 1) / limit,
},
"projects": projects,
"pagination": paginationMeta(offset, limit, int(total)),
})
}
@@ -165,14 +153,12 @@ 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))
}
func handleGetProject(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
userID := optionalUserUUID(c)
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
@@ -185,6 +171,7 @@ func handleGetProject(c *gin.Context) {
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
ProjectID: projectID,
UserID: userID,
IsAdmin: contextIsAdmin(c),
})
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
@@ -215,6 +202,7 @@ func handleUpdateProject(c *gin.Context) {
role, err := queries.GetProjectRoleForUser(c.Request.Context(), sqlcdb.GetProjectRoleForUserParams{
ProjectID: projectID,
UserID: userID,
IsAdmin: contextIsAdmin(c),
})
if errors.Is(err, sql.ErrNoRows) || role == "" || role == "viewer" {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
@@ -268,6 +256,7 @@ func handleUpdateProject(c *gin.Context) {
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
ProjectID: projectID,
UserID: userID,
IsAdmin: contextIsAdmin(c),
})
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
@@ -305,11 +294,43 @@ func handleDeleteProject(c *gin.Context) {
return
}
if ownerID != userID {
if ownerID != userID && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Only project owners can delete projects"})
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 {
engine := engineValue.(*deployment.DeploymentEngine)
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
_ = engine.RemoveProjectContainers(ctx, projectID.String())
_ = engine.RemoveProjectNetwork(ctx, projectID.String())
cancel()
}
deletedRows, err := queries.DeleteProjectByID(c.Request.Context(), projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete project"})
@@ -320,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"})
}
@@ -349,7 +371,7 @@ func parseProjectIDParam(c *gin.Context) (uuid.UUID, bool) {
func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWithStats {
result := ProjectWithStats{
Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.CreatedAt, row.UpdatedAt),
Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.IsApproved, row.CreatedAt, row.UpdatedAt),
Stats: ProjectStats{
ServiceCount: int(row.ServiceCount),
DeploymentCount: int(row.DeploymentCount),
@@ -361,15 +383,16 @@ func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWi
}
func mapSQLCProject(project sqlcdb.Project) Project {
return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.CreatedAt, project.UpdatedAt)
return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.IsApproved, project.CreatedAt, project.UpdatedAt)
}
func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, createdAt, updatedAt sql.NullTime) Project {
func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, isApproved bool, createdAt, updatedAt sql.NullTime) Project {
return Project{
ID: id.String(),
Name: name,
Description: nullableStringValue(description),
OwnerID: ownerID.String(),
IsApproved: isApproved,
CreatedAt: nullableTimestampString(createdAt),
UpdatedAt: nullableTimestampString(updatedAt),
}
+180
View File
@@ -0,0 +1,180 @@
package api
import (
"net/http"
"time"
"containr/internal/database"
"containr/internal/secrets"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Project-level shared variables — referenced from service env values as
// ${{shared.KEY}} and resolved at runtime in resolveServiceEnv.
type ProjectVariable struct {
ID uuid.UUID `json:"id" db:"id"`
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
Key string `json:"key" db:"key"`
Value string `json:"value" db:"value"`
IsSecret bool `json:"is_secret" db:"is_secret"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
func handleGetProjectVariables(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
rows, err := db.(*database.DB).Query(
`SELECT id, project_id, key, value, is_secret, created_at, updated_at
FROM project_variables WHERE project_id = $1 ORDER BY key ASC`,
projectID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
return
}
defer rows.Close()
variables := []ProjectVariable{}
for rows.Next() {
var v ProjectVariable
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan variable"})
return
}
if v.IsSecret {
v.Value = maskedSecretValue
}
variables = append(variables, v)
}
c.JSON(http.StatusOK, gin.H{"variables": variables})
}
func handleUpdateProjectVariables(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var req UpdateVariablesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
storedSecrets := map[string]string{}
secretRows, err := db.(*database.DB).Query(
`SELECT key, value FROM project_variables WHERE project_id = $1 AND is_secret = TRUE`,
projectID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load existing variables"})
return
}
for secretRows.Next() {
var k, v string
if err := secretRows.Scan(&k, &v); err == nil {
storedSecrets[k] = v
}
}
secretRows.Close()
tx, err := db.(*database.DB).Begin()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
return
}
defer tx.Rollback()
if _, err = tx.Exec(`DELETE FROM project_variables WHERE project_id = $1`, projectID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
return
}
now := time.Now()
for _, v := range req.Variables {
value := v.Value
if v.IsSecret && value == maskedSecretValue {
if stored, ok := storedSecrets[v.Key]; ok {
value = stored
}
}
if v.IsSecret {
value = secrets.Encrypt(value)
}
if _, err = tx.Exec(
`INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
uuid.New(), projectID, v.Key, value, v.IsSecret, now, now,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
return
}
}
if err = tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
return
}
rows, err := db.(*database.DB).Query(
`SELECT id, project_id, key, value, is_secret, created_at, updated_at
FROM project_variables WHERE project_id = $1 ORDER BY key ASC`,
projectID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
return
}
defer rows.Close()
variables := []ProjectVariable{}
for rows.Next() {
var v ProjectVariable
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt); err != nil {
continue
}
if v.IsSecret {
v.Value = maskedSecretValue
}
variables = append(variables, v)
}
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Project variables updated successfully"})
}
// resolveSharedVar returns the decrypted value of a project variable.
func resolveSharedVar(db *database.DB, projectID uuid.UUID, key string) (string, bool) {
var value string
err := db.QueryRow(
`SELECT value FROM project_variables WHERE project_id = $1 AND key = $2`,
projectID, key,
).Scan(&value)
if err != nil {
return "", false
}
return secrets.Decrypt(value), true
}
+193
View File
@@ -0,0 +1,193 @@
package api
import (
"net/http"
"strings"
"time"
"containr/internal/database"
"containr/internal/secrets"
"github.com/docker/docker/api/types/registry"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Registry is a stored credential for pulling private images. Password is
// encrypted at rest and never returned — `has_password` signals presence.
type Registry struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Host string `json:"host"` // e.g. ghcr.io, registry.example.com; docker.io for Docker Hub
Username string `json:"username"`
HasPassword bool `json:"has_password"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type registryRequest struct {
Name string `json:"name" binding:"required,min=1,max=255"`
Host string `json:"host" binding:"required,min=1,max=255"`
Username string `json:"username"`
Password string `json:"password"`
}
func handleListRegistries(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
rows, err := db.(*database.DB).Query(
`SELECT id, name, host, username, password <> '', created_at, updated_at
FROM registries WHERE owner_id = $1 ORDER BY created_at DESC`,
userID.(string),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list registries"})
return
}
defer rows.Close()
registries := []Registry{}
for rows.Next() {
var r Registry
if err := rows.Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt); err == nil {
registries = append(registries, r)
}
}
c.JSON(http.StatusOK, gin.H{"registries": registries})
}
func handleCreateRegistry(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
var req registryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
req.Host = strings.TrimSuffix(req.Host, "/")
// Encrypt only a real credential — encrypting "" would still satisfy
// `password <> ''` and report has_password for anonymous registries.
password := ""
if req.Password != "" {
password = secrets.Encrypt(req.Password)
}
var r Registry
err := db.(*database.DB).QueryRow(
`INSERT INTO registries (owner_id, name, host, username, password)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
userID.(string), req.Name, req.Host, req.Username, password,
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
if err != nil {
if strings.Contains(err.Error(), "unique") {
c.JSON(http.StatusConflict, gin.H{"error": "A registry for this host already exists", "code": "CONFLICT"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create registry"})
return
}
c.JSON(http.StatusCreated, gin.H{"registry": r})
}
func handleUpdateRegistry(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
registryID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
return
}
var req registryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
req.Host = strings.TrimSuffix(req.Host, "/")
// Empty password keeps the stored credential — there is no "clear"
// operation; delete + recreate to remove a password.
var passwordArg interface{}
if req.Password != "" {
passwordArg = secrets.Encrypt(req.Password)
}
var r Registry
err = db.(*database.DB).QueryRow(
`UPDATE registries SET name = $1, host = $2, username = $3,
password = COALESCE($4, password), updated_at = now()
WHERE id = $5 AND owner_id = $6
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
req.Name, req.Host, req.Username, passwordArg, registryID, userID.(string),
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
return
}
c.JSON(http.StatusOK, gin.H{"registry": r})
}
func handleDeleteRegistry(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
registryID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
return
}
res, err := db.(*database.DB).Exec(
`DELETE FROM registries WHERE id = $1 AND owner_id = $2`,
registryID, userID.(string),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete registry"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Registry deleted"})
}
// registryAuthFor resolves pull credentials for an image reference: the
// caller's registry whose host matches, else anonymous. docker.io covers
// bare image names (nginx:latest).
func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig {
host := registryHost(imageRef)
var username, password string
err := db.QueryRow(
`SELECT username, password FROM registries WHERE owner_id = $1 AND host = $2`,
ownerID, host,
).Scan(&username, &password)
if err != nil {
return registry.AuthConfig{}
}
return registry.AuthConfig{
Username: username,
Password: secrets.Decrypt(password),
ServerAddress: host,
}
}
@@ -0,0 +1,48 @@
package api
import "testing"
func TestParseCPUReserveShares(t *testing.T) {
cases := map[string]int64{
"": 0,
"0": 0,
"-1": 0,
"abc": 0,
"0.25": 256,
"1": 1024,
"2.5": 2560,
" 0.5 ": 512,
}
for in, want := range cases {
if got := parseCPUReserveShares(in); got != want {
t.Errorf("parseCPUReserveShares(%q) = %d, want %d", in, got, want)
}
}
}
func TestValidateResourceSpecs(t *testing.T) {
if err := validateCPUSpec(""); err != nil {
t.Error("empty cpu should pass")
}
if err := validateCPUSpec("0.5"); err != nil {
t.Error("0.5 should be valid cpu")
}
if err := validateCPUSpec("512mb"); err == nil {
t.Error("512mb should fail cpu validation")
}
if err := validateMemorySpec(""); err != nil {
t.Error("empty memory should pass")
}
if err := validateMemorySpec("512Mi"); err != nil {
t.Error("512Mi should be valid memory")
}
if err := validateMemorySpec("1g"); err != nil {
t.Error("1g should be valid memory")
}
if err := validateMemorySpec("0.5"); err == nil {
t.Error("0.5 should fail memory validation (parses to <1 byte)")
}
if err := validateMemorySpec("lots"); err == nil {
t.Error("'lots' should fail memory validation")
}
}
+366 -122
View File
@@ -4,22 +4,31 @@ import (
"context"
"log"
"net/http"
"os"
"path/filepath"
"time"
"containr/internal/build"
"containr/internal/config"
"containr/internal/database"
"containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/docker"
"containr/internal/ha"
"containr/internal/metrics"
"containr/internal/middleware"
"containr/internal/scaling"
"containr/internal/secrets"
"github.com/gin-gonic/gin"
)
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)
@@ -35,6 +44,13 @@ 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)
}
// Initialize build handler
@@ -55,6 +71,33 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
}
}
haManager := ha.NewHighAvailabilityManager(scheduler, metricsCollector)
if db != nil && db.DB != nil {
haManager.WithPersistence(db.DB)
if err := haManager.LoadHealthChecks(context.Background()); err != nil {
log.Printf("Failed to restore health checks: %v", err)
}
}
if dockerClient != nil && db != nil {
// Service-bound checks probe the service DNS name on the project
// network — the backend container joins it on demand. The old
// 127.0.0.1 fallback stays for node-bound checks.
docker := dockerClient
haManager.WithServiceResolver(func(ctx context.Context, serviceID string) (string, int, error) {
var name, projectID string
var port int
err := db.QueryRowContext(ctx,
`SELECT name, project_id::text, COALESCE(port, 0) FROM services WHERE id = $1`,
serviceID,
).Scan(&name, &projectID, &port)
if err != nil {
return "", 0, err
}
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(projectID)); err != nil {
return "", 0, err
}
return name, port, nil
})
}
haAPIManager := NewHAManager(haManager)
// Initialize scaling handler
@@ -66,9 +109,62 @@ 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)
StartPreviewSweeper(context.Background(), db, deploymentEngine)
// Audit retention — prunes rows past AUDIT_RETENTION_DAYS (default 90).
StartAuditRetention(context.Background(), db)
// Agent health — marks stale heartbeats offline, notifies admins on
// offline/recovery transitions.
StartAgentHealthSweep(context.Background(), db)
// Daily upgrade check — notifies admins when a newer release exists.
StartUpgradeCheck(context.Background(), db, Version)
// Initialize database handler
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
// Per-service serialized deploy queue — one active deployment per
// service, the rest wait as 'queued'.
deployQueue := deployqueue.New()
// Encrypt-at-rest for secret env vars. SECRETS_KEY wins, JWT_SECRET is
// the fallback so existing installs need no new config.
secrets.Configure(os.Getenv("SECRETS_KEY"), cfg.JWTSecret)
// Boot sweep: in-memory engine state dies on restart, so anything that
// looked in-flight is actually orphaned — mark it failed rather than
// leaving phantom 'building'/'queued' rows.
_, _ = db.Exec(
`UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW()
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back')`,
)
// Boot sweep: encrypt any plaintext secret values left over from before
// encryption-at-rest landed. Transparent and idempotent.
if encRows, err := db.Query(`SELECT id, value FROM environment_variables WHERE is_secret = TRUE`); err == nil {
type encRow struct {
id string
value string
}
var toEncrypt []encRow
for encRows.Next() {
var r encRow
if err := encRows.Scan(&r.id, &r.value); err == nil && !secrets.IsEncrypted(r.value) {
toEncrypt = append(toEncrypt, r)
}
}
encRows.Close()
for _, r := range toEncrypt {
_, _ = db.Exec(`UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2`, secrets.Encrypt(r.value), r.id)
}
if len(toEncrypt) > 0 {
log.Printf("secrets: encrypted %d plaintext secret variable(s)", len(toEncrypt))
}
}
// Initialize security handler
securityHandler := NewSecurityHandler(db, cfg.JWTSecret)
@@ -86,6 +182,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
if deploymentEngine != nil {
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)
@@ -94,6 +192,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
c.Next()
})
// Seed the official service template catalog (idempotent upserts).
go SeedOfficialTemplates(context.Background(), db)
go func() {
if err := haManager.Start(context.Background()); err != nil {
log.Printf("HA manager exited: %v", err)
@@ -134,7 +235,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
if redis == nil {
redisStatus = "unhealthy"
checks["redis"] = redisStatus
checks["redis"] = "unhealthy"
checks["redisError"] = "redis client not initialized"
overallStatus = "degraded"
statusCode = http.StatusServiceUnavailable
@@ -165,6 +266,16 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// replaces session auth. Providers POST to /api/git/webhooks/:id.
router.POST("/api/git/webhooks/:id", handleGitWebhookPush)
// GitHub App-level webhook: installed repos deliver to this single
// endpoint, verified against the provisioned webhook secret.
router.POST("/api/git/github-app/webhook", handleGitHubAppWebhook)
// Public API gateway data plane. Lives outside /api/v1 so each service's
// route_prefix (/g/<slug>) maps cleanly; auth is the caller's api_key,
// not a session.
router.Any("/g/:slug", handleGatewayProxy)
router.Any("/g/:slug/*path", handleGatewayProxy)
v1 := router.Group("/api/v1")
{
// Public routes (no authentication required)
@@ -173,153 +284,286 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.GET("/auth/bootstrap", handleAuthBootstrap)
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)
}
// Protected routes (authentication required)
protected := v1.Group("/")
protected.Use(middleware.Auth(cfg.JWTSecret))
// Read routes — public browsing. OptionalAuth resolves a session when
// present so handlers can widen visibility for owners/admins; anonymous
// callers see approved resources only.
read := v1.Group("/")
read.Use(middleware.OptionalAuth(cfg.JWTSecret))
{
// User routes
protected.GET("/user/profile", handleGetProfile)
protected.PUT("/user/profile", handleUpdateProfile)
protected.POST("/users", handleCreateUser)
read.GET("/projects", handleGetProjects)
read.GET("/projects/:id", handleGetProject)
read.GET("/projects/:id/services", handleGetServices)
read.GET("/projects/:id/environments", handleListEnvironments)
read.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
read.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
read.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
read.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
// Project routes
protected.GET("/projects", handleGetProjects)
protected.POST("/projects", handleCreateProject)
read.GET("/services/:id", handleGetService)
read.GET("/services/:id/metrics", handleGetServiceMetrics)
read.GET("/services/:id/runtime", handleGetServiceRuntime)
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)
// Service routes (nested under projects)
protected.GET("/projects/:id/services", handleGetServices)
protected.POST("/projects/:id/services", handleCreateService)
read.GET("/databases", databaseHandler.GetDatabases)
read.GET("/databases/:id", databaseHandler.GetDatabase)
// Generic project routes
protected.GET("/projects/:id", handleGetProject)
protected.PUT("/projects/:id", handleUpdateProject)
protected.DELETE("/projects/:id", handleDeleteProject)
read.GET("/templates", handleGetTemplates)
read.GET("/templates/:id", handleGetTemplate)
// Service routes
protected.GET("/services/:id", handleGetService)
protected.PUT("/services/:id", handleUpdateService)
protected.DELETE("/services/:id", handleDeleteService)
protected.GET("/services/:id/metrics", handleGetServiceMetrics)
read.GET("/cron-jobs", handleGetCronJobs)
read.GET("/cron-jobs/:id", handleGetCronJob)
read.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
// Deployment routes
protected.GET("/services/:id/deployments", handleGetDeployments)
protected.POST("/services/:id/deployments", handleCreateDeployment)
protected.GET("/deployments/:id", handleGetDeployment)
protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
read.GET("/security/scans/:id", securityHandler.GetSecurityScan)
read.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
read.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
// Environment variables routes
protected.GET("/services/:id/variables", handleGetVariables)
protected.PUT("/services/:id/variables", handleUpdateVariables)
read.GET("/preview-environments/:id", handleGetPreviewEnvironment)
// Logs routes
protected.GET("/services/:id/logs", handleGetLogs)
protected.GET("/deployments/:id/logs", handleGetDeploymentLogs)
read.GET("/system/upgrade/status", handleGetUpgradeStatus)
read.GET("/system/host", handleGetHostMonitoring)
// One-off exec console (docker exec, 30s ceiling)
protected.POST("/services/:id/exec", handleExecInService)
// HA + scaling read endpoints (status, policies, health, alerts)
haAPIManager.RegisterReadRoutes(read)
scalingHandler.RegisterReadRoutes(read)
}
// Git integration routes
protected.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
protected.POST("/git/github-app/connect", handleConnectGitHubApp)
protected.GET("/git/providers", handleGetGitProviders)
protected.POST("/git/providers", handleCreateGitProvider)
protected.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
protected.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
protected.POST("/git/repositories/connect", handleConnectGitRepository)
protected.GET("/git/repositories", handleGetConnectedRepositories)
protected.POST("/git/webhooks", handleCreateWebhook)
// Authenticated routes — a valid session is required and handlers
// enforce ownership; platform admins additionally pass every check.
authed := v1.Group("/")
authed.Use(middleware.Auth(cfg.JWTSecret))
authed.Use(middleware.Idempotency())
{
authed.GET("/user/profile", handleGetProfile)
authed.PUT("/user/profile", handleUpdateProfile)
// Build routes
protected.POST("/builds", buildHandler.StartBuild)
protected.GET("/builds", buildHandler.ListBuilds)
protected.GET("/builds/:id", buildHandler.GetBuildStatus)
protected.POST("/builds/:id/cancel", buildHandler.CancelBuild)
protected.GET("/builds/:id/logs", buildHandler.GetBuildLogs)
protected.POST("/builds/plan", buildHandler.GetBuildPlan)
protected.GET("/builds/detect", buildHandler.DetectBuildType)
// Personal access tokens for CLI/MCP/agent auth.
authed.GET("/user/tokens", handleListUserTokens)
authed.POST("/user/tokens", handleCreateUserToken)
authed.DELETE("/user/tokens/:id", handleDeleteUserToken)
// System routes
protected.GET("/system/upgrade/status", handleGetUpgradeStatus)
protected.POST("/system/upgrade/pull", handlePullUpgradeImage)
protected.GET("/system/host", handleGetHostMonitoring)
// Scaling routes
scalingHandler.RegisterRoutes(protected)
haAPIManager.RegisterRoutes(protected)
// Database routes
protected.GET("/databases", databaseHandler.GetDatabases)
protected.POST("/databases", databaseHandler.CreateDatabase)
protected.GET("/databases/:id", databaseHandler.GetDatabase)
protected.PUT("/databases/:id", databaseHandler.UpdateDatabase)
protected.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
protected.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
protected.POST("/databases/:id/backup", databaseHandler.CreateBackup)
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
protected.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
// Notification routes
protected.GET("/notifications", handleListNotifications)
protected.POST("/notifications/:id/read", handleMarkNotificationRead)
protected.POST("/notifications/read-all", handleMarkAllNotificationsRead)
// Node Agent routes
agentHandler.SetupRoutes(protected)
// Preview Environments routes
protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
protected.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
protected.GET("/preview-environments/:id", handleGetPreviewEnvironment)
protected.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
protected.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
protected.POST("/preview-environments/:id/promote", handlePromotePreviewEnvironment)
protected.POST("/preview-environments/cleanup-expired", handleCleanupExpiredPreviewEnvironments)
// Security routes
protected.POST("/security/scans", securityHandler.StartSecurityScan)
protected.GET("/security/scans/:id", securityHandler.GetSecurityScan)
protected.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
protected.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
protected.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
protected.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
protected.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
protected.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
protected.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
protected.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
protected.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
authed.GET("/notifications", handleListNotifications)
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
authed.GET("/notifications/channels", handleListNotificationChannels)
authed.POST("/notifications/channels", handleCreateNotificationChannel)
authed.POST("/notifications/channels/:id/test", handleTestNotificationChannel)
authed.DELETE("/notifications/channels/:id", handleDeleteNotificationChannel)
// WebSocket endpoint
protected.GET("/ws", handleWebSocket)
authed.GET("/ws", handleWebSocket)
// Templates routes
protected.GET("/templates", handleGetTemplates)
protected.GET("/templates/:id", handleGetTemplate)
protected.POST("/templates/:id/deploy", handleCreateFromTemplate)
// 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)
// Cron Jobs routes
protected.GET("/cron-jobs", handleGetCronJobs)
protected.POST("/cron-jobs", handleCreateCronJob)
protected.GET("/cron-jobs/:id", handleGetCronJob)
protected.PUT("/cron-jobs/:id", handleUpdateCronJob)
protected.DELETE("/cron-jobs/:id", handleDeleteCronJob)
protected.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
protected.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
// Instance announcements shown to all signed-in users
authed.GET("/banners/active", handleListActiveBanners)
// Audit Logs routes
protected.GET("/audit-logs", handleGetAuditLogs)
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
// 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)
authed.PUT("/services/:id", handleUpdateService)
authed.DELETE("/services/:id", handleDeleteService)
authed.POST("/services/:id/domains", handleAddServiceDomain)
authed.DELETE("/services/:id/domains/:domain_id", handleDeleteServiceDomain)
authed.POST("/services/:id/domains/:domain_id/default", handleSetDefaultDomain)
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)
authed.POST("/services/:id/deployments", handleCreateDeployment)
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)
// Project-level shared variables — ${{shared.KEY}} refs.
authed.GET("/projects/:id/variables", handleGetProjectVariables)
authed.PUT("/projects/:id/variables", handleUpdateProjectVariables)
// Runtime logs — container stdout routinely echoes secrets,
// so these stay behind authentication like build logs.
authed.GET("/services/:id/logs", handleGetLogs)
authed.GET("/deployments/:id/logs", handleGetDeploymentLogs)
// 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)
authed.PUT("/registries/:id", handleUpdateRegistry)
authed.DELETE("/registries/:id", handleDeleteRegistry)
// Git integration — providers carry per-user credentials.
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
authed.POST("/git/github-app/connect", handleConnectGitHubApp)
authed.GET("/git/providers", handleGetGitProviders)
authed.POST("/git/providers", handleCreateGitProvider)
authed.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
authed.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
authed.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
authed.POST("/git/repositories/connect", handleConnectGitRepository)
authed.GET("/git/repositories", handleGetConnectedRepositories)
authed.POST("/git/webhooks", handleCreateWebhook)
// Builds have no per-user scoping and logs may carry secrets —
// keep every build endpoint behind authentication.
authed.POST("/builds", buildHandler.StartBuild)
authed.GET("/builds", buildHandler.ListBuilds)
authed.GET("/builds/:id", buildHandler.GetBuildStatus)
authed.GET("/builds/:id/logs", buildHandler.GetBuildLogs)
authed.GET("/builds/detect", buildHandler.DetectBuildType)
authed.POST("/builds/:id/cancel", buildHandler.CancelBuild)
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/environments", handleCreateEnvironment)
authed.DELETE("/environments/:id", handleDeleteEnvironment)
authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
authed.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
authed.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
authed.POST("/preview-environments/:id/promote", handlePromotePreviewEnvironment)
authed.POST("/security/scans", securityHandler.StartSecurityScan)
authed.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
authed.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
authed.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
authed.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
// User templates — ownership enforced inside the handlers.
authed.POST("/templates", handleCreateTemplate)
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)
authed.DELETE("/cron-jobs/:id", handleDeleteCronJob)
authed.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
}
// Admin routes — platform-wide controls and cross-user management.
admin := v1.Group("/")
admin.Use(middleware.Auth(cfg.JWTSecret))
admin.Use(middleware.Idempotency())
admin.Use(middleware.RequireAdmin())
{
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)
admin.GET("/settings", handleGetSettings)
admin.PUT("/settings", handleUpdateSettings)
admin.POST("/system/upgrade/pull", handlePullUpgradeImage)
admin.POST("/preview-environments/cleanup-expired", handleCleanupExpiredPreviewEnvironments)
// HA + scaling + agent mutations — platform-level controls.
haAPIManager.RegisterAdminRoutes(admin)
scalingHandler.RegisterAdminRoutes(admin)
agentHandler.SetupAdminRoutes(admin)
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)
// API Gateway routes (merged APwhy) - namespaced to avoid
// colliding with Containr's own /services paths.
gateway := protected.Group("/gateway")
gateway := admin.Group("/gateway")
{
gateway.GET("/services", handleAPwhyServicesList)
gateway.POST("/services", handleAPwhyServicesCreate)
gateway.PATCH("/services/:id", handleAPwhyServicesPatch)
gateway.GET("/services/:id/validate", handleAPwhyServiceValidate)
gateway.GET("/services/:id", handleAPwhyServiceValidate)
gateway.GET("/keys", handleAPwhyKeysList)
gateway.POST("/keys", handleAPwhyKeysCreate)
+845
View File
@@ -0,0 +1,845 @@
package api
import (
"context"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"containr/internal/database"
"containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/secrets"
"github.com/gin-gonic/gin"
"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) {
env, err := resolveServiceEnv(db, service)
if err != nil {
return deployment.RuntimeSpec{}, err
}
spec := deployment.RuntimeSpec{
ProjectID: service.ProjectID.String(),
ServiceID: service.ID.String(),
Name: service.Name,
Image: service.Image,
Env: env,
Replicas: service.Replicas,
Port: int32(service.Port),
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
// silently get ephemeral ports.
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&spec.PublishedPort)
if cmd := strings.TrimSpace(service.Command); cmd != "" {
spec.Command = strings.Fields(cmd)
}
if spec.Replicas < 1 {
spec.Replicas = 1
}
if spec.RestartPolicy == "" {
spec.RestartPolicy = "unless-stopped"
}
spec.NanoCPUs = parseCPULimit(service.CPU)
spec.MemoryBytes = parseMemoryLimit(service.Memory)
spec.MemoryReserve = parseMemoryLimit(service.MemoryReserve)
spec.CPUShares = parseCPUReserveShares(service.CPUReserve)
if service.Builder == "static" && spec.Port == 0 {
// The static builder's image is nginx; expose its default port so
// Traefik has something to route to without extra config.
spec.Port = 80
}
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)
}
if spec.Port > 0 {
env["PORT"] = strconv.Itoa(service.Port)
}
return spec, nil
}
var refVarPattern = regexp.MustCompile(`\$\{\{\s*([A-Za-z0-9_-]+)\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}`)
// resolveServiceEnv loads the service's variables (real values, including
// secrets) and expands ${{service.KEY}} references. KEY may be a variable on
// the referenced service, or the builtins HOST (service name alias) and PORT.
func resolveServiceEnv(db *database.DB, service Service) (map[string]string, error) {
rows, err := db.Query(
`SELECT key, value FROM environment_variables WHERE service_id = $1`,
service.ID,
)
if err != nil {
return nil, err
}
defer rows.Close()
env := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
env[k] = secrets.Decrypt(v)
}
var refServices []struct{ ID, Name string }
svcRows, err := db.Query(
`SELECT id, name FROM services WHERE project_id = $1`,
service.ProjectID,
)
if err != nil {
return nil, err
}
defer svcRows.Close()
for svcRows.Next() {
var s struct{ ID, Name string }
if err := svcRows.Scan(&s.ID, &s.Name); err != nil {
return nil, err
}
refServices = append(refServices, s)
}
byName := map[string]string{}
for _, s := range refServices {
byName[strings.ToLower(s.Name)] = s.ID
}
resolve := func(serviceName, key string) (string, bool) {
if strings.EqualFold(serviceName, "shared") {
return resolveSharedVar(db, service.ProjectID, key)
}
id, ok := byName[strings.ToLower(serviceName)]
if !ok {
return "", false
}
switch strings.ToUpper(key) {
case "HOST":
return serviceName, true
case "PORT":
var port int
if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil && port > 0 {
return strconv.Itoa(port), true
}
return "", false
}
var value string
err := db.QueryRow(
`SELECT value FROM environment_variables WHERE service_id = $1 AND key = $2`,
id, key,
).Scan(&value)
if err != nil {
return "", false
}
return secrets.Decrypt(value), true
}
for k, v := range env {
env[k] = refVarPattern.ReplaceAllStringFunc(v, func(match string) string {
parts := refVarPattern.FindStringSubmatch(match)
if len(parts) != 3 {
return match
}
if resolved, ok := resolve(parts[1], parts[2]); ok {
return resolved
}
return match
})
}
return env, nil
}
func parseCPULimit(cpu string) int64 {
v, err := strconv.ParseFloat(strings.TrimSpace(cpu), 64)
if err != nil || v <= 0 {
return 0
}
return int64(v * 1e9)
}
// parseCPUReserveShares maps a cpu fraction to Docker's relative share
// weight: 1024 shares ≈ one CPU.
func parseCPUReserveShares(cpu string) int64 {
v, err := strconv.ParseFloat(strings.TrimSpace(cpu), 64)
if err != nil || v <= 0 {
return 0
}
return int64(v * 1024)
}
// validateCPUSpec and validateMemorySpec reject values that silently parse
// to zero — a typo like "512mb" must fail loudly instead of dropping the limit.
func validateCPUSpec(v string) error {
if s := strings.TrimSpace(v); s != "" && parseCPULimit(s) <= 0 {
return fmt.Errorf("must be a positive cpu fraction (e.g. 0.5, 2)")
}
return nil
}
func validateMemorySpec(v string) error {
if s := strings.TrimSpace(v); s != "" && parseMemoryLimit(s) <= 0 {
return fmt.Errorf("must be bytes with unit (e.g. 256Mi, 1g)")
}
return nil
}
var memUnits = map[string]int64{"k": 1e3, "m": 1e6, "g": 1e9, "ki": 1 << 10, "mi": 1 << 20, "gi": 1 << 30}
func parseMemoryLimit(mem string) int64 {
s := strings.ToLower(strings.TrimSpace(mem))
for suffix, mult := range memUnits {
if strings.HasSuffix(s, suffix) {
if v, err := strconv.ParseFloat(strings.TrimSuffix(s, suffix), 64); err == nil {
return int64(v * float64(mult))
}
}
}
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
return v
}
return 0
}
func getRuntimeEngine(c *gin.Context) (*deployment.DeploymentEngine, *database.DB, bool) {
dbValue, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return nil, nil, false
}
engineValue, exists := c.Get("deployment_engine")
if !exists || engineValue == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Deployment engine unavailable. Docker may not be configured on this server."})
return nil, nil, false
}
return engineValue.(*deployment.DeploymentEngine), dbValue.(*database.DB), true
}
func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return Service{}, false
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return Service{}, false
}
var service Service
var owner string
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, p.owner_id
FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &owner,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return Service{}, false
}
if owner != userID.(string) && !isAdminUser(db, userID.(string)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return Service{}, false
}
return service, true
}
// loadReadableService loads a service when the caller may read its project:
// approved projects are public, unapproved ones need owner/member/admin.
func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return Service{}, false
}
var service Service
var projectID uuid.UUID
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, s.project_id
FROM services s
WHERE s.id = $1`,
serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &projectID,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return Service{}, false
}
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return Service{}, false
}
return service, true
}
// handleServiceRuntime returns live container state for a service.
func handleGetServiceRuntime(c *gin.Context) {
engine, db, ok := getRuntimeEngine(c)
if !ok {
return
}
service, ok := loadReadableService(c, db)
if !ok {
return
}
state, err := engine.RuntimeState(c.Request.Context(), service.ID.String(), service.Replicas)
if err != nil {
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 != "" {
for i, u := range state.URLs {
state.URLs[i] = rewriteLoopbackURL(u, host)
}
}
c.JSON(http.StatusOK, gin.H{"runtime": state})
}
func reconcileNow(c *gin.Context, engine *deployment.DeploymentEngine, db *database.DB, service Service) bool {
spec, err := serviceRuntimeSpec(db, service)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
state, err := engine.ReconcileService(ctx, spec)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return false
}
status := "stopped"
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)
c.JSON(http.StatusOK, gin.H{"runtime": state})
return true
}
// handleServiceStart starts all stopped replicas.
func handleServiceStart(c *gin.Context) {
engine, db, ok := getRuntimeEngine(c)
if !ok {
return
}
service, ok := loadOwnedService(c, db)
if !ok {
return
}
if err := serviceLifecycle(c.Request.Context(), db, engine, service.ID, "start"); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
_, _ = db.Exec(`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
c.JSON(http.StatusOK, gin.H{"status": "running"})
}
// handleServiceStop stops all replicas without removing them.
func handleServiceStop(c *gin.Context) {
engine, db, ok := getRuntimeEngine(c)
if !ok {
return
}
service, ok := loadOwnedService(c, db)
if !ok {
return
}
if err := serviceLifecycle(c.Request.Context(), db, engine, service.ID, "stop"); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
_, _ = db.Exec(`UPDATE services SET status = 'stopped', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
c.JSON(http.StatusOK, gin.H{"status": "stopped"})
}
// handleServiceRestart restarts all replicas.
func handleServiceRestart(c *gin.Context) {
engine, db, ok := getRuntimeEngine(c)
if !ok {
return
}
service, ok := loadOwnedService(c, db)
if !ok {
return
}
ctx := c.Request.Context()
if err := serviceLifecycle(ctx, db, engine, service.ID, "restart"); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
_, _ = db.Exec(`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
c.JSON(http.StatusOK, gin.H{"status": "running"})
}
// handleServiceRedeploy re-applies the current spec: pulls the image for
// image-sourced services and reconciles containers (env, ports, replicas).
// Runs through the per-service deploy queue so it never overlaps a build.
func handleServiceRedeploy(c *gin.Context) {
engine, db, ok := getRuntimeEngine(c)
if !ok {
return
}
service, ok := loadOwnedService(c, db)
if !ok {
return
}
image := service.Image
if image == "" {
// Git-sourced service: reuse the last successful deployment image.
err := db.QueryRow(
`SELECT image_name || ':' || image_tag FROM deployments
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
ORDER BY created_at DESC LIMIT 1`,
service.ID,
).Scan(&image)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No deployable image yet — run a deployment first"})
return
}
}
service.Image = image
finished := make(chan struct{})
var recErr error
var state *deployment.RuntimeState
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{Run: func(jctx context.Context) {
defer close(finished)
state, recErr = reconcileServiceJob(jctx, db, engine, service)
}})
select {
case <-finished:
if recErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": recErr.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"runtime": state})
case <-c.Request.Context().Done():
c.JSON(http.StatusAccepted, gin.H{"status": "queued"})
}
}
// reconcileServiceJob is the shared queue-job body for redeploy paths:
// build the spec from current DB state, reconcile containers, persist the
// resulting status and published port.
func reconcileServiceJob(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, service Service) (*deployment.RuntimeState, error) {
spec, err := serviceRuntimeSpec(db, service)
if err != nil {
return nil, err
}
state, err := engine.ReconcileService(ctx, spec)
if err != nil {
return nil, err
}
if state != nil {
persistPublishedPort(db, service.ID, state.Ports)
status := "stopped"
if state.Status != "" {
status = state.Status
}
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
}
return state, nil
}
// enqueueServiceRedeploy resolves a deployable image (service.Image, else
// the last successful deployment's tag) and queues a reconcile. Returns
// false when no image is available or the engine is missing.
func enqueueServiceRedeploy(c *gin.Context, db *database.DB, service Service) bool {
engineValue, exists := c.Get("deployment_engine")
if !exists || engineValue == nil {
return false
}
engine, ok := engineValue.(*deployment.DeploymentEngine)
if !ok {
return false
}
image := service.Image
if image == "" {
if err := db.QueryRow(
`SELECT image_name || ':' || image_tag FROM deployments
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
ORDER BY created_at DESC LIMIT 1`,
service.ID,
).Scan(&image); err != nil {
return false
}
}
service.Image = image
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{Run: func(jctx context.Context) {
_, _ = reconcileServiceJob(jctx, db, engine, service)
}})
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) {
if len(ports) == 0 {
return
}
_, _ = db.Exec(
`UPDATE services SET published_port = $1 WHERE id = $2 AND published_port <> $1`,
int(ports[0]), serviceID,
)
}
// liveServiceStatus reconciles the stored status with real container state.
// No-op when Docker is unavailable — the stored status is returned as-is.
func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
engineValue, exists := c.Get("deployment_engine")
if !exists || engineValue == nil {
return
}
engine := engineValue.(*deployment.DeploymentEngine)
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 {
return
}
mergeRemoteRuntime(ctx, engine, service.ID.String(), state)
if len(state.Containers) == 0 {
return
}
if state.Status != service.Status {
service.Status = state.Status
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID)
}
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))
}
}
// runtimeScaleService adjusts replica count both in bookkeeping and reality.
func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
dbValue, _ := c.Get("db")
engineValue, _ := c.Get("deployment_engine")
db, _ := dbValue.(*database.DB)
engine, _ := engineValue.(*deployment.DeploymentEngine)
if db == nil || engine == nil {
return fmt.Errorf("runtime unavailable")
}
var service Service
var owner string
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, p.owner_id
FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`, serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &owner,
)
if err != nil {
return err
}
if service.Image == "" {
if err := db.QueryRow(
`SELECT image_name || ':' || image_tag FROM deployments
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
ORDER BY created_at DESC LIMIT 1`, service.ID,
).Scan(&service.Image); err != nil {
return fmt.Errorf("no deployed image to scale")
}
}
spec, err := serviceRuntimeSpec(db, service)
if err != nil {
return err
}
spec.Replicas = replicas
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
state, err := engine.ReconcileService(ctx, spec)
if err != nil {
return err
}
if state != nil {
persistPublishedPort(db, service.ID, state.Ports)
}
_, _ = 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,
})
}
+37 -12
View File
@@ -24,31 +24,39 @@ func NewScalingHandler(autoScaler *scaling.AutoScaler) *ScalingHandler {
}
}
// RegisterRoutes registers scaling routes
func (h *ScalingHandler) RegisterRoutes(router *gin.RouterGroup) {
// RegisterReadRoutes registers public scaling read endpoints.
func (h *ScalingHandler) RegisterReadRoutes(router *gin.RouterGroup) {
scaling := router.Group("/scaling")
{
scaling.GET("/policies", h.GetScalingPolicies)
scaling.POST("/policies", h.SetScalingPolicy)
scaling.GET("/policies/:serviceId", h.GetScalingPolicy)
scaling.PUT("/policies/:serviceId", h.UpdateScalingPolicy)
scaling.DELETE("/policies/:serviceId", h.DeleteScalingPolicy)
scaling.GET("/services", h.GetServiceStates)
scaling.GET("/services/:serviceId", h.GetServiceState)
scaling.GET("/services/:serviceId/history", h.GetScalingHistory)
scaling.POST("/services/:serviceId/scale", h.ManualScale)
scaling.GET("/status", h.GetScalingStatus)
scaling.POST("/enable", h.EnableAutoScaler)
scaling.POST("/disable", h.DisableAutoScaler)
scaling.GET("/metrics", h.GetScalingMetrics)
scaling.GET("/events", h.GetScalingEvents)
}
}
// RegisterAdminRoutes registers scaling mutation endpoints.
func (h *ScalingHandler) RegisterAdminRoutes(router *gin.RouterGroup) {
scaling := router.Group("/scaling")
{
scaling.POST("/policies", h.SetScalingPolicy)
scaling.PUT("/policies/:serviceId", h.UpdateScalingPolicy)
scaling.DELETE("/policies/:serviceId", h.DeleteScalingPolicy)
scaling.POST("/services/:serviceId/scale", h.ManualScale)
scaling.POST("/enable", h.EnableAutoScaler)
scaling.POST("/disable", h.DisableAutoScaler)
}
}
// GetScalingPolicies returns all scaling policies
func (h *ScalingHandler) GetScalingPolicies(c *gin.Context) {
states := h.autoScaler.GetAllServiceStates()
@@ -204,7 +212,16 @@ func (h *ScalingHandler) ManualScale(c *gin.Context) {
event, err := h.autoScaler.ManualScale(c.Request.Context(), serviceID, request.Replicas, strings.TrimSpace(request.Reason))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
// Service unknown to the autoscaler: the runtime is authoritative —
// reconcile containers directly and persist the replica count.
if runtimeErr := runtimeScaleService(c, serviceID, request.Replicas); runtimeErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "runtime_error": runtimeErr.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Service scaled successfully",
"state": gin.H{"service_id": serviceID, "desired_replicas": request.Replicas},
})
return
}
@@ -214,11 +231,19 @@ func (h *ScalingHandler) ManualScale(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{
// Apply the replica change to live containers (best effort — the
// bookkeeping update above already succeeded).
runtimeErr := runtimeScaleService(c, serviceID, request.Replicas)
resp := gin.H{
"message": "Service scaled successfully",
"event": event,
"state": state,
})
}
if runtimeErr != nil {
resp["runtime_error"] = runtimeErr.Error()
}
c.JSON(http.StatusOK, resp)
}
// GetScalingStatus returns the overall status of the auto-scaler
+3 -1
View File
@@ -49,7 +49,9 @@ func setupScalingTestRouter(t *testing.T) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
handler := NewScalingHandler(autoScaler)
handler.RegisterRoutes(router.Group("/api/v1"))
group := router.Group("/api/v1")
handler.RegisterReadRoutes(group)
handler.RegisterAdminRoutes(group)
return router
}
+24 -13
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),
@@ -589,16 +607,7 @@ func (sh *SecurityHandler) GetAuditLogs(c *gin.Context) {
}
func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string) (string, bool) {
userIDValue, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return "", false
}
userID, ok := userIDValue.(string)
if !ok || userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return "", false
}
userID := optionalUserID(c)
if _, err := uuid.Parse(projectID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
@@ -611,12 +620,14 @@ func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string
SELECT 1
FROM projects p
WHERE p.id = $1
AND (p.owner_id = $2 OR EXISTS (
AND (p.is_approved
OR $3::bool
OR p.owner_id = $2 OR EXISTS (
SELECT 1 FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $2
))
)`,
projectID, userID,
projectID, optionalUserUUID(c), contextIsAdmin(c),
).Scan(&hasAccess)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"})
+244
View File
@@ -0,0 +1,244 @@
package api
import (
"net/http"
"strings"
"time"
"containr/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type CloneServiceRequest struct {
Name string `json:"name"`
ProjectID uuid.UUID `json:"project_id"` // empty = clone within the same project
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
}
// handleCloneService duplicates a service's full configuration — image/git
// source, volumes, domains, access gates, builder settings, and variables —
// into the same or another project. Containers are not cloned; the copy
// starts stopped until its first deploy.
func handleCloneService(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
var req CloneServiceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
source, ok := loadOwnedService(c, db.(*database.DB))
if !ok {
return
}
targetProjectID := source.ProjectID
if req.ProjectID != uuid.Nil {
targetProjectID = req.ProjectID
}
if !ownsProject(db.(*database.DB), targetProjectID, userID.(string), contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = source.Name + "-copy"
}
var count int
_ = db.(*database.DB).QueryRow(
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
targetProjectID, name,
).Scan(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in target project", "code": "CONFLICT"})
return
}
environment := source.Environment
if req.Environment != "" {
environment = req.Environment
}
newID, err := cloneServiceRow(db.(*database.DB), source.ID, targetProjectID, name, environment)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
return
}
LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
}
// cloneServiceRow copies a service's full configuration — source, build
// settings, volumes, access gates, domains, and variables — under a new id
// in the target project. The clone starts stopped. An empty environment
// inherits the source's. Used by the clone endpoint and preview
// environments, which additionally override git_branch and domains.
func cloneServiceRow(db *database.DB, sourceID uuid.UUID, targetProjectID uuid.UUID, name, environment string) (uuid.UUID, error) {
var sourceEnv string
if err := db.QueryRow(`SELECT COALESCE(environment, '') FROM services WHERE id = $1`, sourceID).Scan(&sourceEnv); err != nil {
return uuid.Nil, err
}
if environment == "" {
environment = sourceEnv
}
if environment == "" {
environment = "production"
}
environmentID, err := getProjectEnvironmentID(db, targetProjectID, environment)
if err != nil {
return uuid.Nil, err
}
maintenance, basicAuth := serviceAccess(db, sourceID)
newID := uuid.New()
now := time.Now()
// string() so pq sends text — []byte would go as bytea and fail ::jsonb.
volumesJSON := string(loadServiceVolumesJSON(db, sourceID))
_, err = db.Exec(
`INSERT INTO services
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
build_command, start_command, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, volumes, maintenance_mode, basic_auth_users,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
created_at, updated_at)
SELECT $1, $2, $3, $4, service_type, source_type, source_url, image_name,
build_command, start_command, type, 'stopped', image, command, $5,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, $6::jsonb, $7, $8,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
$9, $9
FROM services WHERE id = $10`,
newID, targetProjectID, name, environmentID, environment, volumesJSON,
maintenance, basicAuth, now, sourceID,
)
if err != nil {
return uuid.Nil, err
}
if _, err := db.Exec(
`INSERT INTO service_domains (service_id, domain, is_default, cert_type)
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
newID, sourceID,
); err != nil {
_, _ = db.Exec(`DELETE FROM services WHERE id = $1`, newID)
return uuid.Nil, err
}
// Variable values are copied as stored — ciphertext stays ciphertext.
if _, err := db.Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
newID, sourceID,
); err != nil {
_, _ = db.Exec(`DELETE FROM services WHERE id = $1`, newID)
return uuid.Nil, err
}
return newID, nil
}
type MoveServiceRequest struct {
ProjectID uuid.UUID `json:"project_id" binding:"required"`
}
// handleMoveService re-parents a service to another project owned by the
// caller. Variables, domains, and volumes follow via the service id;
// running containers keep running (they join the new project's network on
// the next deploy).
func handleMoveService(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID", "code": "VALIDATION"})
return
}
var req MoveServiceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
service, ok := loadOwnedService(c, db.(*database.DB))
if !ok {
return
}
if req.ProjectID == service.ProjectID {
c.JSON(http.StatusBadRequest, gin.H{"error": "Service is already in that project", "code": "VALIDATION"})
return
}
if !ownsProject(db.(*database.DB), req.ProjectID, userID.(string), contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
return
}
var count int
_ = db.(*database.DB).QueryRow(
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
req.ProjectID, service.Name,
).Scan(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "A service with this name already exists in the target project", "code": "CONFLICT"})
return
}
envName := service.Environment
if envName == "" {
envName = "production"
}
environmentID, err := getProjectEnvironmentID(db.(*database.DB), req.ProjectID, envName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
return
}
res, err := db.(*database.DB).Exec(
`UPDATE services SET project_id = $1, environment_id = $2, updated_at = $3 WHERE id = $4`,
req.ProjectID, environmentID, time.Now(), serviceID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to move service"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found", "code": "NOT_FOUND"})
return
}
LogAuditWithRequest(c, "service", serviceID.String(), "service.move", map[string]interface{}{"to_project": req.ProjectID.String()})
c.JSON(http.StatusOK, gin.H{"message": "Service moved", "project_id": req.ProjectID})
}
// ownsProject is true when the user owns the project or is an admin.
func ownsProject(db *database.DB, projectID uuid.UUID, userID string, isAdmin bool) bool {
if isAdmin {
return true
}
var owner string
err := db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, projectID).Scan(&owner)
return err == nil && owner == userID
}
// loadServiceVolumesJSON returns the raw stored mounts for a verbatim copy.
func loadServiceVolumesJSON(db *database.DB, serviceID uuid.UUID) []byte {
var raw []byte
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil || len(raw) == 0 {
return []byte("[]")
}
return raw
}
+750 -84
View File
@@ -2,7 +2,15 @@ package api
import (
"containr/internal/database"
"containr/internal/deployment"
"containr/internal/deployqueue"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -24,37 +32,333 @@ type Service struct {
BuildPath string `json:"build_path" db:"build_path"`
CPU string `json:"cpu" db:"cpu"`
Memory string `json:"memory" db:"memory"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// 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"`
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.
type ServiceVolume struct {
Type string `json:"type"` // volume | bind (empty = volume)
Source string `json:"source"` // volume name or host path
Target string `json:"target"` // container path (absolute)
ReadOnly bool `json:"read_only"`
}
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades
// to no mounts rather than failing the request.
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
var raw []byte
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
return nil
}
var stored []ServiceVolume
if err := json.Unmarshal(raw, &stored); err != nil {
return nil
}
out := make([]deployment.VolumeMount, 0, len(stored))
for _, v := range stored {
out = append(out, deployment.VolumeMount{
Type: v.Type,
Source: v.Source,
Destination: v.Target,
ReadOnly: v.ReadOnly,
})
}
return out
}
// validateServiceVolumes enforces the mount contract — bad paths fail
// fast at write time instead of surfacing as docker create errors later.
func validateServiceVolumes(vols []ServiceVolume) error {
for i, v := range vols {
if v.Type == "" {
v.Type = "volume"
}
if v.Type != "volume" && v.Type != "bind" {
return fmt.Errorf("volumes[%d].type must be volume or bind", i)
}
if strings.TrimSpace(v.Source) == "" {
return fmt.Errorf("volumes[%d].source is required", i)
}
if !strings.HasPrefix(v.Target, "/") {
return fmt.Errorf("volumes[%d].target must be an absolute container path", i)
}
}
return nil
}
// CreateServiceRequest represents a request to create a service
type CreateServiceRequest struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name" binding:"required,min=1,max=255"`
Type string `json:"type" binding:"required,oneof=web worker database cron"`
Image string `json:"image"`
Command string `json:"command"`
Environment string `json:"environment" binding:"required,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"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name" binding:"required,min=1,max=255"`
Type string `json:"type" binding:"required,oneof=web worker database cron"`
Image string `json:"image"`
Command string `json:"command"`
Environment string `json:"environment" binding:"required,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/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"`
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
@@ -72,28 +376,14 @@ func handleGetServices(c *gin.Context) {
return
}
// Check if project exists and user has access
var project Project
err = db.(*database.DB).QueryRow(
"SELECT id, name, owner_id FROM projects WHERE id = $1",
projectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
if err != nil {
// Approved projects are public; unapproved ones need owner/member/admin.
projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID)
if !projectExists {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
// Get user ID from JWT token (set by auth middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
if !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
@@ -110,9 +400,16 @@ func handleGetServices(c *gin.Context) {
COALESCE(build_path, ''),
COALESCE(cpu, ''),
COALESCE(memory, ''),
created_at, updated_at
FROM services
WHERE project_id = $1
COALESCE(replicas, 1), COALESCE(port, 0),
COALESCE(domain, ''), COALESCE(healthcheck_path, ''),
COALESCE(restart_policy, 'unless-stopped'),
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''),
COALESCE(memory_reserve, ''), COALESCE(static_build_cmd, ''),
COALESCE(static_dir, ''),
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,
)
@@ -129,6 +426,10 @@ func handleGetServices(c *gin.Context) {
&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.SleepEnabled, &service.SleepIdleMinutes,
&service.CreatedAt, &service.UpdatedAt,
)
if err != nil {
@@ -138,6 +439,12 @@ func handleGetServices(c *gin.Context) {
services = append(services, service)
}
// 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})
}
@@ -189,7 +496,7 @@ func handleCreateService(c *gin.Context) {
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
if project.OwnerID != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -213,29 +520,83 @@ func handleCreateService(c *gin.Context) {
// Create new service
service := Service{
ID: uuid.New(),
ProjectID: req.ProjectID,
Name: req.Name,
Type: req.Type,
Status: "stopped", // Initial status
Image: req.Image,
Command: req.Command,
Environment: req.Environment,
GitRepo: req.GitRepo,
GitBranch: req.GitBranch,
BuildPath: req.BuildPath,
CPU: req.CPU,
Memory: req.Memory,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ID: uuid.New(),
ProjectID: req.ProjectID,
Name: req.Name,
Type: req.Type,
Status: "stopped", // Initial status
Image: req.Image,
Command: req.Command,
Environment: req.Environment,
GitRepo: req.GitRepo,
GitBranch: req.GitBranch,
BuildPath: req.BuildPath,
CPU: req.CPU,
Memory: req.Memory,
Replicas: req.Replicas,
Port: req.Port,
Domain: req.Domain,
HealthCheckPath: req.HealthCheckPath,
RestartPolicy: req.RestartPolicy,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Set default values if not provided
// Set default values if not provided; app_settings.default_cpu /
// default_memory override the hardcoded fallbacks.
if service.CPU == "" {
service.CPU = "0.5"
service.CPU = firstNonEmpty(settingValue(db.(*database.DB), "default_cpu", "", ""), "0.5")
}
if service.Memory == "" {
service.Memory = "512Mi"
service.Memory = firstNonEmpty(settingValue(db.(*database.DB), "default_memory", "", ""), "512Mi")
}
if req.Builder == "" {
service.Builder = "auto"
} else {
service.Builder = req.Builder
}
service.CPUReserve = req.CPUReserve
service.MemoryReserve = req.MemoryReserve
service.StaticBuildCmd = req.StaticBuildCmd
service.StaticDir = req.StaticDir
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,
} {
v := map[string]string{
"cpu": service.CPU, "cpu_reserve": service.CPUReserve,
"memory": service.Memory, "memory_reserve": service.MemoryReserve,
}[label]
if err := check(v); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
return
}
}
if service.Replicas < 1 {
service.Replicas = 1
}
if service.RestartPolicy == "" {
service.RestartPolicy = "unless-stopped"
}
if err := validateServiceVolumes(req.Volumes); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
service.Volumes = req.Volumes
volumesJSON, _ := json.Marshal(req.Volumes)
if len(req.Volumes) == 0 {
volumesJSON = []byte("[]")
}
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
@@ -246,17 +607,64 @@ 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
`INSERT INTO services
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
build_command, start_command, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, 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)`,
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,
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, $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,
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,
)
@@ -265,6 +673,32 @@ func handleCreateService(c *gin.Context) {
return
}
// Domains live in service_domains; services.domain is the derived default.
if req.Domain != "" && validHostname(strings.ToLower(req.Domain)) {
_, _ = db.(*database.DB).Exec(
`INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true)
ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`,
service.ID, strings.ToLower(req.Domain))
}
if req.MaintenanceMode || len(req.BasicAuth) > 0 {
basicAuthUsers := ""
if encoded, encErr := encodeBasicAuth(req.BasicAuth); encErr == nil {
basicAuthUsers = encoded
}
_, _ = db.(*database.DB).Exec(
`UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`,
req.MaintenanceMode, basicAuthUsers, service.ID)
service.MaintenanceMode = req.MaintenanceMode
service.BasicAuth = basicAuthUsernames(basicAuthUsers)
}
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})
}
@@ -303,14 +737,9 @@ func handleGetService(c *gin.Context) {
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Get service with project ownership check
// Get service — public when its project is approved, otherwise
// owner/member/admin only.
userID := optionalUserUUID(c)
var service Service
err = db.(*database.DB).QueryRow(
`SELECT s.id, s.project_id, s.name,
@@ -324,15 +753,25 @@ func handleGetService(c *gin.Context) {
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
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND p.owner_id = $2`,
serviceID, userID,
WHERE s.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
serviceID, userID, contextIsAdmin(c),
).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,
)
@@ -341,6 +780,18 @@ func handleGetService(c *gin.Context) {
return
}
liveServiceStatus(c, db.(*database.DB), &service)
for _, v := range loadServiceVolumes(db.(*database.DB), service.ID) {
service.Volumes = append(service.Volumes, ServiceVolume{
Type: v.Type, Source: v.Source, Target: v.Destination, ReadOnly: v.ReadOnly,
})
}
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)
c.JSON(http.StatusOK, gin.H{"service": service})
}
@@ -386,16 +837,27 @@ func handleUpdateService(c *gin.Context) {
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
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND p.owner_id = $2`,
serviceID, userID,
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
serviceID, userID, contextIsAdmin(c),
).Scan(
&existingService.ID, &existingService.ProjectID, &existingService.Name, &existingService.Type,
&existingService.Status, &existingService.Image, &existingService.Command,
&existingService.Environment, &existingService.GitRepo, &existingService.GitBranch,
&existingService.BuildPath, &existingService.CPU, &existingService.Memory,
&existingService.Replicas, &existingService.Port, &existingService.Domain,
&existingService.HealthCheckPath, &existingService.RestartPolicy,
&existingService.Builder, &existingService.CPUReserve,
&existingService.MemoryReserve, &existingService.StaticBuildCmd,
&existingService.StaticDir,
&existingService.CreatedAt, &existingService.UpdatedAt,
)
@@ -435,19 +897,151 @@ func handleUpdateService(c *gin.Context) {
if req.Memory != "" {
existingService.Memory = req.Memory
}
if req.Replicas != nil {
existingService.Replicas = *req.Replicas
}
if req.Port != nil {
existingService.Port = *req.Port
}
if req.Domain != nil {
existingService.Domain = *req.Domain
}
if req.HealthCheckPath != nil {
existingService.HealthCheckPath = *req.HealthCheckPath
}
if req.RestartPolicy != "" {
existingService.RestartPolicy = req.RestartPolicy
}
if req.Builder != "" {
existingService.Builder = req.Builder
}
if req.CPUReserve != nil {
existingService.CPUReserve = *req.CPUReserve
}
if req.MemoryReserve != nil {
existingService.MemoryReserve = *req.MemoryReserve
}
if req.StaticBuildCmd != nil {
existingService.StaticBuildCmd = *req.StaticBuildCmd
}
if req.StaticDir != nil {
existingService.StaticDir = *req.StaticDir
}
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,
} {
v := map[string]string{
"cpu": existingService.CPU, "cpu_reserve": existingService.CPUReserve,
"memory": existingService.Memory, "memory_reserve": existingService.MemoryReserve,
}[label]
if err := check(v); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
return
}
}
// lib/pq sends a nil []byte as an empty bytea literal rather than NULL,
// so the COALESCE fallback needs an untyped nil when volumes aren't sent.
var volumesArg interface{}
if req.Volumes != nil {
if err := validateServiceVolumes(*req.Volumes); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
existingService.Volumes = *req.Volumes
volumesJSON, _ := json.Marshal(*req.Volumes)
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
_, err = db.(*database.DB).Exec(
`UPDATE services
`UPDATE services
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10, updated_at = $11
WHERE id = $12`,
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
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,
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,
existingService.BuildPath, existingService.CPU, existingService.Memory,
existingService.UpdatedAt, existingService.ID,
existingService.Replicas, existingService.Port, existingService.Domain,
existingService.HealthCheckPath, existingService.RestartPolicy,
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 {
@@ -455,6 +1049,69 @@ func handleUpdateService(c *gin.Context) {
return
}
// Domain writes route through service_domains — services.domain is the
// derived default and gets rewritten by syncDefaultDomain.
if req.Domain != nil {
domain := strings.ToLower(strings.TrimSpace(*req.Domain))
if domain == "" {
_, _ = db.(*database.DB).Exec(`DELETE FROM service_domains WHERE service_id = $1`, serviceID)
} else if validHostname(domain) {
_, _ = db.(*database.DB).Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID)
_, _ = db.(*database.DB).Exec(
`INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true)
ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`,
serviceID, domain)
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid hostname", "code": "VALIDATION"})
return
}
syncDefaultDomain(db.(*database.DB), serviceID)
_ = db.(*database.DB).QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&existingService.Domain)
}
if req.MaintenanceMode != nil || req.BasicAuth != nil {
maintenance, basicAuth := serviceAccess(db.(*database.DB), serviceID)
if req.MaintenanceMode != nil {
maintenance = *req.MaintenanceMode
}
if req.BasicAuth != nil {
encoded, encErr := encodeBasicAuth(*req.BasicAuth)
if encErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encode basic auth", "code": "INTERNAL"})
return
}
basicAuth = encoded
}
_, _ = db.(*database.DB).Exec(
`UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`,
maintenance, basicAuth, serviceID)
existingService.MaintenanceMode = maintenance
}
existingService.Domains = loadServiceDomains(db.(*database.DB), serviceID)
_, storedAuth := serviceAccess(db.(*database.DB), serviceID)
existingService.BasicAuth = basicAuthUsernames(storedAuth)
// A replica change applies immediately when the service has live
// containers; other spec fields take effect on the next deploy.
// Runs through the deploy queue so it can't overlap a build.
if req.Replicas != nil && existingService.Status == "running" {
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
engine := engineValue.(*deployment.DeploymentEngine)
serviceCopy := existingService
dbCopy := db.(*database.DB)
getDeployQueue(c).Enqueue(serviceCopy.ID, deployqueue.Job{Run: func(jctx context.Context) {
spec, err := serviceRuntimeSpec(dbCopy, serviceCopy)
if err != nil {
return
}
if state, err := engine.ReconcileService(jctx, spec); err == nil && state != nil {
persistPublishedPort(dbCopy, serviceCopy.ID, state.Ports)
}
}})
}
}
loadServiceNode(db.(*database.DB), &existingService)
c.JSON(http.StatusOK, gin.H{"service": existingService})
}
@@ -496,11 +1153,18 @@ func handleDeleteService(c *gin.Context) {
}
// Check if user owns the project
if projectOwnerID != userID.(string) {
if projectOwnerID != userID.(string) && !isAdminUser(db.(*database.DB), userID.(string)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Remove runtime containers before dropping the row.
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, serviceID)
}
}
// Delete service (cascade will handle related records)
_, err = db.(*database.DB).Exec(
"DELETE FROM services WHERE id = $1",
@@ -512,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")
}
}
+368
View File
@@ -0,0 +1,368 @@
package api
import (
"context"
"database/sql"
"log"
"net/http"
"os"
"strings"
"time"
"containr/internal/database"
"containr/internal/docker"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry"
"github.com/gin-gonic/gin"
)
// Platform setting keys. Values stored in app_settings take precedence over
// environment variables; env remains the fallback for optional overrides.
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 {
if db != nil && db.DB != nil {
var value string
err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, key).Scan(&value)
if err == nil {
return value
}
if err != sql.ErrNoRows {
log.Printf("Failed to read setting %s: %v", key, err)
}
}
if envKey != "" {
if value := strings.TrimSpace(os.Getenv(envKey)); value != "" {
return value
}
}
return fallback
}
func setSetting(db *database.DB, key, value string, secret bool) error {
_, err := db.Exec(`
INSERT INTO app_settings (key, value, is_secret, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, is_secret = EXCLUDED.is_secret, updated_at = NOW()
`, key, value, secret)
return err
}
func deleteSetting(db *database.DB, key string) error {
_, err := db.Exec(`DELETE FROM app_settings WHERE key = $1`, key)
return err
}
// signupEnabled controls whether public registration stays open after the
// first account exists. Defaults to closed; the platform owner can reopen it
// in Settings or via SIGNUP_ENABLED=true.
func signupEnabled(db *database.DB) bool {
return strings.EqualFold(settingValue(db, settingSignupEnabled, "SIGNUP_ENABLED", "false"), "true")
}
func isAdminUser(db *database.DB, userID string) bool {
var isAdmin bool
if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil {
return false
}
return isAdmin
}
func requireAdmin(c *gin.Context) (*database.DB, bool) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return nil, false
}
db, _ := c.Get("db")
database, _ := db.(*database.DB)
if database == nil || database.DB == nil || !isAdminUser(database, userID) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return nil, false
}
return database, true
}
type cloudflareTunnelStatus struct {
TokenSet bool `json:"token_set"`
Source string `json:"source"` // "app" | "env" | "none"
Container string `json:"container"`
}
func handleGetSettings(c *gin.Context) {
db, ok := requireAdmin(c)
if !ok {
return
}
dockerClient, _ := c.Get("docker_client")
client, _ := dockerClient.(*docker.Client)
c.JSON(http.StatusOK, gin.H{
"signup_enabled": signupEnabled(db),
"cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client),
"branding": resolveBranding(db),
})
}
func handleUpdateSettings(c *gin.Context) {
db, ok := requireAdmin(c)
if !ok {
return
}
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()})
return
}
if req.SignupEnabled != nil {
value := "false"
if *req.SignupEnabled {
value = "true"
}
if err := setSetting(db, settingSignupEnabled, value, false); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update signup setting"})
return
}
}
if req.CloudflareTunnelToken != nil {
token := strings.TrimSpace(*req.CloudflareTunnelToken)
if token == "" {
if err := deleteSetting(db, settingCloudflareTunnelToken); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear tunnel token"})
return
}
} else if err := setSetting(db, settingCloudflareTunnelToken, token, true); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save tunnel token"})
return
}
if dockerClient, exists := c.Get("docker_client"); exists {
if client, ok := dockerClient.(*docker.Client); ok && client != nil {
go applyCloudflaredTunnel(client, token)
}
}
}
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)
}
// resolvedTunnelToken prefers the in-app value and falls back to the env.
func resolvedTunnelToken(db *database.DB) (string, string) {
if db != nil && db.DB != nil {
var value string
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingCloudflareTunnelToken).Scan(&value); err == nil && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value), "app"
}
}
if value := strings.TrimSpace(os.Getenv("CLOUDFLARED_TOKEN")); value != "" && value != "your_cloudflare_tunnel_token_here" {
return value, "env"
}
return "", "none"
}
func cloudflareTunnelState(ctx context.Context, db *database.DB, client *docker.Client) cloudflareTunnelStatus {
token, source := resolvedTunnelToken(db)
state := cloudflareTunnelStatus{TokenSet: token != "", Source: source, Container: "missing"}
if client == nil {
state.Container = "unavailable"
return state
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
container, err := findCloudflaredContainer(ctx, client)
if err != nil || container == "" {
return state
}
if inspect, err := client.GetContainer(ctx, container); err == nil && inspect.State != nil {
state.Container = inspect.State.Status
}
return state
}
// syncManagedContainers reconciles containers the platform manages from
// settings - currently only the optional Cloudflare Tunnel sidecar.
func syncManagedContainers(client *docker.Client, db *database.DB) {
token, _ := resolvedTunnelToken(db)
if token == "" {
return
}
go applyCloudflaredTunnel(client, token)
}
func findCloudflaredContainer(ctx context.Context, client *docker.Client) (string, error) {
containers, err := client.ListContainers(ctx, true)
if err != nil {
return "", err
}
for _, item := range containers {
for _, name := range item.Names {
if strings.TrimPrefix(name, "/") == cloudflaredContainerName {
return item.ID, nil
}
}
}
return "", nil
}
// applyCloudflaredTunnel ensures a cloudflared container mirrors the stored
// token: created/recreated when set, removed when cleared. It joins the same
// networks as the backend so tunnel targets can use compose service names.
func applyCloudflaredTunnel(client *docker.Client, token string) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
if existing, err := findCloudflaredContainer(ctx, client); err == nil && existing != "" {
if err := client.RemoveContainer(ctx, existing, true); err != nil {
log.Printf("Failed to remove existing cloudflared container: %v", err)
return
}
}
if token == "" {
return
}
if err := client.PullImageWait(ctx, cloudflaredImage, registry.AuthConfig{}); err != nil {
log.Printf("Failed to pull %s: %v", cloudflaredImage, err)
}
networks, err := backendNetworks(ctx, client)
if err != nil {
log.Printf("Failed to resolve backend networks for cloudflared: %v", err)
return
}
id, err := client.CreateContainer(ctx, docker.ContainerConfig{
Name: cloudflaredContainerName,
Image: cloudflaredImage,
Cmd: []string{"tunnel", "--no-autoupdate", "run", "--token", token},
RestartPolicy: "unless-stopped",
Labels: map[string]string{"containr.managed": "cloudflared"},
Networks: networks,
})
if err != nil {
log.Printf("Failed to create cloudflared container: %v", err)
return
}
if err := client.StartContainer(ctx, id); err != nil {
log.Printf("Failed to start cloudflared container: %v", err)
}
}
// backendNetworks returns the networks this backend container sits on, so the
// managed cloudflared container can reach the frontend/backend by name.
func backendNetworks(ctx context.Context, client *docker.Client) (map[string]*network.EndpointSettings, error) {
hostname, err := os.Hostname()
if err != nil {
return nil, err
}
self, err := client.GetContainer(ctx, hostname)
if err != nil {
return nil, err
}
networks := map[string]*network.EndpointSettings{}
for name := range self.NetworkSettings.Networks {
networks[name] = &network.EndpointSettings{}
}
if len(networks) == 0 {
return nil, sql.ErrNoRows
}
return networks, nil
}
+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")
}
}
+9 -1
View File
@@ -40,7 +40,10 @@ type hostMonitoringResponse struct {
UptimeSeconds int64 `json:"uptime_seconds"`
DockerAvailable bool `json:"docker_available"`
Docker map[string]any `json:"docker,omitempty"`
CollectedAt string `json:"collected_at"`
// Scope names what the telemetry actually measures: the physical host for
// native dockerd, or the Docker Desktop VM when the daemon runs in one.
Scope string `json:"scope"`
CollectedAt string `json:"collected_at"`
}
type hostCPU struct {
@@ -159,6 +162,7 @@ func handleGetHostMonitoring(c *gin.Context) {
Storage: storage,
Load: load,
UptimeSeconds: uptime,
Scope: "host",
CollectedAt: time.Now().UTC().Format(time.RFC3339),
}
@@ -173,6 +177,10 @@ func handleGetHostMonitoring(c *gin.Context) {
"images": info.Images,
"driver": info.Driver,
"server": info.ServerVersion,
"os": info.OperatingSystem,
}
if strings.Contains(strings.ToLower(info.OperatingSystem), "docker desktop") {
response.Scope = "docker-desktop-vm"
}
}
}
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)
}
+737 -27
View File
@@ -3,32 +3,42 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/secrets"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
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"`
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"`
@@ -38,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 {
@@ -67,12 +80,18 @@ func handleGetTemplates(c *gin.Context) {
ctx := c.Request.Context()
queries := sqlcdb.New(db.DB)
userID := optionalUserUUID(c)
owner := uuid.NullUUID{UUID: userID, Valid: userID != uuid.Nil}
var templateRows []sqlcdb.ServiceTemplate
var err error
if category != "" {
templateRows, err = queries.ListServiceTemplatesByCategory(ctx, category)
templateRows, err = queries.ListServiceTemplatesByCategoryForUser(ctx, sqlcdb.ListServiceTemplatesByCategoryForUserParams{
Category: category,
OwnerID: owner,
})
} else {
templateRows, err = queries.ListServiceTemplates(ctx)
templateRows, err = queries.ListServiceTemplatesForUser(ctx, owner)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
@@ -80,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})
}
@@ -95,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
}
@@ -103,7 +141,7 @@ func handleGetTemplate(c *gin.Context) {
}
t := mapSQLCTemplate(row)
if t.ID == "" {
if t.ID == "" || !templateVisibleTo(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
@@ -136,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"`
@@ -149,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 {
@@ -169,7 +203,7 @@ func handleCreateFromTemplate(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch project"})
return
}
if ownerID.String() != userID {
if ownerID.String() != userID && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -177,16 +211,23 @@ 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)
if template.ID == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
if template.ID == "" || !templateVisibleTo(c, template) {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
@@ -202,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()})
@@ -325,16 +411,30 @@ func handleCreateFromTemplate(c *gin.Context) {
return
}
// sqlc's schema predates the runtime columns; port and healthcheck_path are
// written via raw SQL like every other service mutation path.
if _, err = tx.ExecContext(ctx,
`UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3`,
config.Port, strings.TrimSpace(config.HealthCheck), serviceID,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
return
}
for key, value := range envVars {
if strings.TrimSpace(key) == "" {
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},
@@ -363,6 +463,246 @@ func handleCreateFromTemplate(c *gin.Context) {
})
}
// templateWriteRequest is the admin payload for creating/updating a user
// template. config must decode into TemplateConfig; variables into
// []TemplateVariable.
type templateWriteRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Category string `json:"category"`
Logo string `json:"logo"`
Config json.RawMessage `json:"config" binding:"required"`
Variables json.RawMessage `json:"variables"`
IsPublic bool `json:"is_public"`
}
var templateCategories = map[string]bool{
"web": true, "frontend": true, "api": true, "database": true,
"worker": true, "cron": true, "app": true, "custom": true,
}
func normalizeTemplateCategory(category string) string {
normalized := strings.ToLower(strings.TrimSpace(category))
if templateCategories[normalized] {
return normalized
}
return "custom"
}
func validateTemplateWrite(c *gin.Context, req *templateWriteRequest) bool {
req.Name = strings.TrimSpace(req.Name)
if len(req.Name) < 2 || len(req.Name) > 120 {
c.JSON(http.StatusBadRequest, gin.H{"error": "name must be 2-120 characters"})
return false
}
req.Category = normalizeTemplateCategory(req.Category)
req.Logo = strings.TrimSpace(req.Logo)
if len(req.Logo) > 500 || (req.Logo != "" && !strings.HasPrefix(req.Logo, "https://") && !strings.HasPrefix(req.Logo, "http://")) {
c.JSON(http.StatusBadRequest, gin.H{"error": "logo must be an http(s) URL up to 500 chars"})
return false
}
if len(req.Config) == 0 || len(req.Config) > 64*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "config is required and must be under 64KB"})
return false
}
var config TemplateConfig
if err := json.Unmarshal(req.Config, &config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "config must be a valid template config object"})
return false
}
if _, err := normalizeTemplateServiceType(config.Type); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return false
}
if len(req.Variables) > 0 {
if len(req.Variables) > 64*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "variables must be under 64KB"})
return false
}
var variables []TemplateVariable
if err := json.Unmarshal(req.Variables, &variables); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "variables must be an array"})
return false
}
for _, v := range variables {
if strings.TrimSpace(v.Key) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "every variable needs a key"})
return false
}
}
}
return true
}
func templateVariablesParam(raw json.RawMessage) pqtype.NullRawMessage {
if len(raw) == 0 {
return pqtype.NullRawMessage{}
}
return pqtype.NullRawMessage{RawMessage: raw, Valid: true}
}
func handleCreateTemplate(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
var req templateWriteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !validateTemplateWrite(c, &req) {
return
}
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
id := "usr-" + uuid.NewString()
if err := queries.CreateUserTemplate(c.Request.Context(), sqlcdb.CreateUserTemplateParams{
ID: id,
Name: req.Name,
Description: nullableText(strings.TrimSpace(req.Description)),
Category: req.Category,
Logo: nullableText(req.Logo),
Config: req.Config,
Variables: templateVariablesParam(req.Variables),
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
IsPublic: req.IsPublic,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"})
return
}
LogAudit(userID, "template", id, "create", map[string]interface{}{"name": req.Name})
row, err := queries.GetServiceTemplateByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusCreated, gin.H{"template": gin.H{"id": id, "name": req.Name}})
return
}
c.JSON(http.StatusCreated, gin.H{"template": mapSQLCTemplate(row)})
}
func handleUpdateTemplate(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
templateID := c.Param("id")
var req templateWriteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !validateTemplateWrite(c, &req) {
return
}
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
rows, err := queries.UpdateUserTemplate(c.Request.Context(), sqlcdb.UpdateUserTemplateParams{
Name: req.Name,
Description: nullableText(strings.TrimSpace(req.Description)),
Category: req.Category,
Logo: nullableText(req.Logo),
Config: req.Config,
Variables: templateVariablesParam(req.Variables),
IsPublic: req.IsPublic,
ID: templateID,
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
return
}
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found or not editable"})
return
}
LogAudit(userID, "template", templateID, "update", map[string]interface{}{"name": req.Name})
row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "Template updated"})
return
}
c.JSON(http.StatusOK, gin.H{"template": mapSQLCTemplate(row)})
}
func handleDeleteTemplate(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
templateID := c.Param("id")
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
rows, err := queries.DeleteUserTemplate(c.Request.Context(), sqlcdb.DeleteUserTemplateParams{
ID: templateID,
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete template"})
return
}
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found or not deletable"})
return
}
LogAudit(userID, "template", templateID, "delete", nil)
c.JSON(http.StatusOK, gin.H{"message": "Template deleted"})
}
// SeedOfficialTemplates upserts the built-in catalog on startup so new
// templates ship with releases without needing a migration per entry.
func SeedOfficialTemplates(ctx context.Context, db *database.DB) {
if db == nil || db.DB == nil {
return
}
queries := sqlcdb.New(db.DB)
for _, t := range SeedTemplates() {
variables := pqtype.NullRawMessage{}
if strings.TrimSpace(t.Variables) != "" {
variables = pqtype.NullRawMessage{RawMessage: json.RawMessage(t.Variables), Valid: true}
}
if err := queries.UpsertServiceTemplate(ctx, sqlcdb.UpsertServiceTemplateParams{
ID: t.ID,
Name: t.Name,
Description: nullableText(t.Description),
Category: t.Category,
Logo: nullableText(t.Logo),
Config: json.RawMessage(t.Config),
Variables: variables,
IsOfficial: sql.NullBool{Bool: true, Valid: true},
}); err != nil {
log.Printf("Failed to seed template %s: %v", t.ID, err)
}
}
}
func SeedTemplates() []ServiceTemplate {
templates := []ServiceTemplate{
{
@@ -480,7 +820,7 @@ func SeedTemplates() []ServiceTemplate {
Name: "Background Worker",
Description: "Background job processing service",
Category: "worker",
Logo: "https://cdn.simpleicons.org/terminal",
Logo: "https://cdn.simpleicons.org/gnubash",
Config: `{"type":"worker","runtime":"node","build_command":"npm install","start_command":"npm run worker"}`,
Variables: `[{"key":"WORKER_CONCURRENCY","label":"Concurrency","default":"4","required":false,"secret":false}]`,
IsOfficial: true,
@@ -490,7 +830,7 @@ func SeedTemplates() []ServiceTemplate {
Name: "Cron Job",
Description: "Scheduled task runner",
Category: "cron",
Logo: "https://cdn.simpleicons.org/clock",
Logo: "https://cdn.simpleicons.org/clockify",
Config: `{"type":"cron","runtime":"node","build_command":"npm install","start_command":"npm run cron"}`,
Variables: `[{"key":"CRON_SCHEDULE","label":"Schedule","default":"0 * * * *","required":true,"secret":false}]`,
IsOfficial: true,
@@ -505,6 +845,355 @@ func SeedTemplates() []ServiceTemplate {
Variables: `[{"key":"IMAGE","label":"Docker Image","default":"","required":true,"secret":false},{"key":"TAG","label":"Image Tag","default":"latest","required":false,"secret":false}]`,
IsOfficial: true,
},
// Self-hosted applications — runtime doubles as the image reference.
{
ID: "tpl-nextcloud",
Name: "Nextcloud",
Description: "Self-hosted file sync, share and collaboration platform",
Category: "app",
Logo: "https://cdn.simpleicons.org/nextcloud",
Config: `{"type":"web","runtime":"nextcloud:30-apache","port":80,"health_check":"/status.php"}`,
Variables: `[{"key":"NEXTCLOUD_ADMIN_USER","label":"Admin User","default":"admin","required":false,"secret":false},{"key":"NEXTCLOUD_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true},{"key":"NEXTCLOUD_TRUSTED_DOMAINS","label":"Trusted Domains","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-gitea",
Name: "Gitea",
Description: "Lightweight self-hosted Git service",
Category: "app",
Logo: "https://cdn.simpleicons.org/gitea",
Config: `{"type":"web","runtime":"gitea/gitea:1","port":3000,"health_check":"/api/healthz"}`,
Variables: `[{"key":"GITEA__server__DOMAIN","label":"Server Domain","default":"localhost","required":false,"secret":false},{"key":"GITEA__server__ROOT_URL","label":"Root URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-ghost",
Name: "Ghost",
Description: "Independent publishing platform for blogs and newsletters",
Category: "app",
Logo: "https://cdn.simpleicons.org/ghost",
Config: `{"type":"web","runtime":"ghost:5-alpine","port":2368,"health_check":"/ghost/api/admin/site/"}`,
Variables: `[{"key":"url","label":"Public URL","default":"","required":true,"secret":false},{"key":"database__client","label":"Database Client","default":"sqlite3","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-uptime-kuma",
Name: "Uptime Kuma",
Description: "Self-hosted monitoring tool for services and websites",
Category: "app",
Logo: "https://cdn.simpleicons.org/uptimerobot",
Config: `{"type":"web","runtime":"louislam/uptime-kuma:1","port":3001,"health_check":"/"}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-vaultwarden",
Name: "Vaultwarden",
Description: "Lightweight Bitwarden-compatible password manager",
Category: "app",
Logo: "https://cdn.simpleicons.org/bitwarden",
Config: `{"type":"web","runtime":"vaultwarden/server:latest","port":80,"health_check":"/alive"}`,
Variables: `[{"key":"ADMIN_TOKEN","label":"Admin Token","default":"","required":false,"secret":true},{"key":"SIGNUPS_ALLOWED","label":"Allow Signups","default":"false","required":false,"secret":false},{"key":"DOMAIN","label":"Domain URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-jellyfin",
Name: "Jellyfin",
Description: "Free software media system for movies, shows and music",
Category: "app",
Logo: "https://cdn.simpleicons.org/jellyfin",
Config: `{"type":"web","runtime":"jellyfin/jellyfin:latest","port":8096,"health_check":"/health"}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-n8n",
Name: "n8n",
Description: "Workflow automation tool with hundreds of integrations",
Category: "app",
Logo: "https://cdn.simpleicons.org/n8n",
Config: `{"type":"web","runtime":"n8nio/n8n:latest","port":5678,"health_check":"/healthz"}`,
Variables: `[{"key":"N8N_HOST","label":"Hostname","default":"","required":false,"secret":false},{"key":"N8N_ENCRYPTION_KEY","label":"Encryption Key","default":"","required":true,"secret":true},{"key":"WEBHOOK_URL","label":"Webhook URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-grafana",
Name: "Grafana",
Description: "Observability dashboards for metrics, logs and traces",
Category: "app",
Logo: "https://cdn.simpleicons.org/grafana",
Config: `{"type":"web","runtime":"grafana/grafana:latest","port":3000,"health_check":"/api/health"}`,
Variables: `[{"key":"GF_SECURITY_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-immich",
Name: "Immich",
Description: "Self-hosted photo and video backup",
Category: "app",
Logo: "https://cdn.simpleicons.org/immich",
Config: `{"type":"web","runtime":"ghcr.io/immich-app/immich-server:release","port":2283,"health_check":"/api/server/ping"}`,
Variables: `[{"key":"DB_PASSWORD","label":"Database Password","default":"","required":true,"secret":true},{"key":"DB_HOSTNAME","label":"Database Host","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-meilisearch",
Name: "Meilisearch",
Description: "Fast, typo-tolerant search engine API",
Category: "app",
Logo: "https://cdn.simpleicons.org/meilisearch",
Config: `{"type":"web","runtime":"getmeili/meilisearch:v1","port":7700,"health_check":"/health"}`,
Variables: `[{"key":"MEILI_MASTER_KEY","label":"Master Key","default":"","required":true,"secret":true},{"key":"MEILI_ENV","label":"Environment","default":"production","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-plausible",
Name: "Plausible Analytics",
Description: "Lightweight, privacy-friendly web analytics",
Category: "app",
Logo: "https://cdn.simpleicons.org/plausibleanalytics",
Config: `{"type":"web","runtime":"ghcr.io/plausible/community-edition:v2","port":8000,"health_check":"/api/health"}`,
Variables: `[{"key":"BASE_URL","label":"Base URL","default":"","required":true,"secret":false},{"key":"SECRET_KEY_BASE","label":"Secret Key Base","default":"","required":true,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-minio",
Name: "MinIO",
Description: "S3-compatible object storage",
Category: "app",
Logo: "https://cdn.simpleicons.org/minio",
Config: `{"type":"web","runtime":"minio/minio:latest","start_command":"server /data --console-address \":9001\"","port":9001,"health_check":"/minio/health/live"}`,
Variables: `[{"key":"MINIO_ROOT_USER","label":"Root User","default":"minioadmin","required":true,"secret":false},{"key":"MINIO_ROOT_PASSWORD","label":"Root Password","default":"","required":true,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-appwrite",
Name: "Appwrite",
Description: "Backend-as-a-service for web and mobile apps",
Category: "app",
Logo: "https://cdn.simpleicons.org/appwrite",
Config: `{"type":"web","runtime":"appwrite/appwrite:1.6","port":80,"health_check":"/health"}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-wordpress",
Name: "WordPress",
Description: "Classic CMS powering a large share of the web",
Category: "app",
Logo: "https://cdn.simpleicons.org/wordpress",
Config: `{"type":"web","runtime":"wordpress:6-apache","port":80,"health_check":"/wp-login.php"}`,
Variables: `[{"key":"WORDPRESS_DB_HOST","label":"Database Host","default":"","required":true,"secret":false},{"key":"WORDPRESS_DB_USER","label":"Database User","default":"wordpress","required":true,"secret":false},{"key":"WORDPRESS_DB_PASSWORD","label":"Database Password","default":"","required":true,"secret":true},{"key":"WORDPRESS_DB_NAME","label":"Database Name","default":"wordpress","required":true,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-paperless",
Name: "Paperless-ngx",
Description: "Document management system with OCR",
Category: "app",
Logo: "https://cdn.simpleicons.org/paperlessngx",
Config: `{"type":"web","runtime":"ghcr.io/paperless-ngx/paperless-ngx:latest","port":8000,"health_check":"/api/"}`,
Variables: `[{"key":"PAPERLESS_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true},{"key":"PAPERLESS_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-ntfy",
Name: "ntfy",
Description: "Push notification service over HTTP",
Category: "app",
Logo: "https://cdn.simpleicons.org/ntfy",
Config: `{"type":"web","runtime":"binwiederhier/ntfy:latest","start_command":"serve","port":80,"health_check":"/v1/health"}`,
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
}
@@ -605,12 +1294,31 @@ func defaultTemplateResources(serviceType string) (cpu, memory string) {
}
}
// templateVisibleTo reports whether the caller may see a template: official and
// published (is_public) templates are public, other user templates are
// owner/admin only.
func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool {
if t.IsOfficial || t.IsPublic {
return true
}
if contextIsAdmin(c) {
return true
}
userID := optionalUserID(c)
return userID != "" && t.OwnerID == userID
}
func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
variables := "[]"
if row.Variables.Valid && len(row.Variables.RawMessage) > 0 {
variables = string(row.Variables.RawMessage)
}
ownerID := ""
if row.OwnerID.Valid {
ownerID = row.OwnerID.UUID.String()
}
return ServiceTemplate{
ID: row.ID,
Name: row.Name,
@@ -620,6 +1328,8 @@ func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
Config: string(row.Config),
Variables: variables,
IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool,
OwnerID: ownerID,
IsPublic: row.IsPublic,
CreatedAt: templateNullTime(row.CreatedAt),
UpdatedAt: templateNullTime(row.UpdatedAt),
}
+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
}
+194
View File
@@ -0,0 +1,194 @@
package api
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"net/http"
"strings"
"time"
"containr/internal/database"
sqlcdb "containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Personal access tokens let the CLI, MCP server, and third-party agents
// authenticate without a browser session. The raw token (cnp_…) is
// returned exactly once at creation; only its sha256 hash persists.
var userTokenScopes = map[string]bool{"read": true, "write": true, "admin": true}
func generateUserToken() (token string, prefix string, hash string, err error) {
buf := make([]byte, 24)
if _, err = rand.Read(buf); err != nil {
return "", "", "", err
}
token = "cnp_" + hex.EncodeToString(buf)
prefix = token[:12] // cnp_ + 8 hex chars, enough to identify in a list
sum := sha256.Sum256([]byte(token))
return token, prefix, hex.EncodeToString(sum[:]), nil
}
func userTokenJSON(t sqlcdb.UserToken) gin.H {
row := gin.H{
"id": t.ID.String(),
"name": t.Name,
"key_prefix": t.KeyPrefix,
"scope": t.Scope,
}
if t.CreatedAt.Valid {
row["created_at"] = t.CreatedAt.Time
}
if t.LastUsedAt.Valid {
row["last_used_at"] = t.LastUsedAt.Time
}
if t.ExpiresAt.Valid {
row["expires_at"] = t.ExpiresAt.Time
}
return row
}
func handleCreateUserToken(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
var req struct {
Name string `json:"name"`
Scope string `json:"scope"`
ExpiresInDays int `json:"expires_in_days"`
}
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 == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"})
return
}
if req.Scope == "" {
req.Scope = "write"
}
if !userTokenScopes[req.Scope] {
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be one of: read, write, admin", "code": "VALIDATION"})
return
}
// A token can never outrank its owner: non-admin users cannot mint
// admin-scope tokens even for themselves.
if req.Scope == "admin" && !c.GetBool("is_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "admin scope requires a platform admin account", "code": "FORBIDDEN"})
return
}
if req.ExpiresInDays < 0 || req.ExpiresInDays > 3650 {
c.JSON(http.StatusBadRequest, gin.H{"error": "expires_in_days must be between 0 (never) and 3650", "code": "VALIDATION"})
return
}
token, prefix, hash, err := generateUserToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token", "code": "INTERNAL"})
return
}
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
return
}
expiresAt := sql.NullTime{}
if req.ExpiresInDays > 0 {
expiresAt = sql.NullTime{Time: time.Now().Add(time.Duration(req.ExpiresInDays) * 24 * time.Hour), Valid: true}
}
row, err := queries.CreateUserToken(c.Request.Context(), sqlcdb.CreateUserTokenParams{
UserID: ownerUUID,
Name: req.Name,
KeyPrefix: prefix,
TokenHash: hash,
Scope: req.Scope,
ExpiresAt: expiresAt,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token", "code": "INTERNAL"})
return
}
LogAudit(userID, "user_token", row.ID.String(), "create", map[string]interface{}{"name": req.Name, "scope": req.Scope})
payload := userTokenJSON(row)
payload["token"] = token
c.JSON(http.StatusCreated, payload)
}
func handleListUserTokens(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
return
}
rows, err := queries.ListUserTokens(c.Request.Context(), ownerUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens", "code": "INTERNAL"})
return
}
out := make([]gin.H, 0, len(rows))
for _, t := range rows {
out = append(out, userTokenJSON(t))
}
c.JSON(http.StatusOK, gin.H{"tokens": out})
}
func handleDeleteUserToken(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
return
}
tokenUUID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID", "code": "VALIDATION"})
return
}
affected, err := queries.RevokeUserToken(c.Request.Context(), sqlcdb.RevokeUserTokenParams{
ID: tokenUUID,
UserID: ownerUUID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token", "code": "INTERNAL"})
return
}
if affected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found", "code": "NOT_FOUND"})
return
}
LogAudit(userID, "user_token", tokenUUID.String(), "revoke", nil)
c.JSON(http.StatusOK, gin.H{"message": "Token revoked"})
}
+31 -35
View File
@@ -2,6 +2,7 @@ package api
import (
"containr/internal/database"
"containr/internal/secrets"
"net/http"
"time"
@@ -25,6 +26,7 @@ type EnvironmentVariable struct {
type UpdateVariablesRequest struct {
Variables []VariableInput `json:"variables" binding:"required"`
Redeploy bool `json:"redeploy"`
}
type VariableInput struct {
@@ -47,26 +49,12 @@ func handleGetVariables(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -123,26 +111,12 @@ func handleUpdateVariables(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -187,6 +161,9 @@ func handleUpdateVariables(c *gin.Context) {
value = stored
}
}
if v.IsSecret {
value = secrets.Encrypt(value)
}
varID := uuid.New()
_, err = tx.Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
@@ -232,5 +209,24 @@ func handleUpdateVariables(c *gin.Context) {
variables = append(variables, v)
}
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Environment variables updated successfully"})
resp := gin.H{"variables": variables, "message": "Environment variables updated successfully"}
if req.Redeploy {
// Load the service spec inputs and queue a reconcile behind any
// in-flight deployment — new env lands in the recreated containers.
var svc Service
svcErr := db.(*database.DB).QueryRow(
`SELECT id, project_id, name, COALESCE(image, ''), COALESCE(command, ''),
COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(domain, ''),
COALESCE(healthcheck_path, ''), COALESCE(restart_policy, 'unless-stopped'),
COALESCE(cpu, ''), COALESCE(memory, ''),
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''), COALESCE(memory_reserve, '')
FROM services WHERE id = $1`,
serviceID,
).Scan(&svc.ID, &svc.ProjectID, &svc.Name, &svc.Image, &svc.Command,
&svc.Replicas, &svc.Port, &svc.Domain, &svc.HealthCheckPath,
&svc.RestartPolicy, &svc.CPU, &svc.Memory,
&svc.Builder, &svc.CPUReserve, &svc.MemoryReserve)
resp["redeploy_queued"] = svcErr == nil && enqueueServiceRedeploy(c, db.(*database.DB), svc)
}
c.JSON(http.StatusOK, resp)
}
+105
View File
@@ -0,0 +1,105 @@
package api
import (
"net/http"
"time"
"containr/internal/docker"
"github.com/gin-gonic/gin"
)
// VolumeInfo is one docker volume plus whether any container mounts it.
type VolumeInfo struct {
Name string `json:"name"`
Driver string `json:"driver"`
Mountpoint string `json:"mountpoint"`
Scope string `json:"scope"`
InUse bool `json:"in_use"`
UsedBy []string `json:"used_by,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
func dockerClientOr500(c *gin.Context) (*docker.Client, bool) {
v, exists := c.Get("docker_client")
if !exists || v == nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "Docker client not available")
return nil, false
}
return v.(*docker.Client), true
}
// handleAdminListVolumes returns the node's docker volume inventory with
// in-use flags. Admin-only: mountpoints reveal host filesystem layout.
func handleAdminListVolumes(c *gin.Context) {
client, ok := dockerClientOr500(c)
if !ok {
return
}
ctx := c.Request.Context()
vols, err := client.ListVolumes(ctx)
if err != nil {
respondError(c, http.StatusBadGateway, "DEPENDENCY_UNAVAILABLE", "Failed to list volumes: "+err.Error())
return
}
containers, err := client.ListContainers(ctx, true)
if err != nil {
respondError(c, http.StatusBadGateway, "DEPENDENCY_UNAVAILABLE", "Failed to list containers: "+err.Error())
return
}
inUse := map[string][]string{}
for _, cont := range containers {
for _, m := range cont.Mounts {
if m.Type == "volume" {
name := cont.ID[:12]
if len(cont.Names) > 0 {
name = cont.Names[0]
}
inUse[m.Name] = append(inUse[m.Name], name)
}
}
}
out := make([]VolumeInfo, 0, len(vols.Volumes))
for _, v := range vols.Volumes {
if v == nil {
continue
}
info := VolumeInfo{
Name: v.Name,
Driver: v.Driver,
Mountpoint: v.Mountpoint,
Scope: v.Scope,
Labels: v.Labels,
UsedBy: inUse[v.Name],
}
info.InUse = len(info.UsedBy) > 0
if t, err := time.Parse(time.RFC3339Nano, v.CreatedAt); err == nil {
info.CreatedAt = t
}
out = append(out, info)
}
c.JSON(http.StatusOK, gin.H{"volumes": out})
}
// handleAdminDeleteVolume removes a docker volume. Docker refuses removal
// of in-use volumes — that error is surfaced as a conflict.
func handleAdminDeleteVolume(c *gin.Context) {
client, ok := dockerClientOr500(c)
if !ok {
return
}
name := c.Param("name")
if name == "" {
respondError(c, http.StatusBadRequest, "VALIDATION", "Volume name is required")
return
}
if err := client.RemoveVolume(c.Request.Context(), name, false); err != nil {
respondError(c, http.StatusConflict, "CONFLICT", "Failed to remove volume: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"deleted": name})
}
+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)
}
}
}
+12 -3
View File
@@ -211,8 +211,12 @@ func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest)
// Build Docker image
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
// Create build context tar
buildCtx, err := createBuildContext(req.SourcePath, optimizedDockerfile)
// createBuildContext wants the Dockerfile contents, not its path.
dockerfileContent, err := os.ReadFile(optimizedDockerfile)
if err != nil {
return nil, fmt.Errorf("failed to read Dockerfile: %w", err)
}
buildCtx, err := createBuildContext(req.SourcePath, string(dockerfileContent))
if err != nil {
return nil, fmt.Errorf("failed to create build context: %w", err)
}
@@ -222,6 +226,7 @@ func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest)
Dockerfile: "Dockerfile",
Tags: []string{imageName},
BuildArgs: buildArgs,
NoCache: req.NoCache,
Remove: true,
}
@@ -534,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
}
+82 -6
View File
@@ -3,6 +3,8 @@ package build
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"time"
@@ -16,6 +18,7 @@ type BuildManager struct {
railpackBuilder *RailpackBuilder
nixpacksBuilder *NixpacksBuilder
dockerfileBuilder *DockerfileBuilder
staticBuilder *StaticBuilder
dockerClient *docker.Client
cacheManager *CacheManager
parallelBuilder *ParallelBuilder
@@ -28,6 +31,7 @@ const (
BuildTypeRailpack BuildType = "railpack"
BuildTypeNixpacks BuildType = "nixpacks"
BuildTypeDockerfile BuildType = "dockerfile"
BuildTypeStatic BuildType = "static"
BuildTypePrebuilt BuildType = "prebuilt"
)
@@ -39,6 +43,7 @@ func NewBuildManager(workDir string, dockerClient *docker.Client) *BuildManager
railpackBuilder: NewRailpackBuilder(workDir, dockerClient),
nixpacksBuilder: NewNixpacksBuilder(workDir, dockerClient),
dockerfileBuilder: NewDockerfileBuilder(workDir, dockerClient),
staticBuilder: NewStaticBuilder(dockerClient),
dockerClient: dockerClient,
cacheManager: cacheManager,
parallelBuilder: parallelBuilder,
@@ -80,6 +85,8 @@ func (bm *BuildManager) Build(ctx context.Context, req *types.BuildRequest) (*ty
return bm.nixpacksBuilder.Build(ctx, req)
case BuildTypeDockerfile:
return bm.dockerfileBuilder.Build(ctx, req)
case BuildTypeStatic:
return bm.staticBuilder.Build(ctx, req)
case BuildTypePrebuilt:
return bm.buildPrebuilt(ctx, req)
default:
@@ -87,16 +94,85 @@ 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 == "" {
return nil, fmt.Errorf("prebuilt image not specified")
}
// Pull the prebuilt image
auth := registry.AuthConfig{}
_, err := bm.dockerClient.PullImage(ctx, req.PrebuiltImage, auth)
if err != nil {
// Pull the prebuilt image — must drain the pull stream or the daemon
// aborts it and the tag below races a missing image.
auth := registry.AuthConfig{Username: req.PullUsername, Password: req.PullPassword}
if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil {
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
}
@@ -104,7 +180,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
if req.ImageName != "" && req.ImageTag != "" {
targetImage := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
if targetImage != req.PrebuiltImage {
err = bm.dockerClient.TagImage(ctx, req.PrebuiltImage, targetImage)
err := bm.dockerClient.TagImage(ctx, req.PrebuiltImage, targetImage)
if err != nil {
return nil, fmt.Errorf("failed to tag image: %w", err)
}
@@ -114,7 +190,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
// Push to registry if specified
if req.RegistryURL != "" {
err = bm.dockerClient.PushImage(ctx, req.PrebuiltImage, req.RegistryURL)
err := bm.dockerClient.PushImage(ctx, req.PrebuiltImage, req.RegistryURL)
if err != nil {
return nil, fmt.Errorf("failed to push image: %w", err)
}
+11 -5
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)
}
@@ -498,7 +503,8 @@ func (n *NixpacksBuilder) Build(ctx context.Context, req *types.BuildRequest) (*
BuildArgs: map[string]*string{
"BUILDKIT_INLINE_CACHE": strPtr("1"),
},
Remove: true,
NoCache: req.NoCache,
Remove: true,
}
_, err = n.dockerClient.BuildImage(ctx, buildCtx, buildOptions)
Loaded 100 of 336 files, more files were not shown because too many files have changed in this diff. Show more