Compare commits

..
6 Commits
Author SHA1 Message Date
Tomáš DvořákandDevin 1409186a1f feat(nodes): per-node default domain with auto service hostnames (#65)
node_agents.default_domain gives each agent a base domain. Services
placed on it (explicit pin or spread) with no explicit service_domains
get <name>.<default_domain> — resolved in serviceDomainNames so edge
routing, wake placeholders, and public_url all see the same names.
Spread services union every eligible node's base domain; explicit
domains always win.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

Authored By: TDvorak <[email protected]>
2026-10-09 20:30:33 +02:00
60 changed files with 3223 additions and 217 deletions

No files matched your search

+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
+9 -1
View File
@@ -163,11 +163,13 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Boot stack - name: Boot stack
# .github/compose.ci.yml swaps every docker.io ref for
# mirror.gcr.io — anonymous Hub pulls 429 on shared CI IPs.
run: | run: |
BETTER_AUTH_SECRET=$(openssl rand -hex 32) BETTER_AUTH_SECRET=$(openssl rand -hex 32)
BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32) BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN
docker compose -f infra/docker-compose.yml up -d --build postgres dragonfly backend frontend traefik docker compose -f infra/docker-compose.yml -f .github/compose.ci.yml up -d --build postgres dragonfly backend frontend traefik
- name: Wait for backend health - name: Wait for backend health
run: | run: |
@@ -204,7 +206,13 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Docker Buildx - name: Setup Docker Buildx
# Mirror docker.io through gcr — shared CI IPs hit anonymous
# rate limits on base-image metadata resolution otherwise.
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
with:
config-inline: |
[registry."docker.io"]
mirrors = ["mirror.gcr.io"]
- name: Login to GHCR - name: Login to GHCR
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
+1 -1
View File
@@ -49,7 +49,7 @@ dedicated console.
- **Public project catalog** — approved projects are browsable anonymously; new projects stay private until an admin approves them - **Public project catalog** — approved projects are browsable anonymously; new projects stay private until an admin approves them
- **Admin console** (`/admin`) — platform stats, project approval/edit/delete, user admin management - **Admin console** (`/admin`) — platform stats, project approval/edit/delete, user admin management
- **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references - **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references
- **Deployments & builds** — Docker deploys with history, image rollback, live build status - **Deployments & builds** — Docker deploys with history, image rollback, live build status; git-sourced services build directly on remote node agents (no registry required)
- **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page - **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page
- **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App - **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App
- **Managed databases** — create PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Dragonfly, or ClickHouse directly from `/databases`, with backup/restore - **Managed databases** — create PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Dragonfly, or ClickHouse directly from `/databases`, with backup/restore
+52 -31
View File
@@ -192,8 +192,11 @@ goroutines; this fixes correctness *and* unlocks the ops surface.
compares vs edge IP → `ok|wrong-target|pending`. compares vs edge IP → `ok|wrong-target|pending`.
- [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels; - [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
no proxy code needed. no proxy code needed.
- [ ] **Routing overrides** — `services.traefik_labels` jsonb with - [x] **Routing overrides** — `services.traefik_labels` jsonb with
allowlist validation (middlewares, headers, redirects). allowlist validation. Keys `middlewares.<name>.<type>[.<field>]` scope
to the service router (`svc-<id>-<name>`), auto-attach after builtins;
router/service/provider-level keys rejected. API + CLI
(`--traefik-label`) + MCP + service page Access editor.
- [x] **Builder override** — `services.builder` enum - [x] **Builder override** — `services.builder` enum
`auto|railpack|nixpacks|dockerfile|static`; static builder = build + `auto|railpack|nixpacks|dockerfile|static`; static builder = build +
nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug. nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug.
@@ -223,29 +226,40 @@ goroutines; this fixes correctness *and* unlocks the ops surface.
{target_node_id}`: dump → stream transfer → provision → restore → {target_node_id}`: dump → stream transfer → provision → restore →
re-point bound services. Deferred until Phase F lands real second re-point bound services. Deferred until Phase F lands real second
nodes — nothing to migrate between today. nodes — nothing to migrate between today.
- [ ] **Backup import** — upload archive → new restore point (pairs with - [x] **Backup import** — `POST /databases/:id/backups/import` accepts a
`.tar.gz` (multipart `file` or raw body, 2 GiB cap, gzip-magic
validated), stores it in the backups volume via a holder container,
registers a completed restore point, and ships offsite when a backup
target is configured. `api/backuptargets.go`. (pairs with
migrators). migrators).
## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates) ## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates)
- [ ] **Template = ordered service graph** — `services[]` entries with - [x] **Template = ordered service graph** — `services[]` entries with
`{name, type, source, build, ports, volumes, variables, depends_on}`; `{name, type, source, build, ports, volumes, variables, depends_on}`;
deploy order from dependency refs. deploy order from dependency refs. `templategraph.go`,
- [ ] **Expressions** — `${{service.KEY}}` (already the runtime ref `topoSortServices`.
syntax — reuse), `{{secret(len,"charset")}}`, `{{random:int}}`, - [x] **Expressions** — `{{service.KEY.prop}}` (host/port/user/password/
`{{VAR:-default}}`; `POST /templates/:id/plan` returns the resolved database/url), `{{secret}}`/`{{secret(N)}}`, `{{random:N}}`,
graph without deploying (agents love this). `{{VAR:-default}}` (incl. explicitly-empty defaults); `POST
- [ ] **Compose import** — `POST /templates/import/compose` (compose-go /templates/:id/plan` returns the resolved graph without deploying.
parser → graph), `POST /templates/import/github` (compose file from - [x] **Compose import** — `POST /templates/import/compose` (yaml →
repo path). Implements `docs/superpowers/specs/2026-04-14-*`. graph, known DB images become managed databases), `POST
- [ ] **Graph deploy pipeline** — creates project + services in /templates/import/git` (compose file fetched from any git repo — clone
dependency order, resolves vars, triggers builds; defined URL, ssh remote, or owner/repo via connected providers, `ref` selects
partial-failure policy. branch/commit); `templates import-compose|import-git` CLI, MCP tools,
paste-or-git toggle in the template UI.
- [x] **Graph deploy pipeline** — creates services in dependency order,
resolves vars/secrets, triggers builds. `deployTemplateGraph`,
`POST /templates/deploy` (inline), `POST /projects/:id/import-compose`.
- [ ] **Visual template builder** — canvas mode: drop git/image/db - [ ] **Visual template builder** — canvas mode: drop git/image/db
nodes, wire refs, set order, save/publish. Reuses existing canvas nodes, wire refs, set order, save/publish. Reuses existing canvas
primitives (dflow's `templates/compose` is exactly this). primitives (dflow's `templates/compose` is exactly this).
- [ ] **Catalog growth** — convert `templates/*.md` (20 app guides) into - [x] **Catalog growth** — seeded graph stacks for n8n, Gitea,
real graph templates; screenshots + badges per the old spec. Nextcloud, Vaultwarden, Umami, Immich (Plausible already shipped).
`TestSeedTemplatesResolveCleanly` keeps every seed parseable,
topo-sortable, and fully resolved. Remaining single-service guides can
convert incrementally via the same endpoint.
**Gate**: paste n8n compose → 3-service project with generated secrets **Gate**: paste n8n compose → 3-service project with generated secrets
and wired refs, one click or one CLI call. and wired refs, one click or one CLI call.
@@ -334,19 +348,22 @@ and wired refs, one click or one CLI call.
CLI, `containr_nodes_upgrade` MCP (confirm-gated), node-detail page CLI, `containr_nodes_upgrade` MCP (confirm-gated), node-detail page
button (online-gated). Ceilings: linux amd64/arm64 only; agents on button (online-gated). Ceilings: linux amd64/arm64 only; agents on
other platforms get a `NO_BINARY` 409. other platforms get a `NO_BINARY` 409.
- [🟡] **Build-on-node** — private registry pulls shipped: the dispatch - [x] **Build-on-node** — private registry pulls + real builds shipped.
payload carries the project owner's `registries` credentials for the Git sources materialize via `internal/source` checkout (branch/commit,
image host; the agent `docker login`s for the pull and logs out after, `GIT_ASKPASS` credential injection, dumb-transport fallback), package
and command payloads are scrubbed of passwords on completion. Agent through `BuildManager.PackageContext`, ship as agent-token artifacts,
also verifies the container is still running ~1.5s after `run` so and `docker build` on the node (`build_image` command). Remote rollbacks
crash-loops fail the deploy instead of reporting healthy. Remaining: `docker save` → artifact → `load_image` on the node. Verified end-to-end
real builds on the node (source checkout + build daemon) — pulls on a live agent (PR #60).
already work.
- [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/ - [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/
OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module
(dflow's second provider), cloud-init → auto-enroll. (dflow's second provider), cloud-init → auto-enroll.
- [ ] **Global/wildcard domain per node** (`nodes.default_domain`) — - [x] **Global/wildcard domain per node** (`node_agents.default_domain`) —
auto-domains for services on that node. `PUT /agents/:id {default_domain}`; domainless services placed on the
node get `<name>.<default_domain>` (pinned → node's base; spread →
union of eligible nodes). `serviceDomainNames` resolves them so
routing, wake placeholders, and `public_url` all pick them up.
CLI `nodes update --domain`, MCP arg, node-detail editor.
## 8. Phase G — Platform polish ## 8. Phase G — Platform polish
@@ -419,8 +436,11 @@ and wired refs, one click or one CLI call.
filters. Applied: audit logs (server-backed), builds (server + client filters. Applied: audit logs (server-backed), builds (server + client
date-range), activity (severity server + client category/search). date-range), activity (severity server + client category/search).
Notifications table has no list surface yet — apply when one lands. Notifications table has no list surface yet — apply when one lands.
- [ ] **Notification producers round-out** — backups, agent offline, - [x] **Notification producers round-out** — all shipped: deployments
upgrade available, security findings. (deployments.go), backups (databases.go `createBackupProcess` → owner),
agent offline/online sweep (`reconcileAgentHealth` → admins),
upgrade-available (`checkForUpgrade`, tag-persisted) and security-scan
findings (`scanner.OnScanComplete` → project owner).
- [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) — - [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) —
`services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper `services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper
polls container NetworkIO deltas; idle past threshold → workload polls container NetworkIO deltas; idle past threshold → workload
@@ -485,8 +505,9 @@ and wired refs, one click or one CLI call.
a prior deployment, database start/stop/restart/backup-now. a prior deployment, database start/stop/restart/backup-now.
- **Observe** — polling log tail (300 lines, 5s refresh), per-service - **Observe** — polling log tail (300 lines, 5s refresh), per-service
deployment history, env-doctor issues surfaced on the service view. deployment history, env-doctor issues surfaced on the service view.
- [ ] **env var edit + apply-redeploy, domain management, replica - [x] **env var edit + domain management + replica scaling** (PR #61 —
scaling, cron execution history** — next iteration. mobile v1.5: variables editor, domains screen, scale + sleep
settings, notifications tab). **cron execution history** still open.
- [ ] **Push notifications** — zero-infra path = self-hosted - [ ] **Push notifications** — zero-infra path = self-hosted
ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional. ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional.
- [ ] **Offline resilience** — cached last-known state + unreachable - [ ] **Offline resilience** — cached last-known state + unreachable
+6 -4
View File
@@ -1,5 +1,7 @@
# Build Go API binary # Build Go API binary
FROM golang:1.26-alpine AS go-builder ARG GO_IMAGE=golang:1.26-alpine
ARG NODE_IMAGE=node:22-alpine
FROM ${GO_IMAGE} AS go-builder
RUN apk add --no-cache git ca-certificates tzdata RUN apk add --no-cache git ca-certificates tzdata
@@ -18,7 +20,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X main.version=${V
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-X main.version=${VERSION}" -o /agents/containr-agent-linux-arm64 ./cmd/agent CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-X main.version=${VERSION}" -o /agents/containr-agent-linux-arm64 ./cmd/agent
# Build embedded Better Auth runtime dependencies # Build embedded Better Auth runtime dependencies
FROM node:22-alpine AS auth-builder FROM ${NODE_IMAGE} AS auth-builder
WORKDIR /auth WORKDIR /auth
@@ -28,9 +30,9 @@ RUN npm ci --omit=dev
COPY auth/src ./src COPY auth/src ./src
# Final runtime image (Go API + Better Auth sidecar in one container) # Final runtime image (Go API + Better Auth sidecar in one container)
FROM node:22-alpine FROM ${NODE_IMAGE}
RUN apk --no-cache add ca-certificates tzdata wget RUN apk --no-cache add ca-certificates tzdata wget git
RUN addgroup -g 1001 -S appgroup && \ RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup adduser -u 1001 -S appuser -G appgroup
+129 -2
View File
@@ -316,7 +316,7 @@ func processCommands(ctx context.Context, client *http.Client, cfg agentConfig,
if item.Type == "self_upgrade" { if item.Type == "self_upgrade" {
result, reexecPath, runErr = selfUpgrade(cfg, item.Payload) result, reexecPath, runErr = selfUpgrade(cfg, item.Payload)
} else { } else {
result, runErr = executeCommand(item) result, runErr = executeCommand(cfg, item)
} }
status := "completed" status := "completed"
errText := "" errText := ""
@@ -343,7 +343,7 @@ func processCommands(ctx context.Context, client *http.Client, cfg agentConfig,
return nil return nil
} }
func executeCommand(item command) (string, error) { func executeCommand(cfg agentConfig, item command) (string, error) {
switch item.Type { switch item.Type {
case "create_container": case "create_container":
return createContainer(item.Payload) return createContainer(item.Payload)
@@ -360,6 +360,10 @@ func executeCommand(item command) (string, error) {
return out, nil return out, nil
} }
return out, err return out, err
case "build_image":
return buildImage(cfg, item.Payload)
case "load_image":
return loadImage(cfg, item.Payload)
case "prune": case "prune":
return dockerPrune(item.Payload) return dockerPrune(item.Payload)
case "system_df": case "system_df":
@@ -568,6 +572,120 @@ func createContainer(payload map[string]interface{}) (string, error) {
return string(out), nil 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 // registryHostOf mirrors the server's registryHost: first path segment with
// a "." or ":" is a registry host; bare names mean Docker Hub. // a "." or ":" is a registry host; bare names mean Docker Hub.
func registryHostOf(imageRef string) string { func registryHostOf(imageRef string) string {
@@ -779,6 +897,15 @@ func runDocker(args ...string) (string, error) {
return strings.TrimSpace(string(output)), err return strings.TrimSpace(string(output)), err
} }
// runDockerStdin pipes stdin into docker — build contexts and saved image
// tarballs stream in without a second copy on disk.
func runDockerStdin(stdin io.Reader, args ...string) (string, error) {
cmd := exec.Command("docker", args...)
cmd.Stdin = stdin
output, err := cmd.CombinedOutput()
return strings.TrimSpace(string(output)), err
}
func dockerAvailable() bool { func dockerAvailable() bool {
_, err := exec.LookPath("docker") _, err := exec.LookPath("docker")
if err != nil { if err != nil {
+63 -32
View File
@@ -30,21 +30,24 @@ import (
// NodeAgent represents a container orchestration agent // NodeAgent represents a container orchestration agent
type NodeAgent struct { type NodeAgent struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
IPAddress string `json:"ip_address"` IPAddress string `json:"ip_address"`
Port int `json:"port"` Port int `json:"port"`
Status string `json:"status"` Status string `json:"status"`
Version string `json:"version"` Version string `json:"version"`
Capabilities AgentCapabilities `json:"capabilities"` Capabilities AgentCapabilities `json:"capabilities"`
Resources NodeResources `json:"resources"` Resources NodeResources `json:"resources"`
LastHeartbeat time.Time `json:"last_heartbeat"` LastHeartbeat time.Time `json:"last_heartbeat"`
AutoPrune bool `json:"auto_prune"` AutoPrune bool `json:"auto_prune"`
Schedulable bool `json:"schedulable"` Schedulable bool `json:"schedulable"`
// Tags are operator-set placement labels; services with placement_tags // Tags are operator-set placement labels; services with placement_tags
// only schedule onto agents carrying every required tag. // only schedule onto agents carrying every required tag.
Tags []string `json:"tags"` Tags []string `json:"tags"`
// DefaultDomain is this node's base domain — services placed here with
// no explicit domains get <name>.<default_domain> automatically.
DefaultDomain string `json:"default_domain"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata"` Metadata map[string]interface{} `json:"metadata"`
@@ -272,17 +275,18 @@ func (h *NodeAgentHandler) ListNodeOptions(c *gin.Context) {
func agentFromRow(row sqlcdb.NodeAgent) NodeAgent { func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
agent := NodeAgent{ agent := NodeAgent{
ID: row.ID, ID: row.ID,
Name: row.Name, Name: row.Name,
Hostname: row.Hostname, Hostname: row.Hostname,
IPAddress: row.IpAddress, IPAddress: row.IpAddress,
Port: int(row.Port), Port: int(row.Port),
Status: row.Status.String, Status: row.Status.String,
Version: row.Version.String, Version: row.Version.String,
AutoPrune: row.AutoPrune, AutoPrune: row.AutoPrune,
Schedulable: row.Schedulable, Schedulable: row.Schedulable,
Tags: []string{}, Tags: []string{},
Metadata: map[string]interface{}{}, DefaultDomain: row.DefaultDomain,
Metadata: map[string]interface{}{},
} }
if row.LastHeartbeat.Valid { if row.LastHeartbeat.Valid {
agent.LastHeartbeat = row.LastHeartbeat.Time agent.LastHeartbeat = row.LastHeartbeat.Time
@@ -572,6 +576,18 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
// Tags persist through their own column write — UpdateAgent keeps the // Tags persist through their own column write — UpdateAgent keeps the
// register-path signature untouched. // register-path signature untouched.
resp := agentFromRow(updated) 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 { if raw, ok := updates["tags"]; ok {
var tags []string var tags []string
if b, mErr := json.Marshal(raw); mErr == nil { if b, mErr := json.Marshal(raw); mErr == nil {
@@ -769,7 +785,9 @@ func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
} }
// scrubCommandSecrets rewrites a completed command's payload with any // scrubCommandSecrets rewrites a completed command's payload with any
// registry password blanked. Best-effort — a failed scrub leaves the row. // 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) { func scrubCommandSecrets(ctx context.Context, q *sqlcdb.Queries, commandID string, payload pqtype.NullRawMessage) {
if !payload.Valid || len(payload.RawMessage) == 0 { if !payload.Valid || len(payload.RawMessage) == 0 {
return return
@@ -778,15 +796,24 @@ func scrubCommandSecrets(ctx context.Context, q *sqlcdb.Queries, commandID strin
if err := json.Unmarshal(payload.RawMessage, &doc); err != nil { if err := json.Unmarshal(payload.RawMessage, &doc); err != nil {
return return
} }
container, ok := doc["container"].(map[string]interface{}) dirty := false
if !ok { 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 return
} }
reg, ok := container["registry"].(map[string]interface{})
if !ok || reg["password"] == nil {
return
}
reg["password"] = "***"
if err := q.ScrubCommandPayload(ctx, sqlcdb.ScrubCommandPayloadParams{ if err := q.ScrubCommandPayload(ctx, sqlcdb.ScrubCommandPayloadParams{
ID: commandID, ID: commandID,
Payload: rawJSON(doc), Payload: rawJSON(doc),
@@ -1458,6 +1485,10 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
// public; the enroll token is the actual secret. // public; the enroll token is the actual secret.
agents.GET("/install.sh", h.ServeInstallScript) agents.GET("/install.sh", h.ServeInstallScript)
agents.GET("/download/:platform", h.ServeAgentBinary) 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)
} }
} }
+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()))
}
}
}
+121 -28
View File
@@ -5,11 +5,15 @@ import (
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue" "containr/internal/deployqueue"
"containr/internal/docker" "containr/internal/docker"
"containr/internal/source"
"containr/internal/types"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -377,6 +381,23 @@ func runDeploymentAndSyncWithImage(
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute) ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
defer cancel() defer cancel()
// The checkout and any shipped artifacts die with this deployment.
var artifactIDs []string
checkoutRoot := filepath.Join(containrWorkDir, "repos", dbDeployment.ID.String())
defer func() {
_ = os.RemoveAll(checkoutRoot)
for _, id := range artifactIDs {
removeArtifact(id)
}
}()
failDeploy := func(msg string) {
failedAt := time.Now()
_, _ = db.Exec(
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
msg, failedAt, dbDeployment.ID,
)
}
sourcePath := strings.TrimSpace(service.BuildPath) sourcePath := strings.TrimSpace(service.BuildPath)
if sourcePath == "" { if sourcePath == "" {
sourcePath = "." sourcePath = "."
@@ -421,19 +442,31 @@ func runDeploymentAndSyncWithImage(
maintURL = maintenanceURL(db) maintURL = maintenanceURL(db)
} }
// Node-pinned services dispatch to the agent's command queue. Builds run // Node-pinned and spread services dispatch to agent command queues.
// on the local host, so remote placement only works for registry-pulled
// images — git-sourced and rollback deploys fail fast with that message.
nodeID := serviceNodeID(db, service.ID) nodeID := serviceNodeID(db, service.ID)
spread := serviceSpread(db, service.ID) spread := serviceSpread(db, service.ID)
if (nodeID != "" || spread) && (service.GitRepo != "" || imageOverride != "") { remote := nodeID != "" || spread
failedAt := time.Now()
msg := "service uses remote node placement but remote nodes only run registry-pulled images (git builds and rollbacks stay local — build-on-node is not yet supported)" // Materialize the git checkout — local builds consume it directly;
_, _ = db.Exec( // remote builds package it into a context artifact the node builds.
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`, if service.GitRepo != "" && imageOverride == "" {
msg, failedAt, dbDeployment.ID, var ownerID string
) _ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
return branch := req.Branch
if branch == "" {
branch = service.GitBranch
}
cloneSpec, err := resolveGitCloneSpec(db, ownerID, service.GitRepo, branch, req.CommitHash, service.BuildPath)
if err != nil {
failDeploy("resolve git source: " + err.Error())
return
}
src, err := source.Checkout(ctx, checkoutRoot, cloneSpec)
if err != nil {
failDeploy("git checkout: " + err.Error())
return
}
sourcePath = src
} }
deployReq := &deployment.DeploymentRequest{ deployReq := &deployment.DeploymentRequest{
@@ -480,14 +513,36 @@ func runDeploymentAndSyncWithImage(
if image == "" { if image == "" {
image = service.Image image = service.Image
} }
var ownerID string if remote && strings.HasPrefix(image, "containr-") {
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID) // Locally-built tag — no registry serves it. Ship the image
auth := registryAuthFor(db, ownerID, image) // itself as a docker-save artifact; the node loads it.
deployReq.BuildConfig = &deployment.BuildConfig{ rc, err := engine.DockerClient().SaveImage(ctx, image)
BuildType: "prebuilt", if err != nil {
PrebuiltImage: image, failDeploy("export rollback image: " + err.Error())
PullUsername: auth.Username, return
PullPassword: auth.Password, }
artifactID, err := putArtifact(rc)
_ = rc.Close()
if err != nil {
failDeploy("store rollback image artifact: " + err.Error())
return
}
artifactIDs = append(artifactIDs, artifactID)
deployReq.Config.RemoteLoad = &deployment.RemoteLoadSpec{ArtifactID: artifactID}
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "remote",
PrebuiltImage: image,
}
} else {
var ownerID string
_ = 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 { } else {
// "auto" leaves BuildType empty so the manager detects it; the static // "auto" leaves BuildType empty so the manager detects it; the static
@@ -497,16 +552,54 @@ func runDeploymentAndSyncWithImage(
if service.Builder != "" && service.Builder != "auto" { if service.Builder != "" && service.Builder != "auto" {
buildType = service.Builder buildType = service.Builder
} }
deployReq.BuildConfig = &deployment.BuildConfig{ buildArgs := map[string]string{}
BuildType: buildType, buildCommand := ""
SourcePath: sourcePath,
Branch: req.Branch,
Commit: req.CommitHash,
NoCache: req.NoCache,
}
if buildType == "static" { if buildType == "static" {
deployReq.BuildConfig.BuildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build") buildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build")
deployReq.BuildConfig.BuildArgs = map[string]string{"STATIC_DIR": firstNonEmpty(service.StaticDir, "dist")} buildArgs["STATIC_DIR"] = firstNonEmpty(service.StaticDir, "dist")
}
if remote {
// Package the context — the node docker-builds it itself.
packReq := &types.BuildRequest{
BuildType: buildType,
SourcePath: sourcePath,
BuildCommand: buildCommand,
BuildArgs: buildArgs,
Environment: env,
}
rc, err := engine.BuildManager().PackageContext(ctx, packReq)
if err != nil {
failDeploy("package build context: " + err.Error())
return
}
artifactID, err := putArtifact(rc)
_ = rc.Close()
if err != nil {
failDeploy("store build context artifact: " + err.Error())
return
}
artifactIDs = append(artifactIDs, artifactID)
deployReq.Config.RemoteBuild = &deployment.RemoteBuildSpec{
ArtifactID: artifactID,
BuildArgs: buildArgs,
NoCache: req.NoCache,
}
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "remote",
Branch: req.Branch,
Commit: req.CommitHash,
NoCache: req.NoCache,
}
} else {
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: buildType,
SourcePath: sourcePath,
BuildCommand: buildCommand,
BuildArgs: buildArgs,
Branch: req.Branch,
Commit: req.CommitHash,
NoCache: req.NoCache,
}
} }
} }
+68
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"net" "net"
"net/http" "net/http"
"regexp" "regexp"
@@ -10,6 +11,7 @@ import (
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -68,6 +70,72 @@ func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) [
if len(out) == 0 && fallback != "" { if len(out) == 0 && fallback != "" {
out = []string{fallback} out = []string{fallback}
} }
if len(out) == 0 {
out = serviceAutoDomains(db, serviceID)
}
return out
}
// dnsLabel normalizes a service name into a DNS label: lowercase alnum
// plus single dashes, never leading/trailing, ≤63 chars.
func dnsLabel(name string) string {
var b strings.Builder
lastWasSep := false
for _, r := range strings.ToLower(strings.TrimSpace(name)) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastWasSep = false
continue
}
if !lastWasSep && b.Len() > 0 {
b.WriteByte('-')
lastWasSep = true
}
}
out := strings.Trim(b.String(), "-")
if len(out) > 63 {
out = strings.Trim(out[:63], "-")
}
return out
}
// serviceAutoDomains generates <service>.<node-default-domain> hostnames
// for services with no explicit domains. Pinned services take their node's
// base domain; spread services union every eligible node's base domain.
// Local services get nothing — there is no instance-level base domain yet.
func serviceAutoDomains(db *database.DB, serviceID uuid.UUID) []string {
var name string
var nodeID sql.NullString
var spread bool
if err := db.QueryRow(
`SELECT name, node_id, COALESCE(spread, false) FROM services WHERE id = $1`, serviceID,
).Scan(&name, &nodeID, &spread); err != nil {
return nil
}
label := dnsLabel(name)
if label == "" {
return nil
}
var bases []string
ctx := context.Background()
q := sqlcdb.New(db.DB)
switch {
case nodeID.Valid && nodeID.String != "":
if a, err := q.GetAgent(ctx, nodeID.String); err == nil && a.DefaultDomain != "" {
bases = []string{a.DefaultDomain}
}
case spread:
var err error
bases, err = q.ListSchedulableAgentDomains(ctx, json.RawMessage(tagsJSON(servicePlacementTags(db, serviceID))))
if err != nil {
bases = nil
}
}
out := make([]string, 0, len(bases))
for _, base := range bases {
out = append(out, label+"."+base)
}
return out return out
} }
+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))
}
}
+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
}
+44 -1
View File
@@ -157,6 +157,45 @@ func (r *agentNodeRunner) reconcileOnAgents(ctx context.Context, spec deployment
}) })
} }
// 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"} state := &deployment.RuntimeState{Desired: replicas, Status: "running"}
for i := 0; i < replicas; i++ { for i := 0; i < replicas; i++ {
agentID := targetFor(i) agentID := targetFor(i)
@@ -375,6 +414,10 @@ func (r *agentNodeRunner) RetireService(ctx context.Context, serviceID string) e
// poll on their own interval (≥5s), so this blocks — it runs inside the // poll on their own interval (≥5s), so this blocks — it runs inside the
// deploy queue worker, never on a request path. // 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) { 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 var cid sql.NullString
if containerID != "" { if containerID != "" {
cid = sql.NullString{String: containerID, Valid: true} cid = sql.NullString{String: containerID, Valid: true}
@@ -390,7 +433,7 @@ func (r *agentNodeRunner) enqueueAndWait(ctx context.Context, agentID, container
return "", err return "", err
} }
deadline := time.Now().Add(8 * time.Minute) // image pulls on a cold node deadline := time.Now().Add(timeout) // image pulls/builds on a cold node
for { for {
row, err := r.q.GetCommandForAgent(ctx, sqlcdb.GetCommandForAgentParams{ row, err := r.q.GetCommandForAgent(ctx, sqlcdb.GetCommandForAgentParams{
ID: cmd.ID, ID: cmd.ID,
+4
View File
@@ -5,6 +5,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"path/filepath"
"time" "time"
"containr/internal/build" "containr/internal/build"
@@ -43,6 +44,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
dockerClient = client dockerClient = client
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient) buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient) deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
initArtifacts(filepath.Join("/tmp/containr-builds", "artifacts"))
purgeStaleArtifacts()
// Node-pinned services dispatch container lifecycle to the agent's // Node-pinned services dispatch container lifecycle to the agent's
// command queue instead of the local Docker socket. // command queue instead of the local Docker socket.
deploymentEngine.SetNodeRunner(newAgentNodeRunner(db)) deploymentEngine.SetNodeRunner(newAgentNodeRunner(db))
@@ -484,6 +487,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.POST("/templates/:id/deploy", handleCreateFromTemplate) authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
authed.POST("/templates/:id/plan", handlePlanTemplate) authed.POST("/templates/:id/plan", handlePlanTemplate)
authed.POST("/templates/import/compose", handleImportCompose) authed.POST("/templates/import/compose", handleImportCompose)
authed.POST("/templates/import/git", handleImportGitCompose)
authed.POST("/templates/deploy", handleDeployInlineGraph) authed.POST("/templates/deploy", handleDeployInlineGraph)
authed.POST("/cron-jobs", handleCreateCronJob) authed.POST("/cron-jobs", handleCreateCronJob)
+3
View File
@@ -121,6 +121,7 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
spec.Volumes = loadServiceVolumes(db, service.ID) spec.Volumes = loadServiceVolumes(db, service.ID)
spec.Domains = serviceDomainNames(db, service.ID, service.Domain) spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID) spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
spec.TraefikLabels = serviceTraefikLabels(db, service.ID)
if spec.Maintenance { if spec.Maintenance {
spec.MaintenanceURL = maintenanceURL(db) spec.MaintenanceURL = maintenanceURL(db)
} }
@@ -658,6 +659,8 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
persistPublishedPort(db, service.ID, state.Ports) persistPublishedPort(db, service.ID, state.Ports)
if service.Domain != "" { if service.Domain != "" {
service.PublicURL = "https://" + service.Domain service.PublicURL = "https://" + service.Domain
} else if auto := serviceAutoDomains(db, service.ID); len(auto) > 0 {
service.PublicURL = "https://" + auto[0]
} else if len(state.URLs) > 0 { } else if len(state.URLs) > 0 {
service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c)) service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c))
} }
+127 -6
View File
@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"regexp"
"strings" "strings"
"time" "time"
@@ -58,9 +59,12 @@ type Service struct {
Spread bool `json:"spread" db:"-"` Spread bool `json:"spread" db:"-"`
// PlacementTags restrict auto/spread candidates to agents carrying all // PlacementTags restrict auto/spread candidates to agents carrying all
// listed tags. An explicit pin overrides them — pin + tags is rejected. // listed tags. An explicit pin overrides them — pin + tags is rejected.
PlacementTags []string `json:"placement_tags,omitempty" db:"-"` PlacementTags []string `json:"placement_tags,omitempty" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"` // TraefikLabels holds per-service middleware overrides —
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` // "middlewares.<name>.<type>.<field>" → value. Lazy-read.
TraefikLabels map[string]string `json:"traefik_labels,omitempty" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
// loadServiceSleep fills the sleep-mode columns, which live outside the // loadServiceSleep fills the sleep-mode columns, which live outside the
@@ -123,6 +127,78 @@ func normalizePlacementTags(tags []string) []string {
return out 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. // tagsJSON marshals a tag list for a jsonb column; empty stays an array, not null.
func tagsJSON(tags []string) []byte { func tagsJSON(tags []string) []byte {
if len(tags) == 0 { if len(tags) == 0 {
@@ -139,6 +215,7 @@ func loadServiceNode(db *database.DB, s *Service) {
s.NodeID = serviceNodeID(db, s.ID) s.NodeID = serviceNodeID(db, s.ID)
s.Spread = serviceSpread(db, s.ID) s.Spread = serviceSpread(db, s.ID)
s.PlacementTags = servicePlacementTags(db, s.ID) s.PlacementTags = servicePlacementTags(db, s.ID)
s.TraefikLabels = serviceTraefikLabels(db, s.ID)
if s.NodeID == "" { if s.NodeID == "" {
return return
} }
@@ -224,6 +301,10 @@ type CreateServiceRequest struct {
MemoryReserve string `json:"memory_reserve"` MemoryReserve string `json:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd"` StaticBuildCmd string `json:"static_build_cmd"`
StaticDir string `json:"static_dir"` StaticDir string `json:"static_dir"`
// SleepEnabled/SleepIdleMinutes opt the service into scale-to-zero at
// create time — previously update-only, which surprised API/CLI users.
SleepEnabled *bool `json:"sleep_enabled"`
SleepIdleMinutes *int `json:"sleep_idle_minutes"`
// NodeID pins placement to a node agent; "auto" picks the least-loaded // NodeID pins placement to a node agent; "auto" picks the least-loaded
// online agent, "local"/"" runs on the Containr host. // online agent, "local"/"" runs on the Containr host.
NodeID string `json:"node_id"` NodeID string `json:"node_id"`
@@ -233,6 +314,10 @@ type CreateServiceRequest struct {
// PlacementTags restrict auto/spread candidates to agents carrying all // PlacementTags restrict auto/spread candidates to agents carrying all
// listed tags. Mutually exclusive with an explicit node pin. // listed tags. Mutually exclusive with an explicit node pin.
PlacementTags []string `json:"placement_tags"` PlacementTags []string `json:"placement_tags"`
// TraefikLabels defines extra Traefik middlewares for this service's
// router — keys "middlewares.<name>.<type>[.<field>]", validated against
// an allowlist. Defined middlewares attach to the router automatically.
TraefikLabels map[string]string `json:"traefik_labels"`
} }
// UpdateServiceRequest represents a request to update a service // UpdateServiceRequest represents a request to update a service
@@ -271,6 +356,9 @@ type UpdateServiceRequest struct {
// listed tags; pointer so [] clears the requirement. Mutually exclusive // listed tags; pointer so [] clears the requirement. Mutually exclusive
// with an explicit node pin. // with an explicit node pin.
PlacementTags *[]string `json:"placement_tags"` PlacementTags *[]string `json:"placement_tags"`
// TraefikLabels replaces the service's middleware overrides; pointer so
// {} clears them and nil leaves them untouched.
TraefikLabels *map[string]string `json:"traefik_labels"`
} }
// handleGetServices retrieves all services for a project // handleGetServices retrieves all services for a project
@@ -474,6 +562,14 @@ func handleCreateService(c *gin.Context) {
if service.Builder == "static" && service.StaticDir == "" { if service.Builder == "static" && service.StaticDir == "" {
service.StaticDir = "dist" service.StaticDir = "dist"
} }
if len(req.TraefikLabels) > 0 {
labels, err := normalizeTraefikLabels(req.TraefikLabels)
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
service.TraefikLabels = labels
}
for label, check := range map[string]func(string) error{ for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec, "cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec, "memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
@@ -511,6 +607,16 @@ func handleCreateService(c *gin.Context) {
sourceType := inferServiceSourceType(service) sourceType := inferServiceSourceType(service)
if req.SleepEnabled != nil {
service.SleepEnabled = *req.SleepEnabled
}
if req.SleepIdleMinutes != nil {
if *req.SleepIdleMinutes < 1 || *req.SleepIdleMinutes > 1440 {
c.JSON(http.StatusBadRequest, gin.H{"error": "sleep_idle_minutes must be 1-1440", "code": "VALIDATION"})
return
}
service.SleepIdleMinutes = *req.SleepIdleMinutes
}
if req.Spread != nil { if req.Spread != nil {
service.Spread = *req.Spread service.Spread = *req.Spread
} }
@@ -542,9 +648,10 @@ func handleCreateService(c *gin.Context) {
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain, git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, volumes, healthcheck_path, restart_policy, volumes,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir, builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
node_id, spread, placement_tags, created_at, updated_at) node_id, spread, placement_tags, sleep_enabled, sleep_idle_minutes,
traefik_labels, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
$27, $28, $29, $30, $31, $32, $33, $34, $35, $36)`, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39)`,
service.ID, service.ProjectID, service.Name, environmentID, service.Type, service.ID, service.ProjectID, service.Name, environmentID, service.Type,
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image, sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
"", service.Command, service.Type, service.Status, service.Image, service.Command, "", service.Command, service.Type, service.Status, service.Image, service.Command,
@@ -555,6 +662,9 @@ func handleCreateService(c *gin.Context) {
sql.NullString{String: service.NodeID, Valid: service.NodeID != ""}, sql.NullString{String: service.NodeID, Valid: service.NodeID != ""},
service.Spread, service.Spread,
tagsJSON(service.PlacementTags), tagsJSON(service.PlacementTags),
service.SleepEnabled,
sql.NullInt32{Int32: int32(service.SleepIdleMinutes), Valid: service.SleepIdleMinutes > 0},
traefikLabelsJSON(service.TraefikLabels),
service.CreatedAt, service.UpdatedAt, service.CreatedAt, service.UpdatedAt,
) )
@@ -831,6 +941,16 @@ func handleUpdateService(c *gin.Context) {
} }
existingService.SleepIdleMinutes = *req.SleepIdleMinutes existingService.SleepIdleMinutes = *req.SleepIdleMinutes
} }
if req.TraefikLabels != nil {
labels, err := normalizeTraefikLabels(*req.TraefikLabels)
if err != nil {
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
return
}
existingService.TraefikLabels = labels
} else {
existingService.TraefikLabels = serviceTraefikLabels(db.(*database.DB), serviceID)
}
for label, check := range map[string]func(string) error{ for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec, "cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec, "memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
@@ -907,7 +1027,7 @@ func handleUpdateService(c *gin.Context) {
builder = $19, cpu_reserve = $20, memory_reserve = $21, builder = $19, cpu_reserve = $20, memory_reserve = $21,
static_build_cmd = $22, static_dir = $23, static_build_cmd = $22, static_dir = $23,
sleep_enabled = $24, sleep_idle_minutes = $25, node_id = $26, spread = $27, sleep_enabled = $24, sleep_idle_minutes = $25, node_id = $26, spread = $27,
placement_tags = $28::jsonb placement_tags = $28::jsonb, traefik_labels = $29::jsonb
WHERE id = $18`, WHERE id = $18`,
existingService.Name, existingService.Type, existingService.Image, existingService.Command, existingService.Name, existingService.Type, existingService.Image, existingService.Command,
existingService.Environment, existingService.GitRepo, existingService.GitBranch, existingService.Environment, existingService.GitRepo, existingService.GitBranch,
@@ -921,6 +1041,7 @@ func handleUpdateService(c *gin.Context) {
sql.NullString{String: existingService.NodeID, Valid: existingService.NodeID != ""}, sql.NullString{String: existingService.NodeID, Valid: existingService.NodeID != ""},
existingService.Spread, existingService.Spread,
tagsJSON(existingService.PlacementTags), tagsJSON(existingService.PlacementTags),
traefikLabelsJSON(existingService.TraefikLabels),
) )
if err != nil { if err != nil {
@@ -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")
}
}
+63 -23
View File
@@ -264,9 +264,20 @@ func putToSleep(ctx context.Context, db *database.DB, dc *docker.Client, engine
} }
} }
// wakeCGIScript is installed as /cgi-bin/wake in the wake container. It
// fires the wake callback on the first reachable candidate URL, in the
// background, then serves the reloading placeholder page. Deliberately
// single-quote-free — the file is written through printf '%s'.
const wakeCGIScript = `#!/bin/sh
for u in $WAKE_URLS; do wget -q -O /dev/null --timeout=20 "$u" && break; done >/dev/null 2>&1 &
printf "Content-Type: text/html\r\n\r\n"
printf "%s" "$WAKE_PAGE"
`
// spawnWakeContainer runs a busybox httpd holding the service's Traefik // spawnWakeContainer runs a busybox httpd holding the service's Traefik
// host rule at higher priority. On boot it calls the wake endpoint, then // host rule at higher priority. Inbound hits redirect to /cgi-bin/wake,
// keeps serving a reloading page until the next reconcile removes it. // which calls the internal wake endpoint and serves a reloading page
// until the reconcile removes the placeholder.
func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, svc sleepCandidate, domains []string, apiPort int) error { func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client, engine *deployment.DeploymentEngine, svc sleepCandidate, domains []string, apiPort int) error {
edgeNet := "" edgeNet := ""
for _, n := range networkList(ctx, dc) { for _, n := range networkList(ctx, dc) {
@@ -296,19 +307,25 @@ func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client,
"traefik.http.services." + router + ".loadbalancer.server.port": "80", "traefik.http.services." + router + ".loadbalancer.server.port": "80",
} }
wakeURL := wakeEndpointURL(ctx, dc, apiPort, svc.ID, edgeNet) wakeURLs := wakeEndpointURLs(ctx, dc, apiPort, svc.ID, edgeNet)
page := `<!doctype html><html><head><meta http-equiv="refresh" content="4"><title>Waking up</title>` + page := `<!doctype html><html><head><meta http-equiv="refresh" content="4"><title>Waking up</title>` +
`<style>body{font-family:system-ui,sans-serif;background:#0b0d12;color:#dde2ea;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}` + `<style>body{font-family:system-ui,sans-serif;background:#0b0d12;color:#dde2ea;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}` +
`.card{text-align:center;max-width:34rem;padding:2rem}h1{font-size:1.25rem;font-weight:600}p{color:#98a2b3}</style></head>` + `.card{text-align:center;max-width:34rem;padding:2rem}h1{font-size:1.25rem;font-weight:600}p{color:#98a2b3}</style></head>` +
`<body><div class="card"><h1>Service is waking up</h1><p>This Containr service was sleeping and is resuming now. The page reloads automatically.</p></div></body></html>` `<body><div class="card"><h1>Service is waking up</h1><p>This Containr service was sleeping and is resuming now. The page reloads automatically.</p></div></body></html>`
// httpd without -f daemonizes, so the sequence is: write page, start // The wake callback must fire when a request arrives, not at boot —
// httpd, fire the wake callback, then hold PID1 on sleep infinity. // firing unconditionally makes the service flap sleep→wake forever.
// index.html redirects to /cgi-bin/wake; the CGI fires the callback
// (first reachable URL wins) and serves the reloading page.
indexPage := `<!doctype html><html><head><meta http-equiv="refresh" content="0;url=/cgi-bin/wake"></head><body></body></html>`
// httpd without -f daemonizes, so PID1 ends on sleep infinity. URLs
// and the page travel via env so the CGI script needs no quoting.
cmd := fmt.Sprintf( cmd := fmt.Sprintf(
`mkdir -p /www && printf '%%s' '%s' > /www/index.html && `+ `mkdir -p /www/cgi-bin && printf '%%s' '%s' > /www/index.html && `+
`httpd -p 80 -h /www && `+ `printf '%%s' '%s' > /www/cgi-bin/wake && chmod +x /www/cgi-bin/wake && `+
`(sleep 2; wget -q -O /dev/null --timeout=20 '%s' 2>/dev/null || true) && `+ `printf '%%s\n' 'E404:/cgi-bin/wake' > /www/httpd.conf && `+
`sleep infinity`, `httpd -p 80 -h /www && sleep infinity`,
strings.ReplaceAll(page, "'", "'\\''"), wakeURL) strings.ReplaceAll(indexPage, "'", "'\\''"),
strings.ReplaceAll(wakeCGIScript, "'", "'\\''"))
endpoints := map[string]*network.EndpointSettings{edgeNet: {}} endpoints := map[string]*network.EndpointSettings{edgeNet: {}}
if ownNet := dc.OwnNetworkName(ctx); ownNet != "" && ownNet != edgeNet { if ownNet := dc.OwnNetworkName(ctx); ownNet != "" && ownNet != edgeNet {
@@ -329,9 +346,13 @@ func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client,
} }
id, err := dc.CreateContainer(ctx, docker.ContainerConfig{ id, err := dc.CreateContainer(ctx, docker.ContainerConfig{
Name: name, Name: name,
Image: wakeImage, Image: wakeImage,
Cmd: []string{"sh", "-c", cmd}, Cmd: []string{"sh", "-c", cmd},
Env: []string{
"WAKE_URLS=" + strings.Join(wakeURLs, " "),
"WAKE_PAGE=" + page,
},
Labels: labels, Labels: labels,
Networks: endpoints, Networks: endpoints,
}) })
@@ -353,25 +374,41 @@ func networkList(ctx context.Context, dc *docker.Client) []string {
return names return names
} }
// wakeEndpointURL builds the internal URL the wake container calls. The // wakeEndpointURLs builds ordered candidate URLs for the wake container to
// edge network's gateway reaches the host's published API port; when the // call, deduplicated. No single address covers every topology: a containerized
// API itself is containerized its own name resolves on the shared network. // API resolves by name on the shared network; Docker Desktop containers reach
func wakeEndpointURL(ctx context.Context, dc *docker.Client, apiPort int, serviceID uuid.UUID, edgeNet string) string { // the host via host.docker.internal; on native Docker the edge network's
// gateway routes to host-published ports. busybox wget walks the chain until
// one answers.
func wakeEndpointURLs(ctx context.Context, dc *docker.Client, apiPort int, serviceID uuid.UUID, edgeNet string) []string {
var hosts []string
if dc.OwnNetworkName(ctx) != "" { if dc.OwnNetworkName(ctx) != "" {
if self, err := dc.GetContainer(ctx, selfID()); err == nil { if self, err := dc.GetContainer(ctx, selfID()); err == nil {
name := strings.TrimPrefix(self.Name, "/") if name := strings.TrimPrefix(self.Name, "/"); name != "" {
if name != "" { hosts = append(hosts, name)
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", name, apiPort, serviceID)
} }
} }
} }
hosts = append(hosts, "host.docker.internal")
if gw := networkGateway(ctx, dc, edgeNet); gw != "" { if gw := networkGateway(ctx, dc, edgeNet); gw != "" {
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", gw, apiPort, serviceID) hosts = append(hosts, gw)
} }
if probe := dc.HostProbeHost(ctx); probe != "" { if probe := dc.HostProbeHost(ctx); probe != "" {
return fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", probe, apiPort, serviceID) hosts = append(hosts, probe)
} }
return fmt.Sprintf("http://127.0.0.1:%d/api/v1/internal/wake/%s", apiPort, serviceID) seen := map[string]bool{}
var urls []string
for _, h := range hosts {
if h == "" || seen[h] {
continue
}
seen[h] = true
urls = append(urls, fmt.Sprintf("http://%s:%d/api/v1/internal/wake/%s", h, apiPort, serviceID))
}
if len(urls) == 0 {
urls = append(urls, fmt.Sprintf("http://127.0.0.1:%d/api/v1/internal/wake/%s", apiPort, serviceID))
}
return urls
} }
func networkGateway(ctx context.Context, dc *docker.Client, name string) string { func networkGateway(ctx context.Context, dc *docker.Client, name string) string {
@@ -490,10 +527,13 @@ func triggerServiceWake(c *gin.Context) (int, gin.H) {
defer cancel() defer cancel()
spec, err := serviceRuntimeSpec(db, service) spec, err := serviceRuntimeSpec(db, service)
if err != nil { if err != nil {
log.Printf("wake: runtime spec for %s failed: %v", service.ID, err)
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
return return
} }
state, err := engine.ReconcileService(ctx, spec) state, err := engine.ReconcileService(ctx, spec)
if err != nil { if err != nil {
log.Printf("wake: reconcile for %s failed: %v", service.ID, err)
_, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID) _, _ = db.Exec(`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
return return
} }
+130 -1
View File
@@ -11,6 +11,8 @@ import (
"log" "log"
"math/big" "math/big"
"net/http" "net/http"
"os"
"path/filepath"
"regexp" "regexp"
"sort" "sort"
"strconv" "strconv"
@@ -22,6 +24,7 @@ import (
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue" "containr/internal/deployqueue"
"containr/internal/secrets" "containr/internal/secrets"
"containr/internal/source"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -227,7 +230,9 @@ func resolveStaticExpr(value string, userVars map[string]string, unresolved *[]s
if val, ok := userVars[key]; ok { if val, ok := userVars[key]; ok {
return val return val
} }
if sub[2] != "" { // {{VAR:-x}} supplies a default; {{VAR:-}} declares an explicitly
// empty one. Both resolve — only a bare {{VAR}} is unresolved.
if strings.Contains(m, ":-") {
return sub[2] return sub[2]
} }
if unresolved != nil { if unresolved != nil {
@@ -1112,6 +1117,130 @@ func handleImportCompose(c *gin.Context) {
}) })
} }
// handleImportGitCompose fetches a compose file out of a git repository and
// runs it through the same conversion as handleImportCompose. Works with any
// git remote — connected providers (including GitHub App installs) supply
// credentials for private repos. Nothing is persisted; the response feeds
// the template create form or a direct deploy.
func handleImportGitCompose(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
var req struct {
Repo string `json:"repo" binding:"required"`
Path string `json:"path"`
Ref string `json:"ref"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
branch, commit := "", ""
if ref := strings.TrimSpace(req.Ref); ref != "" {
if isCommitSHA(ref) {
commit = ref
} else {
branch = ref
}
}
spec, err := resolveGitCloneSpec(db, userID, req.Repo, branch, commit, "")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Minute)
defer cancel()
checkoutRoot := filepath.Join(containrWorkDir, "repos", "import-"+uuid.NewString())
defer os.RemoveAll(checkoutRoot)
repoDir, err := source.Checkout(ctx, checkoutRoot, spec)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "git fetch failed: " + err.Error(), "code": "GIT_FETCH"})
return
}
// Default search order mirrors common compose conventions.
candidates := []string{req.Path}
if strings.TrimSpace(req.Path) == "" {
candidates = []string{"compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"}
}
var raw []byte
for _, rel := range candidates {
p, rerr := resolveRepoFile(repoDir, rel)
if rerr != nil {
continue
}
raw, err = os.ReadFile(p)
if err == nil {
break
}
}
if raw == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "compose file not found in repository", "code": "NOT_FOUND"})
return
}
if len(raw) > 512*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Compose file exceeds 512 KiB"})
return
}
var file composeFileDef
if err := yaml.Unmarshal(raw, &file); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid compose YAML: " + err.Error()})
return
}
services, warnings, convErr := composeToSpecs(&file)
if convErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": convErr.Error()})
return
}
config := TemplateConfig{Version: 2, Services: services}
configJSON, _ := json.Marshal(config)
c.JSON(http.StatusOK, gin.H{
"config": json.RawMessage(configJSON),
"variables": []TemplateVariable{},
"warnings": warnings,
})
}
// resolveRepoFile maps a repo-relative file path onto the checkout and
// rejects escapes — same containment rule as ResolveBuildPath, for files.
func resolveRepoFile(checkoutDir, rel string) (string, error) {
rel = strings.TrimSpace(rel)
if rel == "" || filepath.IsAbs(rel) {
return "", fmt.Errorf("invalid repo path %q", rel)
}
clean := filepath.Clean(rel)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes the checkout: %q", rel)
}
resolved := filepath.Join(checkoutDir, clean)
info, err := os.Stat(resolved)
if err != nil {
return "", fmt.Errorf("path %q not found in checkout", rel)
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("path %q is not a file", rel)
}
return resolved, nil
}
func isCommitSHA(ref string) bool {
if len(ref) != 40 {
return false
}
for _, r := range ref {
if !('0' <= r && r <= '9') && !('a' <= r && r <= 'f') {
return false
}
}
return true
}
// composeToSpecs converts a parsed compose file into graph service specs. // composeToSpecs converts a parsed compose file into graph service specs.
// Compose service names become keys; declaration order follows YAML document // Compose service names become keys; declaration order follows YAML document
// order — yaml.v3 maps lose order, so sort keys for stability. // order — yaml.v3 maps lose order, so sort keys for stability.
@@ -1,6 +1,8 @@
package api package api
import ( import (
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -124,3 +126,42 @@ func TestComposeToSpecsWarnings(t *testing.T) {
t.Errorf("missing env warning: %v", warnings) t.Errorf("missing env warning: %v", warnings)
} }
} }
func TestResolveRepoFileContainment(t *testing.T) {
dir := t.TempDir()
inner := filepath.Join(dir, "sub")
if err := os.MkdirAll(inner, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(inner, "compose.yml"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
p, err := resolveRepoFile(dir, "sub/compose.yml")
if err != nil {
t.Fatalf("valid file rejected: %v", err)
}
if p != filepath.Join(dir, "sub", "compose.yml") {
t.Errorf("unexpected path %q", p)
}
for _, bad := range []string{"", "/etc/passwd", "../escape.yml", "..", "sub/../../out.yml"} {
if _, err := resolveRepoFile(dir, bad); err == nil {
t.Errorf("path %q should have been rejected", bad)
}
}
if _, err := resolveRepoFile(dir, "sub"); err == nil {
t.Error("directory accepted as file")
}
}
func TestIsCommitSHA(t *testing.T) {
if !isCommitSHA("0123456789abcdef0123456789abcdef01234567") {
t.Error("40-hex ref not recognised as a commit")
}
for _, bad := range []string{"main", "v1.2.3", "0123456789abcdef0123456789abcdef0123456", "0123456789abcdef0123456789abcdef0123456G"} {
if isCommitSHA(bad) {
t.Errorf("ref %q misclassified as commit SHA", bad)
}
}
}
+92
View File
@@ -1102,6 +1102,98 @@ func SeedTemplates() []ServiceTemplate {
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`, Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true, IsOfficial: true,
}, },
{
ID: "tpl-n8n",
Name: "n8n + PostgreSQL + Redis",
Description: "Workflow automation in queue mode with managed PostgreSQL and Redis",
Category: "stack",
Logo: "https://cdn.simpleicons.org/n8n",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"n8n","POSTGRES_USER":"n8n"},"volumes":[{"source":"n8n-db","target":"/var/lib/postgresql/data"}]},
{"key":"redis","type":"database","runtime":"redis"},
{"key":"n8n","type":"web","runtime":"docker.n8n.io/n8nio/n8n:latest","port":5678,"health_check":"/healthz","depends_on":["db","redis"],
"environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"5432","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","QUEUE_BULL_REDIS_HOST":"{{service.redis.host}}","QUEUE_BULL_REDIS_PASSWORD":"{{service.redis.password}}","EXECUTIONS_MODE":"queue","N8N_ENCRYPTION_KEY":"{{secret(32)}}","N8N_HOST":"{{APP_URL:-http://localhost:5678}}","N8N_EDITOR_BASE_URL":"{{APP_URL:-http://localhost:5678}}"},
"volumes":[{"source":"n8n-data","target":"/home/node/.n8n"}]},
{"key":"worker","name":"n8n-worker","type":"worker","runtime":"docker.n8n.io/n8nio/n8n:latest","start_command":"worker","depends_on":["n8n","db","redis"],
"environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"5432","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","QUEUE_BULL_REDIS_HOST":"{{service.redis.host}}","QUEUE_BULL_REDIS_PASSWORD":"{{service.redis.password}}","EXECUTIONS_MODE":"queue","N8N_ENCRYPTION_KEY":"{{secret(32)}}","QUEUE_HEALTH_CHECK_ACTIVE":"true"},
"volumes":[{"source":"n8n-data","target":"/home/node/.n8n"}]}]}`,
Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-gitea",
Name: "Gitea + PostgreSQL",
Description: "Self-hosted git service with managed PostgreSQL",
Category: "stack",
Logo: "https://cdn.simpleicons.org/gitea",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"gitea","POSTGRES_USER":"gitea"},"volumes":[{"source":"gitea-db","target":"/var/lib/postgresql/data"}]},
{"key":"gitea","type":"web","runtime":"gitea/gitea:latest","port":3000,"health_check":"/api/healthz","depends_on":["db"],
"environment":{"GITEA__database__DB_TYPE":"postgres","GITEA__database__HOST":"{{service.db.host}}:{{service.db.port}}","GITEA__database__NAME":"{{service.db.database}}","GITEA__database__USER":"{{service.db.user}}","GITEA__database__PASSWD":"{{service.db.password}}","GITEA__security__INSTALL_LOCK":"true","GITEA__security__SECRET_KEY":"{{secret(32)}}","GITEA__security__INTERNAL_TOKEN":"{{secret(64)}}","GITEA__server__DOMAIN":"{{DOMAIN:-localhost}}","GITEA__server__ROOT_URL":"{{ROOT_URL:-http://localhost:3000}}","GITEA__server__SSH_PORT":"2222","USER_UID":"1000","USER_GID":"1000"},
"volumes":[{"source":"gitea-data","target":"/data"}]}]}`,
Variables: `[{"key":"DOMAIN","label":"Public domain","default":"","required":false,"secret":false},{"key":"ROOT_URL","label":"Root URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-nextcloud",
Name: "Nextcloud + PostgreSQL + Redis",
Description: "Self-hosted file sync and collaboration with managed PostgreSQL and Redis",
Category: "stack",
Logo: "https://cdn.simpleicons.org/nextcloud",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"nextcloud","POSTGRES_USER":"nextcloud"},"volumes":[{"source":"nextcloud-db","target":"/var/lib/postgresql/data"}]},
{"key":"redis","type":"database","runtime":"redis"},
{"key":"nextcloud","type":"web","runtime":"nextcloud:latest","port":80,"depends_on":["db","redis"],
"environment":{"POSTGRES_HOST":"{{service.db.host}}","POSTGRES_DB":"{{service.db.database}}","POSTGRES_USER":"{{service.db.user}}","POSTGRES_PASSWORD":"{{service.db.password}}","REDIS_HOST":"{{service.redis.host}}","REDIS_HOST_PASSWORD":"{{service.redis.password}}","NEXTCLOUD_ADMIN_USER":"admin","NEXTCLOUD_ADMIN_PASSWORD":"{{secret(24)}}","NEXTCLOUD_TRUSTED_DOMAINS":"{{DOMAIN:-localhost}}","OVERWRITEHOST":"{{DOMAIN:-}}","OVERWRITEPROTOCOL":"https"},
"volumes":[{"source":"nextcloud-data","target":"/var/www/html"}]}]}`,
Variables: `[{"key":"DOMAIN","label":"Trusted domain","default":"localhost","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-vaultwarden",
Name: "Vaultwarden + PostgreSQL",
Description: "Bitwarden-compatible password manager with managed PostgreSQL",
Category: "stack",
Logo: "https://cdn.simpleicons.org/vaultwarden",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"vaultwarden","POSTGRES_USER":"vaultwarden"},"volumes":[{"source":"vaultwarden-db","target":"/var/lib/postgresql/data"}]},
{"key":"vaultwarden","type":"web","runtime":"vaultwarden/server:latest","port":80,"health_check":"/alive","depends_on":["db"],
"environment":{"DATABASE_URL":"{{service.db.url}}","ADMIN_TOKEN":"{{secret(48)}}","DOMAIN":"{{DOMAIN:-http://localhost:80}}","SIGNUPS_ALLOWED":"false","WEBSOCKET_ENABLED":"true"},
"volumes":[{"source":"vaultwarden-data","target":"/data"}]}]}`,
Variables: `[{"key":"DOMAIN","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-umami",
Name: "Umami + PostgreSQL",
Description: "Lightweight privacy-first web analytics with managed PostgreSQL",
Category: "stack",
Logo: "https://cdn.simpleicons.org/umami",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"umami","POSTGRES_USER":"umami"},"volumes":[{"source":"umami-db","target":"/var/lib/postgresql/data"}]},
{"key":"umami","type":"web","runtime":"ghcr.io/umami-software/umami:postgresql-latest","port":3000,"depends_on":["db"],
"environment":{"DATABASE_URL":"{{service.db.url}}","APP_SECRET":"{{secret(32)}}"}}]}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-immich",
Name: "Immich + PostgreSQL + Redis",
Description: "Self-hosted photo and video backup with managed PostgreSQL and Redis",
Category: "stack",
Logo: "https://cdn.simpleicons.org/immich",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"immich","POSTGRES_USER":"immich"},"volumes":[{"source":"immich-db","target":"/var/lib/postgresql/data"}]},
{"key":"redis","type":"database","runtime":"redis"},
{"key":"ml","name":"immich-machine-learning","type":"worker","runtime":"ghcr.io/immich-app/immich-machine-learning:release",
"environment":{"DB_DATABASE_NAME":"{{service.db.database}}","DB_HOSTNAME":"{{service.db.host}}","DB_USERNAME":"{{service.db.user}}","DB_PASSWORD":"{{service.db.password}}","REDIS_HOSTNAME":"{{service.redis.host}}","REDIS_PASSWORD":"{{service.redis.password}}"},
"volumes":[{"source":"immich-model-cache","target":"/cache"}]},
{"key":"immich","name":"immich-server","type":"web","runtime":"ghcr.io/immich-app/immich-server:release","port":2283,"health_check":"/api/server/ping","depends_on":["db","redis","ml"],
"environment":{"DB_DATABASE_NAME":"{{service.db.database}}","DB_HOSTNAME":"{{service.db.host}}","DB_USERNAME":"{{service.db.user}}","DB_PASSWORD":"{{service.db.password}}","REDIS_HOSTNAME":"{{service.redis.host}}","REDIS_PASSWORD":"{{service.redis.password}}"},
"volumes":[{"source":"immich-upload","target":"/usr/src/app/upload"}]}]}`,
Variables: `[]`,
IsOfficial: true,
},
} }
return templates return templates
} }
+33 -1
View File
@@ -1,6 +1,9 @@
package api package api
import "testing" import (
"encoding/json"
"testing"
)
func TestNormalizeTemplateServiceType(t *testing.T) { func TestNormalizeTemplateServiceType(t *testing.T) {
valid := []string{"web", "worker", "database", "cron"} valid := []string{"web", "worker", "database", "cron"}
@@ -71,3 +74,32 @@ func TestResolveTemplateRuntimeImage(t *testing.T) {
t.Fatalf("expected unknown runtime passthrough, got %q", got) t.Fatalf("expected unknown runtime passthrough, got %q", got)
} }
} }
func TestSeedTemplatesResolveCleanly(t *testing.T) {
for _, tpl := range SeedTemplates() {
var config TemplateConfig
if err := json.Unmarshal([]byte(tpl.Config), &config); err != nil {
t.Fatalf("%s: invalid config JSON: %v", tpl.ID, err)
}
var variables []TemplateVariable
if err := json.Unmarshal([]byte(tpl.Variables), &variables); err != nil {
t.Fatalf("%s: invalid variables JSON: %v", tpl.ID, err)
}
if len(config.Services) == 0 {
continue // legacy single-service template
}
sorted, err := topoSortServices(config.Services)
if err != nil {
t.Fatalf("%s: topo sort: %v", tpl.ID, err)
}
if err := validateTemplateGraph(sorted); err != nil {
t.Fatalf("%s: graph validation: %v", tpl.ID, err)
}
// User vars left empty — every expression must still resolve via
// defaults, secrets, or service refs.
graph := resolveTemplateGraph(sorted, map[string]string{})
if len(graph.unresolved) > 0 {
t.Fatalf("%s: unresolved expressions: %v", tpl.ID, graph.unresolved)
}
}
}
+5 -1
View File
@@ -539,12 +539,16 @@ func createBuildContext(sourcePath, dockerfileContent string) (io.ReadCloser, er
return nil, err return nil, err
} }
// Add source files to tar // Add source files to tar. The checkout's .git metadata is never build
// context — it bloats the tar and ships clone URLs to remote daemons.
err = filepath.Walk(sourcePath, func(file string, fi os.FileInfo, err error) error { err = filepath.Walk(sourcePath, func(file string, fi os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
if fi.IsDir() { if fi.IsDir() {
if fi.Name() == ".git" {
return filepath.SkipDir
}
return nil return nil
} }
+71
View File
@@ -3,6 +3,8 @@ package build
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"os"
"path/filepath" "path/filepath"
"time" "time"
@@ -92,6 +94,75 @@ func (bm *BuildManager) Build(ctx context.Context, req *types.BuildRequest) (*ty
} }
} }
// PackageContext produces the same build-context tar Build would hand the
// local daemon, without building it. Node agents download the tar and run
// docker build themselves — the Dockerfile lands at the tar root.
func (bm *BuildManager) PackageContext(ctx context.Context, req *types.BuildRequest) (io.ReadCloser, error) {
if req.BuildType == "" {
detectedType, err := bm.DetectBuildType(ctx, req.SourcePath)
if err != nil {
return nil, fmt.Errorf("failed to detect build type: %w", err)
}
req.BuildType = string(detectedType)
}
var dockerfileContent string
switch BuildType(req.BuildType) {
case BuildTypeDockerfile:
name, err := bm.dockerfileBuilder.DetectDockerfile(ctx, req.SourcePath)
if err != nil {
return nil, fmt.Errorf("failed to detect Dockerfile: %w", err)
}
path := filepath.Join(req.SourcePath, name)
if optimized, err := bm.dockerfileBuilder.OptimizeDockerfile(ctx, path); err == nil {
path = optimized
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read Dockerfile: %w", err)
}
dockerfileContent = string(data)
case BuildTypeRailpack:
if err := bm.railpackBuilder.DetectRailpack(ctx, req.SourcePath); err != nil {
return nil, fmt.Errorf("Railpack cannot build this project: %w", err)
}
content, err := bm.railpackBuilder.generateDockerfile(ctx, req.SourcePath, &RailpackConfig{
BuildCmd: req.BuildCommand,
StartCmd: req.StartCommand,
Env: req.Environment,
})
if err != nil {
return nil, fmt.Errorf("failed to generate Dockerfile with Railpack: %w", err)
}
dockerfileContent = content
case BuildTypeNixpacks:
plan, err := bm.nixpacksBuilder.GeneratePlan(ctx, req.SourcePath, &NixpacksConfig{
BuildCmd: req.BuildCommand,
StartCmd: req.StartCommand,
Env: req.Environment,
})
if err != nil {
return nil, fmt.Errorf("failed to generate build plan: %w", err)
}
path, err := bm.nixpacksBuilder.generateDockerfile(plan)
if err != nil {
return nil, fmt.Errorf("failed to generate Dockerfile: %w", err)
}
defer os.Remove(path)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read generated Dockerfile: %w", err)
}
dockerfileContent = string(data)
case BuildTypeStatic:
dockerfileContent = staticDockerfile
default:
return nil, fmt.Errorf("unsupported build type for remote packaging: %s", req.BuildType)
}
return createBuildContext(req.SourcePath, dockerfileContent)
}
// buildPrebuilt handles prebuilt image deployments // buildPrebuilt handles prebuilt image deployments
func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) { func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
if req.PrebuiltImage == "" { if req.PrebuiltImage == "" {
+9 -4
View File
@@ -476,17 +476,22 @@ func (n *NixpacksBuilder) Build(ctx context.Context, req *types.BuildRequest) (*
} }
// Create temporary Dockerfile // Create temporary Dockerfile
dockerfile, err := n.generateDockerfile(plan) dockerfilePath, err := n.generateDockerfile(plan)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate Dockerfile: %w", err) return nil, fmt.Errorf("failed to generate Dockerfile: %w", err)
} }
defer os.Remove(dockerfile) defer os.Remove(dockerfilePath)
// Build Docker image // Build Docker image
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag) imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
// Create build context tar // Create build context tar — createBuildContext takes the Dockerfile
buildCtx, err := createBuildContext(req.SourcePath, dockerfile) // contents, not its path.
dockerfileData, err := os.ReadFile(dockerfilePath)
if err != nil {
return nil, fmt.Errorf("failed to read generated Dockerfile: %w", err)
}
buildCtx, err := createBuildContext(req.SourcePath, string(dockerfileData))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create build context: %w", err) return nil, fmt.Errorf("failed to create build context: %w", err)
} }
+6 -2
View File
@@ -84,7 +84,7 @@ var nodesDeleteCmd = &cobra.Command{
var nodesUpdateCmd = &cobra.Command{ var nodesUpdateCmd = &cobra.Command{
Use: "update <id>", Use: "update <id>",
Short: "Update a node agent (--name, --auto-prune, --tags)", Short: "Update a node agent (--name, --auto-prune, --tags, --domain)",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := client() c, err := client()
@@ -108,8 +108,11 @@ var nodesUpdateCmd = &cobra.Command{
} }
body["tags"] = tags body["tags"] = tags
} }
if cmd.Flags().Changed("domain") {
body["default_domain"], _ = cmd.Flags().GetString("domain")
}
if len(body) == 0 { if len(body) == 0 {
return &APIError{Message: "nothing to update — pass --name, --auto-prune or --tags", ExitCode: ExitError} return &APIError{Message: "nothing to update — pass --name, --auto-prune, --tags or --domain", ExitCode: ExitError}
} }
data, err := c.Do("PUT", "/agents/"+args[0], body) data, err := c.Do("PUT", "/agents/"+args[0], body)
if err != nil { if err != nil {
@@ -304,6 +307,7 @@ func init() {
nodesUpdateCmd.Flags().String("name", "", "node display name") nodesUpdateCmd.Flags().String("name", "", "node display name")
nodesUpdateCmd.Flags().Bool("auto-prune", false, "enqueue a daily bounded docker prune") nodesUpdateCmd.Flags().Bool("auto-prune", false, "enqueue a daily bounded docker prune")
nodesUpdateCmd.Flags().String("tags", "", "comma-separated placement tags (services with placement_tags only schedule onto matching nodes)") nodesUpdateCmd.Flags().String("tags", "", "comma-separated placement tags (services with placement_tags only schedule onto matching nodes)")
nodesUpdateCmd.Flags().String("domain", "", "base domain for this node — domainless services placed here get <name>.<domain> automatically (empty clears)")
nodesPruneCmd.Flags().String("until", "", "only prune objects older than this duration (e.g. 168h)") nodesPruneCmd.Flags().String("until", "", "only prune objects older than this duration (e.g. 168h)")
nodesPruneCmd.Flags().Bool("volumes", false, "also prune volumes (destructive — asks twice)") nodesPruneCmd.Flags().Bool("volumes", false, "also prune volumes (destructive — asks twice)")
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd) nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
+45 -1
View File
@@ -117,6 +117,14 @@ var servicesCreateCmd = &cobra.Command{
if v, _ := cmd.Flags().GetBool("spread"); v { if v, _ := cmd.Flags().GetBool("spread"); v {
body["spread"] = v body["spread"] = v
} }
if cmd.Flags().Changed("sleep") {
v, _ := cmd.Flags().GetBool("sleep")
body["sleep_enabled"] = v
}
if cmd.Flags().Changed("sleep-idle") {
v, _ := cmd.Flags().GetInt("sleep-idle")
body["sleep_idle_minutes"] = v
}
if v, _ := cmd.Flags().GetString("placement-tags"); v != "" { if v, _ := cmd.Flags().GetString("placement-tags"); v != "" {
tags := []string{} tags := []string{}
for _, t := range strings.Split(v, ",") { for _, t := range strings.Split(v, ",") {
@@ -126,6 +134,11 @@ var servicesCreateCmd = &cobra.Command{
} }
body["placement_tags"] = tags body["placement_tags"] = tags
} }
if labels, err := traefikLabelFlag(cmd); err != nil {
return err
} else if labels != nil {
body["traefik_labels"] = labels
}
data, err := c.Do("POST", "/projects/"+args[0]+"/services", body) data, err := c.Do("POST", "/projects/"+args[0]+"/services", body)
if err != nil { if err != nil {
return err return err
@@ -140,6 +153,28 @@ var servicesCreateCmd = &cobra.Command{
}, },
} }
// traefikLabelFlag parses --traefik-label key=value pairs into the API's
// map shape. Returns nil when the flag was never passed; an empty map when
// only empty values were given (clears overrides on update).
func traefikLabelFlag(cmd *cobra.Command) (map[string]string, error) {
if !cmd.Flags().Changed("traefik-label") {
return nil, nil
}
pairs, _ := cmd.Flags().GetStringArray("traefik-label")
labels := map[string]string{}
for _, p := range pairs {
if strings.TrimSpace(p) == "" {
continue
}
k, v, ok := strings.Cut(p, "=")
if !ok || strings.TrimSpace(k) == "" {
return nil, &APIError{Message: fmt.Sprintf("invalid --traefik-label %q — want key=value", p), ExitCode: ExitError}
}
labels[strings.TrimSpace(k)] = v
}
return labels, nil
}
var servicesDeleteCmd = &cobra.Command{ var servicesDeleteCmd = &cobra.Command{
Use: "delete <id>", Use: "delete <id>",
Short: "Delete a service", Short: "Delete a service",
@@ -233,6 +268,11 @@ var servicesUpdateCmd = &cobra.Command{
v, _ := cmd.Flags().GetInt("sleep-idle") v, _ := cmd.Flags().GetInt("sleep-idle")
body["sleep_idle_minutes"] = v body["sleep_idle_minutes"] = v
} }
if labels, err := traefikLabelFlag(cmd); err != nil {
return err
} else if labels != nil {
body["traefik_labels"] = labels
}
if cmd.Flags().Changed("maintenance") { if cmd.Flags().Changed("maintenance") {
v, _ := cmd.Flags().GetString("maintenance") v, _ := cmd.Flags().GetString("maintenance")
body["maintenance_mode"] = v == "on" || v == "true" body["maintenance_mode"] = v == "on" || v == "true"
@@ -520,9 +560,12 @@ func init() {
f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)") f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)")
f.String("static-cmd", "", "build command for the static builder") f.String("static-cmd", "", "build command for the static builder")
f.String("static-dir", "", "output dir for the static builder (default dist)") f.String("static-dir", "", "output dir for the static builder (default dist)")
f.String("node", "", "pin to a node agent id, or 'auto' for least-loaded (image services only)") f.String("node", "", "pin to a node agent id, or 'auto' for least-loaded")
f.Bool("spread", false, "distribute replicas across all online schedulable nodes") f.Bool("spread", false, "distribute replicas across all online schedulable nodes")
f.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
f.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)")
f.String("placement-tags", "", "comma-separated node tags required for remote placement (auto/spread only)") f.String("placement-tags", "", "comma-separated node tags required for remote placement (auto/spread only)")
f.StringArray("traefik-label", nil, "Traefik middleware override, key=value (e.g. middlewares.rl.ratelimit.average=100); repeatable")
uf := servicesUpdateCmd.Flags() uf := servicesUpdateCmd.Flags()
uf.String("name", "", "service name") uf.String("name", "", "service name")
@@ -547,6 +590,7 @@ func init() {
uf.String("static-dir", "", "output dir for the static builder (empty clears)") uf.String("static-dir", "", "output dir for the static builder (empty clears)")
uf.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)") uf.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
uf.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)") uf.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)")
uf.StringArray("traefik-label", nil, "Traefik middleware override, key=value; repeatable; empty list clears via --traefik-label ''")
uf.String("node", "", "pin to a node agent id, 'auto' for least-loaded, 'local' to clear") uf.String("node", "", "pin to a node agent id, 'auto' for least-loaded, 'local' to clear")
uf.Bool("spread", false, "spread replicas across all online schedulable nodes (--spread=false to disable)") uf.Bool("spread", false, "spread replicas across all online schedulable nodes (--spread=false to disable)")
uf.String("placement-tags", "", "comma-separated node tags required for remote placement (empty clears)") uf.String("placement-tags", "", "comma-separated node tags required for remote placement (empty clears)")
+28 -1
View File
@@ -194,6 +194,32 @@ var templatesImportComposeCmd = &cobra.Command{
}, },
} }
var templatesImportGitCmd = &cobra.Command{
Use: "import-git <repo> [path]",
Short: "Fetch a compose file from a git repo and convert it into a template graph config",
Long: "repo accepts a clone URL, ssh remote, or owner/repo shorthand resolved via connected git providers (private repos included). path defaults to compose.yaml/yml or docker-compose.yml/yaml. --ref selects a branch or commit.",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
body := map[string]string{"repo": args[0]}
if len(args) > 1 {
body["path"] = args[1]
}
if v, _ := cmd.Flags().GetString("ref"); v != "" {
body["ref"] = v
}
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/templates/import/git", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesDeployGraphCmd = &cobra.Command{ var templatesDeployGraphCmd = &cobra.Command{
Use: "deploy-graph <project-id> <config.json|->", Use: "deploy-graph <project-id> <config.json|->",
Short: "Deploy an ad-hoc service graph (v2 config with services[]) without a stored template", Short: "Deploy an ad-hoc service graph (v2 config with services[]) without a stored template",
@@ -257,5 +283,6 @@ func init() {
templatesDeployCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)") templatesDeployCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
templatesPlanCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)") templatesPlanCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
templatesDeployGraphCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)") templatesDeployGraphCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd, templatesPlanCmd, templatesImportComposeCmd, templatesDeployGraphCmd) templatesImportGitCmd.Flags().String("ref", "", "git branch or commit (default: remote HEAD)")
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd, templatesPlanCmd, templatesImportComposeCmd, templatesImportGitCmd, templatesDeployGraphCmd)
} }
@@ -75,7 +75,7 @@ INSERT INTO node_agents (
id, name, hostname, ip_address, port, status, version, id, name, hostname, ip_address, port, status, version,
capabilities, resources, last_heartbeat, metadata capabilities, resources, last_heartbeat, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain
` `
type CreateAgentParams struct { type CreateAgentParams struct {
@@ -124,6 +124,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeA
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags, &i.Tags,
&i.DefaultDomain,
) )
return i, err return i, err
} }
@@ -258,7 +259,7 @@ func (q *Queries) DeleteServiceContainersOnAgent(ctx context.Context, arg Delete
} }
const getAgent = `-- name: GetAgent :one const getAgent = `-- name: GetAgent :one
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags FROM node_agents WHERE id = $1 SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain FROM node_agents WHERE id = $1
` `
func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) { func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
@@ -281,12 +282,13 @@ func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags, &i.Tags,
&i.DefaultDomain,
) )
return i, err return i, err
} }
const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags FROM node_agents SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain FROM node_agents
WHERE hostname = $1 AND ip_address = $2 WHERE hostname = $1 AND ip_address = $2
LIMIT 1 LIMIT 1
` `
@@ -316,6 +318,7 @@ func (q *Queries) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAnd
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags, &i.Tags,
&i.DefaultDomain,
) )
return i, err return i, err
} }
@@ -523,7 +526,7 @@ func (q *Queries) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHea
} }
const listAgents = `-- name: ListAgents :many const listAgents = `-- name: ListAgents :many
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags FROM node_agents ORDER BY created_at ASC SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain FROM node_agents ORDER BY created_at ASC
` `
func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) { func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
@@ -552,6 +555,7 @@ func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags, &i.Tags,
&i.DefaultDomain,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -695,6 +699,39 @@ func (q *Queries) ListPendingCommands(ctx context.Context, nodeAgentID string) (
return items, nil return items, nil
} }
const listSchedulableAgentDomains = `-- name: ListSchedulableAgentDomains :many
SELECT DISTINCT default_domain FROM node_agents
WHERE status = 'online' AND schedulable
AND default_domain <> ''
AND tags @> $1::jsonb
ORDER BY default_domain
`
// Base domains of every online, schedulable agent carrying the required
// placement tags — auto-domain candidates for spread services.
func (q *Queries) ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listSchedulableAgentDomains, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var default_domain string
if err := rows.Scan(&default_domain); err != nil {
return nil, err
}
items = append(items, default_domain)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSchedulableAgents = `-- name: ListSchedulableAgents :many const listSchedulableAgents = `-- name: ListSchedulableAgents :many
SELECT id, name FROM node_agents SELECT id, name FROM node_agents
WHERE status = 'online' AND schedulable WHERE status = 'online' AND schedulable
@@ -910,6 +947,20 @@ func (q *Queries) ScrubCommandPayload(ctx context.Context, arg ScrubCommandPaylo
return err return err
} }
const setAgentDefaultDomain = `-- name: SetAgentDefaultDomain :exec
UPDATE node_agents SET default_domain = $2, updated_at = NOW() WHERE id = $1
`
type SetAgentDefaultDomainParams struct {
ID string `json:"id"`
DefaultDomain string `json:"default_domain"`
}
func (q *Queries) SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error {
_, err := q.db.ExecContext(ctx, setAgentDefaultDomain, arg.ID, arg.DefaultDomain)
return err
}
const setAgentSchedulable = `-- name: SetAgentSchedulable :exec const setAgentSchedulable = `-- name: SetAgentSchedulable :exec
UPDATE node_agents SET schedulable = $2, updated_at = NOW() WHERE id = $1 UPDATE node_agents SET schedulable = $2, updated_at = NOW() WHERE id = $1
` `
@@ -954,7 +1005,7 @@ UPDATE node_agents SET
schedulable = $13, schedulable = $13,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain
` `
type UpdateAgentParams struct { type UpdateAgentParams struct {
@@ -1007,6 +1058,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags, &i.Tags,
&i.DefaultDomain,
) )
return i, err return i, err
} }
@@ -178,6 +178,7 @@ type NodeAgent struct {
AutoPrune bool `json:"auto_prune"` AutoPrune bool `json:"auto_prune"`
Schedulable bool `json:"schedulable"` Schedulable bool `json:"schedulable"`
Tags json.RawMessage `json:"tags"` Tags json.RawMessage `json:"tags"`
DefaultDomain string `json:"default_domain"`
} }
type Notification struct { type Notification struct {
@@ -279,6 +280,7 @@ type Service struct {
NodeID sql.NullString `json:"node_id"` NodeID sql.NullString `json:"node_id"`
Spread bool `json:"spread"` Spread bool `json:"spread"`
PlacementTags json.RawMessage `json:"placement_tags"` PlacementTags json.RawMessage `json:"placement_tags"`
TraefikLabels json.RawMessage `json:"traefik_labels"`
} }
type ServiceDomain struct { type ServiceDomain struct {
@@ -95,6 +95,9 @@ type Querier interface {
// display, when the caller owns or is a member of it, or when the caller is a // display, when the caller owns or is a member of it, or when the caller is a
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false. // platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
// Base domains of every online, schedulable agent carrying the required
// placement tags — auto-domain candidates for spread services.
ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error)
ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error) ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error)
// Only agents carrying every required placement tag (jsonb array containment). // Only agents carrying every required placement tag (jsonb array containment).
ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error) ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error)
@@ -118,6 +121,7 @@ type Querier interface {
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error
SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error
SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error
SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
@@ -0,0 +1,60 @@
package deployment
import (
"strings"
"testing"
)
func TestEdgeLabelsCustomMiddlewares(t *testing.T) {
spec := RuntimeSpec{
ServiceID: "01234567-89ab-cdef-0123-456789abcdef",
Port: 8080,
Domains: []string{"app.example.com"},
TraefikLabels: map[string]string{
"middlewares.rl.ratelimit.average": "100",
"middlewares.rl.ratelimit.period": "1m",
"middlewares.sec.headers.customresponseheaders.X-Frame-Options": "DENY",
},
}
labels := edgeLabels(spec, "containr-edge")
if got := labels["traefik.http.middlewares.svc-01234567-rl.ratelimit.average"]; got != "100" {
t.Fatalf("ratelimit label missing: %v", labels)
}
if got := labels["traefik.http.middlewares.svc-01234567-sec.headers.customresponseheaders.X-Frame-Options"]; got != "DENY" {
t.Fatalf("headers label missing: %v", labels)
}
chain := labels["traefik.http.routers.svc-01234567.middlewares"]
if chain != "svc-01234567-rl,svc-01234567-sec" {
t.Fatalf("middleware chain wrong: %q", chain)
}
}
func TestEdgeLabelsCustomAfterBuiltins(t *testing.T) {
spec := RuntimeSpec{
ServiceID: "01234567-89ab-cdef-0123-456789abcdef",
Port: 8080,
Domains: []string{"app.example.com"},
BasicAuthUsers: "u:$apr1$x",
TraefikLabels: map[string]string{
"middlewares.rl.ratelimit.average": "10",
},
}
labels := edgeLabels(spec, "containr-edge")
chain := labels["traefik.http.routers.svc-01234567.middlewares"]
if !strings.HasPrefix(chain, "svc-01234567-auth,") || !strings.HasSuffix(chain, "svc-01234567-rl") {
t.Fatalf("builtin should precede custom: %q", chain)
}
}
func TestEdgeLabelsNoCustom(t *testing.T) {
spec := RuntimeSpec{
ServiceID: "01234567-89ab-cdef-0123-456789abcdef",
Port: 8080,
Domains: []string{"app.example.com"},
}
labels := edgeLabels(spec, "containr-edge")
if _, ok := labels["traefik.http.routers.svc-01234567.middlewares"]; ok {
t.Fatal("middleware chain should be absent with no middlewares")
}
}
+37 -4
View File
@@ -95,12 +95,29 @@ type ServiceConfig struct {
// reuses it so public URLs survive restarts. 0 = pick ephemeral. // reuses it so public URLs survive restarts. 0 = pick ephemeral.
PublishedPort int32 `json:"published_port,omitempty"` PublishedPort int32 `json:"published_port,omitempty"`
// NodeID pins the service to a registered node agent; "" = local Docker // NodeID pins the service to a registered node agent; "" = local Docker
// host. Remote placement requires a registry-pullable image — builds stay // host.
// on the local host (build-on-node is not yet supported).
NodeID string `json:"node_id,omitempty"` NodeID string `json:"node_id,omitempty"`
// Spread distributes replicas across every online, schedulable agent. // Spread distributes replicas across every online, schedulable agent.
// Same registry-image constraint as NodeID.
Spread bool `json:"spread,omitempty"` Spread bool `json:"spread,omitempty"`
// RemoteBuild, when set, makes each target node docker-build the image
// itself from a packaged context artifact instead of pulling a registry
// tag. The image name comes from the deployment, not the spec.
RemoteBuild *RemoteBuildSpec `json:"remote_build,omitempty"`
// RemoteLoad ships a docker-save tarball to each target node — used for
// rollbacks of locally-built tags that no registry can serve.
RemoteLoad *RemoteLoadSpec `json:"remote_load,omitempty"`
}
// RemoteBuildSpec is the agent-side docker build request.
type RemoteBuildSpec struct {
ArtifactID string `json:"artifact_id"`
BuildArgs map[string]string `json:"build_args,omitempty"`
NoCache bool `json:"no_cache,omitempty"`
}
// RemoteLoadSpec asks the agent to docker-load an image tarball artifact.
type RemoteLoadSpec struct {
ArtifactID string `json:"artifact_id"`
} }
type PortMapping struct { type PortMapping struct {
@@ -211,6 +228,12 @@ func (de *DeploymentEngine) DockerClient() *docker.Client {
return de.dockerClient return de.dockerClient
} }
// BuildManager exposes the engine's builders for context packaging — remote
// nodes build the tar this produces themselves.
func (de *DeploymentEngine) BuildManager() *build.BuildManager {
return de.buildManager
}
// SetNodeRunner wires remote-node dispatch for services pinned to an agent. // SetNodeRunner wires remote-node dispatch for services pinned to an agent.
func (de *DeploymentEngine) SetNodeRunner(r NodeRunner) { func (de *DeploymentEngine) SetNodeRunner(r NodeRunner) {
de.nodeRunner = r de.nodeRunner = r
@@ -326,11 +349,19 @@ func (de *DeploymentEngine) executeDeployment(ctx context.Context, deployment *D
}) })
} }
// buildImage builds the container image // buildImage builds the container image. "remote" build types produce no
// local image — the target node builds or loads it from an artifact; the
// deployment still needs a deterministic tag so every node names it alike.
func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployment, buildConfig *BuildConfig) (string, error) { func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployment, buildConfig *BuildConfig) (string, error) {
if buildConfig == nil { if buildConfig == nil {
return "", fmt.Errorf("build config is required") return "", fmt.Errorf("build config is required")
} }
if buildConfig.BuildType == "remote" {
if buildConfig.PrebuiltImage != "" {
return buildConfig.PrebuiltImage, nil
}
return fmt.Sprintf("containr-%s-%s:%s", deployment.ServiceID, deployment.Environment, deployment.ID), nil
}
buildReq := &types.BuildRequest{ buildReq := &types.BuildRequest{
BuildType: buildConfig.BuildType, BuildType: buildConfig.BuildType,
@@ -393,6 +424,8 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
BasicAuthUsers: cfg.BasicAuthUsers, BasicAuthUsers: cfg.BasicAuthUsers,
NodeID: cfg.NodeID, NodeID: cfg.NodeID,
Spread: cfg.Spread, Spread: cfg.Spread,
RemoteBuild: cfg.RemoteBuild,
RemoteLoad: cfg.RemoteLoad,
} }
for _, pm := range cfg.PortMappings { for _, pm := range cfg.PortMappings {
if spec.Port == 0 { if spec.Port == 0 {
+87 -37
View File
@@ -55,6 +55,10 @@ type RuntimeSpec struct {
Maintenance bool // redirect traffic to MaintenanceURL Maintenance bool // redirect traffic to MaintenanceURL
MaintenanceURL string // absolute URL; empty = serve empty response MaintenanceURL string // absolute URL; empty = serve empty response
BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
// TraefikLabels defines extra middlewares for this service's router —
// keys "middlewares.<name>.<type>[.<field>]" (API-validated against an
// allowlist). Defined middlewares attach to the router after builtins.
TraefikLabels map[string]string
// NodeID pins the service to a node agent; "" runs on the local host. // NodeID pins the service to a node agent; "" runs on the local host.
NodeID string NodeID string
// Spread distributes replicas across all online, schedulable agents // Spread distributes replicas across all online, schedulable agents
@@ -63,6 +67,12 @@ type RuntimeSpec struct {
// PlacementTags restricts auto/spread candidates to agents carrying // PlacementTags restricts auto/spread candidates to agents carrying
// every listed tag. An explicit NodeID pin bypasses tag filtering. // every listed tag. An explicit NodeID pin bypasses tag filtering.
PlacementTags []string PlacementTags []string
// RemoteBuild makes target nodes docker-build Image from a packaged
// context artifact before replicas start (git-sourced remote deploys).
RemoteBuild *RemoteBuildSpec
// RemoteLoad makes target nodes docker-load a saved image tarball —
// remote rollbacks of locally-built tags.
RemoteLoad *RemoteLoadSpec
} }
// RuntimeContainer describes one live replica. // RuntimeContainer describes one live replica.
@@ -268,44 +278,9 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
projectNet: {Aliases: []string{spec.Name}}, projectNet: {Aliases: []string{spec.Name}},
} }
if edgeNet != "" { if edgeNet != "" {
router := "svc-" + spec.ServiceID[:8]
domains := spec.Domains
if len(domains) == 0 && spec.Domain != "" {
domains = []string{spec.Domain}
}
sort.Strings(domains)
rules := make([]string, 0, len(domains))
for _, d := range domains {
rules = append(rules, "Host(`"+d+"`)")
}
endpoints[edgeNet] = &network.EndpointSettings{} endpoints[edgeNet] = &network.EndpointSettings{}
labels["traefik.enable"] = "true" for k, v := range edgeLabels(spec, edgeNet) {
labels["traefik.docker.network"] = edgeNet labels[k] = v
labels["traefik.http.routers."+router+".rule"] = strings.Join(rules, " || ")
labels["traefik.http.routers."+router+".entrypoints"] = "web"
labels["traefik.http.services."+router+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
var middlewares []string
if spec.Maintenance {
if spec.MaintenanceURL != "" {
mw := router + "-maint"
labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
middlewares = append(middlewares, mw)
} else {
// No redirect target configured — take the site offline with
// empty responses instead of leaking traffic to the app.
labels["traefik.http.routers."+router+".service"] = "noop@internal"
}
}
if spec.BasicAuthUsers != "" {
mw := router + "-auth"
labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
middlewares = append(middlewares, mw)
}
if len(middlewares) > 0 {
labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
} }
} }
@@ -393,6 +368,81 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
// vpnkit drops a conflicting binding). // vpnkit drops a conflicting binding).
var errPortNotBound = errors.New("requested host port not bound") var errPortNotBound = errors.New("requested host port not bound")
// edgeLabels builds the Traefik router/service/middleware labels for a
// replica on the edge network: host rules, load balancer port, builtin
// maintenance/basic-auth middlewares, then operator-defined overrides.
func edgeLabels(spec RuntimeSpec, edgeNet string) map[string]string {
router := "svc-" + spec.ServiceID[:8]
domains := spec.Domains
if len(domains) == 0 && spec.Domain != "" {
domains = []string{spec.Domain}
}
sort.Strings(domains)
rules := make([]string, 0, len(domains))
for _, d := range domains {
rules = append(rules, "Host(`"+d+"`)")
}
labels := map[string]string{
"traefik.enable": "true",
"traefik.docker.network": edgeNet,
"traefik.http.routers." + router + ".rule": strings.Join(rules, " || "),
"traefik.http.routers." + router + ".entrypoints": "web",
"traefik.http.services." + router + ".loadbalancer.server.port": fmt.Sprintf("%d", spec.Port),
}
var middlewares []string
if spec.Maintenance {
if spec.MaintenanceURL != "" {
mw := router + "-maint"
labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
middlewares = append(middlewares, mw)
} else {
// No redirect target configured — take the site offline with
// empty responses instead of leaking traffic to the app.
labels["traefik.http.routers."+router+".service"] = "noop@internal"
}
}
if spec.BasicAuthUsers != "" {
mw := router + "-auth"
labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
middlewares = append(middlewares, mw)
}
// Operator-defined middlewares: "middlewares.<name>.<rest>" becomes
// "<router>-<name>.<rest>", and every defined name joins the router's
// chain after builtins. Names sort for a deterministic chain order.
customMWs := map[string][]string{}
for k, v := range spec.TraefikLabels {
rest := strings.TrimPrefix(k, "middlewares.")
dot := strings.Index(rest, ".")
if dot <= 0 {
continue
}
customMWs[rest[:dot]] = append(customMWs[rest[:dot]], rest[dot+1:]+"="+v)
}
mwNames := make([]string, 0, len(customMWs))
for name := range customMWs {
mwNames = append(mwNames, name)
}
sort.Strings(mwNames)
for _, name := range mwNames {
mw := router + "-" + name
sort.Strings(customMWs[name])
for _, kv := range customMWs[name] {
if i := strings.Index(kv, "="); i > 0 {
labels["traefik.http.middlewares."+mw+"."+kv[:i]] = kv[i+1:]
}
}
middlewares = append(middlewares, mw)
}
if len(middlewares) > 0 {
labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
}
return labels
}
// isPortConflict reports whether a container create failed because the // isPortConflict reports whether a container create failed because the
// requested host port was already bound. // requested host port was already bound.
func isPortConflict(err error) bool { func isPortConflict(err error) bool {
+7
View File
@@ -336,6 +336,13 @@ func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options
return resp, nil return resp, nil
} }
// SaveImage streams refs as a docker-save tarball. Remote nodes receive
// these through the artifact endpoint and docker load them — that is how
// locally-built rollback tags reach nodes without a registry.
func (c *Client) SaveImage(ctx context.Context, refs ...string) (io.ReadCloser, error) {
return c.cli.ImageSave(ctx, refs)
}
// RemoveImage removes an image // RemoveImage removes an image
func (c *Client) RemoveImage(ctx context.Context, imageID string, force bool) ([]image.DeleteResponse, error) { func (c *Client) RemoveImage(ctx context.Context, imageID string, force bool) ([]image.DeleteResponse, error) {
return c.cli.ImageRemove(ctx, imageID, image.RemoveOptions{ return c.cli.ImageRemove(ctx, imageID, image.RemoveOptions{
+4 -3
View File
@@ -53,7 +53,7 @@ func init() {
// Services // Services
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false}) register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false}) register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir, node_id (agent id | 'auto' = least-loaded online node; image services only — remote nodes can't run git builds), spread (bool — distribute replicas across every online schedulable node; mutually exclusive with a node pin), placement_tags (string array — remote placement only onto nodes carrying every tag; re-resolved per reconcile, pin overrides).", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false}) register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir, sleep_enabled (bool — scale to zero on idle), sleep_idle_minutes (1-1440, default 15), node_id (agent id | 'auto' = least-loaded online node; git services build on the node itself), spread (bool — distribute replicas across every online schedulable node; mutually exclusive with a node pin), placement_tags (string array — remote placement only onto nodes carrying every tag; re-resolved per reconcile, pin overrides), traefik_labels (object — extra Traefik middlewares for this service's router; keys 'middlewares.<name>.<type>[.<field>]', allowlist-validated; defined middlewares attach to the router automatically).", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true}) register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false}) register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false}) register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
@@ -66,7 +66,7 @@ func init() {
register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false}) register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false})
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false}) register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false}) register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes. node_id pins placement to a node agent ('auto' = least-loaded, 'local' = run on the Containr host). spread (bool) distributes replicas across every online schedulable node; mutually exclusive with a pin — clear node_id to 'local' first if pinned. placement_tags (string array; [] clears) restricts auto/spread placement to nodes carrying every tag; mutually exclusive with an explicit pin — clear the pin first.", "PUT", "/services/{id}", []string{"id"}, nil, false}) register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes. node_id pins placement to a node agent ('auto' = least-loaded, 'local' = run on the Containr host). spread (bool) distributes replicas across every online schedulable node; mutually exclusive with a pin — clear node_id to 'local' first if pinned. placement_tags (string array; [] clears) restricts auto/spread placement to nodes carrying every tag; mutually exclusive with an explicit pin — clear the pin first. traefik_labels (object; {} clears) replaces the service's Traefik middleware overrides — keys 'middlewares.<name>.<type>[.<field>]', e.g. {'middlewares.rl.ratelimit.average': '100'}.", "PUT", "/services/{id}", []string{"id"}, nil, false})
// Domains // Domains
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false}) register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
@@ -133,6 +133,7 @@ func init() {
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false}) register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
register(spec{"containr_templates_plan", "Dry-resolve a template: expanded expressions, service refs, missing variables. Args: id, variables (object).", "POST", "/templates/{id}/plan", []string{"id"}, nil, false}) register(spec{"containr_templates_plan", "Dry-resolve a template: expanded expressions, service refs, missing variables. Args: id, variables (object).", "POST", "/templates/{id}/plan", []string{"id"}, nil, false})
register(spec{"containr_templates_import_compose", "Convert docker-compose YAML into a v2 template graph config. Args: compose_yaml.", "POST", "/templates/import/compose", nil, []string{"compose_yaml"}, false}) register(spec{"containr_templates_import_compose", "Convert docker-compose YAML into a v2 template graph config. Args: compose_yaml.", "POST", "/templates/import/compose", nil, []string{"compose_yaml"}, false})
register(spec{"containr_templates_import_git", "Fetch a compose file from a git repo (clone URL, ssh remote, or owner/repo via connected providers) and convert it into a v2 template graph config. Args: repo (required), path (default: compose.yaml/yml or docker-compose.yml/yaml), ref (branch or commit).", "POST", "/templates/import/git", nil, []string{"repo", "path", "ref"}, false})
register(spec{"containr_templates_deploy_graph", "Deploy an ad-hoc service graph without a stored template. Args: project_id, config ({services:[...]}), variables, plan, region.", "POST", "/templates/deploy", nil, []string{"project_id", "config", "variables", "plan", "region"}, false}) register(spec{"containr_templates_deploy_graph", "Deploy an ad-hoc service graph without a stored template. Args: project_id, config ({services:[...]}), variables, plan, region.", "POST", "/templates/deploy", nil, []string{"project_id", "config", "variables", "plan", "region"}, false})
register(spec{"containr_projects_import_compose", "Deploy a docker-compose file straight into a project. Args: id (project), compose_yaml, variables, plan, region.", "POST", "/projects/{id}/import-compose", []string{"id"}, []string{"compose_yaml", "variables", "plan", "region"}, false}) register(spec{"containr_projects_import_compose", "Deploy a docker-compose file straight into a project. Args: id (project), compose_yaml, variables, plan, region.", "POST", "/projects/{id}/import-compose", []string{"id"}, []string{"compose_yaml", "variables", "plan", "region"}, false})
@@ -173,7 +174,7 @@ func init() {
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false}) register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false}) register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false}) register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
register(spec{"containr_nodes_update", "Update a node agent (admin). Args: id; optional auto_prune, name, tags (string array — placement labels services can require via placement_tags; [] clears).", "PUT", "/agents/{id}", []string{"id"}, []string{"auto_prune", "name", "tags"}, false}) register(spec{"containr_nodes_update", "Update a node agent (admin). Args: id; optional auto_prune, name, tags (string array — placement labels services can require via placement_tags; [] clears), default_domain (base domain — services placed on this node with no explicit domains get <name>.<default_domain>; empty clears).", "PUT", "/agents/{id}", []string{"id"}, []string{"auto_prune", "name", "tags", "default_domain"}, false})
register(spec{"containr_nodes_cordon", "Cordon a node: blocks new pins and 'auto' placements; running replicas stay (admin). Args: id.", "POST", "/agents/{id}/cordon", []string{"id"}, nil, false}) register(spec{"containr_nodes_cordon", "Cordon a node: blocks new pins and 'auto' placements; running replicas stay (admin). Args: id.", "POST", "/agents/{id}/cordon", []string{"id"}, nil, false})
register(spec{"containr_nodes_uncordon", "Re-open a node for placement (admin). Args: id.", "POST", "/agents/{id}/uncordon", []string{"id"}, nil, false}) register(spec{"containr_nodes_uncordon", "Re-open a node for placement (admin). Args: id.", "POST", "/agents/{id}/uncordon", []string{"id"}, nil, false})
register(spec{"containr_nodes_drain", "Drain a node: cordon + remove all service containers via the agent + unpin affected services so they redeploy locally. Requires the node online (admin). Args: id.", "POST", "/agents/{id}/drain", []string{"id"}, nil, true}) register(spec{"containr_nodes_drain", "Drain a node: cordon + remove all service containers via the agent + unpin affected services so they redeploy locally. Requires the node online (admin). Args: id.", "POST", "/agents/{id}/drain", []string{"id"}, nil, true})
+190
View File
@@ -0,0 +1,190 @@
// Package source materializes git checkouts for builds. Services reference
// a repository by URL (or owner/repo); deployments fetch the exact branch or
// commit into an isolated workdir, then point the builders at build_path
// inside it. Credentials travel through GIT_ASKPASS so tokens never land in
// argv, .git/config, logs, or the build context.
package source
import (
"context"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Spec describes one checkout.
type Spec struct {
CloneURL string // https URL (NormalizeCloneURL output); local paths allowed for tests
Branch string // ref to fetch when Commit is empty
Commit string // optional SHA pinned over Branch
BuildPath string // subdirectory inside the repo; "" or "." = repo root
Token string // optional http credential
User string // credential username; defaults per provider in ResolveUser
}
// NormalizeCloneURL converts the forms services store into an https clone
// URL: ssh remotes (git@host:org/repo, ssh://git@host/org/repo) map onto
// https so token auth works uniformly. A bare "owner/repo" is returned
// unchanged — the caller resolves the host through git_repositories.
func NormalizeCloneURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("empty repository reference")
}
// scp-style ssh remote: [email protected]:org/repo(.git)
if strings.HasPrefix(raw, "git@") && strings.Contains(raw, ":") {
host := raw[len("git@"):strings.Index(raw, ":")]
path := strings.TrimPrefix(raw[strings.Index(raw, ":")+1:], "/")
return "https://" + host + "/" + path, nil
}
if strings.HasPrefix(raw, "ssh://") {
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("invalid ssh remote %q", raw)
}
return "https://" + u.Host + u.Path, nil
}
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
return raw, nil
}
if strings.Contains(raw, "://") {
return "", fmt.Errorf("unsupported repository scheme in %q", raw)
}
if strings.Count(raw, "/") == 1 && !strings.ContainsAny(raw, " @:") {
return raw, nil // owner/repo — resolved against git_repositories upstream
}
return "", fmt.Errorf("unsupported repository reference %q", raw)
}
// ValidateRemoteURL rejects non-http(s) clones before a deploy accepts them.
// Local checkouts (tests, mounted workspaces) bypass this via Checkout.
func ValidateRemoteURL(cloneURL string) error {
if strings.HasPrefix(cloneURL, "http://") || strings.HasPrefix(cloneURL, "https://") {
return nil
}
return fmt.Errorf("clone URL must be http(s), got %q", cloneURL)
}
// Checkout fetches spec into destDir/repo and returns the directory the
// builders should consume (checkout root + build_path). Callers
// RemoveAll(destDir) — the checkout is a per-deployment workspace, never
// shared. The askpass helper lives outside repo so it can't leak into the
// build context.
func Checkout(ctx context.Context, destDir string, spec Spec) (string, error) {
repoDir := filepath.Join(destDir, "repo")
if err := os.MkdirAll(repoDir, 0o755); err != nil {
return "", err
}
env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
if spec.Token != "" {
// Askpass keeps the token out of argv and .git/config.
askpass := filepath.Join(destDir, ".git-askpass")
script := "#!/bin/sh\ncase \"$1\" in\n*sername*) printf '%s' \"$CONTAINR_GIT_USER\" ;;\n*) printf '%s' \"$CONTAINR_GIT_TOKEN\" ;;\nesac\n"
if err := os.WriteFile(askpass, []byte(script), 0o700); err != nil {
return "", fmt.Errorf("write askpass: %w", err)
}
user := spec.User
if user == "" {
user = "x-access-token"
}
env = append(env,
"GIT_ASKPASS="+askpass,
"CONTAINR_GIT_USER="+user,
"CONTAINR_GIT_TOKEN="+spec.Token,
)
}
run := func(args ...string) error {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = repoDir
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git %s: %s", args[0], strings.TrimSpace(string(out)))
}
return nil
}
if err := run("init", "-q"); err != nil {
return "", err
}
if err := run("remote", "add", "origin", spec.CloneURL); err != nil {
return "", err
}
ref := spec.Commit
if ref == "" {
ref = spec.Branch
}
if ref == "" {
ref = "HEAD"
}
// Cheap path first, widening on failure: dumb transports reject
// --depth outright, and some hosts refuse direct SHA fetches so a
// pinned commit may only be reachable through its branch.
attempts := [][]string{
{"fetch", "--depth", "1", "origin", ref},
{"fetch", "origin", ref},
}
if spec.Commit != "" {
branchRef := spec.Branch
if branchRef == "" {
branchRef = "HEAD"
}
attempts = append(attempts,
[]string{"fetch", "--depth", "100", "origin", branchRef},
[]string{"fetch", "origin", branchRef},
)
}
var fetchErr error
for _, args := range attempts {
if fetchErr = run(args...); fetchErr == nil {
break
}
}
if fetchErr != nil {
return "", fetchErr
}
if err := run("checkout", "-q", spec.CommitOrFetchHead()); err != nil {
return "", err
}
return ResolveBuildPath(repoDir, spec.BuildPath)
}
// CommitOrFetchHead is the checkout target: the pinned commit or FETCH_HEAD.
func (s Spec) CommitOrFetchHead() string {
if s.Commit != "" {
return s.Commit
}
return "FETCH_HEAD"
}
// ResolveBuildPath maps build_path onto the checkout and rejects escapes —
// a path like ../../etc must never leave the workspace.
func ResolveBuildPath(checkoutDir, buildPath string) (string, error) {
bp := strings.TrimSpace(buildPath)
if bp == "" || bp == "." {
return checkoutDir, nil
}
if filepath.IsAbs(bp) {
return "", fmt.Errorf("build_path must be relative, got %q", bp)
}
clean := filepath.Clean(bp)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("build_path escapes the checkout: %q", bp)
}
resolved := filepath.Join(checkoutDir, clean)
info, err := os.Stat(resolved)
if err != nil {
return "", fmt.Errorf("build_path %q not found in checkout", bp)
}
if !info.IsDir() {
return "", fmt.Errorf("build_path %q is not a directory", bp)
}
return resolved, nil
}
+152
View File
@@ -0,0 +1,152 @@
package source
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestNormalizeCloneURL(t *testing.T) {
cases := []struct {
in, want string
err bool
}{
{"https://github.com/org/repo.git", "https://github.com/org/repo.git", false},
{"[email protected]:org/repo.git", "https://github.com/org/repo.git", false},
{"ssh://[email protected]/org/repo.git", "https://gitlab.example.com/org/repo.git", false},
{"org/repo", "org/repo", false},
{" https://gitlab.com/a/b.git ", "https://gitlab.com/a/b.git", false},
{"", "", true},
{"ftp://example.com/repo", "", true},
{"weird string", "", true},
}
for _, c := range cases {
got, err := NormalizeCloneURL(c.in)
if c.err {
if err == nil {
t.Errorf("%q: expected error, got %q", c.in, got)
}
continue
}
if err != nil {
t.Errorf("%q: unexpected error %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("%q: got %q, want %q", c.in, got, c.want)
}
}
}
func TestValidateRemoteURL(t *testing.T) {
if err := ValidateRemoteURL("https://github.com/o/r.git"); err != nil {
t.Errorf("https should pass: %v", err)
}
if err := ValidateRemoteURL("file:///tmp/repo"); err == nil {
t.Error("file:// should be rejected")
}
if err := ValidateRemoteURL("/tmp/repo"); err == nil {
t.Error("local path should be rejected")
}
}
// initRepo creates a small git repo with a branch and returns its path.
func initRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
run := func(args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %s", args, out)
}
}
run("init", "-q", "-b", "main")
if err := os.WriteFile(filepath.Join(dir, "app.txt"), []byte("v1"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "sub", "inner.txt"), []byte("sub"), 0o644); err != nil {
t.Fatal(err)
}
run("add", ".")
run("commit", "-q", "-m", "first")
return dir
}
func TestCheckoutBranch(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
repo := initRepo(t)
dest := t.TempDir()
dir, err := Checkout(context.Background(), dest, Spec{CloneURL: repo, Branch: "main"})
if err != nil {
t.Fatalf("checkout: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "app.txt")); err != nil {
t.Errorf("expected app.txt in checkout: %v", err)
}
}
func TestCheckoutBuildPath(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
repo := initRepo(t)
dest := t.TempDir()
dir, err := Checkout(context.Background(), dest, Spec{CloneURL: repo, Branch: "main", BuildPath: "sub"})
if err != nil {
t.Fatalf("checkout: %v", err)
}
if filepath.Base(dir) != "sub" {
t.Errorf("expected sub build path, got %s", dir)
}
if _, err := os.Stat(filepath.Join(dir, "inner.txt")); err != nil {
t.Errorf("expected inner.txt under build path: %v", err)
}
}
func TestCheckoutCommit(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
repo := initRepo(t)
out, err := exec.Command("git", "-C", repo, "rev-parse", "HEAD").Output()
if err != nil {
t.Fatal(err)
}
sha := string(out[:len(out)-1])
dest := t.TempDir()
dir, err := Checkout(context.Background(), dest, Spec{CloneURL: repo, Commit: sha, Branch: "main"})
if err != nil {
t.Fatalf("checkout commit: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "app.txt")); err != nil {
t.Errorf("expected app.txt in pinned checkout: %v", err)
}
}
func TestResolveBuildPathEscapes(t *testing.T) {
root := t.TempDir()
if _, err := ResolveBuildPath(root, "../../etc"); err == nil {
t.Error("escape should be rejected")
}
if _, err := ResolveBuildPath(root, "/abs/path"); err == nil {
t.Error("absolute path should be rejected")
}
if _, err := ResolveBuildPath(root, "missing"); err == nil {
t.Error("missing dir should be rejected")
}
if got, err := ResolveBuildPath(root, "."); err != nil || got != root {
t.Errorf("dot should resolve to root, got %q err=%v", got, err)
}
}
@@ -0,0 +1,9 @@
-- +goose Up
-- Routing overrides: per-service Traefik middleware definitions stored as
-- { "middlewares.<name>.<type>.<field>": "value" }. Validated against an
-- allowlist; applied to the service's router and auto-attached to its
-- middleware chain at reconcile time.
ALTER TABLE services ADD COLUMN IF NOT EXISTS traefik_labels jsonb NOT NULL DEFAULT '{}';
-- +goose Down
ALTER TABLE services DROP COLUMN IF EXISTS traefik_labels;
@@ -0,0 +1,8 @@
-- +goose Up
-- Per-node base domain: services pinned/spread onto an agent with
-- default_domain get auto-generated hostnames (<service>.<domain>) when
-- they have no explicit service_domains rows.
ALTER TABLE node_agents ADD COLUMN IF NOT EXISTS default_domain VARCHAR(255) NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE node_agents DROP COLUMN IF EXISTS default_domain;
+12
View File
@@ -179,6 +179,18 @@ LIMIT 1;
-- name: SetAgentTags :exec -- name: SetAgentTags :exec
UPDATE node_agents SET tags = $2::jsonb, updated_at = NOW() WHERE id = $1; UPDATE node_agents SET tags = $2::jsonb, updated_at = NOW() WHERE id = $1;
-- name: SetAgentDefaultDomain :exec
UPDATE node_agents SET default_domain = $2, updated_at = NOW() WHERE id = $1;
-- name: ListSchedulableAgentDomains :many
-- Base domains of every online, schedulable agent carrying the required
-- placement tags — auto-domain candidates for spread services.
SELECT DISTINCT default_domain FROM node_agents
WHERE status = 'online' AND schedulable
AND default_domain <> ''
AND tags @> $1::jsonb
ORDER BY default_domain;
-- name: ListServiceContainers :many -- name: ListServiceContainers :many
SELECT id, name, node_agent_id, status, ports, resources FROM container_instances SELECT id, name, node_agent_id, status, ports, resources FROM container_instances
WHERE service_id = $1; WHERE service_id = $1;
+4 -2
View File
@@ -69,7 +69,8 @@ CREATE TABLE services (
static_dir VARCHAR(255) NOT NULL DEFAULT '', static_dir VARCHAR(255) NOT NULL DEFAULT '',
node_id VARCHAR(255), node_id VARCHAR(255),
spread BOOLEAN NOT NULL DEFAULT false, spread BOOLEAN NOT NULL DEFAULT false,
placement_tags JSONB NOT NULL DEFAULT '[]' placement_tags JSONB NOT NULL DEFAULT '[]',
traefik_labels JSONB NOT NULL DEFAULT '{}'
); );
CREATE TABLE registries ( CREATE TABLE registries (
@@ -198,7 +199,8 @@ CREATE TABLE node_agents (
metadata JSONB, metadata JSONB,
auto_prune BOOLEAN NOT NULL DEFAULT false, auto_prune BOOLEAN NOT NULL DEFAULT false,
schedulable BOOLEAN NOT NULL DEFAULT true, schedulable BOOLEAN NOT NULL DEFAULT true,
tags JSONB NOT NULL DEFAULT '[]' tags JSONB NOT NULL DEFAULT '[]',
default_domain VARCHAR(255) NOT NULL DEFAULT ''
); );
CREATE TABLE container_instances ( CREATE TABLE container_instances (
+4 -2
View File
@@ -1,5 +1,7 @@
# Build stage # Build stage
FROM node:22-alpine AS builder ARG NODE_IMAGE=node:22-alpine
ARG NGINX_IMAGE=nginx:alpine
FROM ${NODE_IMAGE} AS builder
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
@@ -24,7 +26,7 @@ ENV VITE_AUTH_URL=${VITE_AUTH_URL}
RUN npm run build RUN npm run build
# Production stage # Production stage
FROM nginx:alpine FROM ${NGINX_IMAGE}
# Copy custom nginx config # Copy custom nginx config
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
@@ -48,6 +48,7 @@ export function NodeDetailPage() {
const [confirmVolumes, setConfirmVolumes] = useState(false); const [confirmVolumes, setConfirmVolumes] = useState(false);
const [confirmDrain, setConfirmDrain] = useState(false); const [confirmDrain, setConfirmDrain] = useState(false);
const [tagDraft, setTagDraft] = useState<string | null>(null); const [tagDraft, setTagDraft] = useState<string | null>(null);
const [domainDraft, setDomainDraft] = useState<string | null>(null);
const agentQuery = useQuery({ const agentQuery = useQuery({
queryKey: ['node-agent', id], queryKey: ['node-agent', id],
@@ -80,8 +81,8 @@ export function NodeDetailPage() {
queryClient.invalidateQueries({ queryKey: ['usage-agents'] }); queryClient.invalidateQueries({ queryKey: ['usage-agents'] });
}; };
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: (body: { name?: string; auto_prune?: boolean; tags?: string[] }) => updateAgent(id, body), mutationFn: (body: { name?: string; auto_prune?: boolean; tags?: string[]; default_domain?: string }) => updateAgent(id, body),
onSuccess: () => { setRename(null); setTagDraft(null); invalidate(); }, onSuccess: () => { setRename(null); setTagDraft(null); setDomainDraft(null); invalidate(); },
}); });
const pruneMutation = useMutation({ const pruneMutation = useMutation({
mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }), mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }),
@@ -218,6 +219,44 @@ export function NodeDetailPage() {
</> </>
)} )}
</p> </p>
<p className="v-mono mt-1 text-[11px] text-[var(--text-tertiary)] flex items-center gap-1.5 flex-wrap">
domain:
{domainDraft === null ? (
<>
<span className={agent.defaultDomain ? 'text-[var(--text-secondary)]' : 'text-[var(--text-muted)]'}>
{agent.defaultDomain || 'none'}
</span>
{isAdmin && (
<button
onClick={() => setDomainDraft(agent.defaultDomain || '')}
className="text-[var(--text-muted)] hover:text-[var(--text-primary)]"
title="Node base domain — domainless services placed here get <name>.<domain> hostnames"
>
<Pencil size={11} />
</button>
)}
</>
) : (
<>
<input
value={domainDraft}
onChange={(e) => setDomainDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() });
if (e.key === 'Escape') setDomainDraft(null);
}}
autoFocus
placeholder="apps.node1.example.com — empty clears"
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-64"
/>
<button
onClick={() => updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() })}
className="text-[var(--accent-primary)] hover:underline"
>save</button>
<button onClick={() => setDomainDraft(null)} className="text-[var(--text-muted)] hover:underline">cancel</button>
</>
)}
</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online <span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online
@@ -485,7 +485,7 @@ export function ServiceDetailPage() {
const [volumesForm, setVolumesForm] = useState<ServiceVolume[] | null>(null); const [volumesForm, setVolumesForm] = useState<ServiceVolume[] | null>(null);
const [domainInput, setDomainInput] = useState(''); const [domainInput, setDomainInput] = useState('');
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null); const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null); const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string; traefikLabels: string } | null>(null);
const [sleepDraft, setSleepDraft] = useState<{ id: string; enabled: boolean; minutes: string } | null>(null); const [sleepDraft, setSleepDraft] = useState<{ id: string; enabled: boolean; minutes: string } | null>(null);
const [nodeDraft, setNodeDraft] = useState<string | null>(null); const [nodeDraft, setNodeDraft] = useState<string | null>(null);
const nodesQuery = useQuery({ const nodesQuery = useQuery({
@@ -2490,7 +2490,13 @@ export function ServiceDetailPage() {
<button <button
type="button" type="button"
onClick={() => onClick={() =>
setAccessForm({ maintenance: Boolean(service.maintenanceMode), basicAuth: '' }) setAccessForm({
maintenance: Boolean(service.maintenanceMode),
basicAuth: '',
traefikLabels: Object.entries(service.traefikLabels ?? {})
.map(([k, v]) => `${k}=${v}`)
.join('\n'),
})
} }
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors" className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
> >
@@ -2511,6 +2517,14 @@ export function ServiceDetailPage() {
{(service.basicAuth ?? []).length > 0 ? (service.basicAuth ?? []).join(', ') : 'off'} {(service.basicAuth ?? []).length > 0 ? (service.basicAuth ?? []).join(', ') : 'off'}
</p> </p>
</div> </div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Routing overrides</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{Object.keys(service.traefikLabels ?? {}).length > 0
? `${Object.keys(service.traefikLabels ?? {}).length} label(s)`
: 'off'}
</p>
</div>
</div> </div>
) : ( ) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4"> <div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
@@ -2532,6 +2546,16 @@ export function ServiceDetailPage() {
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]" className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/> />
</div> </div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Routing overrides — Traefik middleware labels, key=value per line; empty clears</label>
<textarea
value={accessForm.traefikLabels}
onChange={(e) => setAccessForm({ ...accessForm, traefikLabels: e.target.value })}
rows={3}
placeholder={"middlewares.rl.ratelimit.average=100\nmiddlewares.sec.headers.customresponseheaders.X-Frame-Options=DENY"}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
@@ -2546,8 +2570,17 @@ export function ServiceDetailPage() {
return i > 0 ? { username: l.slice(0, i), password: l.slice(i + 1) } : null; return i > 0 ? { username: l.slice(0, i), password: l.slice(i + 1) } : null;
}) })
.filter((x): x is { username: string; password: string } => x !== null); .filter((x): x is { username: string; password: string } => x !== null);
const labels: Record<string, string> = {};
accessForm.traefikLabels
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.forEach((l) => {
const i = l.indexOf('=');
if (i > 0) labels[l.slice(0, i).trim()] = l.slice(i + 1).trim();
});
updateServiceMutation.mutate( updateServiceMutation.mutate(
{ maintenance_mode: accessForm.maintenance, basic_auth: creds }, { maintenance_mode: accessForm.maintenance, basic_auth: creds, traefik_labels: labels },
{ onSuccess: () => setAccessForm(null) }, { onSuccess: () => setAccessForm(null) },
); );
}} }}
@@ -8,6 +8,7 @@ import {
getCurrentUserProfile, getCurrentUserProfile,
getTemplateById, getTemplateById,
importComposeTemplate, importComposeTemplate,
importGitComposeTemplate,
listProjects, listProjects,
listTemplates, listTemplates,
updateTemplate, updateTemplate,
@@ -154,6 +155,10 @@ export function TemplatesPage() {
const [composeYaml, setComposeYaml] = useState(''); const [composeYaml, setComposeYaml] = useState('');
const [composeWarnings, setComposeWarnings] = useState<string[]>([]); const [composeWarnings, setComposeWarnings] = useState<string[]>([]);
const [composeError, setComposeError] = useState<string | null>(null); const [composeError, setComposeError] = useState<string | null>(null);
const [composeMode, setComposeMode] = useState<'paste' | 'git'>('paste');
const [gitRepo, setGitRepo] = useState('');
const [gitPath, setGitPath] = useState('');
const [gitRef, setGitRef] = useState('');
const [lastDeployment, setLastDeployment] = useState<{ const [lastDeployment, setLastDeployment] = useState<{
projectId: string; projectId: string;
@@ -364,7 +369,10 @@ export function TemplatesPage() {
}); });
const composeImportMutation = useMutation({ const composeImportMutation = useMutation({
mutationFn: (yaml: string) => importComposeTemplate(yaml), mutationFn: () =>
composeMode === 'git'
? importGitComposeTemplate(gitRepo.trim(), gitPath.trim(), gitRef.trim())
: importComposeTemplate(composeYaml),
onSuccess: (result) => { onSuccess: (result) => {
setComposeWarnings(result.warnings); setComposeWarnings(result.warnings);
setComposeError(null); setComposeError(null);
@@ -1107,7 +1115,7 @@ export function TemplatesPage() {
<div> <div>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Import docker-compose</h2> <h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Import docker-compose</h2>
<p className="text-xs text-[var(--text-tertiary)]"> <p className="text-xs text-[var(--text-tertiary)]">
Paste a compose file — known database images become managed databases, everything else becomes services. Paste a compose file or pull one from a git repo — known database images become managed databases.
</p> </p>
</div> </div>
<button <button
@@ -1118,13 +1126,67 @@ export function TemplatesPage() {
<X size={15} /> <X size={15} />
</button> </button>
</div> </div>
<textarea <div className="flex gap-1 border-b border-[var(--border-subtle)] px-5 pt-3">
value={composeYaml} {(['paste', 'git'] as const).map((m) => (
onChange={(e) => setComposeYaml(e.target.value)} <button
spellCheck={false} key={m}
placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'} type="button"
className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none" onClick={() => setComposeMode(m)}
/> className={`rounded-t-[var(--radius-md)] px-3 py-1.5 text-xs font-medium ${
composeMode === m
? 'bg-[var(--surface-muted)] text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
}`}
>
{m === 'paste' ? 'Paste YAML' : 'From git repo'}
</button>
))}
</div>
{composeMode === 'paste' ? (
<textarea
value={composeYaml}
onChange={(e) => setComposeYaml(e.target.value)}
spellCheck={false}
placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'}
className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/>
) : (
<div className="flex min-h-[320px] flex-1 flex-col gap-3 bg-[var(--bg-void)] p-4">
<label className="text-xs text-[var(--text-tertiary)]">
Repository
<input
value={gitRepo}
onChange={(e) => setGitRepo(e.target.value)}
spellCheck={false}
placeholder="owner/repo or https://github.com/owner/repo.git"
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/>
</label>
<label className="text-xs text-[var(--text-tertiary)]">
Compose file path <span className="opacity-60">(optional — compose.yaml / docker-compose.yml searched)</span>
<input
value={gitPath}
onChange={(e) => setGitPath(e.target.value)}
spellCheck={false}
placeholder="deploy/compose.yml"
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/>
</label>
<label className="text-xs text-[var(--text-tertiary)]">
Branch or commit <span className="opacity-60">(optional)</span>
<input
value={gitRef}
onChange={(e) => setGitRef(e.target.value)}
spellCheck={false}
placeholder="main"
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/>
</label>
<p className="text-[11px] text-[var(--text-tertiary)]">
Private repos use your connected git providers for credentials.
</p>
</div>
)}
{composeError ? ( {composeError ? (
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]"> <div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
{composeError} {composeError}
@@ -1140,8 +1202,8 @@ export function TemplatesPage() {
</button> </button>
<button <button
type="button" type="button"
onClick={() => composeImportMutation.mutate(composeYaml)} onClick={() => composeImportMutation.mutate()}
disabled={!composeYaml.trim() || composeImportMutation.isPending} disabled={(composeMode === 'paste' ? !composeYaml.trim() : !gitRepo.trim()) || composeImportMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
> >
{composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />} {composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />}
+90 -2
View File
@@ -3243,6 +3243,80 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/templates/import/git": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Import a compose file from a git repository
* @description Fetches a compose file out of a git repository and runs it through
* the same conversion as POST /templates/import/compose. `repo`
* accepts a clone URL, ssh remote, or `owner/repo` shorthand —
* connected git providers (including GitHub App installs) supply
* credentials, so private repositories work. `path` defaults to
* `compose.yaml`, `compose.yml`, `docker-compose.yml`, then
* `docker-compose.yaml`; `ref` selects a branch or commit SHA.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description Clone URL, ssh remote, or owner/repo shorthand */
repo: string;
/** @description Repo-relative compose file path (searched by convention when empty) */
path?: string;
/** @description Branch name or 40-char commit SHA */
ref?: string;
};
};
};
responses: {
/** @description Converted config */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
config?: components["schemas"]["TemplateConfig"];
variables?: Record<string, never>[];
warnings?: string[];
};
};
};
/** @description Invalid repo reference, compose file missing, or conversion error */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Git fetch failed */
502: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/templates/deploy": { "/templates/deploy": {
parameters: { parameters: {
query?: never; query?: never;
@@ -8105,7 +8179,7 @@ export interface paths {
}; };
/** /**
* Update node agent * Update node agent
* @description Partial update — accepts any subset of agent fields (name, status, auto_prune, tags). `tags` sets the node's placement labels; services with placement_tags only schedule onto agents carrying every required tag. * @description Partial update — accepts any subset of agent fields (name, status, auto_prune, tags, default_domain). `tags` sets the node's placement labels; services with placement_tags only schedule onto agents carrying every required tag. `default_domain` gives the node a base domain — domainless services placed there get `<name>.<default_domain>` auto-hostnames; empty string clears it.
*/ */
put: { put: {
parameters: { parameters: {
@@ -10939,7 +11013,7 @@ export interface components {
sleep_enabled?: boolean; sleep_enabled?: boolean;
/** @description Idle timeout in minutes before sleeping (1-1440, default 15) */ /** @description Idle timeout in minutes before sleeping (1-1440, default 15) */
sleep_idle_minutes?: number; sleep_idle_minutes?: number;
/** @description Node agent id this service is pinned to; empty/null = local Docker host. 'auto' on create/update resolves to the least-loaded online agent. Remote nodes run registry-pulled images only (no git builds, no Traefik domains). */ /** @description Node agent id this service is pinned to; empty/null = local Docker host. 'auto' on create/update resolves to the least-loaded online agent. Git-sourced services build on the node itself (build context ships as an agent-token artifact); rollbacks of locally-built tags ship the image via docker save/load. */
node_id?: string; node_id?: string;
/** @description Display name of the pinned node agent (read-only) */ /** @description Display name of the pinned node agent (read-only) */
node_name?: string; node_name?: string;
@@ -10947,6 +11021,10 @@ export interface components {
spread?: boolean; spread?: boolean;
/** @description Node tags required for remote placement — auto/spread only consider agents carrying every tag; re-resolved each reconcile. Mutually exclusive with an explicit node_id pin (read-only on responses when a pin is set). */ /** @description Node tags required for remote placement — auto/spread only consider agents carrying every tag; re-resolved each reconcile. Mutually exclusive with an explicit node_id pin (read-only on responses when a pin is set). */
placement_tags?: string[]; placement_tags?: string[];
/** @description Extra Traefik middlewares defined for this service's router — keys 'middlewares.<name>.<type>[.<field>]' (e.g. 'middlewares.rl.ratelimit.average'), allowlist-validated; each defined middleware attaches to the router after builtins */
traefik_labels?: {
[key: string]: string;
};
/** @description Computed public URL (published port or domain) */ /** @description Computed public URL (published port or domain) */
public_url?: string; public_url?: string;
/** /**
@@ -11023,6 +11101,10 @@ export interface components {
spread?: boolean; spread?: boolean;
/** @description Restrict remote placement to agents carrying every listed tag; mutually exclusive with an explicit pin or 'local' */ /** @description Restrict remote placement to agents carrying every listed tag; mutually exclusive with an explicit pin or 'local' */
placement_tags?: string[]; placement_tags?: string[];
/** @description Extra Traefik middlewares on the service router — keys 'middlewares.<name>.<type>[.<field>]'; allowlist-validated */
traefik_labels?: {
[key: string]: string;
};
}; };
UpdateServiceRequest: { UpdateServiceRequest: {
/** @description Service name */ /** @description Service name */
@@ -11090,6 +11172,10 @@ export interface components {
spread?: boolean; spread?: boolean;
/** @description Restrict remote placement to agents carrying every listed tag; [] clears. Mutually exclusive with a pin or 'local' — clear the pin first */ /** @description Restrict remote placement to agents carrying every listed tag; [] clears. Mutually exclusive with a pin or 'local' — clear the pin first */
placement_tags?: string[]; placement_tags?: string[];
/** @description Replace the service's Traefik middleware overrides — keys 'middlewares.<name>.<type>[.<field>]'; {} clears */
traefik_labels?: {
[key: string]: string;
};
}; };
ServiceDomain: { ServiceDomain: {
/** Format: uuid */ /** Format: uuid */
@@ -12211,6 +12297,8 @@ export interface components {
schedulable?: boolean; schedulable?: boolean;
/** @description Operator-set placement labels; services with placement_tags require all of them on the target node. */ /** @description Operator-set placement labels; services with placement_tags require all of them on the target node. */
tags?: string[]; tags?: string[];
/** @description Node base domain — services placed on this node (pin or spread) with no explicit service_domains get `<name>.<default_domain>` hostnames automatically. Wildcard DNS for `*.<default_domain>` must resolve to the Containr edge. */
default_domain?: string;
/** Format: date-time */ /** Format: date-time */
last_heartbeat?: string; last_heartbeat?: string;
/** Format: date-time */ /** Format: date-time */
+20 -1
View File
@@ -88,6 +88,7 @@ export type ServiceEntity = {
nodeName?: string; nodeName?: string;
spread?: boolean; spread?: boolean;
placementTags?: string[]; placementTags?: string[];
traefikLabels?: Record<string, string>;
publicUrl?: string; publicUrl?: string;
}; };
@@ -361,6 +362,7 @@ export type NodeAgentEntity = {
autoPrune: boolean; autoPrune: boolean;
schedulable: boolean; schedulable: boolean;
tags: string[]; tags: string[];
defaultDomain?: string;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
lastHeartbeat?: string; lastHeartbeat?: string;
createdAt?: string; createdAt?: string;
@@ -702,6 +704,7 @@ function normalizeService(service: RawService): ServiceEntity | null {
nodeName: service.node_name, nodeName: service.node_name,
spread: service.spread, spread: service.spread,
placementTags: service.placement_tags, placementTags: service.placement_tags,
traefikLabels: service.traefik_labels,
publicUrl: service.public_url, publicUrl: service.public_url,
}; };
} }
@@ -1105,6 +1108,7 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
autoPrune: agent.auto_prune ?? false, autoPrune: agent.auto_prune ?? false,
schedulable: agent.schedulable ?? true, schedulable: agent.schedulable ?? true,
tags: Array.isArray(agent.tags) ? agent.tags : [], tags: Array.isArray(agent.tags) ? agent.tags : [],
defaultDomain: agent.default_domain || '',
metadata: (agent.metadata ?? {}) as Record<string, unknown>, metadata: (agent.metadata ?? {}) as Record<string, unknown>,
createdAt: agent.created_at, createdAt: agent.created_at,
updatedAt: agent.updated_at, updatedAt: agent.updated_at,
@@ -1144,7 +1148,7 @@ export async function listAgents(): Promise<NodeAgentEntity[]> {
return normalizeAgentArray(payload.agents); return normalizeAgentArray(payload.agents);
} }
export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean; tags?: string[] }): Promise<NodeAgentEntity | null> { export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean; tags?: string[]; default_domain?: string }): Promise<NodeAgentEntity | null> {
const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`, { const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -1924,6 +1928,21 @@ export async function importComposeTemplate(composeYaml: string): Promise<Compos
return { config: payload.config, warnings: payload.warnings ?? [] }; return { config: payload.config, warnings: payload.warnings ?? [] };
} }
export async function importGitComposeTemplate(
repo: string,
path: string,
ref: string,
): Promise<ComposeImportResult> {
const payload = await requestJson<{ config?: Record<string, unknown>; warnings?: string[] }>(
'/templates/import/git',
{ method: 'POST', body: JSON.stringify({ repo, path: path || undefined, ref: ref || undefined }) },
);
if (!payload.config || typeof payload.config !== 'object') {
throw new ApiError('Git import response is invalid', 500);
}
return { config: payload.config, warnings: payload.warnings ?? [] };
}
export type ProjectComposeImportResult = { export type ProjectComposeImportResult = {
created: Array<{ key: string; kind: string; id: string; name: string; deployment_id?: string }>; created: Array<{ key: string; kind: string; id: string; name: string; deployment_id?: string }>;
warnings: string[]; warnings: string[];
+93
View File
@@ -68,6 +68,36 @@ export interface Template {
icon?: string; icon?: string;
} }
export interface Variable {
id?: string;
key: string;
value: string;
is_secret: boolean;
}
export interface ServiceDomain {
id: string;
domain: string;
is_default: boolean;
cert_type?: string;
cert_status?: string;
}
export interface AppNotification {
id: string;
kind: string;
title: string;
body?: string;
resource_type?: string;
resource_id?: string;
read_at?: string | null;
created_at?: string;
}
// Masked value the API returns for secret variables; echoing it back on
// update preserves the stored ciphertext.
export const MASKED_SECRET = '********';
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
public status: number, public status: number,
@@ -145,6 +175,69 @@ export class Api {
return this.req<EnvCheck>('GET', `/services/${id}/env-check`); return this.req<EnvCheck>('GET', `/services/${id}/env-check`);
} }
serviceVariables(id: string) {
return this.req<{ variables: Variable[] }>('GET', `/services/${id}/variables`).then(
(r) => r.variables ?? [],
);
}
updateVariables(id: string, variables: { key: string; value: string; is_secret: boolean }[]) {
return this.req<unknown>('PUT', `/services/${id}/variables`, { variables });
}
serviceDomains(id: string) {
return this.req<{ domains: ServiceDomain[] }>('GET', `/services/${id}/domains`).then(
(r) => r.domains ?? [],
);
}
addDomain(id: string, domain: string) {
return this.req<unknown>('POST', `/services/${id}/domains`, { domain });
}
deleteDomain(id: string, domainId: string) {
return this.req<unknown>('DELETE', `/services/${id}/domains/${domainId}`);
}
setDefaultDomain(id: string, domainId: string) {
return this.req<unknown>('POST', `/services/${id}/domains/${domainId}/default`);
}
updateService(
id: string,
patch: Partial<{
name: string;
command: string;
replicas: number;
port: number;
domain: string;
restart_policy: string;
healthcheck_path: string;
cpu: string;
memory: string;
sleep_enabled: boolean;
sleep_idle_minutes: number;
maintenance_mode: boolean;
}>,
) {
return this.req<unknown>('PUT', `/services/${id}`, patch);
}
notifications() {
return this.req<{ notifications: AppNotification[]; unread: number }>(
'GET',
'/notifications?limit=50',
);
}
markNotificationRead(id: string) {
return this.req<unknown>('POST', `/notifications/${id}/read`);
}
markAllNotificationsRead() {
return this.req<unknown>('POST', '/notifications/read-all');
}
serviceDeployments(id: string) { serviceDeployments(id: string) {
return this.req<{ deployments: Deployment[] }>( return this.req<{ deployments: Deployment[] }>(
'GET', 'GET',
+20
View File
@@ -1,9 +1,19 @@
import React from 'react'; import React from 'react';
import { Tabs } from 'expo-router'; import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useQuery } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme'; import { colors } from '../../theme';
export default function TabsLayout() { export default function TabsLayout() {
const api = useApi();
const notifs = useQuery({
queryKey: ['notifications'],
queryFn: () => api.notifications(),
refetchInterval: 60_000,
});
const unread = notifs.data?.unread ?? 0;
return ( return (
<Tabs <Tabs
screenOptions={{ screenOptions={{
@@ -41,6 +51,16 @@ export default function TabsLayout() {
), ),
}} }}
/> />
<Tabs.Screen
name="notifications"
options={{
title: 'Alerts',
tabBarBadge: unread > 0 ? unread : undefined,
tabBarIcon: ({ color, size }) => (
<Ionicons name="notifications-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen <Tabs.Screen
name="settings" name="settings"
options={{ options={{
+107
View File
@@ -0,0 +1,107 @@
import React from 'react';
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
import { relTime } from '../../time';
import { ActionButton, Card, Empty, ErrorBox, Loading } from '../../components';
const KIND_COLOR: Record<string, string> = {
deployment_failed: colors.err,
deployment: colors.accent,
alert: colors.warn,
scale: colors.info,
};
export default function Notifications() {
const api = useApi();
const qc = useQueryClient();
const notifs = useQuery({
queryKey: ['notifications'],
queryFn: () => api.notifications(),
refetchInterval: 30_000,
});
const markAll = useMutation({
mutationFn: () => api.markAllNotificationsRead(),
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
});
const markRead = useMutation({
mutationFn: (id: string) => api.markNotificationRead(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
});
if (notifs.isLoading) return <Loading />;
if (notifs.error) return <ErrorBox error={notifs.error} />;
const items = notifs.data?.notifications ?? [];
const unread = notifs.data?.unread ?? 0;
return (
<FlatList
style={styles.root}
contentContainerStyle={{ padding: 16 }}
refreshControl={
<RefreshControl
refreshing={notifs.isRefetching}
onRefresh={() => notifs.refetch()}
tintColor={colors.accent}
/>
}
ListHeaderComponent={
unread > 0 ? (
<ActionButton
label={`Mark all read (${unread})`}
disabled={markAll.isPending}
onPress={() => markAll.mutate()}
/>
) : null
}
ListEmptyComponent={<Empty text="No notifications" />}
data={items}
keyExtractor={(n) => n.id}
renderItem={({ item }) => {
const isUnread = !item.read_at;
const dot = KIND_COLOR[item.kind] ?? colors.accent;
return (
<Card
style={isUnread ? { borderColor: colors.accent } : undefined}
>
<View style={styles.row}>
<View style={[styles.dot, { backgroundColor: isUnread ? dot : colors.border }]} />
<View style={{ flex: 1 }}>
<Text style={[styles.title, !isUnread && { color: colors.textDim }]}>
{item.title}
</Text>
{item.body ? <Text style={styles.body}>{item.body}</Text> : null}
<Text style={styles.meta}>
{item.kind}
{item.created_at ? ` · ${relTime(item.created_at)}` : ''}
</Text>
</View>
{isUnread ? (
<Text
style={styles.markRead}
onPress={() => markRead.mutate(item.id)}
>
Mark read
</Text>
) : null}
</View>
</Card>
);
}}
/>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
row: { flexDirection: 'row', alignItems: 'flex-start' },
dot: { width: 8, height: 8, borderRadius: 4, marginTop: 5, marginRight: 10 },
title: { color: colors.text, fontSize: 15, fontWeight: '600' },
body: { color: colors.textDim, fontSize: 13, marginTop: 4, lineHeight: 18 },
meta: { color: colors.textDim, fontSize: 11, marginTop: 6 },
markRead: { color: colors.accent, fontSize: 12, fontWeight: '600', marginLeft: 8 },
});
+3
View File
@@ -37,6 +37,9 @@ function AuthGate() {
<Stack.Screen name="project/[id]" options={{ title: 'Project' }} /> <Stack.Screen name="project/[id]" options={{ title: 'Project' }} />
<Stack.Screen name="service/[id]" options={{ title: 'Service' }} /> <Stack.Screen name="service/[id]" options={{ title: 'Service' }} />
<Stack.Screen name="service/logs" options={{ title: 'Logs' }} /> <Stack.Screen name="service/logs" options={{ title: 'Logs' }} />
<Stack.Screen name="service/variables" options={{ title: 'Variables' }} />
<Stack.Screen name="service/domains" options={{ title: 'Domains' }} />
<Stack.Screen name="service/settings" options={{ title: 'Service settings' }} />
</Stack.Protected> </Stack.Protected>
<Stack.Screen name="login" options={{ headerShown: false }} /> <Stack.Screen name="login" options={{ headerShown: false }} />
</Stack> </Stack>
+23
View File
@@ -11,6 +11,7 @@ import {
Empty, Empty,
ErrorBox, ErrorBox,
Loading, Loading,
Row,
SectionTitle, SectionTitle,
StatusBadge, StatusBadge,
} from '../../components'; } from '../../components';
@@ -142,6 +143,28 @@ export default function ServiceDetail() {
/> />
</View> </View>
<SectionTitle>Manage</SectionTitle>
<Card>
<Row
title="Variables"
subtitle="Environment and secrets"
onPress={() =>
router.push(`/service/variables?id=${s.id}&name=${encodeURIComponent(s.name)}`)
}
/>
<Row
title="Domains"
subtitle={s.domain ?? 'Hostnames routed to this service'}
onPress={() => router.push(`/service/domains?id=${s.id}`)}
/>
<Row
title="Scale & sleep"
subtitle={`${s.replicas ?? 1} replica${(s.replicas ?? 1) === 1 ? '' : 's'}` +
(s.sleep_enabled ? ` · sleeps after ${s.sleep_idle_minutes}m idle` : '')}
onPress={() => router.push(`/service/settings?id=${s.id}`)}
/>
</Card>
{s.sleep_enabled ? ( {s.sleep_enabled ? (
<Text style={styles.hint}> <Text style={styles.hint}>
Sleep enabled — scales to zero after {s.sleep_idle_minutes}m idle, wakes on traffic. Sleep enabled — scales to zero after {s.sleep_idle_minutes}m idle, wakes on traffic.
+151
View File
@@ -0,0 +1,151 @@
import React, { useState } from 'react';
import {
Alert,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams } from 'expo-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useApi } from '../../session';
import { colors } from '../../theme';
import { ActionButton, Card, Empty, ErrorBox, Loading } from '../../components';
export default function ServiceDomains() {
const { id } = useLocalSearchParams<{ id: string }>();
const api = useApi();
const qc = useQueryClient();
const [newDomain, setNewDomain] = useState('');
const domains = useQuery({
queryKey: ['domains', id],
queryFn: () => api.serviceDomains(id!),
enabled: !!id,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['domains', id] });
qc.invalidateQueries({ queryKey: ['service', id] });
};
const onErr = (e: unknown) =>
Alert.alert('Failed', e instanceof Error ? e.message : String(e));
const add = useMutation({
mutationFn: (d: string) => api.addDomain(id!, d),
onSuccess: () => {
setNewDomain('');
invalidate();
},
onError: onErr,
});
const del = useMutation({
mutationFn: (domainId: string) => api.deleteDomain(id!, domainId),
onSuccess: invalidate,
onError: onErr,
});
const makeDefault = useMutation({
mutationFn: (domainId: string) => api.setDefaultDomain(id!, domainId),
onSuccess: invalidate,
onError: onErr,
});
if (domains.isLoading) return <Loading />;
if (domains.error) return <ErrorBox error={domains.error} />;
const submit = () => {
const d = newDomain.trim().toLowerCase();
if (d) add.mutate(d);
};
return (
<ScrollView style={styles.root} contentContainerStyle={{ padding: 16 }}>
<Text style={styles.hint}>
Hostnames routed to this service. The default domain is the one shown on cards and used
for generated links.
</Text>
{domains.data?.length === 0 ? <Empty text="No domains configured" /> : null}
{(domains.data ?? []).map((d) => (
<Card key={d.id}>
<View style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={styles.domain}>{d.domain}</Text>
<Text style={styles.meta}>
{d.is_default ? 'default · ' : ''}
{d.cert_status ? `tls: ${d.cert_status}` : 'tls pending'}
</Text>
</View>
{!d.is_default ? (
<TouchableOpacity
onPress={() => makeDefault.mutate(d.id)}
hitSlop={8}
style={styles.iconBtn}
>
<Ionicons name="star-outline" size={20} color={colors.accent} />
</TouchableOpacity>
) : (
<Ionicons name="star" size={20} color={colors.warn} style={styles.iconBtn} />
)}
<TouchableOpacity
onPress={() =>
Alert.alert('Remove domain', `Detach ${d.domain} from this service?`, [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Remove', style: 'destructive', onPress: () => del.mutate(d.id) },
])
}
hitSlop={8}
style={styles.iconBtn}
>
<Ionicons name="trash-outline" size={18} color={colors.err} />
</TouchableOpacity>
</View>
</Card>
))}
<View style={styles.addRow}>
<TextInput
style={styles.input}
value={newDomain}
onChangeText={setNewDomain}
placeholder="app.example.com"
placeholderTextColor={colors.textDim}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
onSubmitEditing={submit}
/>
<ActionButton
label={add.isPending ? '…' : 'Add'}
kind="primary"
disabled={add.isPending || newDomain.trim() === ''}
onPress={submit}
/>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
hint: { color: colors.textDim, fontSize: 12, marginBottom: 12, lineHeight: 17 },
row: { flexDirection: 'row', alignItems: 'center' },
domain: { color: colors.text, fontSize: 15, fontWeight: '600' },
meta: { color: colors.textDim, fontSize: 12, marginTop: 3 },
iconBtn: { padding: 6, marginLeft: 10 },
addRow: { flexDirection: 'row', alignItems: 'center', marginTop: 8 },
input: {
flex: 1,
color: colors.text,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 10,
padding: 12,
marginRight: 8,
fontSize: 14,
},
});
+174
View File
@@ -0,0 +1,174 @@
import React, { useState } from 'react';
import {
Alert,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useApi } from '../../session';
import { Service } from '../../api';
import { colors } from '../../theme';
import { ActionButton, Card, ErrorBox, Loading, SectionTitle } from '../../components';
export default function ServiceSettings() {
const { id } = useLocalSearchParams<{ id: string }>();
const api = useApi();
const service = useQuery({
queryKey: ['service', id],
queryFn: () => api.service(id!),
enabled: !!id,
});
if (service.isLoading) return <Loading />;
if (service.error || !service.data) {
return <ErrorBox error={service.error ?? new Error('Not found')} />;
}
// Form mounts once data exists; its state initializes from the fetch.
return <Form serviceId={id!} initial={service.data} />;
}
function Form({ serviceId, initial }: { serviceId: string; initial: Service }) {
const api = useApi();
const qc = useQueryClient();
const [replicas, setReplicas] = useState(initial.replicas ?? 1);
const [port, setPort] = useState(String(initial.port ?? 0));
const [sleepEnabled, setSleepEnabled] = useState(!!initial.sleep_enabled);
const [sleepMinutes, setSleepMinutes] = useState(
String(initial.sleep_idle_minutes || 30),
);
const save = useMutation({
mutationFn: () =>
api.updateService(serviceId, {
replicas,
port: parseInt(port, 10) || 0,
sleep_enabled: sleepEnabled,
sleep_idle_minutes: Math.min(1440, Math.max(1, parseInt(sleepMinutes, 10) || 30)),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['service', serviceId] });
qc.invalidateQueries({ queryKey: ['services'] });
Alert.alert('Saved', 'Changes apply on the next deploy.');
},
onError: (e) => Alert.alert('Save failed', e instanceof Error ? e.message : String(e)),
});
const clampReplicas = (n: number) => Math.min(16, Math.max(0, n));
return (
<ScrollView style={styles.root} contentContainerStyle={{ padding: 16 }}>
<SectionTitle>Scaling</SectionTitle>
<Card>
<View style={styles.stepRow}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Replicas</Text>
<Text style={styles.sub}>Containers run per deploy; 0 parks the service.</Text>
</View>
<TouchableOpacity
style={styles.stepBtn}
onPress={() => setReplicas((r) => clampReplicas(r - 1))}
disabled={replicas <= 0}
>
<Text style={styles.stepText}>−</Text>
</TouchableOpacity>
<Text style={styles.stepValue}>{replicas}</Text>
<TouchableOpacity
style={styles.stepBtn}
onPress={() => setReplicas((r) => clampReplicas(r + 1))}
disabled={replicas >= 16}
>
<Text style={styles.stepText}>+</Text>
</TouchableOpacity>
</View>
<View style={styles.field}>
<Text style={styles.label}>Container port</Text>
<TextInput
style={styles.input}
value={port}
onChangeText={setPort}
keyboardType="number-pad"
placeholder="0 = none"
placeholderTextColor={colors.textDim}
/>
</View>
</Card>
<SectionTitle>Scale to zero</SectionTitle>
<Card>
<View style={styles.switchRow}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Sleep when idle</Text>
<Text style={styles.sub}>Scales to zero after idle; incoming traffic wakes it.</Text>
</View>
<Switch
value={sleepEnabled}
onValueChange={setSleepEnabled}
trackColor={{ false: colors.border, true: colors.accentDim }}
thumbColor={sleepEnabled ? colors.accent : colors.textDim}
/>
</View>
{sleepEnabled ? (
<View style={styles.field}>
<Text style={styles.label}>Idle minutes before sleep (1–1440)</Text>
<TextInput
style={styles.input}
value={sleepMinutes}
onChangeText={setSleepMinutes}
keyboardType="number-pad"
placeholder="30"
placeholderTextColor={colors.textDim}
/>
</View>
) : null}
</Card>
<ActionButton
label={save.isPending ? 'Saving…' : 'Save settings'}
kind="primary"
disabled={save.isPending}
onPress={() => save.mutate()}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
label: { color: colors.text, fontSize: 15, fontWeight: '600' },
sub: { color: colors.textDim, fontSize: 12, marginTop: 2 },
stepRow: { flexDirection: 'row', alignItems: 'center' },
stepBtn: {
width: 38,
height: 38,
borderRadius: 8,
backgroundColor: colors.surfaceAlt,
alignItems: 'center',
justifyContent: 'center',
},
stepText: { color: colors.text, fontSize: 20, fontWeight: '700' },
stepValue: {
color: colors.text,
fontSize: 18,
fontWeight: '700',
minWidth: 36,
textAlign: 'center',
},
switchRow: { flexDirection: 'row', alignItems: 'center' },
field: { marginTop: 12 },
input: {
color: colors.text,
backgroundColor: colors.surfaceAlt,
borderRadius: 8,
padding: 10,
marginTop: 6,
fontSize: 14,
},
});
+187
View File
@@ -0,0 +1,187 @@
import React, { useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams } from 'expo-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useApi } from '../../session';
import { MASKED_SECRET, Variable } from '../../api';
import { colors } from '../../theme';
import { ActionButton, Card, Empty, ErrorBox, Loading } from '../../components';
interface Row {
key: string;
value: string;
is_secret: boolean;
// Secret untouched by the user — submits the masked placeholder so the
// server keeps the stored ciphertext.
keepSecret: boolean;
}
export default function ServiceVariables() {
const { id, name } = useLocalSearchParams<{ id: string; name?: string }>();
const api = useApi();
const vars = useQuery({
queryKey: ['variables', id],
queryFn: () => api.serviceVariables(id!),
enabled: !!id,
});
if (vars.isLoading) return <Loading />;
if (vars.error) return <ErrorBox error={vars.error} />;
if (!vars.data) return <ErrorBox error={new Error('Not found')} />;
// Editor mounts once data exists; its state initializes from the fetch.
return <Editor serviceId={id!} name={name} initial={vars.data} />;
}
function Editor({
serviceId,
name,
initial,
}: {
serviceId: string;
name?: string;
initial: Variable[];
}) {
const api = useApi();
const qc = useQueryClient();
const [rows, setRows] = useState<Row[]>(() =>
initial.map((v) => ({
key: v.key,
value: v.is_secret ? '' : v.value,
is_secret: v.is_secret,
keepSecret: v.is_secret,
})),
);
const save = useMutation({
mutationFn: () => {
const clean = rows
.filter((r) => r.key.trim() !== '')
.map((r) => ({
key: r.key.trim(),
value: r.keepSecret ? MASKED_SECRET : r.value,
is_secret: r.is_secret,
}));
return api.updateVariables(serviceId, clean);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['variables', serviceId] });
qc.invalidateQueries({ queryKey: ['env-check', serviceId] });
Alert.alert('Saved', 'Variables apply on the next deploy/restart.');
},
onError: (e) => Alert.alert('Save failed', e instanceof Error ? e.message : String(e)),
});
const set = (i: number, patch: Partial<Row>) =>
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...patch } : r)));
return (
<KeyboardAvoidingView
style={styles.root}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={{ padding: 16 }}>
<Text style={styles.hint}>
Variables for {name ?? 'this service'}. Secret values are stored encrypted — leave a
secret blank to keep it, type to replace.
</Text>
{rows.length === 0 ? <Empty text="No variables yet" /> : null}
{rows.map((r, i) => (
<Card key={i}>
<View style={styles.rowHead}>
<TextInput
style={styles.keyInput}
value={r.key}
onChangeText={(t) => set(i, { key: t.toUpperCase().replace(/\s/g, '_') })}
placeholder="KEY"
placeholderTextColor={colors.textDim}
autoCapitalize="characters"
autoCorrect={false}
/>
<TouchableOpacity
onPress={() =>
set(i, r.is_secret ? { is_secret: false, keepSecret: false } : { is_secret: true })
}
hitSlop={8}
style={styles.iconBtn}
>
<Ionicons
name={r.is_secret ? 'lock-closed' : 'lock-open-outline'}
size={18}
color={r.is_secret ? colors.warn : colors.textDim}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setRows((rs) => rs.filter((_, j) => j !== i))}
hitSlop={8}
style={styles.iconBtn}
>
<Ionicons name="trash-outline" size={18} color={colors.err} />
</TouchableOpacity>
</View>
<TextInput
style={styles.valueInput}
value={r.value}
onChangeText={(t) => set(i, { value: t, keepSecret: false })}
placeholder={r.keepSecret ? '•••••••• (unchanged)' : 'value'}
placeholderTextColor={colors.textDim}
secureTextEntry={r.is_secret && !r.keepSecret}
autoCapitalize="none"
autoCorrect={false}
multiline={false}
/>
</Card>
))}
<ActionButton
label="+ Add variable"
onPress={() =>
setRows((rs) => [...rs, { key: '', value: '', is_secret: false, keepSecret: false }])
}
/>
<ActionButton
label={save.isPending ? 'Saving…' : 'Save variables'}
kind="primary"
disabled={save.isPending}
onPress={() => save.mutate()}
/>
<View style={{ height: 40 }} />
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
hint: { color: colors.textDim, fontSize: 12, marginBottom: 12, lineHeight: 17 },
rowHead: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
keyInput: {
flex: 1,
color: colors.text,
fontSize: 14,
fontWeight: '700',
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
},
valueInput: {
color: colors.text,
fontSize: 14,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
backgroundColor: colors.surfaceAlt,
borderRadius: 8,
padding: 10,
},
iconBtn: { padding: 6, marginLeft: 6 },
});
+77 -2
View File
@@ -2156,6 +2156,62 @@ paths:
items: items:
type: string type: string
/templates/import/git:
post:
tags:
- Templates
summary: Import a compose file from a git repository
description: |
Fetches a compose file out of a git repository and runs it through
the same conversion as POST /templates/import/compose. `repo`
accepts a clone URL, ssh remote, or `owner/repo` shorthand —
connected git providers (including GitHub App installs) supply
credentials, so private repositories work. `path` defaults to
`compose.yaml`, `compose.yml`, `docker-compose.yml`, then
`docker-compose.yaml`; `ref` selects a branch or commit SHA.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- repo
properties:
repo:
type: string
description: Clone URL, ssh remote, or owner/repo shorthand
path:
type: string
description: Repo-relative compose file path (searched by convention when empty)
ref:
type: string
description: Branch name or 40-char commit SHA
responses:
'200':
description: Converted config
content:
application/json:
schema:
type: object
properties:
config:
$ref: '#/components/schemas/TemplateConfig'
variables:
type: array
items:
type: object
warnings:
type: array
items:
type: string
'400':
description: Invalid repo reference, compose file missing, or conversion error
'502':
description: Git fetch failed
/templates/deploy: /templates/deploy:
post: post:
tags: tags:
@@ -5160,7 +5216,7 @@ paths:
tags: tags:
- Agents - Agents
summary: Update node agent summary: Update node agent
description: Partial update — accepts any subset of agent fields (name, status, auto_prune, tags). `tags` sets the node's placement labels; services with placement_tags only schedule onto agents carrying every required tag. description: Partial update — accepts any subset of agent fields (name, status, auto_prune, tags, default_domain). `tags` sets the node's placement labels; services with placement_tags only schedule onto agents carrying every required tag. `default_domain` gives the node a base domain — domainless services placed there get `<name>.<default_domain>` auto-hostnames; empty string clears it.
security: security:
- bearerAuth: [] - bearerAuth: []
parameters: parameters:
@@ -7013,7 +7069,7 @@ components:
description: Idle timeout in minutes before sleeping (1-1440, default 15) description: Idle timeout in minutes before sleeping (1-1440, default 15)
node_id: node_id:
type: string type: string
description: Node agent id this service is pinned to; empty/null = local Docker host. 'auto' on create/update resolves to the least-loaded online agent. Remote nodes run registry-pulled images only (no git builds, no Traefik domains). description: Node agent id this service is pinned to; empty/null = local Docker host. 'auto' on create/update resolves to the least-loaded online agent. Git-sourced services build on the node itself (build context ships as an agent-token artifact); rollbacks of locally-built tags ship the image via docker save/load.
node_name: node_name:
type: string type: string
description: Display name of the pinned node agent (read-only) description: Display name of the pinned node agent (read-only)
@@ -7024,6 +7080,10 @@ components:
type: array type: array
items: { type: string } items: { type: string }
description: Node tags required for remote placement — auto/spread only consider agents carrying every tag; re-resolved each reconcile. Mutually exclusive with an explicit node_id pin (read-only on responses when a pin is set). description: Node tags required for remote placement — auto/spread only consider agents carrying every tag; re-resolved each reconcile. Mutually exclusive with an explicit node_id pin (read-only on responses when a pin is set).
traefik_labels:
type: object
additionalProperties: { type: string }
description: Extra Traefik middlewares defined for this service's router — keys 'middlewares.<name>.<type>[.<field>]' (e.g. 'middlewares.rl.ratelimit.average'), allowlist-validated; each defined middleware attaches to the router after builtins
public_url: public_url:
type: string type: string
description: Computed public URL (published port or domain) description: Computed public URL (published port or domain)
@@ -7125,6 +7185,10 @@ components:
type: array type: array
items: { type: string } items: { type: string }
description: Restrict remote placement to agents carrying every listed tag; mutually exclusive with an explicit pin or 'local' description: Restrict remote placement to agents carrying every listed tag; mutually exclusive with an explicit pin or 'local'
traefik_labels:
type: object
additionalProperties: { type: string }
description: Extra Traefik middlewares on the service router — keys 'middlewares.<name>.<type>[.<field>]'; allowlist-validated
UpdateServiceRequest: UpdateServiceRequest:
type: object type: object
@@ -7221,6 +7285,10 @@ components:
type: array type: array
items: { type: string } items: { type: string }
description: Restrict remote placement to agents carrying every listed tag; [] clears. Mutually exclusive with a pin or 'local' — clear the pin first description: Restrict remote placement to agents carrying every listed tag; [] clears. Mutually exclusive with a pin or 'local' — clear the pin first
traefik_labels:
type: object
additionalProperties: { type: string }
description: Replace the service's Traefik middleware overrides — keys 'middlewares.<name>.<type>[.<field>]'; {} clears
ServiceDomain: ServiceDomain:
type: object type: object
@@ -9110,6 +9178,13 @@ components:
type: array type: array
items: { type: string } items: { type: string }
description: Operator-set placement labels; services with placement_tags require all of them on the target node. description: Operator-set placement labels; services with placement_tags require all of them on the target node.
default_domain:
type: string
description: >-
Node base domain — services placed on this node (pin or spread)
with no explicit service_domains get `<name>.<default_domain>`
hostnames automatically. Wildcard DNS for `*.<default_domain>`
must resolve to the Containr edge.
last_heartbeat: last_heartbeat:
type: string type: string
format: date-time format: date-time
+1 -1
View File
@@ -19,7 +19,7 @@ Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
- **Variables**: service env values support `${{service.KEY}}` cross-service refs (builtins HOST/PORT) and `${{shared.KEY}}` project shared variables (`GET|PUT /projects/{id}/variables`, CLI `variables shared …`). Resolved at deploy/reconcile, not live-updated. - **Variables**: service env values support `${{service.KEY}}` cross-service refs (builtins HOST/PORT) and `${{shared.KEY}}` project shared variables (`GET|PUT /projects/{id}/variables`, CLI `variables shared …`). Resolved at deploy/reconcile, not live-updated.
- **Volumes**: service mounts via `PUT /services/{id}` `volumes: [{type: volume|bind, source, target, read_only}]` — replaces the whole list (`[]` clears), applied on next deploy. `GET /admin/volumes` inventories node volumes with `in_use` flags; `DELETE /admin/volumes/{name}` removes unused ones (admin). - **Volumes**: service mounts via `PUT /services/{id}` `volumes: [{type: volume|bind, source, target, read_only}]` — replaces the whole list (`[]` clears), applied on next deploy. `GET /admin/volumes` inventories node volumes with `in_use` flags; `DELETE /admin/volumes/{name}` removes unused ones (admin).
- **Domains**: multiple hostnames per service via `GET|POST /services/{id}/domains`, `DELETE /services/{id}/domains/{domain_id}`, `POST …/{domain_id}/default` (mirrors `services.domain`). `GET /services/{id}/domains/check` returns `ok|wrong-target|pending` per domain vs `app_settings.public_ip`/`PUBLIC_IP`. `PUT` `maintenance_mode` redirects all traffic to `GET /maintenance` (needs `base_url`/`BASE_URL` setting); `basic_auth: [{username,password}]` stores bcrypt htpasswd and gates the route — responses return usernames only. - **Domains**: multiple hostnames per service via `GET|POST /services/{id}/domains`, `DELETE /services/{id}/domains/{domain_id}`, `POST …/{domain_id}/default` (mirrors `services.domain`). `GET /services/{id}/domains/check` returns `ok|wrong-target|pending` per domain vs `app_settings.public_ip`/`PUBLIC_IP`. `PUT` `maintenance_mode` redirects all traffic to `GET /maintenance` (needs `base_url`/`BASE_URL` setting); `basic_auth: [{username,password}]` stores bcrypt htpasswd and gates the route — responses return usernames only.
- **Builders & resources**: service field `builder` = `auto|railpack|nixpacks|dockerfile|static` (auto detects; static builds assets with `static_build_cmd` and serves `static_dir` via nginx:80). `cpu`/`memory` are hard caps; `cpu_reserve`/`memory_reserve` are soft reservations (CPU shares / MemoryReservation) — empty string clears. `app_settings.default_cpu`/`default_memory` set instance defaults; `app_settings.capacity_policy=block` rejects deploys exceeding node memory (default `warn`). - **Builders & resources**: service field `builder` = `auto|railpack|nixpacks|dockerfile|static` (auto detects; static builds assets with `static_build_cmd` and serves `static_dir` via nginx:80). `cpu`/`memory` are hard caps; `cpu_reserve`/`memory_reserve` are soft reservations (CPU shares / MemoryReservation) — empty string clears. `app_settings.default_cpu`/`default_memory` set instance defaults; `app_settings.capacity_policy=block` rejects deploys exceeding node memory (default `warn`). Services pinned to remote node agents (`node_id`) build git sources on the node itself: the server checks out the repo, packages a Docker context artifact served over the agent-token endpoint (`GET /api/agents/artifacts/{id}`), and the node's `build_image` command runs `docker build` — no registry needed. Rollbacks of locally-built tags ship the image as a docker-save artifact consumed by `load_image`.
- **Clone & move**: `POST /services/{id}/clone` `{name?, project_id?, environment?}` copies config, volumes, domains, access gates, and variables (secret ciphertext verbatim; clone starts stopped). `POST /services/{id}/move` `{project_id}` re-parents a service — both require ownership of source and target; name conflicts → `CONFLICT`. Cloned domains are verbatim copies: the same hostname on two services makes routing ambiguous. - **Clone & move**: `POST /services/{id}/clone` `{name?, project_id?, environment?}` copies config, volumes, domains, access gates, and variables (secret ciphertext verbatim; clone starts stopped). `POST /services/{id}/move` `{project_id}` re-parents a service — both require ownership of source and target; name conflicts → `CONFLICT`. Cloned domains are verbatim copies: the same hostname on two services makes routing ambiguous.
- **Registries**: `GET|POST /registries`, `PUT|DELETE /registries/{id}` — owner-scoped private-image pull credentials, encrypted at rest, never returned (`has_password` signals presence). `host` matched against the image reference on image deploys (`docker.io` for Docker Hub; scheme/case normalized). Empty `password` on update keeps the stored credential. - **Registries**: `GET|POST /registries`, `PUT|DELETE /registries/{id}` — owner-scoped private-image pull credentials, encrypted at rest, never returned (`has_password` signals presence). `host` matched against the image reference on image deploys (`docker.io` for Docker Hub; scheme/case normalized). Empty `password` on update keeps the stored credential.
- **External databases**: `POST /databases/register-external` (probes first; `{name,type,host,port,database,username,password,ssl}`), `POST /databases/test-connection` (ad-hoc probe → `{ok,latency_ms,error}`), `POST /databases/{id}/test-connection` (re-probe with stored credentials). Rows carry `provider:"external"` + `external{host,port,database,username,has_password,ssl}`; passwords encrypted at rest, never returned. Lifecycle actions and backups reject external rows — no container exists. - **External databases**: `POST /databases/register-external` (probes first; `{name,type,host,port,database,username,password,ssl}`), `POST /databases/test-connection` (ad-hoc probe → `{ok,latency_ms,error}`), `POST /databases/{id}/test-connection` (re-probe with stored credentials). Rows carry `provider:"external"` + `external{host,port,database,username,has_password,ssl}`; passwords encrypted at rest, never returned. Lifecycle actions and backups reject external rows — no container exists.
+10 -1
View File
@@ -38,8 +38,17 @@ for platform in $PLATFORMS; do
-ldflags "$LDFLAGS" \ -ldflags "$LDFLAGS" \
-o "bin/${MCP_NAME}" \ -o "bin/${MCP_NAME}" \
./cmd/mcp ./cmd/mcp
done
echo "✅ Built bin/${OUTPUT_NAME} + bin/${MCP_NAME}" # Node agents are linux-only — the download endpoint serves the same two
# arches, and install-agent.sh falls back to these GH release assets.
for arch in amd64 arm64; do
echo "📦 Building agent for linux/${arch}..."
CGO_ENABLED=0 GOOS=linux GOARCH=$arch go build \
-ldflags "-X main.version=${VERSION}" \
-o "bin/containr-agent-linux-${arch}" \
./cmd/agent
echo "✅ Built bin/containr-agent-linux-${arch}"
done done
echo "🎉 CLI build complete!" echo "🎉 CLI build complete!"
+2 -1
View File
@@ -103,7 +103,8 @@ services:
- n8n_storage:/home/node/.n8n - n8n_storage:/home/node/.n8n
command: worker command: worker
depends_on: depends_on:
- n8n n8n:
condition: service_started
redis: redis:
condition: service_healthy condition: service_healthy
postgres: postgres: