Compare commits

...
2 Commits
Author SHA1 Message Date
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
21 changed files with 699 additions and 55 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'
+21 -14
View File
@@ -228,24 +228,31 @@ goroutines; this fixes correctness *and* unlocks the ops surface.
## 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.
+5 -3
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,7 +30,7 @@ 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 git RUN apk --no-cache add ca-certificates tzdata wget git
+1
View File
@@ -487,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)
+19 -2
View File
@@ -224,6 +224,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"`
@@ -511,6 +515,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 +556,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,
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)`,
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 +570,8 @@ 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},
service.CreatedAt, service.UpdatedAt, service.CreatedAt, service.UpdatedAt,
) )
+60 -20
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 {
@@ -332,6 +349,10 @@ func spawnWakeContainer(ctx context.Context, db *database.DB, dc *docker.Client,
Name: name, Name: name,
Image: wakeImage, Image: wakeImage,
Cmd: []string{"sh", "-c", cmd}, Cmd: []string{"sh", "-c", cmd},
Env: []string{
"WAKE_URLS=" + strings.Join(wakeURLs, " "),
"WAKE_PAGE=" + page,
},
Labels: labels, Labels: labels,
Networks: endpoints, Networks: endpoints,
}) })
@@ -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)
}
}
}
+11 -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, ",") {
@@ -520,8 +528,10 @@ 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)")
uf := servicesUpdateCmd.Flags() uf := servicesUpdateCmd.Flags()
+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)
} }
+2 -1
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).", "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})
@@ -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})
+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
@@ -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,6 +1126,23 @@ export function TemplatesPage() {
<X size={15} /> <X size={15} />
</button> </button>
</div> </div>
<div className="flex gap-1 border-b border-[var(--border-subtle)] px-5 pt-3">
{(['paste', 'git'] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setComposeMode(m)}
className={`rounded-t-[var(--radius-md)] px-3 py-1.5 text-xs font-medium ${
composeMode === m
? 'bg-[var(--surface-muted)] text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
}`}
>
{m === 'paste' ? 'Paste YAML' : 'From git repo'}
</button>
))}
</div>
{composeMode === 'paste' ? (
<textarea <textarea
value={composeYaml} value={composeYaml}
onChange={(e) => setComposeYaml(e.target.value)} onChange={(e) => setComposeYaml(e.target.value)}
@@ -1125,6 +1150,43 @@ export function TemplatesPage() {
placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'} placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'}
className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none" className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/> />
) : (
<div className="flex min-h-[320px] flex-1 flex-col gap-3 bg-[var(--bg-void)] p-4">
<label className="text-xs text-[var(--text-tertiary)]">
Repository
<input
value={gitRepo}
onChange={(e) => setGitRepo(e.target.value)}
spellCheck={false}
placeholder="owner/repo or https://github.com/owner/repo.git"
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/>
</label>
<label className="text-xs text-[var(--text-tertiary)]">
Compose file path <span className="opacity-60">(optional — compose.yaml / docker-compose.yml searched)</span>
<input
value={gitPath}
onChange={(e) => setGitPath(e.target.value)}
spellCheck={false}
placeholder="deploy/compose.yml"
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/>
</label>
<label className="text-xs text-[var(--text-tertiary)]">
Branch or commit <span className="opacity-60">(optional)</span>
<input
value={gitRef}
onChange={(e) => setGitRef(e.target.value)}
spellCheck={false}
placeholder="main"
className="mono mt-1 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-[12.5px] text-[var(--text-secondary)] outline-none focus:border-[var(--accent-primary)]"
/>
</label>
<p className="text-[11px] text-[var(--text-tertiary)]">
Private repos use your connected git providers for credentials.
</p>
</div>
)}
{composeError ? ( {composeError ? (
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]"> <div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
{composeError} {composeError}
@@ -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} />}
+75 -1
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;
@@ -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;
+15
View File
@@ -1924,6 +1924,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[];
+57 -1
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:
@@ -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)
+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: